정답을 구하는 것과 정답임을 설명하는 것은 다르다

vx_developer·2026년 9월 12일

코테보다가

목록 보기
12/25
post-thumbnail

알고리즘을 처음 배울 때는 입력을 받아 정답을 반환하는 코드를 작성한다.

쿠폰 목록에서 할인 금액이 가장 큰 쿠폰을 찾는다면 다음과 같이 구현할 수 있다.

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이다.

확인한 사용 가능 쿠폰이 없으므로 최선의 후보도 없다. 따라서 불변 조건이 성립한다.

쿠폰 하나를 더 확인해도 조건이 유지되는가

새 쿠폰을 확인했을 때 두 가지 경우가 있다.

  1. 사용할 수 없는 쿠폰이면 건너뛴다.
  2. 사용할 수 있는 쿠폰이면 현재 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;
}

이 비교 함수는 다음 순서로 판단한다.

  1. 실제 할인 금액이 큰 쿠폰을 선택한다.
  2. 할인 금액이 같으면 먼저 만료되는 쿠폰을 선택한다.
  3. 만료 시각도 같으면 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,
    };
  }
);

이 흐름은 처리 순간의 최신 쿠폰 상태를 확인한다.

쿠폰 차감, 사용 기록 생성과 주문 할인 반영은 모두 성공하거나 모두 취소된다.

여기에서 알고리즘이 지켜야 하는 불변 조건은 배열을 순회할 때보다 넓어진다.

  • 쿠폰의 남은 사용 횟수는 0보다 작아질 수 없다.
  • 한 번만 사용할 수 있는 쿠폰에는 성공한 사용 기록이 하나만 존재해야 한다.
  • 성공한 쿠폰 사용에는 반드시 대응하는 주문 할인 기록이 있어야 한다.
  • 실패한 쿠폰 사용은 주문 금액을 바꾸지 않아야 한다.
  • 다른 사용자의 쿠폰은 현재 주문에 적용될 수 없다.
  • 확정된 할인 금액은 주문의 할인 대상 금액을 넘을 수 없다.

서비스의 정답은 함수의 반환값 하나가 아니다.

저장된 여러 데이터 사이의 관계가 처리 전후에도 올바르게 유지되는 상태다.

데이터베이스 제약도 정확성 근거의 일부다

애플리케이션 코드에서 중복 사용을 확인할 수 있다.

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(
    "쿠폰 상태를 처리할 수 없다."
  );
}

사용자에게는 내부 데이터 구조를 노출하지 않는 안전한 오류를 반환한다.

운영 로그에는 어떤 쿠폰에서 어떤 불변 조건이 깨졌는지 남긴다.

정확성을 보장하는 설계에는 다음 두 방향이 함께 필요하다.

  1. 잘못된 상태가 생성되지 않도록 막는다.
  2. 예상하지 못한 경로로 상태가 깨졌을 때 빠르게 발견한다.

다음과 같은 운영 지표도 사용할 수 있다.

  • 남은 횟수가 음수인 쿠폰 수
  • 하나의 쿠폰에 생성된 중복 사용 기록 수
  • 사용 기록 없이 할인된 주문 수
  • 같은 요청 ID로 서로 다른 결과가 생성된 횟수
  • 추천 금액과 최종 적용 금액이 달라진 횟수
  • 동시 사용 충돌로 거절된 요청 수

불변 조건이 문서와 테스트에만 있으면 운영 중 위반을 늦게 발견할 수 있다.

중요한 조건은 데이터베이스 제약, 로그와 지표로도 관찰할 수 있어야 한다.

해결책의 정확성을 검증할 때 물어봐야 할 질문

