알고리즘을 처음 배울 때는 입력을 받아 정답을 반환하는 코드를 작성한다.
쿠폰 목록에서 할인 금액이 가장 큰 쿠폰을 찾는다면 다음과 같이 구현할 수 있다.
function findBestCoupon(coupons) {
let bestCoupon = null;
for (const coupon of coupons) {
if (
bestCoupon === null ||
coupon.discountAmount >
bestCoupon.discountAmount
) {
bestCoupon = coupon;
}
}
return bestCoupon;
}
이 코드는 쿠폰을 하나씩 확인하면서 지금까지 발견한 가장 큰 할인 쿠폰을 기억한다.
입력과 출력이 명확한 문제에서 반복문의 동작을 이해하고 정답을 계산하는 방법을 배우기에는 유용한 설명이다.
하지만 실제 쿠폰 서비스에서는 함수가 어떤 쿠폰을 반환했다는 사실만으로 그 쿠폰이 정답이라고 판단할 수 없다.
실행 결과를 얻는 일과 그 결과가 올바르다고 설명하는 일은 서로 다른 문제다.
실제 서비스에서 정답임을 설명한다는 것은 몇 개의 실행 결과를 제시하는 일이 아니라, 허용된 모든 입력과 상태 변화에서 유지되어야 하는 조건을 밝히고 구현이 그 조건을 지키는 이유를 설명하는 일이다.
크리스가 결제할 주문에 가장 유리한 쿠폰을 추천한다고 생각해보자.
처음에는 쿠폰 객체에 저장된 discountValue가 가장 큰 항목을 선택할 수 있다.
function selectBestCoupon(coupons) {
return [...coupons].sort(
(first, second) =>
second.discountValue -
first.discountValue
)[0];
}
코드는 동작한다.
그러나 discountValue의 의미는 쿠폰 종류에 따라 다를 수 있다.
const coupons = [
{
id: "coupon_fixed",
type: "FIXED",
discountValue: 3000,
},
{
id: "coupon_percentage",
type: "PERCENTAGE",
discountValue: 20,
},
];
정액 쿠폰의 3000은 3,000원을 뜻하고, 비율 쿠폰의 20은 20%를 뜻한다.
서로 다른 단위의 숫자를 직접 비교하면 3000이 더 크지만, 실제로 어느 쿠폰이 유리한지는 주문 금액과 적용 조건에 따라 달라진다.
따라서 먼저 정답의 조건을 자연어로 정의해야 한다.
현재 사용자와 주문에 적용할 수 있는 모든 쿠폰의 실제 할인 금액을 계산하고, 할인 금액이 가장 큰 쿠폰을 선택한다.
이 문장도 아직 완전하지 않다.
할인 금액이 같을 때의 규칙을 추가해야 한다.
할인 금액이 같다면 만료 시각이 더 가까운 쿠폰을 선택하고, 만료 시각도 같다면 쿠폰 ID가 작은 항목을 선택한다.
이제 정답을 판단할 수 있는 기준이 생겼다.
| 구분 | 쿠폰 추천에서의 의미 |
|---|---|
| 입력 조건 | 주문과 쿠폰 데이터가 검증된 형식이어야 한다 |
| 후보 조건 | 현재 사용자와 주문에 적용 가능한 쿠폰이어야 한다 |
| 결과 조건 | 반환된 쿠폰은 모든 후보 중 실제 할인 금액이 가장 커야 한다 |
| 동점 조건 | 할인 금액이 같을 때도 항상 같은 규칙으로 선택해야 한다 |
| 빈 결과 | 적용 가능한 쿠폰이 없으면 null을 반환해야 한다 |
코드를 검증하려면 먼저 이 조건을 합의해야 한다.
요구사항이 불명확한 상태에서는 구현이 맞는지 틀리는지도 판정할 수 없다.
쿠폰의 실제 할인 금액은 주문 상태와 정책을 반영해 계산해야 한다.
function calculateDiscount(coupon, order) {
if (coupon.type === "FIXED") {
return Math.min(
coupon.discountValue,
order.eligibleAmount
);
}
const percentageDiscount = Math.floor(
order.eligibleAmount *
(coupon.discountValue / 100)
);
return Math.min(
percentageDiscount,
coupon.maxDiscount,
order.eligibleAmount
);
}
정액 쿠폰은 할인 대상 금액을 넘지 않도록 제한한다.
비율 쿠폰은 주문의 할인 대상 금액에 할인율을 적용한 뒤 소수점을 버리고, 최대 할인 금액과 주문 금액을 넘지 않도록 제한한다.
이 계산을 거쳐야 서로 다른 쿠폰을 같은 단위인 실제 할인 금액으로 비교할 수 있다.
하지만 모든 쿠폰의 할인 금액을 계산하기 전에 후보가 될 수 있는지도 확인해야 한다.
function isEligibleCoupon({
coupon,
order,
userId,
now,
}) {
return (
coupon.recipientId === userId &&
coupon.status === "ACTIVE" &&
coupon.remainingUses > 0 &&
now < coupon.expiresAt &&
order.eligibleAmount >=
coupon.minimumOrderAmount &&
coupon.applicableProductIds.includes(
order.productId
)
);
}
이 함수는 소유자, 상태, 남은 사용 횟수, 만료 시각, 최소 주문 금액과 대상 상품을 확인한다.
현실의 “사용할 수 있는 쿠폰”이라는 개념이 코드에서는 여러 입력과 상태에 대한 조건으로 바뀐 것이다.
이제 후보와 비교 기준을 함께 사용할 수 있다.
function selectBestCoupon({
coupons,
order,
userId,
now,
}) {
let best = null;
for (const coupon of coupons) {
if (
!isEligibleCoupon({
coupon,
order,
userId,
now,
})
) {
continue;
}
const candidate = {
coupon,
discountAmount:
calculateDiscount(coupon, order),
};
if (
best === null ||
isBetterCandidate(candidate, best)
) {
best = candidate;
}
}
return best;
}
각 쿠폰은 먼저 사용 가능 여부를 통과해야 한다.
그다음 현재 주문에서 얻을 수 있는 실제 할인 금액을 계산하고, 지금까지의 최선과 비교한다.
이 코드는 결과를 구한다. 그렇다면 이 결과가 항상 가장 좋은 쿠폰이라고 어떻게 설명할 수 있을까?
위 알고리즘의 핵심은 best가 무엇을 의미하는지 설명하는 데 있다.
반복문의 각 단계가 끝날 때 다음 조건이 유지되어야 한다.
지금까지 확인한 사용 가능 쿠폰 중
best는 정해진 비교 규칙에 따라 가장 좋은 후보다.
이처럼 실행 중에도 계속 유지되어야 하는 조건을 불변 조건이라고 한다.
불변 조건은 어려운 수학 문장을 만들기 위한 용어가 아니다. 반복문이 진행되는 동안 변수가 어떤 의미를 잃지 않아야 하는지 설명하는 방법이다.
정확성은 세 단계로 확인할 수 있다.
아직 쿠폰을 하나도 확인하지 않았을 때 best는 null이다.
확인한 사용 가능 쿠폰이 없으므로 최선의 후보도 없다. 따라서 불변 조건이 성립한다.
새 쿠폰을 확인했을 때 두 가지 경우가 있다.
best와 비교한다.사용할 수 없는 쿠폰은 정답 후보가 아니므로 건너뛰어도 최선이 바뀌지 않는다.
사용 가능한 쿠폰이 현재 best보다 좋다면 best를 교체한다. 그렇지 않다면 기존 값을 유지한다.
따라서 새로운 쿠폰까지 확인한 뒤에도 best는 지금까지 확인한 후보 중 가장 좋다.
반복문이 끝나면 모든 쿠폰을 확인한 상태다.
불변 조건에 따라 best는 모든 사용 가능 쿠폰 중 가장 좋은 후보다. 사용 가능한 쿠폰이 하나도 없다면 null이다.
이 설명은 특정 예제의 결과에 의존하지 않는다.
쿠폰이 1개든 1만 개든 같은 조건이 유지된다면 결과가 올바르다는 근거가 된다.
불변 조건은 코드가 어떤 값을 계산하는지 설명하는 문장이 아니라, 계산 과정의 어느 순간에도 변수와 상태가 지켜야 하는 의미를 설명하는 문장이다.
두 쿠폰의 실제 할인 금액이 같을 수 있다.
다음 구현은 먼저 발견한 쿠폰을 그대로 유지한다.
function isBetterCandidate(candidate, best) {
return (
candidate.discountAmount >
best.discountAmount
);
}
입력 배열의 순서가 데이터베이스 조회나 네트워크 응답에 따라 달라지면 같은 주문에서도 다른 쿠폰이 선택될 수 있다.
할인 금액만 중요하다면 어느 쿠폰을 반환해도 금액상 정답일 수 있다. 그러나 사용자 경험과 테스트 결과는 매번 달라질 수 있다.
동점 규칙을 명시하면 결과를 안정적으로 만들 수 있다.
function isBetterCandidate(candidate, best) {
if (
candidate.discountAmount !==
best.discountAmount
) {
return (
candidate.discountAmount >
best.discountAmount
);
}
if (
candidate.coupon.expiresAt.getTime() !==
best.coupon.expiresAt.getTime()
) {
return (
candidate.coupon.expiresAt <
best.coupon.expiresAt
);
}
return candidate.coupon.id < best.coupon.id;
}
이 비교 함수는 다음 순서로 판단한다.
이제 입력 순서가 달라져도 같은 후보 집합에서는 같은 결과를 얻을 수 있다.
“가장 좋은 쿠폰”은 코드에 원래 존재하는 성질이 아니다.
제품이 정한 우선순위를 비교 가능한 규칙으로 변환한 결과다. 따라서 알고리즘의 정확성을 설명하려면 비교 규칙 자체가 비즈니스 요구사항과 일치하는지도 확인해야 한다.
크리스의 브라우저가 다음 데이터를 서버로 보냈다고 생각해보자.
const requestBody = {
couponIds: [
"coupon_a",
"coupon_b",
],
orderAmount: 100000,
userId: "chris",
};
형식만 보면 쿠폰 추천 알고리즘에 필요한 입력이 모두 있다.
그러나 이 값을 그대로 신뢰할 수는 없다.
userId는 다른 사용자의 ID로 바꿀 수 있다.orderAmount는 더 큰 할인이나 최소 주문 조건 통과를 위해 조작할 수 있다.couponIds에는 다른 사용자의 쿠폰이 포함될 수 있다.서버는 외부 요청을 검증된 입력으로 변환해야 한다.
const command = {
couponIds: parseCouponIds(
request.body.couponIds
),
orderId: parseOrderId(
request.body.orderId
),
requesterId: session.user.id,
evaluatedAt: clock.now(),
};
쿠폰 ID와 주문 ID는 형식을 검증한다.
요청자는 클라이언트가 주장한 userId가 아니라 인증된 세션에서 가져온다. 현재 시간도 사용자 기기가 아니라 서버가 관리하는 시계를 사용한다.
최종 계산에 필요한 데이터는 저장소에서 다시 조회한다.
const order =
await orderRepository.findByIdForUser({
orderId: command.orderId,
userId: command.requesterId,
});
const coupons =
await couponRepository.findByIdsForUser({
couponIds: command.couponIds,
userId: command.requesterId,
});
주문 금액은 저장된 상품, 수량과 가격을 기준으로 서버에서 계산한다.
쿠폰 상태와 남은 횟수도 데이터베이스의 최신 값을 사용한다.
브라우저가 보낸 값은 사용자의 의도를 전달하는 입력이다. 데이터베이스에 저장된 주문과 쿠폰은 최종 판단에 사용하는 Source of Truth다.
알고리즘의 논리가 옳다는 설명에는 입력이 신뢰할 수 있는 데이터로 만들어졌다는 근거도 포함되어야 한다.
추천 함수가 가장 좋은 쿠폰을 정확히 찾았다고 생각해보자.
const recommendation =
selectBestCoupon({
coupons,
order,
userId: currentUser.id,
now: serverNow,
});
이 결과는 계산 시점에서는 올바르다.
하지만 크리스가 결제를 확정하기 전에 다른 기기에서 같은 쿠폰을 사용할 수 있다. 운영자가 캠페인을 중단하거나 쿠폰의 적용 정책을 변경할 수도 있다.
추천 결과를 그대로 믿고 상태를 변경하면 오래된 계산을 현재의 사실처럼 사용할 수 있다.
await couponRepository.markAsUsed(
recommendation.coupon.id
);
await orderRepository.applyDiscount({
orderId: order.id,
discountAmount:
recommendation.discountAmount,
});
두 작업이 따로 실행되므로 쿠폰만 사용되고 주문 할인은 실패할 수도 있다.
동시에 두 요청이 실행되면 같은 쿠폰이 두 번 사용될 가능성도 있다.
최종 확정 단계에서는 최신 상태를 다시 검증하고 관련 변경을 함께 처리해야 한다.
const result = await database.transaction(
async (transaction) => {
const coupon =
await couponRepository.redeemIfAvailable(
{
couponId:
recommendation.coupon.id,
userId: currentUser.id,
redeemedAt: serverNow,
},
transaction
);
if (!coupon) {
return {
ok: false,
reason: "COUPON_NOT_REDEEMABLE",
};
}
const redemption =
await redemptionRepository.create(
{
couponId: coupon.id,
orderId: order.id,
discountAmount:
recommendation.discountAmount,
},
transaction
);
await orderRepository.applyDiscount(
{
orderId: order.id,
discountAmount:
recommendation.discountAmount,
},
transaction
);
return {
ok: true,
redemptionId: redemption.id,
};
}
);
이 흐름은 처리 순간의 최신 쿠폰 상태를 확인한다.
쿠폰 차감, 사용 기록 생성과 주문 할인 반영은 모두 성공하거나 모두 취소된다.
여기에서 알고리즘이 지켜야 하는 불변 조건은 배열을 순회할 때보다 넓어진다.
서비스의 정답은 함수의 반환값 하나가 아니다.
저장된 여러 데이터 사이의 관계가 처리 전후에도 올바르게 유지되는 상태다.
애플리케이션 코드에서 중복 사용을 확인할 수 있다.
const previous =
await redemptionRepository.findByCouponId(
couponId
);
if (!previous) {
await redemptionRepository.create({
couponId,
orderId,
});
}
하나의 요청만 실행될 때는 동작한다.
그러나 두 요청이 동시에 previous를 조회하면 둘 다 사용 기록이 없다고 판단할 수 있다. 이후 두 요청이 각각 기록을 생성하면 1회용 쿠폰이 두 번 사용된다.
애플리케이션의 if만으로는 공유된 상태에 대한 불변 조건을 보장하기 어렵다.
데이터베이스에 다음 제약을 둘 수 있다.
CREATE UNIQUE INDEX
one_redemption_per_coupon
ON coupon_redemptions (coupon_id);
같은 coupon_id로 두 번째 사용 기록을 생성하려 하면 데이터베이스가 거부한다.
남은 횟수가 여러 번인 쿠폰이라면 조건부 변경을 사용할 수 있다.
UPDATE coupons
SET remaining_uses = remaining_uses - 1
WHERE id = $1
AND recipient_id = $2
AND status = 'ACTIVE'
AND remaining_uses > 0
AND expires_at > $3
RETURNING id, remaining_uses;
이 쿼리는 변경 순간에도 모든 조건을 만족할 때만 남은 횟수를 차감한다.
두 요청이 동시에 도착해도 먼저 성공한 요청이 마지막 횟수를 사용하면 다음 요청은 변경할 행을 찾지 못한다.
정확성을 설명할 때는 서비스 함수의 조건문만 보면 안 된다.
최신 상태를 누가 판단하는지, 동시 변경을 어느 저장소가 막는지, 여러 변경을 어떤 트랜잭션으로 묶는지까지 확인해야 한다.
크리스가 쿠폰 사용 요청을 보냈고 서버는 처리를 완료했다고 생각해보자.
응답이 네트워크에서 사라지면 앱은 같은 요청을 다시 보낼 수 있다.
await redeemCoupon({
couponId: "coupon_123",
requestId: "request_456",
});
재시도된 요청이 새로운 작업으로 처리되면 쿠폰이 다시 차감되거나 “이미 사용됨”이라는 실패를 반환할 수 있다.
사용자는 첫 요청이 성공했는지 알 수 없게 된다.
하나의 사용자 행동을 식별하는 요청 ID를 저장할 수 있다.
const previousResult =
await redemptionRepository.findByRequestId(
command.requestId
);
if (previousResult) {
return previousResult;
}
같은 요청 ID가 이미 처리되었다면 상태를 다시 변경하지 않고 이전 결과를 반환한다.
데이터베이스에도 요청 ID의 중복을 막는 제약을 둘 수 있다.
CREATE UNIQUE INDEX
one_result_per_request
ON coupon_redemptions (request_id);
이제 다음 불변 조건을 설명할 수 있다.
같은
requestId로 요청이 여러 번 전달되어도 쿠폰과 주문 상태에는 한 번만 반영된다.
네트워크가 있는 서비스에서 요청이 정확히 한 번만 전달된다고 보장하기는 어렵다.
따라서 올바른 결과의 정의에는 중복 전달과 재시도 이후의 상태도 포함되어야 한다.
AI가 만든 쿠폰 선택 코드에 많은 테스트를 작성할 수 있다.
it("실제 할인 금액이 가장 큰 쿠폰을 선택한다", () => {
const result = selectBestCoupon({
coupons,
order,
userId: "chris",
now,
});
expect(result.coupon.id).toBe(
"coupon_percentage"
);
});
이 테스트는 준비한 입력에서 원하는 쿠폰이 선택되는지 확인한다.
하지만 하나의 테스트가 통과했다는 사실은 그 입력에 대한 증거일 뿐이다.
먼저 어떤 주장들을 검증해야 하는지 나누는 편이 좋다.
| 정확성에 대한 주장 | 적절한 검증 |
|---|---|
| 사용 불가능한 쿠폰은 선택되지 않는다 | 상태별 단위 테스트 |
| 실제 할인 금액이 가장 큰 후보를 선택한다 | 여러 후보 조합 테스트 |
| 입력 순서가 달라도 결과가 같다 | 순서를 바꾼 테스트 |
| 동점 규칙이 항상 적용된다 | 할인·만료 시각 동점 테스트 |
| 할인 금액은 주문 금액을 넘지 않는다 | 불변 조건 테스트 |
| 최적화한 구현도 같은 결과를 만든다 | 기준 알고리즘과 비교 |
| 같은 쿠폰은 동시에 한 번만 사용된다 | 데이터베이스 동시성 테스트 |
| 재시도해도 상태가 중복 변경되지 않는다 | 동일 요청 ID 반복 테스트 |
| 일부 저장에 실패하면 모두 취소된다 | 트랜잭션 통합 테스트 |
불변 조건을 테스트 코드로 직접 표현할 수도 있다.
const result = selectBestCoupon(input);
if (result) {
expect(result.discountAmount)
.toBeGreaterThanOrEqual(0);
expect(result.discountAmount)
.toBeLessThanOrEqual(
input.order.eligibleAmount
);
expect(
isEligibleCoupon({
coupon: result.coupon,
order: input.order,
userId: input.userId,
now: input.now,
})
).toBe(true);
}
이 테스트는 특정 쿠폰 ID만 확인하지 않는다.
어떤 쿠폰이 선택되더라도 할인 금액이 허용 범위 안에 있고 실제 사용 가능한 후보여야 한다는 조건을 확인한다.
테스트는 설명한 근거가 구현에서도 유지되는지 확인하는 수단이다.
반대로 왜 맞는지 설명하지 못한 채 테스트 개수만 늘리면 비슷한 입력을 반복해서 확인할 수 있다.
다음과 같은 규칙을 제안할 수 있다.
할인율이 가장 높은 쿠폰이 가장 유리하다.
주문 금액이 10만 원이고 모든 쿠폰에 한도가 없다면 그럴 수 있다.
그러나 다음 입력을 생각해보자.
const coupons = [
{
id: "coupon_30_percent",
type: "PERCENTAGE",
discountValue: 30,
maxDiscount: 2000,
},
{
id: "coupon_5000_fixed",
type: "FIXED",
discountValue: 5000,
},
];
30% 쿠폰은 최대 할인 한도 때문에 2,000원만 할인할 수 있다. 정액 쿠폰은 5,000원을 할인한다.
이 입력은 “할인율이 가장 높으면 가장 유리하다”라는 규칙의 반례다.
반례는 특별히 이상한 데이터를 찾는 일이 아니다.
제안한 해결 원리가 성립하려면 어떤 조건이 필요한지 확인하는 도구다.
AI가 다음과 같은 최적화를 제안할 수도 있다.
쿠폰을 할인율순으로 정렬하고 첫 번째 쿠폰을 반환하면 된다.
이 제안을 검증하려면 곧바로 코드를 실행하기보다 질문해야 한다.
조건 중 하나라도 성립하지 않으면 정렬 기준이 실제 정답 기준과 달라질 수 있다.
좋은 반례는 코드를 깨뜨리는 데서 끝나지 않는다. 해결책이 암묵적으로 의존한 가정을 명시적인 설계 조건으로 바꾼다.
쿠폰이 적을 때는 모든 후보를 확인하는 방식이 단순하고 안전하다.
쿠폰 수가 매우 많아져 데이터베이스에서 가장 좋은 후보를 먼저 조회하도록 변경할 수 있다.
SELECT id, type, discount_value, expires_at
FROM coupons
WHERE recipient_id = $1
AND status = 'ACTIVE'
AND remaining_uses > 0
AND expires_at > $2
ORDER BY discount_value DESC
LIMIT 1;
이 쿼리는 빠를 수 있지만 discount_value의 단위가 쿠폰 종류에 따라 다르다면 잘못된 쿠폰을 선택한다.
또한 최소 주문 금액, 최대 할인과 상품별 적용 조건이 결과에 반영되지 않았다.
빠른 코드가 기존 코드와 같은 정답을 만들려면 어떤 정보로 후보를 제거하거나 정렬해도 되는지 설명해야 한다.
예를 들어 모든 쿠폰이 정액 할인이고 다음 조건이 보장된다면 저장된 할인 금액으로 정렬할 수 있다.
SELECT id, discount_value, expires_at
FROM coupons
WHERE recipient_id = $1
AND type = 'FIXED'
AND status = 'ACTIVE'
AND remaining_uses > 0
AND minimum_order_amount <= $2
AND discount_value <= $2
AND expires_at > $3
ORDER BY
discount_value DESC,
expires_at ASC,
id ASC
LIMIT 1;
이 쿼리는 앞서 정의한 조건이 실제 정책과 일치할 때만 올바르다.
최적화의 검증은 “더 빨라졌다”에서 끝나지 않는다.
기존 알고리즘이 보장하던 후보 조건, 최적 조건과 동점 조건이 새로운 데이터 조회 방식에서도 유지되는지 확인해야 한다.
작은 입력에서는 단순한 기준 구현과 결과를 비교할 수 있다.
const expected =
selectBestByFullScan(input);
const actual =
await selectBestOptimized(input);
expect(actual).toEqual(expected);
기준 구현은 느리더라도 모든 후보를 명확한 규칙으로 평가한다.
최적화한 구현이 다양한 작은 입력에서 같은 결과를 만드는지 비교하면 잘못 제거한 후보나 누락한 조건을 발견할 수 있다.
이 비교가 모든 입력에 대한 증명을 대신하지는 않는다. 그러나 설명한 조건과 실제 구현 사이의 차이를 찾는 강한 검증 수단이 된다.
쿠폰 추천과 사용 흐름에는 서로 다른 정확성 책임이 있다.
| 책임 | 주로 담당할 위치 |
|---|---|
| 요청 형식과 크기 검증 | API 경계 |
| 요청자 신원 확인 | 인증 계층 |
| 쿠폰과 주문의 소유 관계 확인 | 서비스 로직과 조회 조건 |
| 할인 정책 계산 | 도메인 계산 함수 |
| 후보 비교와 동점 처리 | 선택 알고리즘 |
| 최신 쿠폰 상태 확인 | 데이터베이스 |
| 동시 사용 방지 | 조건부 변경과 제약 조건 |
| 여러 상태의 일관된 변경 | 트랜잭션 |
| 중복 요청 방지 | 요청 기록과 고유 제약 |
| 사용자에게 보여줄 결과 변환 | API와 UI |
| 위반 탐지와 원인 추적 | 로그와 운영 지표 |
모든 검증을 하나의 함수에 넣는다고 정확성이 높아지는 것은 아니다.
애플리케이션 메모리만으로는 다른 서버에서 동시에 변경되는 상태를 통제하기 어렵다. 데이터베이스 제약만으로는 할인 정책의 의미를 충분히 설명하기 어렵다. 프런트엔드 검증만으로는 조작된 요청을 막을 수 없다.
각 조건을 가장 확실하게 보장할 수 있는 위치에 두어야 한다.
정답임을 설명하는 과정은 결국 “누가 이 조건을 책임지는가?”라는 설계 판단으로 이어진다.
설계상 일어나지 않아야 하는 상태도 기존 데이터 오류나 배포 과정의 실수로 생길 수 있다.
예를 들어 남은 사용 횟수가 음수인 쿠폰을 발견했다고 생각해보자.
if (coupon.remainingUses < 0) {
logger.error(
"Coupon invariant violated",
{
couponId: coupon.id,
remainingUses:
coupon.remainingUses,
}
);
throw new Error(
"쿠폰 상태를 처리할 수 없다."
);
}
사용자에게는 내부 데이터 구조를 노출하지 않는 안전한 오류를 반환한다.
운영 로그에는 어떤 쿠폰에서 어떤 불변 조건이 깨졌는지 남긴다.
정확성을 보장하는 설계에는 다음 두 방향이 함께 필요하다.
다음과 같은 운영 지표도 사용할 수 있다.
불변 조건이 문서와 테스트에만 있으면 운영 중 위반을 늦게 발견할 수 있다.
중요한 조건은 데이터베이스 제약, 로그와 지표로도 관찰할 수 있어야 한다.
알고리즘이나 AI가 제안한 코드를 적용하기 전에 다음 질문을 확인할 수 있다.
모든 함수에 긴 증명서를 작성해야 한다는 뜻은 아니다.
잘못되었을 때 피해가 큰 규칙부터 정답 조건, 불변 조건과 책임 위치를 명확히 하면 된다.
금액, 권한, 재고와 상태 변경을 다루는 코드일수록 근거의 범위를 넓혀야 한다.
코드가 어떤 값을 반환하면 정답을 구한 것처럼 보인다.
예제 테스트가 통과하고 화면에 예상한 쿠폰이 표시되면 구현이 완료되었다고 생각하기도 쉽다.
하지만 실제 서비스에서 결과의 정확성은 더 넓은 조건에 의존한다.
AI는 실행 가능한 해결책과 테스트 후보를 빠르게 만들 수 있다. 그러나 생성된 코드가 어떤 조건에서 올바른지, 어떤 가정에 의존하는지, 서비스의 상태와 책임까지 안전하게 다루는지는 별도로 검증해야 한다.
검증의 출발점은 결과를 여러 번 실행해보는 일이 아니다.
정답의 의미를 문장으로 정의하고, 그 의미가 계산 과정과 상태 변화에서 어떻게 유지되는지 설명하는 일이다.
정답임을 설명한다는 것은 출력값이 그럴듯하다고 주장하는 일이 아니다. 입력 조건, 불변 조건, 종료 후의 결과 조건과 상태 변화의 책임을 연결하여 다른 가능한 결과가 정답이 될 수 없는 이유를 밝히는 일이다.
코드가 정답을 한 번 만들 수 있다는 사실은 중요하다.
그러나 실제 서비스가 그 코드를 신뢰하려면 어떤 입력과 실행 순서에서도 지켜져야 하는 약속이 무엇인지 알아야 한다. 그 약속을 코드, 데이터베이스, 테스트와 운영 지표가 함께 보장할 때 비로소 결과를 정답이라고 설명할 수 있다.
다음 글에서는 시간 복잡도가 코드의 실행 시간을 맞히는 공식이 아니라, 데이터가 증가할 때 처리 비용이 어떻게 변하는지 예측하는 방법인 이유를 살펴본다.