문제 분해는 큰 함수를 작은 함수로 나누는 일이 아니다

vx_developer·2026년 9월 12일

코테보다가

목록 보기
11/26
post-thumbnail

프로그래밍을 처음 배울 때 문제 분해는 큰 문제를 작은 문제로 나누는 과정이라고 배운다.

하나의 긴 함수에 모든 코드를 작성하는 대신 작은 함수로 나누는 방식이다.

function applyCoupon(coupon, order) {
  validateCoupon(coupon);
  const discount = calculateDiscount(
    coupon,
    order
  );
  return createDiscountedOrder(
    order,
    discount
  );
}

쿠폰 검증, 할인 계산과 주문 결과 생성을 각각의 함수로 분리했다.

코드의 흐름을 읽고 각 기능을 따로 이해하는 데 유용한 구조다. 입문 단계에서 함수의 책임을 나누는 연습으로도 적절하다.

하지만 실제 서비스에서 함수의 개수를 늘렸다고 문제가 제대로 분해된 것은 아니다.

크리스가 결제 화면에서 쿠폰을 사용한다고 생각해보자.

  • 요청한 사용자가 쿠폰의 실제 소유자인가?
  • 쿠폰 ID와 주문 ID는 어디에서 오는가?
  • 주문 금액은 클라이언트가 보낸 값을 믿어도 되는가?
  • 쿠폰이 활성 상태인지 누가 확인하는가?
  • 만료 여부는 어떤 시간을 기준으로 판단하는가?
  • 상품 할인과 배송비 할인은 같은 방식으로 계산하는가?
  • 쿠폰 사용과 주문 할인은 함께 성공해야 하는가?
  • 알림 전송에 실패하면 쿠폰 사용도 취소해야 하는가?
  • 두 요청이 동시에 같은 쿠폰을 사용하면 어떻게 되는가?
  • 같은 정책을 미리보기와 결제 확정에서 어떻게 재사용하는가?

이 문제를 단순히 여러 함수로 나누면 코드의 길이는 짧아질 수 있다. 그러나 각 판단의 입력과 결과, 실행 순서와 실패 책임이 불명확하면 복잡성은 그대로 남는다.

실제 서비스에서 문제 분해는 긴 코드를 여러 함수로 자르는 일이 아니라, 복잡한 요구사항을 입력과 출력이 명확하고 독립적으로 판단하고 검증할 수 있는 작은 결정과 책임으로 바꾸는 일이다.

코드를 나누기 전에 해결해야 할 질문을 나눠야 한다

쿠폰 적용 기능을 하나의 함수에 작성할 수 있다.

async function applyCoupon(request) {
  const coupon =
    await database.findCoupon(
      request.body.couponId
    );

  const order =
    await database.findOrder(
      request.body.orderId
    );

  if (
    coupon.status === "ACTIVE" &&
    coupon.userId === request.body.userId &&
    new Date() < coupon.expiresAt &&
    order.amount >= coupon.minimumAmount
  ) {
    const discount =
      order.amount * coupon.discountRate;

    await database.updateCoupon(
      coupon.id,
      "USED"
    );

    await database.updateOrder(
      order.id,
      order.amount - discount
    );

    await sendNotification(request.body.userId);

    return {
      success: true,
      discount,
    };
  }

  return {
    success: false,
  };
}

이 코드는 쿠폰을 조회하고, 사용 조건을 확인하고, 할인을 계산하고, 상태를 변경하고, 알림을 보낸다.

동작하는 것처럼 보이지만 서로 다른 문제가 한곳에 섞여 있다.

  • 외부 요청의 형식 검증
  • 로그인한 사용자 확인
  • 쿠폰과 주문 조회
  • 쿠폰 사용 권한 판단
  • 사용 가능 여부 판단
  • 할인 금액 계산
  • 쿠폰 상태와 주문 상태 변경
  • 알림 전송
  • 사용자 응답 생성

이 함수 안에 작은 함수를 몇 개 추가하는 것만으로는 충분하지 않다.

먼저 각 단계가 어떤 질문에 답하는지 나눠야 한다.

