본문 바로가기

FrontEnd

[번역] 자바스크립트 함수형 파이프라인으로 리팩토링

반응형

키워드 : javascript, 자바스크립트, 함수형 프로그래밍, 리팩토링, functional programming, refactoring

원문 : https://refactor-like-a-superhero.vercel.app/en/chapters/09-functional-pipeline

 

Functional Pipeline | Refactor Like a Superhero

Book about how to refactor code efficiently and without pain.

refactor-like-a-superhero.vercel.app

함수형 파이프라인

코드를 비즈니스 로직과 최대한 유사하게 보이게 프로그래밍 하는 방법

데이터 변환

비즈니스 워크플로우는 데이터 변환입니다.
예를 들어 주문에 할인을 적용하는 것은 한 데이터 상태에서 다른 데이터 상태로의 전환으로 표현될 수 있습니다.

“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]

잘 구성된 코드에서 워크플로는 선형으로 보이며 그 안의 데이터는 한 번에 하나씩 여러 단계를 거칩니다.
데이터의 최종 상태는 워크플로에서 원하는 결과입니다.

Data goes through a chain of different states. The output is the desired workflow result

이러한 종류의 코드 구성을 함수형 파이프라인이라고 합니다.
비즈니스 로직을 리팩토링할 때 코드의 워크플로를 보다 명확하고 분명하게 만들 수 있습니다.

데이터 상태

"Domain Modeling Made Functional"에서,
Scott Wlaschin은 비즈니스 워크플로 및 해당 데이터를 기반으로 프로그램을 설계하는 방법을 설명합니다.
제안은 워크플로 단계를 별도의 기능으로 표현하는 것입니다.
이 아이디어를 리팩토링의 기초로 사용할 수 있습니다.
 
이를 위해선 먼저 데이터가 통과하는 모든 단계를 이해해야 합니다.
이러한 단계는 작업 흐름을 나누는 방법과 코드에서 고려해야 할 사항을 이해하는 데 도움이 됩니다.
 
온라인 상점을 예로 들어 이 접근 방식을 분석해 보겠습니다.
makeOrder 함수가 주문을 컴파일하고 할인 쿠폰을 적용하고 프로모션 제품을 추가한다고 가정합니다.
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 };
}​
makeOrder 함수는 다음과 같습니다.
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;
}
변경 후 워크플로우 단계는 별도의 함수로 캡슐화됩니다.
이러한 함수는 order 객체만 변경하여 유효하게 유지합니다.
makeOrder 함수는 더 이상 맘대로 order 데이터를 변경하지 않고 해당 함수만 호출합니다.
잘못된 주문 가능성을 줄이고 데이터 변환 테스트를 더 쉽게 만듭니다.
이제 makeOrder 코드는 우리가 시작한 워크플로 단계 목록과 유사합니다.
각 단계의 세부 사항은 해당 기능의 이름 뒤에 숨겨져 있습니다.
이름은 전체 단계를 설명하므로 코드를 더 쉽게 읽을 수 있습니다.
 
또한 워크플로에 새 단계를 추가할 때 이제 올바른 위치에 새 함수를 삽입하기만 하면 됩니다.
다른 변환 단계는 변경되지 않습니다.
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 {}
이제 함수 서명은 도메인에 대한 더 많은 지식을 전달하기 때문에 도메인을 더 정확하게 설명할 수 있습니다.
함수 restorePassword 및 verifyEmail은 해당 요구 사항 및 제약 조건에 대해 경고합니다.
isValidEmail 함수는 모든 이메일을 처리할 준비가 되었으며 확인이 필수가 아니라고 말합니다.

타입스크립트는 정적 타이핑을 사용하므로 zod나 branded type을 사용해야 할 수도 있습니다
하지만 타입만으로도 설명적인 효과를 보여줍니다.


데이터 검증

함수형 파이프라인은 선형적 코드 실행에 의존합니다.
워크플로 내의 단계는 차례대로 실행되며 변환 체인을 통해 데이터를 아래로 전달합니다.

 

이 아이디어가 작동하려면 워크플로 내의 데이터가 안전하고 파이프라인을 중단하지 않아야 합니다.
그러나 "외부" 데이터가 안전하다고 보장할 수는 없습니다.
따라서 코드에서 데이터를 신뢰할 수 있는 영역과 신뢰할 수 없는 영역을 구분하려고 합니다.

이상적으로 비즈니스 워크플로우는 데이터가 검증된 안전한 "섬"이 되어야 합니다.

도메인 주도 설계에서는 해당 개념을 Bounded Context로 정의합니다.
DDD에 따르면 데이터 유효성 검사는 컨텍스트 입력과 같은 컨텍스트 경계에서 더 편리합니다.
이 경우 컨텍스트 "내부"에서 데이터가 이미 검증되고 안전하기 때문에 추가 검사가 필요하지 않습니다.

All validation occurs at the boundaries; the data inside the context is considered valid and safe

코드에서 이 규칙을 사용하여 런타임에 불필요한 데이터 검사를 제거할 수 있습니다.
워크플로 시작 시 데이터의 유효성을 검사하면 이후에 요구 사항을 충족한다고 가정할 수 있습니다.

CartProducts 컴포넌트 렌더링 함수 내 속성 검증 대신
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>
  );
}
따라서 API 응답이 변경되면 해당 데이터를 사용하는 모든 컴포넌트를 업데이트할 필요가 없습니다. 셀렉터 함수만 업데이트하면 됩니다.
이는 서버가 종종 클라이언트 코드와의 하위 호환성을 깨뜨리는 경우에 특히 유용합니다.
이 기법은 "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>
  );
}
이 접근 방식은 코드 간의 책임을 분리하는 데 도움이 됩니다.
  • 컴포넌트는 렌더링 관심사만 다룹니다.
    • 컴포넌트의 인터페이스 덕택에 다른 요소와의 결합도가 감소합니다.
    • 컴포넌트를 쉽게 교체할 수 있습니다.
  • 셀렉터는 데이터를 변환합니다.
    • 셀렉터는 전부 일반 함수이기 때문에, 테스트를 위해 복잡한 함후가 필요하지 않습니다.
    • 일반적으로 데이터 변환을 적용하고 셀렉터를 사용하는 것이 좋습니다.

 

지금까지 예제들의 데이터는 "원시 서버 데이터" → "유효한 데이터" → "작업 준비" → "UI에 표시됨" 상태 체인을 거칩니다.
함수형 파이프라인은 모든 작업을 유사한 체인으로 설명하는 데 도움이 됩니다.
체인은 우리가 코드로 표현하는 워크플로의 명확한 정신적 모델을 구축하는 데 도움이 되므로
코드들의 분해를 쉽게 해줍니다.

 

반응형