프로그래밍을 처음 배울 때 문제 분해는 큰 문제를 작은 문제로 나누는 과정이라고 배운다.
하나의 긴 함수에 모든 코드를 작성하는 대신 작은 함수로 나누는 방식이다.
function applyCoupon(coupon, order) {
validateCoupon(coupon);
const discount = calculateDiscount(
coupon,
order
);
return createDiscountedOrder(
order,
discount
);
}
쿠폰 검증, 할인 계산과 주문 결과 생성을 각각의 함수로 분리했다.
코드의 흐름을 읽고 각 기능을 따로 이해하는 데 유용한 구조다. 입문 단계에서 함수의 책임을 나누는 연습으로도 적절하다.
하지만 실제 서비스에서 함수의 개수를 늘렸다고 문제가 제대로 분해된 것은 아니다.
크리스가 결제 화면에서 쿠폰을 사용한다고 생각해보자.
이 문제를 단순히 여러 함수로 나누면 코드의 길이는 짧아질 수 있다. 그러나 각 판단의 입력과 결과, 실행 순서와 실패 책임이 불명확하면 복잡성은 그대로 남는다.
실제 서비스에서 문제 분해는 긴 코드를 여러 함수로 자르는 일이 아니라, 복잡한 요구사항을 입력과 출력이 명확하고 독립적으로 판단하고 검증할 수 있는 작은 결정과 책임으로 바꾸는 일이다.
쿠폰 적용 기능을 하나의 함수에 작성할 수 있다.
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);
문제 분해는 코드 파일을 나누는 것만이 아니다.
외부 세계의 데이터를 어디까지 검증하고, 어떤 형태로 다음 책임에 전달할지 경계를 만드는 일이다.
크리스의 브라우저에는 다음과 같은 쿠폰이 표시되어 있을 수 있다.
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;
}
이 코드는 계산과 변경을 섞는다.
같은 입력에서 예상 결과만 확인하고 싶은 호출자도 쿠폰 상태 변경의 영향을 받는다.
문제를 분해할 때는 다음 두 질문을 구분해야 한다.
계산은 가능한 결과를 만든다. 상태 변경은 권한과 최신 상태를 다시 확인한 뒤 실제 기록을 바꾼다.
쿠폰 사용의 모든 단계를 임의의 순서로 실행할 수는 없다.
쿠폰을 조회하기 전에 상태를 확인할 수 없고, 할인 금액을 계산하기 전에 주문의 할인 대상 금액을 알아야 한다. 상태 변경에 성공하기 전에 성공 응답을 반환해서도 안 된다.
전체 흐름은 다음과 같이 구성할 수 있다.
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 요청 테스트 |
| 조건부 쿠폰 차감 | 데이터베이스 통합 테스트 |
| 동시 사용 | 동시 요청 테스트 |
| 알림 재시도 | 작업 처리 테스트 |
| 전체 결제 흐름 | 사용자 흐름 테스트 |
문제를 분해하면 테스트하기 쉬워지는 것이 아니라, 각 실패를 어느 범위에서 검증해야 하는지 분명해진다.
복잡한 서비스 기능을 구현하기 전에 다음 질문으로 문제를 나눌 수 있다.
true와 false만으로 실패 의미를 잃고 있지는 않은가?이 질문은 무조건 많은 계층과 함수를 만들기 위한 목록이 아니다.
변경 가능성, 실패의 영향과 실제 처리 비용을 기준으로 필요한 경계만 만드는 데 사용해야 한다.
큰 문제를 작은 함수로 나누는 연습은 중요하다.
긴 코드를 읽기 쉽게 만들고 반복되는 로직을 재사용하는 데 도움이 된다.
하지만 실제 서비스의 문제는 코드 줄 수만으로 복잡해지지 않는다.
외부 입력과 신뢰 데이터가 섞이고, 여러 비즈니스 규칙이 서로 다른 이유로 변경되며, 계산과 상태 변경이 연결되고, 일부 작업만 실패할 수 있기 때문에 복잡해진다.
따라서 문제를 분해할 때는 다음 순서로 생각할 수 있다.
문제 분해는 큰 함수를 여러 작은 함수로 바꾸는 리팩터링이 아니다. 복잡한 현실의 요구사항을 입력과 출력, 판단 기준과 실패 책임이 명확한 작은 결정으로 바꾸고, 그 결정들의 올바른 실행 관계를 설계하는 일이다.
좋은 문제 분해는 코드의 조각을 많이 만드는 것이 아니다.
무엇을 판단하는지, 어떤 데이터가 필요한지, 실패하면 어디까지 영향을 주는지, 누가 그 결과를 책임지는지 설명할 수 있게 만드는 것이다.
다음 글에서는 정답을 계산하는 것과 그 결과가 정답이라고 설명하는 일이 왜 다른지, 알고리즘의 정확성을 불변 조건과 근거로 검증하는 방법을 살펴본다.