결정답해야 하는 질문
요청 검증쿠폰 ID와 주문 ID를 해석할 수 있는가
사용자 확인누가 쿠폰 사용을 요청했는가
데이터 조회판단에 필요한 최신 쿠폰과 주문은 무엇인가
권한 판단요청자가 이 쿠폰과 주문을 사용할 수 있는가
자격 판단쿠폰이 현재 사용 가능한가
할인 계산현재 주문에서 얼마를 할인해야 하는가
상태 변경어떤 데이터를 함께 변경해야 하는가
후속 작업알림과 로그는 언제 처리해야 하는가
결과 변환사용자에게 어떤 결과를 반환해야 하는가

문제 분해의 출발점은 함수 이름이 아니라 이 질문들이다.

작은 함수가 같은 데이터를 모두 알아야 한다면 아직 충분히 나뉘지 않았다

큰 함수를 다음과 같이 나눌 수 있다.

async function applyCoupon(request) {
  const context = await loadEverything(request);

  validateEverything(context);

  const result = calculateEverything(context);

  await saveEverything(context, result);

  return buildResponse(context, result);
}

함수 이름은 여러 개지만 각 함수가 거대한 context 객체 전체에 의존한다.

validateEverything이 사용자, 주문, 쿠폰, 서버 시간과 요청 헤더를 모두 읽고, calculateEverything도 같은 데이터를 다시 읽는다면 각 책임의 경계는 여전히 불명확하다.

한 부분을 수정할 때 다른 부분에서 어떤 값을 사용하는지 모두 확인해야 한다.

각 결정에 실제로 필요한 입력만 전달하는 편이 낫다.

const ownershipResult = validateCouponOwner({
  couponOwnerId: coupon.recipientId,
  requesterId: currentUser.id,
});

const availabilityResult =
  evaluateCouponAvailability({
    status: coupon.status,
    remainingUses: coupon.remainingUses,
    expiresAt: coupon.expiresAt,
    now: serverNow,
  });

validateCouponOwner는 쿠폰 소유 관계만 판단한다.

evaluateCouponAvailability는 쿠폰 상태, 남은 횟수와 만료 시각만 판단한다.

이제 각 함수가 답하는 질문과 필요한 데이터가 분명해진다.

함수가 작다는 사실보다 중요한 것은 그 함수가 알지 않아도 되는 정보를 받지 않는가이다.

요청 객체를 서비스 전체에 전달하면 외부 입력과 신뢰 데이터가 섞인다

API에서 받은 요청 객체를 여러 계층에 그대로 전달하는 코드를 생각해보자.

await couponService.redeem(request);

서비스 로직 안에서는 다음 값이 모두 같은 수준의 입력처럼 보인다.

request.body.userId;
request.body.orderAmount;
request.body.couponId;
request.headers["idempotency-key"];

하지만 신뢰도는 서로 다르다.

  • couponId는 사용자가 선택한 대상이다.
  • userId는 클라이언트가 주장한 값이므로 신뢰할 수 없다.
  • orderAmount는 서버가 다시 계산해야 한다.
  • 중복 요청 식별자는 형식과 사용 범위를 검증해야 한다.

API 경계에서 외부 요청을 검증된 명령으로 바꿀 수 있다.

const command = {
  couponId: parseCouponId(
    request.params.couponId
  ),
  orderId: parseOrderId(
    request.body.orderId
  ),
  requesterId: session.user.id,
  requestId: parseRequestId(
    request.headers["idempotency-key"]
  ),
  requestedAt: clock.now(),
};

이 코드는 외부 요청에서 필요한 식별자를 검증하고, 요청자와 현재 시간은 서버가 신뢰할 수 있는 출처에서 가져온다.

서비스 로직은 HTTP 요청의 구조를 알 필요가 없다.

const result =
  await redeemCoupon(command);

문제 분해는 코드 파일을 나누는 것만이 아니다.

외부 세계의 데이터를 어디까지 검증하고, 어떤 형태로 다음 책임에 전달할지 경계를 만드는 일이다.

조회와 판단을 나누면 Source of Truth가 분명해진다

크리스의 브라우저에는 다음과 같은 쿠폰이 표시되어 있을 수 있다.

const displayedCoupon = {
  id: "coupon_123",
  status: "ACTIVE",
  remainingUses: 1,
  isExpired: false,
};

이 데이터를 그대로 서버에 보내 판단하면 화면이 마지막으로 조회한 상태에 의존하게 된다.