알고리즘이나 AI가 제안한 코드를 적용하기 전에 다음 질문을 확인할 수 있다.

  1. 이 문제에서 정답이라고 부르는 결과는 정확히 무엇인가?
  2. 함수가 실행되기 전에 만족해야 하는 입력 조건은 무엇인가?
  3. 함수가 끝난 뒤 반드시 만족해야 하는 결과 조건은 무엇인가?
  4. 결과가 없을 때는 오류인가, 정상적인 빈 상태인가?
  5. 후보가 될 수 있는 대상과 제외해야 하는 대상을 구분했는가?
  6. 비교하는 값의 단위와 의미가 같은가?
  7. 계산된 실제 값과 저장된 원본 값을 혼동하고 있지는 않은가?
  8. 동점이 생기면 어떤 규칙으로 결과를 결정하는가?
  9. 입력 순서가 달라져도 결과의 의미가 유지되는가?
  10. 반복문이나 탐색 중 계속 유지되어야 하는 불변 조건은 무엇인가?
  11. 불변 조건이 시작할 때 성립하는가?
  12. 한 단계를 실행한 뒤에도 같은 조건이 유지되는가?
  13. 실행이 끝났을 때 불변 조건이 원하는 결과를 보장하는가?
  14. 해결책이 암묵적으로 가정하는 조건은 무엇인가?
  15. 그 가정을 깨뜨리는 작은 반례를 만들 수 있는가?
  16. 외부 입력을 검증하기 전에 신뢰하고 있지는 않은가?
  17. 사용자, 주문 금액과 현재 시간은 신뢰할 수 있는 출처에서 가져오는가?
  18. 최종 판단의 Source of Truth는 어디에 있는가?
  19. 추천이나 미리보기와 최종 상태 변경을 구분했는가?
  20. 상태 변경 직전에 최신 조건을 다시 확인하는가?
  21. 동시 요청에서도 유지되어야 하는 상태 불변 조건은 무엇인가?
  22. 데이터베이스 제약이나 조건부 변경으로 보장해야 하는 규칙이 있는가?
  23. 함께 성공하거나 함께 실패해야 하는 변경을 트랜잭션으로 묶었는가?
  24. 같은 요청이 재시도되어도 상태가 한 번만 변경되는가?
  25. 테스트 하나마다 어떤 정확성 주장을 확인하는지 설명할 수 있는가?
  26. 예제뿐 아니라 경곗값, 잘못된 입력과 동시 실행을 확인했는가?
  27. 최적화한 구현을 단순한 기준 구현과 비교할 수 있는가?
  28. 더 빠른 방법이 기존의 후보 조건과 최적 조건을 유지하는가?
  29. 불변 조건이 깨졌을 때 발견할 로그와 지표가 있는가?
  30. 코드를 보여주지 않고도 이 해결책이 올바른 이유를 설명할 수 있는가?

모든 함수에 긴 증명서를 작성해야 한다는 뜻은 아니다.

잘못되었을 때 피해가 큰 규칙부터 정답 조건, 불변 조건과 책임 위치를 명확히 하면 된다.

금액, 권한, 재고와 상태 변경을 다루는 코드일수록 근거의 범위를 넓혀야 한다.

정답은 출력값이 아니라 지켜진 약속이다

코드가 어떤 값을 반환하면 정답을 구한 것처럼 보인다.

예제 테스트가 통과하고 화면에 예상한 쿠폰이 표시되면 구현이 완료되었다고 생각하기도 쉽다.

하지만 실제 서비스에서 결과의 정확성은 더 넓은 조건에 의존한다.

  • 입력이 신뢰할 수 있는 원본 데이터에서 만들어졌는가?
  • 모든 유효한 후보를 같은 기준으로 비교했는가?
  • 선택 과정에서 최선이라는 의미가 계속 유지되는가?
  • 동점과 빈 결과가 명확하게 정의되어 있는가?
  • 처리하는 동안 공유된 상태가 바뀌어도 규칙을 지키는가?
  • 중복 요청과 동시 요청에서도 상태가 한 번만 변경되는가?
  • 관련 데이터가 함께 성공하거나 함께 실패하는가?
  • 최적화 이후에도 기존의 정확성 조건이 유지되는가?
  • 잘못된 상태가 생겼을 때 발견하고 추적할 수 있는가?

AI는 실행 가능한 해결책과 테스트 후보를 빠르게 만들 수 있다. 그러나 생성된 코드가 어떤 조건에서 올바른지, 어떤 가정에 의존하는지, 서비스의 상태와 책임까지 안전하게 다루는지는 별도로 검증해야 한다.

검증의 출발점은 결과를 여러 번 실행해보는 일이 아니다.

정답의 의미를 문장으로 정의하고, 그 의미가 계산 과정과 상태 변화에서 어떻게 유지되는지 설명하는 일이다.

정답임을 설명한다는 것은 출력값이 그럴듯하다고 주장하는 일이 아니다. 입력 조건, 불변 조건, 종료 후의 결과 조건과 상태 변화의 책임을 연결하여 다른 가능한 결과가 정답이 될 수 없는 이유를 밝히는 일이다.

코드가 정답을 한 번 만들 수 있다는 사실은 중요하다.

그러나 실제 서비스가 그 코드를 신뢰하려면 어떤 입력과 실행 순서에서도 지켜져야 하는 약속이 무엇인지 알아야 한다. 그 약속을 코드, 데이터베이스, 테스트와 운영 지표가 함께 보장할 때 비로소 결과를 정답이라고 설명할 수 있다.

다음 글에서는 시간 복잡도가 코드의 실행 시간을 맞히는 공식이 아니라, 데이터가 증가할 때 처리 비용이 어떻게 변하는지 예측하는 방법인 이유를 살펴본다.

profile
Vision eXperience Developer

0개의 댓글