키워드 : javascript, 자바스크립트, 함수형 프로그래밍, 리팩토링, functional programming, refactoring
원문 : https://refactor-like-a-superhero.vercel.app/en/chapters/09-functional-pipeline
함수형 파이프라인
코드를 비즈니스 로직과 최대한 유사하게 보이게 프로그래밍 하는 방법
데이터 변환
비즈니스 워크플로우는 데이터 변환입니다.
예를 들어 주문에 할인을 적용하는 것은 한 데이터 상태에서 다른 데이터 상태로의 전환으로 표현될 수 있습니다.
“Coupon Application”:
[Created Order] + [Valid Coupon] -> [Discounted Order]
“Selecting Product Recommendations”:
[Product Cart] + [Shopping History] ->
[Product Categories] + [Recommendation Weights] ->
[Recommendation List]
잘못 구성된 코드에서 비즈니스 워크플로는 이러한 체인과 유사하지 않습니다.
그것들은 복잡하고 명확하지 않으며 종종 도메인의 언어를 말하지 않습니다.
결과적으로 명확한 워크플로 설명 대신 다음과 같이 애매한 모습이 나타납니다.
“Selecting Product Recommendations”:
[Product Cart] + ... + [Magic 🔮] -> [Recommendation List]
잘 구성된 코드에서 워크플로는 선형으로 보이며 그 안의 데이터는 한 번에 하나씩 여러 단계를 거칩니다.
데이터의 최종 상태는 워크플로에서 원하는 결과입니다.
이러한 종류의 코드 구성을 함수형 파이프라인이라고 합니다.
비즈니스 로직을 리팩토링할 때 코드의 워크플로를 보다 명확하고 분명하게 만들 수 있습니다.
데이터 상태
function makeOrder(user, products, coupon) {
if (!user || !products.length) throw new InvalidOrderDataError();
const data = {
createdAt: Date.now(),
products,
total: totalPrice(products),
discount: selectDiscount(data, coupon),
};
if (!selectDiscount(data, coupon)) data.discount = 0;
if (data.total >= 2000 && isPromoParticipant(user)) {
data.products.push(FREE_PRODUCT_OF_THE_DAY);
}
data.id = generateId();
data.user = user.id;
return data;
}
- 입력 검증
- order 객체 생성
- 유효한 쿠폰 팔인 적용
- 특정 조건에서 프로모션 쿠폰 추가
하지만 위 코드에서 당 기능들을 분리하기는 쉽지 않아 보입니다.
디테일이 너무 많고 섞여있습니디.
또한 order객체를 order 모듈 밖에서 수정하고 있습니다.
워크플로 단계와 그 안에 나타나는 데이터 상태를 강조해 보겠습니다.
우리의 목표는 코드를 이 목록처럼 보이게 하는 것입니다.
코드를 "섹션"으로 그룹화하는 것 부터 시작합니다.
각 섹션은 서로 다른 워크플로 단계를 나타냅니다.
“Show order in UI”:
- “Validate Input Data”:
[Raw Unvalidated Input] -> [Validated User] + [Validated Product List]
- “Create Order”:
[User] + [Product List] -> [Created Order]
- “Apply Discount Coupon”:
[Order] + [Coupon] -> [Discounted Order]
- “Apply Promo”:
[Order] + [User] -> [Order with Promo Products]
// Create Order:
function createOrder(user, products) {
return {
id: generateId(),
createdAt: Date.now(),
user: user.id,
products,
total: totalPrice(products),
};
}
// Apply Discount:
function applyCoupon(order, coupon) {
const discount = selectDiscount(order, coupon) ?? 0;
return { ...order, discount };
}
// Apply Promos:
function applyPromo(order, user) {
if (!isPromoParticipant(user) || order.total < 2000) return order;
const products = [...order.products, FREE_PRODUCT_OF_THE_DAY];
return { ...order, products };
}
function makeOrder(user, products, coupon) {
if (!user || !products.length) throw new InvalidOrderDataError();
const created = createOrder(user, products);
const withDiscount = applyCoupon(created, coupon);
const order = applyPromo(withDiscount, user);
return order;
}
function makeOrder(user, products, coupon, shipDate) {
if (!user || !products.length) throw new InvalidOrderDataError();
const created = createOrder(user, products);
const withDiscount = applyCoupon(created, coupon);
const withPromo = applyPromo(withDiscount, user);
const order = addShipment(withPromo, shipDate); // New workflow step.
return order;
}
또한 특정 단계를 찾아 제거하기 쉬우며,
해당 함수 호출을 삭제하면 프로세스에서 해당 단계와 관련된 모든 코드가 제거됩니다.
파이프라인을 찾는 것이 항상 쉬운 것은 아닙니다.
비즈니스 로직은 코드베이스 전체에 "분산"될 수 있습니다.
이러한 경우 패턴을 발견하기 위해 응용 프로그램 부분이
서로 통신하는 방법에 대한 다이어그램을 그리는 것이 도움이 될 수 있습니다.
유효하지 않은 데이터 전달 금지하기(Unrepresentable Invalid States)
일부 비즈니스 워크플로는 특정 상태의 데이터가 존재할 때만 동작해야 합니다.
예를 들어 우리는 결제가 완료되거나 배송 주소가 누락된 경우 주문 상품을 배송하고 싶지 않습니다.
이러한 주문은 이 워크플로에 유효하지 않습니다.
유효하지 않은 데이터의 전달을 "금지"하면 코드를 더 안정적으로 만들 수 있습니다.
이를 위해 유효하지 않은 데이터를 전달하는 것이 더 어렵거나 불가능하도록 코드를 설계할 수 있습니다.
예를 들어 정적 타이핑 언어의 타입을 사용하여 이를 수행할 수 있습니다.
각 데이터 상태를 별도의 타입으로 설명하고 워크플로우에 유효한 타입을 지정할 수 있습니다.
이렇게 하면 함수 및 메서드 서명에 도메인 제약 조건이 직접 추가됩니다.
분명히 우리는 특정 언어의 제약에 대해 기억해야 합니다. 예를 들어 TypeScript에서는 "서명 유효성 검사"를 달성하기가 어려우며
여전히 JS 런타임을 활용해야 합니다.
하지만 앞으로 설명할 내용은 런타임 검사 없이 많은 도메인 지식을 코드에 직접 반영할 수 있습니다.
예를 들어 상점 사용자의 이메일 주소를 설명하는 CustomerEmail 타입을 살펴보겠습니다.
type CustomerEmail = {
value: EmailAddress;
verified: boolean;
};
타입에는 이메일이 확인되었는지 여부를 나타내는 확인 플래그가 있습니다.
플래그의 문제는 그것이 어떤 조건에서 참이 될 것인지를 설명하지 않는다는 것입니다.
타입에 이메일 검증 논리에 대한 정보가 충분하게 존재하지 않습니다.
function restoreAccount(email: CustomerEmail): void {
if (email.verified) {
// Send the user to the “Reset Password” page.
} else {
return;
}
}
현재 CustomerEmail 구현에서 restoreAccount 함수는 유효하지 않은 데이터를 허용합니다.
타입에 플래그가 하나만 있으면 괜찮을 수 있습니다.
그러나 플래그가 많을수록 타입에 포함된 상태가 많아지고,
일관성 없는 데이터로 인해 오류가 발생할 가능성이 높아집니다.
서로 다른 데이터 상태를 서로 다른 타입으로 분리하여 이 문제를 해결할 수 있습니다.
// For unverified emails, we use one type:
type UnverifiedEmail = {
/*...*/
};
// ...And for verified emails, we use another:
type VerifiedEmail = {
/*...*/
};
// The “any email” can be used
// when the verification isn't important:
type CustomerEmail = UnverifiedEmail | VerifiedEmail;
또한 각각의 워크플로에서 해당 상태들을 사용합니다.
// If the function cares about the email verification,
// it can require a specific type in its signature:
function restorePassword(email: VerifiedEmail): void {}
function verifyEmail(email: UnverifiedEmail): void {}
// If the function can handle any email, it can use the common type.
// This way, we can see the requirements for email verification
// right in the function signature:
function isValidEmail(email: CustomerEmail): boolean {}
타입스크립트는 정적 타이핑을 사용하므로 zod나 branded type을 사용해야 할 수도 있습니다
하지만 타입만으로도 설명적인 효과를 보여줍니다.
데이터 검증
함수형 파이프라인은 선형적 코드 실행에 의존합니다.
워크플로 내의 단계는 차례대로 실행되며 변환 체인을 통해 데이터를 아래로 전달합니다.
이 아이디어가 작동하려면 워크플로 내의 데이터가 안전하고 파이프라인을 중단하지 않아야 합니다.
그러나 "외부" 데이터가 안전하다고 보장할 수는 없습니다.
따라서 코드에서 데이터를 신뢰할 수 있는 영역과 신뢰할 수 없는 영역을 구분하려고 합니다.
이상적으로 비즈니스 워크플로우는 데이터가 검증된 안전한 "섬"이 되어야 합니다.
도메인 주도 설계에서는 해당 개념을 Bounded Context로 정의합니다.
DDD에 따르면 데이터 유효성 검사는 컨텍스트 입력과 같은 컨텍스트 경계에서 더 편리합니다.
이 경우 컨텍스트 "내부"에서 데이터가 이미 검증되고 안전하기 때문에 추가 검사가 필요하지 않습니다.
코드에서 이 규칙을 사용하여 런타임에 불필요한 데이터 검사를 제거할 수 있습니다.
워크플로 시작 시 데이터의 유효성을 검사하면 이후에 요구 사항을 충족한다고 가정할 수 있습니다.
function CartProducts({ items }) {
return (
!!items && (
<ul>
{items.map((item) =>
item ? <li key={item.id}>{item.name ?? "—"}</li> : null
)}
</ul>
)
);
}
function validateCart(cart) {
if (!exists(cart)) return [];
if (hasInvalidItem(cart)) return [];
return cart;
}
// ...
const validCart = validateCart(serverCart);
function CartProducts({ items }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
놓친 상태 확인
종종 입력 유효성 검사는 이전에 알아차리지 못한 데이터 상태를 발견하는 데 도움이 됩니다.
이전 스니펫의 CartProducts 컴포넌트 코드는 더 단순해졌고 결함을 더 쉽게 발견할 수 있게 되었습니다.
// If we render a valid but empty cart,
// the component will render an empty list:
const validEmptyCart = [];
<CartProducts items={validEmptyCart} />;
// Results in <ul></ul>
// To fix the problem with the empty list, we can split
// the “Empty cart” and “Cart with products” states
// into different components:
const EmptyCart = () => <p>The cart is empty</p>;
const CartProducts = ({ items }) => {};
// Then, when rendering, we can first handle all the edge cases,
// and then proceed to Happy Path:
function Cart({ serverCart }) {
const cart = validateCart(serverCart);
if (isEmpty(cart)) return <EmptyCart />;
return <CartProducts items={cart} />;
}
리팩토링은 코드 기능을 변경해서는 안 되므로 버그를 별도로 수정하는 것이 좋습니다.
DTO and Deserialization
직렬화 또는 역직렬화로 인해 데이터가 손상될 수 있는 경우에도 처음에 유효성 검사를 수행하는 것이 유용합니다
DTO는 서버에서 클라이언트로 애플리케이션의 한 부분에서 다른 부분으로 보낼 때 정보를 포장하는 객체입니다.
DTO의 구조와 형식은 의도적으로 단순합니다. 문자열, 숫자, 부울, 배열 및 객체만 있습니다.
예를 들어 서버와 클라이언트 간의 통신에 자주 사용되는 JSON에는 복잡한 형식이나 구조가 없습니다.
복잡한 도메인 타입과 DTO 간의 "변환" 중에 문제가 발생할 수 있으며 데이터가 무효화될 수 있습니다.
이 데이터는 사용 전에 확인하지 않으면 작업 흐름을 중단시킬 수 있습니다.
Data Mapping and Selectors
동일한 데이터는 다른 목적으로 활용될 수 있습니다.
예를들어 UI는 목적에 따라 장바구니를 다른 모습으로 렌더링할 수 있습니다.
셀렉터는 이럴 때 도움이 됩니다.
아래 코드는 서버 데이터 구조에 의존합니다.
서버 데이터가 변경되면 컴포넌트도 변경됩니다.
function CartProducts({ serverCart }) {
return (
<ul>
{serverCart.map((item) => (
<li key={item.id}>
{item.product.name}: {item.product.price} × {item.count}
</li>
))}
</ul>
);
}
셀렉터는 렌더링에 사용하는 데이터 구조와 서버 응답을 분리하는 데 도움이 될 수 있습니다.
예를 들어 이러한 셀렉터를 함수로 나타낼 수 있습니다.
// The `toClientCart` function “converts” the data into a structure,
// which the application components will use.
function toClientCart(cart, products) {
return cart.map(({ productId, ...item }) => {
const product = products.find(({ id }) => productId === id);
return { ...item, product };
});
}
const serverCart = await fetchCart(userId)
const cart = toClientCart(serverCart, serverProducts)
// ...
<CartProducts items={cart} />
function CartProducts({ items }) {
return (
<ul>
{items.map(({ id, count, product }) => (
<li key={id}>
{product.name} {product.price} × {count}
</li>
))}
</ul>
);
}
이는 서버가 종종 클라이언트 코드와의 하위 호환성을 깨뜨리는 경우에 특히 유용합니다.
이 기법은 "Adapter" 패턴의 특수한 경우라고 할 수 있습니다
UI가 동일한 데이터에 대해 다른 표현을 가지고 있는 경우에도 셀렉터를 사용합니다.
예를 들어, 제품에 장바구니에 포함되었는지 여부를 나타낼 수 있습니다.
// We use the existing server data,
// but create a different structure:
function toClientShowcase(products, cart) {
return products.map((product) => ({
...product,
inCart: cart.some(({ productId }) => productId === product.id),
}));
}
Showcase 컴포넌트는 서버 응답에 대해 알 필요가 없습니다.
셀렉터의 결과를 입력으로 사용합니다.
function Showcase({ items }) {
return (
<ul>
{items.map(({ product, inCart }) => {
<li key={product.id}>
{product.name} <input type="checkbox" checked={inCart} disabled />
</li>;
})}
</ul>
);
}
- 컴포넌트는 렌더링 관심사만 다룹니다.
- 컴포넌트의 인터페이스 덕택에 다른 요소와의 결합도가 감소합니다.
- 컴포넌트를 쉽게 교체할 수 있습니다.
- 셀렉터는 데이터를 변환합니다.
- 셀렉터는 전부 일반 함수이기 때문에, 테스트를 위해 복잡한 함후가 필요하지 않습니다.
- 일반적으로 데이터 변환을 적용하고 셀렉터를 사용하는 것이 좋습니다.
'FrontEnd' 카테고리의 다른 글
웹 컴포넌트(Web Component) 딥 다이브 (0) | 2023.04.08 |
---|---|
[번역] 자바스크립트 리팩토링과 추상화 (0) | 2023.04.05 |
[번역] 리액트 Compound Component 잘 사용하기 (0) | 2023.04.04 |
Leetcode 241: Different Ways to Add Parentheses(수식 모든 경우의 수 계산하기)[BFS,DFS] (0) | 2023.04.03 |
프론트엔드 클린 아키텍처 with React (0) | 2023.04.02 |