쿠폰은 이미 다른 요청에서 사용되었거나 그 사이 만료되었을 수 있다.

클라이언트는 사용하려는 대상만 전달해야 한다.

const command = {
  couponId: displayedCoupon.id,
  orderId,
};

서버는 최신 데이터를 조회한다.

const coupon =
  await couponRepository.findById(
    command.couponId
  );

const order =
  await orderRepository.findById(
    command.orderId
  );

쿠폰의 상태, 남은 횟수와 만료 시각은 데이터베이스의 최신 값이 Source of Truth가 된다.

반면 isExpired는 저장해야 할 원본 상태가 아니다.

const isExpired =
  command.requestedAt >= coupon.expiresAt;

만료 여부는 원본 데이터인 expiresAt과 서버가 결정한 현재 시간으로 계산한다.

데이터 조회와 비즈니스 판단을 분리하면 무엇을 저장하고 무엇을 계산해야 하는지도 선명해진다.

권한과 사용 가능 여부는 서로 다른 질문이다

쿠폰을 사용할 수 없는 이유를 하나의 조건으로 묶을 수 있다.

if (
  coupon.recipientId !== userId ||
  coupon.status !== "ACTIVE" ||
  coupon.remainingUses <= 0 ||
  now >= coupon.expiresAt
) {
  return false;
}

결과는 간단하지만 무엇이 실패했는지 알기 어렵다.

특히 권한 문제와 비즈니스 상태 문제는 책임이 다르다.

function validateCouponAccess({
  coupon,
  requesterId,
}) {
  if (coupon.recipientId !== requesterId) {
    return {
      allowed: false,
      reason: "COUPON_NOT_FOUND",
    };
  }

  return {
    allowed: true,
  };
}

이 함수는 요청자가 쿠폰에 접근할 수 있는지만 판단한다.

권한이 없는 사용자에게 쿠폰이 존재한다는 사실을 노출하지 않기 위해 외부 결과는 COUPON_NOT_FOUND로 표현할 수 있다.

사용 가능 여부는 별도로 판단한다.

function evaluateCouponAvailability({
  coupon,
  now,
}) {
  if (coupon.status !== "ACTIVE") {
    return {
      available: false,
      reason: "COUPON_NOT_ACTIVE",
    };
  }

  if (coupon.remainingUses <= 0) {
    return {
      available: false,
      reason: "NO_REMAINING_USES",
    };
  }

  if (now >= coupon.expiresAt) {
    return {
      available: false,
      reason: "COUPON_EXPIRED",
    };
  }

  return {
    available: true,
  };
}

이 함수는 접근 권한을 이미 통과한 쿠폰의 현재 상태만 평가한다.

두 판단을 나누면 다음 차이가 생긴다.

  • 권한 검증은 정보 노출을 통제한다.
  • 사용 가능 여부 검증은 쿠폰 정책을 설명한다.
  • 각 실패 결과를 다른 방식으로 기록하고 응답할 수 있다.
  • 정책 변경 시 영향을 받는 부분이 분명해진다.

여러 조건이 하나의 if 안에 들어갈 수 있다는 사실과 하나의 책임이라는 사실은 다르다.

계산과 상태 변경을 분리해야 같은 규칙을 안전하게 재사용할 수 있다

결제 화면에서는 쿠폰을 적용하기 전에 예상 할인 금액을 보여줘야 한다.

const preview =
  calculateCouponDiscount({
    coupon,
    order,
  });

이 함수는 주어진 쿠폰과 주문으로 할인 금액을 계산할 뿐 데이터베이스를 변경하지 않는다.

function calculateCouponDiscount({
  coupon,
  order,
}) {
  const rawDiscount = Math.floor(
    order.eligibleAmount *
      coupon.discountRate
  );

  return Math.min(
    rawDiscount,
    coupon.maxDiscount,
    order.eligibleAmount
  );
}

계산 결과는 최대 할인 금액과 할인 대상 주문 금액을 넘지 않는다.

이 순수한 계산은 미리보기, 결제 확정 전 검증과 테스트에서 재사용할 수 있다.

반대로 계산 함수 안에서 쿠폰 상태까지 변경하면 단순히 할인 금액을 미리 확인하는 것만으로 쿠폰이 사용될 수 있다.

function calculateCouponDiscount(
  coupon,
  order
) {
  coupon.status = "USED";

  return order.amount * coupon.discountRate;
}

이 코드는 계산과 변경을 섞는다.

같은 입력에서 예상 결과만 확인하고 싶은 호출자도 쿠폰 상태 변경의 영향을 받는다.

문제를 분해할 때는 다음 두 질문을 구분해야 한다.

  1. 어떤 결과가 나와야 하는가?
  2. 그 결과를 서비스 상태에 반영해도 되는가?

계산은 가능한 결과를 만든다. 상태 변경은 권한과 최신 상태를 다시 확인한 뒤 실제 기록을 바꾼다.

순서가 필요한 결정과 독립적인 결정을 구분해야 한다

쿠폰 사용의 모든 단계를 임의의 순서로 실행할 수는 없다.

쿠폰을 조회하기 전에 상태를 확인할 수 없고, 할인 금액을 계산하기 전에 주문의 할인 대상 금액을 알아야 한다. 상태 변경에 성공하기 전에 성공 응답을 반환해서도 안 된다.

전체 흐름은 다음과 같이 구성할 수 있다.

flowchart TD
    A["검증된 쿠폰 사용 명령"]
    --> B["최신 쿠폰과 주문 조회"]
    B --> C["권한과 사용 조건 판단"]
    C --> D["할인 금액 계산"]
    D --> E["상태 변경 트랜잭션"]
    E --> F["결과 응답"]
    E --> G["알림 후속 작업"]

이 흐름에서 앞 단계의 출력이 다음 단계의 입력이 된다.

그러나 모든 판단이 서로 강하게 결합될 필요는 없다.

예를 들어 쿠폰의 만료 여부와 주문 금액 조건은 필요한 데이터가 준비되었다면 별도의 규칙으로 평가할 수 있다.

const validations = [
  validateCouponStatus(context),
  validateExpiry(context),
  validateMinimumAmount(context),
  validateApplicableProducts(context),
];

각 규칙은 독립된 결과를 반환한다.

const failure = validations.find(
  (result) => !result.valid
);

if (failure) {
  return failure;
}

이 코드는 규칙 중 하나가 실패하면 그 결과를 반환한다.

실행 순서가 보안이나 성능에 영향을 줄 수 있으므로 무조건 병렬로 처리하거나 순서를 없애서는 안 된다. 다만 어떤 판단이 다른 판단의 결과에 의존하는지 명확히 하면 불필요한 결합을 줄일 수 있다.

문제 분해는 모든 작업을 서로 독립적으로 만드는 것이 아니다.

반드시 이어져야 하는 의존성과 독립적으로 처리할 수 있는 판단을 구분하는 일이다.

함수의 반환값은 다음 결정을 가능하게 해야 한다

검증 함수가 불리언만 반환할 수 있다.

function validateCoupon(coupon, order) {
  return (
    coupon.status === "ACTIVE" &&
    order.amount >= coupon.minimumAmount
  );
}

호출자는 false가 무엇을 의미하는지 알 수 없다.

if (!validateCoupon(coupon, order)) {
  return {
    success: false,
  };
}

사용자는 주문 금액을 늘리면 되는지, 다른 쿠폰을 선택해야 하는지 알 수 없다.

각 결정은 다음 단계가 사용할 수 있는 결과를 반환해야 한다.

function validateMinimumAmount({
  orderAmount,
  minimumAmount,
}) {
  if (orderAmount < minimumAmount) {
    return {
      valid: false,
      reason: "MINIMUM_AMOUNT_NOT_MET",
      requiredAmount: minimumAmount,
    };
  }

  return {
    valid: true,
  };
}

이 결과에는 실패 여부뿐 아니라 실패의 의미와 필요한 정보가 들어 있다.

API 계층은 이를 사용자 응답으로 변환할 수 있다.

return response.status(409).json({
  code: result.reason,
  requiredAmount: result.requiredAmount,
});

문제를 잘 분해하려면 작은 함수가 존재하는지만 보지 말아야 한다.

각 함수의 결과가 다음 책임자가 추가 추측 없이 판단할 수 있는 형태인지 확인해야 한다.

모든 규칙을 별도 데이터베이스 조회로 나누면 오히려 느려질 수 있다

책임을 나눈다는 이유로 각 검증 함수가 필요한 데이터를 따로 조회할 수도 있다.

await validateOwner(couponId, userId);
await validateStatus(couponId);
await validateExpiry(couponId);
await validateRemainingUses(couponId);

각 함수가 데이터베이스에서 쿠폰을 다시 조회한다면 같은 데이터를 네 번 읽을 수 있다.

코드는 작은 함수로 나뉘었지만 서비스 전체의 처리 비용은 커진다. 조회 사이에 쿠폰 상태가 바뀌면 서로 다른 시점의 데이터를 기준으로 판단할 수도 있다.

필요한 데이터를 한 번 조회한 뒤 각 규칙에 전달하는 편이 자연스럽다.

const coupon =
  await couponRepository.findById(couponId);

const context = {
  coupon,
  requesterId,
  order,
  now: serverNow,
};

const accessResult =
  validateCouponAccess(context);

const availabilityResult =
  evaluateCouponAvailability(context);

이 코드는 데이터 조회와 규칙 평가의 책임을 구분하면서도 같은 쿠폰을 반복해서 조회하지 않는다.

문제 분해는 네트워크 요청, 데이터베이스 조회와 트랜잭션 같은 실제 비용을 무시해서는 안 된다.

논리적인 책임은 나누되 필요한 데이터를 효율적으로 공유할 방법도 설계해야 한다.

함께 성공해야 하는 상태 변경은 다시 하나로 묶어야 한다

쿠폰을 사용하면 다음 상태가 바뀔 수 있다.

  • 쿠폰의 남은 횟수가 감소한다.
  • 쿠폰 사용 기록이 생성된다.
  • 주문에 할인 금액이 반영된다.

각 작업의 코드는 별도 함수로 나눌 수 있다.

await decreaseCouponUses(couponId);
await createRedemption(redemption);
await applyOrderDiscount(orderId, discount);

함수는 분리되어 있지만 첫 번째 작업만 성공하고 두 번째 작업이 실패할 수 있다.

그러면 쿠폰 횟수는 줄었지만 사용 기록과 주문 할인은 없는 상태가 남는다.

논리적인 책임은 분리하더라도 함께 성공하거나 함께 실패해야 하는 작업은 하나의 트랜잭션 경계로 묶어야 한다.

await database.transaction(
  async (transaction) => {
    const updatedCoupon =
      await redeemCouponIfAvailable(
        command,
        transaction
      );

    await createRedemption(
      {
        couponId: updatedCoupon.id,
        orderId: order.id,
        discountAmount,
      },
      transaction
    );

    await applyOrderDiscount(
      {
        orderId: order.id,
        discountAmount,
      },
      transaction
    );
  }
);

각 함수는 자신의 상태 변경을 담당하지만 세 작업의 성공 여부는 하나로 관리된다.

여기에서 중요한 설계 판단은 함수 개수보다 트랜잭션의 범위다.

분해는 무조건 떼어놓는 일이 아니다. 독립적으로 판단할 것은 나누고, 일관성을 위해 함께 처리해야 하는 것은 명확한 경계 안에 다시 묶는 일이다.

핵심 처리와 후속 작업은 실패 책임이 다르다

쿠폰 사용에 성공하면 크리스에게 알림을 보낼 수 있다.

await redeemCoupon(command);
await notificationService.sendCouponUsed(
  currentUser.id
);

알림 서비스가 일시적으로 실패했다고 쿠폰 사용과 주문 할인을 모두 취소해야 할까?

대부분의 경우 쿠폰 사용은 유지하고 알림을 나중에 다시 보내는 편이 자연스럽다.

핵심 상태 변경과 후속 작업의 실패 책임이 다르기 때문이다.

const result =
  await redeemCoupon(command);

await notificationQueue.enqueue({
  type: "COUPON_REDEEMED",
  userId: currentUser.id,
  redemptionId: result.redemptionId,
});

쿠폰 사용은 트랜잭션 안에서 확정하고, 알림은 별도의 작업으로 기록한다.

알림 전송에 실패하면 작업 대기열에서 재시도할 수 있다.

작업실패했을 때의 판단
쿠폰 권한 검증상태를 변경하지 않고 요청 실패
할인 계산상태를 변경하지 않고 요청 실패
쿠폰 차감주문 할인과 함께 실패
사용 기록 생성쿠폰 차감과 함께 실패
주문 할인 반영관련 변경과 함께 실패
알림 전송핵심 처리는 유지하고 재시도
운영 통계 집계핵심 처리는 유지하고 나중에 복구

문제 분해는 기능을 구성하는 작업 목록을 만드는 데서 끝나지 않는다.

각 작업이 실패했을 때 전체 결과에 어떤 영향을 줘야 하는지 결정해야 한다.

변경 이유가 다른 규칙은 분리해야 한다

쿠폰 정책은 시간이 지나면서 바뀐다.

  • 최소 주문 금액이 변경될 수 있다.
  • 최대 할인 금액이 변경될 수 있다.
  • 특정 상품 제외 규칙이 추가될 수 있다.
  • 회원 등급별 혜택이 달라질 수 있다.
  • 쿠폰 중복 사용 정책이 바뀔 수 있다.

모든 규칙이 하나의 조건문에 들어 있으면 하나를 수정할 때 다른 조건까지 함께 확인해야 한다.

if (
  coupon.status === "ACTIVE" &&
  order.amount >= coupon.minimumAmount &&
  coupon.recipientId === user.id &&
  !coupon.excludedProductIds.includes(
    order.productId
  ) &&
  user.membershipLevel >= coupon.requiredLevel
) {
  // 쿠폰 적용
}

조건은 한 번에 읽을 수 있지만 서로 다른 변경 이유가 섞여 있다.

규칙별로 책임을 구분할 수 있다.

const couponRules = [
  validateCouponStatus,
  validateMinimumAmount,
  validateApplicableProducts,
  validateMembershipLevel,
];

각 규칙은 하나의 정책 질문에 답한다.

새로운 회원 등급 정책이 추가되면 소유권이나 만료 규칙까지 수정할 필요가 없다.

좋은 문제 분해는 현재 코드가 짧아 보이는지를 기준으로 하지 않는다.

서로 다른 이유로 변경되는 규칙이 서로 영향을 적게 주도록 경계를 만드는 것이다.

분해된 결정은 테스트할 수 있어야 한다

하나의 큰 함수에서는 실패 원인을 재현하기 위해 데이터베이스, 인증과 알림 서비스를 모두 준비해야 할 수 있다.

결정을 분리하면 각각의 규칙을 작은 입력으로 검증할 수 있다.

it("최소 주문 금액이 부족하면 거절한다", () => {
  const result = validateMinimumAmount({
    orderAmount: 9000,
    minimumAmount: 10000,
  });

  expect(result).toEqual({
    valid: false,
    reason: "MINIMUM_AMOUNT_NOT_MET",
    requiredAmount: 10000,
  });
});

이 테스트는 데이터베이스나 HTTP 서버 없이 최소 주문 금액 정책만 확인한다.

상태 변경은 실제 저장소를 사용하는 통합 테스트로 확인해야 한다.

const results = await Promise.all([
  redeemCoupon(command),
  redeemCoupon(command),
]);

expect(
  results.filter((result) => result.ok)
).toHaveLength(1);

이 테스트는 남은 횟수가 1회인 쿠폰에 두 요청이 동시에 들어와도 하나만 성공하는지 확인한다.

분해된 책임마다 적절한 검증 방식이 다르다.

책임적절한 검증
할인 계산순수 함수 단위 테스트
만료 경계고정된 시간을 사용하는 단위 테스트
권한 판단서비스 규칙 테스트
입력 검증API 요청 테스트
조건부 쿠폰 차감데이터베이스 통합 테스트
동시 사용동시 요청 테스트
알림 재시도작업 처리 테스트
전체 결제 흐름사용자 흐름 테스트

문제를 분해하면 테스트하기 쉬워지는 것이 아니라, 각 실패를 어느 범위에서 검증해야 하는지 분명해진다.

문제를 분해할 때 물어봐야 할 질문

복잡한 서비스 기능을 구현하기 전에 다음 질문으로 문제를 나눌 수 있다.

  1. 사용자가 최종적으로 얻으려는 결과는 무엇인가?
  2. 그 결과를 만들기 위해 어떤 결정이 필요한가?
  3. 각 결정은 정확히 하나의 질문에 답하는가?
  4. 각 결정에 반드시 필요한 입력은 무엇인가?
  5. 알지 않아도 되는 데이터를 함께 전달하고 있지는 않은가?
  6. 외부 요청을 서비스 로직에 그대로 전달하고 있지는 않은가?
  7. 외부 입력은 어느 경계에서 검증된 명령으로 바뀌는가?
  8. 인증, 권한과 비즈니스 검증을 구분했는가?
  9. 최종 판단의 Source of Truth는 어디에 있는가?
  10. 원본 데이터와 계산된 데이터를 구분했는가?
  11. 데이터 조회와 규칙 판단을 분리했는가?
  12. 같은 데이터를 여러 함수에서 반복 조회하고 있지는 않은가?
  13. 계산과 상태 변경을 분리했는가?
  14. 각 결정의 결과가 다음 단계에서 사용할 수 있는 형태인가?
  15. 단순한 true와 false만으로 실패 의미를 잃고 있지는 않은가?
  16. 반드시 순서대로 실행해야 하는 단계는 무엇인가?
  17. 서로 독립적으로 판단할 수 있는 규칙은 무엇인가?
  18. 함께 성공하거나 함께 실패해야 하는 상태 변경은 무엇인가?
  19. 트랜잭션의 시작과 끝은 어디인가?
  20. 핵심 처리와 나중에 재시도할 후속 작업을 구분했는가?
  21. 서로 다른 이유로 변경되는 정책이 한곳에 섞여 있지는 않은가?
  22. 각 책임을 독립적으로 테스트할 수 있는가?
  23. 함수를 나눈 결과 데이터베이스나 네트워크 호출이 불필요하게 늘어나지는 않았는가?
  24. 전체 흐름을 코드 없이도 입력, 결정, 상태 변경과 출력으로 설명할 수 있는가?

이 질문은 무조건 많은 계층과 함수를 만들기 위한 목록이 아니다.

변경 가능성, 실패의 영향과 실제 처리 비용을 기준으로 필요한 경계만 만드는 데 사용해야 한다.

문제를 잘 나누면 해결 순서와 책임이 보인다

큰 문제를 작은 함수로 나누는 연습은 중요하다.

긴 코드를 읽기 쉽게 만들고 반복되는 로직을 재사용하는 데 도움이 된다.

하지만 실제 서비스의 문제는 코드 줄 수만으로 복잡해지지 않는다.

외부 입력과 신뢰 데이터가 섞이고, 여러 비즈니스 규칙이 서로 다른 이유로 변경되며, 계산과 상태 변경이 연결되고, 일부 작업만 실패할 수 있기 때문에 복잡해진다.

따라서 문제를 분해할 때는 다음 순서로 생각할 수 있다.

  1. 사용자가 원하는 최종 결과를 정의한다.
  2. 결과를 만들기 위해 필요한 결정을 찾는다.
  3. 각 결정의 입력과 출력을 명확히 한다.
  4. 외부 입력과 신뢰할 수 있는 데이터를 구분한다.
  5. 조회, 판단, 계산과 상태 변경의 책임을 나눈다.
  6. 반드시 함께 성공해야 하는 작업을 하나의 경계로 묶는다.
  7. 나중에 다시 처리할 수 있는 후속 작업을 분리한다.
  8. 각 책임이 변경되고 실패하는 이유를 확인한다.
  9. 각 결정을 가장 작은 적절한 범위에서 검증한다.
  10. 분해로 인해 실제 조회와 통신 비용이 증가하지 않는지 측정한다.

문제 분해는 큰 함수를 여러 작은 함수로 바꾸는 리팩터링이 아니다. 복잡한 현실의 요구사항을 입력과 출력, 판단 기준과 실패 책임이 명확한 작은 결정으로 바꾸고, 그 결정들의 올바른 실행 관계를 설계하는 일이다.

좋은 문제 분해는 코드의 조각을 많이 만드는 것이 아니다.

무엇을 판단하는지, 어떤 데이터가 필요한지, 실패하면 어디까지 영향을 주는지, 누가 그 결과를 책임지는지 설명할 수 있게 만드는 것이다.

다음 글에서는 정답을 계산하는 것과 그 결과가 정답이라고 설명하는 일이 왜 다른지, 알고리즘의 정확성을 불변 조건과 근거로 검증하는 방법을 살펴본다.

profile
Vision eXperience Developer

0개의 댓글