알고리즘을 처음 배울 때 완전 탐색은 가능한 모든 경우를 하나씩 확인하는 방법이라고 배운다.
세 개의 쿠폰 중 가장 큰 할인 금액을 찾는다면 모든 쿠폰을 확인할 수 있다.
function findBestCoupon(coupons) {
let bestCoupon = null;
for (const coupon of coupons) {
if (
bestCoupon === null ||
coupon.discountAmount >
bestCoupon.discountAmount
) {
bestCoupon = coupon;
}
}
return bestCoupon;
}
이 코드는 쿠폰을 처음부터 끝까지 확인하면서 지금까지 발견한 가장 큰 할인 쿠폰을 기억한다.
가능한 후보를 빠짐없이 확인한다는 완전 탐색의 기본 개념을 이해하기에는 충분한 예제다.
완전 탐색은 구현이 단순하고 모든 후보를 확인하기 때문에 정답을 놓치지 않는다는 장점이 있다.
하지만 실제 쿠폰 서비스에서 “가장 좋은 쿠폰”을 찾는 문제는 하나의 쿠폰만 비교하는 것으로 끝나지 않을 수 있다.
가능한 경우를 모두 확인한다는 설명은 틀리지 않다. 그러나 실제 서비스에서는 무엇을 하나의 경우로 볼 것인지, 모든 경우가 몇 개인지, 어떤 경우를 확인하지 않아도 되는지까지 판단해야 한다.
실제 서비스에서 완전 탐색은 생각 없이 모든 경우를 실행하는 방법이 아니라, 가능한 선택의 전체 범위를 명확히 정의하고 정확한 기준점을 만든 뒤 근거가 있는 조건으로 탐색 범위를 줄여가는 출발점이다.
크리스가 다음 세 쿠폰을 가지고 있다고 생각해보자.
const coupons = [
{
id: "coupon_a",
title: "Welcome",
discountAmount: 1000,
},
{
id: "coupon_b",
title: "Birthday",
discountAmount: 2000,
},
{
id: "coupon_c",
title: "Dinner Date",
discountAmount: 3000,
},
];
쿠폰을 하나만 사용할 수 있다면 가능한 선택은 비교적 단순하다.
coupon_a를 사용한다.coupon_b를 사용한다.coupon_c를 사용한다.하지만 여러 쿠폰을 함께 사용할 수 있다면 선택의 범위가 달라진다.
a만 선택한다.b만 선택한다.c만 선택한다.a와 b를 선택한다.a와 c를 선택한다.b와 c를 선택한다.a, b, c를 모두 선택한다.쿠폰이 세 개일 때는 8가지 조합이 생긴다.
각 쿠폰마다 선택하거나 선택하지 않는 두 가지 결정이 있으므로 쿠폰이 n개라면 최대 2ⁿ개의 조합이 생긴다.
| 쿠폰 수 | 가능한 조합 수 |
|---|---|
| 3 | 8 |
| 10 | 1,024 |
| 20 | 1,048,576 |
| 30 | 1,073,741,824 |
| 50 | 1,125,899,906,842,624 |
완전 탐색이 가능한지는 반복문이 몇 개 있는지만으로 판단할 수 없다.
입력 크기와 하나의 후보를 평가하는 비용, 요청당 허용 시간까지 함께 봐야 한다.
쿠폰 조합을 찾는 가장 직접적인 방법은 각 쿠폰을 선택하는 경우와 선택하지 않는 경우를 모두 확인하는 것이다.
function findBestCombination(coupons, order) {
let best = {
couponIds: [],
discountAmount: 0,
};
function search(index, selected) {
if (index === coupons.length) {
const discountAmount =
calculateCombinedDiscount(
selected,
order
);
if (
discountAmount >
best.discountAmount
) {
best = {
couponIds: selected.map(
(coupon) => coupon.id
),
discountAmount,
};
}
return;
}
search(index + 1, selected);
selected.push(coupons[index]);
search(index + 1, selected);
selected.pop();
}
search(0, []);
return best;
}
각 쿠폰에서는 두 가지 선택을 한다.
마지막 쿠폰까지 결정하면 선택된 조합의 실제 할인 금액을 계산하고, 지금까지의 최선보다 크면 결과를 갱신한다.
이 구현은 쿠폰 수가 많을 때 느리다. 그렇다고 처음부터 가치가 없는 코드는 아니다.
가능한 조합을 빠짐없이 확인하기 때문에 입력이 작다면 신뢰할 수 있는 기준 결과를 만든다.
이 기준은 이후에 더 빠른 방법을 설계할 때 중요해진다.
최적화된 알고리즘이 정말 같은 답을 만드는지 확인하려면 먼저 느리더라도 명확하게 옳은 기준 알고리즘이 필요하다.
2ⁿ이라는 증가 속도를 보면 완전 탐색은 항상 피해야 할 것처럼 느껴질 수 있다.
하지만 실제로 처리해야 하는 쿠폰 수가 최대 8개라면 가능한 조합은 256개뿐이다.
각 조합의 할인 금액을 계산하는 비용이 작고 요청량도 낮다면 완전 탐색이 충분히 적절할 수 있다.
반대로 사용자가 최대 100개의 쿠폰을 보유할 수 있다면 모든 조합을 확인할 수 없다.
따라서 먼저 다음 조건을 확인해야 한다.
| 제약 | 확인할 내용 |
|---|---|
| 입력 크기 | 한 사용자가 비교할 쿠폰은 최대 몇 개인가 |
| 조합 규칙 | 쿠폰을 최대 몇 개까지 함께 사용할 수 있는가 |
| 평가 비용 | 조합 하나의 할인 금액을 계산하는 데 어떤 작업이 필요한가 |
| 실행 빈도 | 결제 화면을 열 때마다 실행하는가 |
| 응답 시간 | 사용자는 얼마나 기다릴 수 있는가 |
| 데이터 위치 | 쿠폰과 주문 데이터가 이미 메모리에 있는가 |
| 변경 빈도 | 쿠폰 정책과 장바구니가 얼마나 자주 바뀌는가 |
| 정확성 요구 | 가장 좋은 조합이 반드시 필요한가, 충분히 좋은 조합도 허용되는가 |
완전 탐색을 사용할 수 있는지는 알고리즘 이름이 아니라 이 제약 조건으로 판단해야 한다.
사용자가 가진 모든 쿠폰을 곧바로 조합에 넣는 코드를 생각해보자.
const bestCombination =
findBestCombination(userCoupons, order);
userCoupons에는 이미 사용한 쿠폰, 만료된 쿠폰과 현재 주문에 적용할 수 없는 쿠폰도 포함될 수 있다.
이 데이터까지 조합에 넣으면 정답이 될 수 없는 후보를 반복해서 확인한다.
탐색을 시작하기 전에 기본 조건으로 후보를 줄일 수 있다.
const eligibleCoupons = userCoupons.filter(
(coupon) =>
coupon.status === "ACTIVE" &&
coupon.remainingUses > 0 &&
serverNow < coupon.expiresAt &&
isApplicableToOrder(coupon, order)
);
이 코드는 활성 상태이고 남은 횟수가 있으며, 만료되지 않았고 현재 주문에 적용할 수 있는 쿠폰만 남긴다.
쿠폰이 20개에서 8개로 줄어들면 가능한 모든 조합은 약 100만 개에서 256개로 줄어든다.
다만 이 필터링에 사용하는 데이터의 출처도 중요하다.
const userCoupons =
await couponRepository.findEligibleByUser({
userId: currentUser.id,
orderId,
now: serverNow,
});
쿠폰 상태, 소유자와 남은 사용 횟수는 클라이언트가 보낸 값을 신뢰하지 않고 서버와 데이터베이스의 최신 데이터를 사용해야 한다.
화면에 표시된 쿠폰 목록은 사용자가 마지막으로 조회한 복사본이다. 최종 후보를 정하는 Source of Truth는 서버가 조회한 최신 쿠폰 상태다.
완전 탐색의 비용은 탐색 함수 안에서만 줄이는 것이 아니다. 탐색에 들어오기 전 어떤 데이터를 후보로 가져올지 결정하는 순간부터 줄일 수 있다.
쿠폰 서비스의 정책이 다음과 같다고 생각해보자.
한 주문에는 쿠폰을 최대 3개까지 적용할 수 있다.
그렇다면 4개 이상의 쿠폰을 선택한 조합은 확인할 필요가 없다.
function findBestCombination(coupons, order) {
let best = {
couponIds: [],
discountAmount: 0,
};
function search(index, selected) {
if (
selected.length === 3 ||
index === coupons.length
) {
const discountAmount =
calculateCombinedDiscount(
selected,
order
);
if (
discountAmount >
best.discountAmount
) {
best = {
couponIds: selected.map(
(coupon) => coupon.id
),
discountAmount,
};
}
return;
}
search(index + 1, selected);
const coupon = coupons[index];
if (canStackWithSelected(coupon, selected)) {
selected.push(coupon);
search(index + 1, selected);
selected.pop();
}
}
search(0, []);
return best;
}
이 코드는 다음 두 경우에 탐색을 멈춘다.
또한 현재 선택된 쿠폰들과 함께 사용할 수 있는 경우에만 새로운 쿠폰을 추가한다.
예를 들어 배송비 쿠폰을 하나만 사용할 수 있다면 두 번째 배송비 쿠폰을 선택하는 분기는 만들 필요가 없다.
이것이 가지치기다.
가지치기는 단순히 실행 시간을 줄이기 위해 일부 경우를 건너뛰는 일이 아니다. 건너뛴 경우가 정답이 될 수 없다는 근거가 있을 때만 탐색을 중단하는 판단이다.
정액 할인 쿠폰의 할인 금액이 큰 순서로 정렬하고 앞의 세 개만 선택할 수도 있다.
const selectedCoupons = [...coupons]
.sort(
(first, second) =>
second.discountAmount -
first.discountAmount
)
.slice(0, 3);
코드는 짧고 빠르다.
모든 쿠폰이 서로 함께 사용 가능하고, 할인 금액이 주문 상태와 관계없이 고정되어 있다면 올바를 수 있다.
하지만 다음과 같은 정책이 있다면 결과가 달라질 수 있다.
이 상황에서는 개별 할인 금액이 가장 큰 쿠폰 세 개가 전체적으로 가장 큰 할인을 보장하지 않는다.
빠른 선택 방법을 사용하려면 다음과 같은 근거가 필요하다.
이 조건을 설명할 수 없다면 단순 정렬은 빠르지만 틀린 결과를 만들 수 있다.
완전 탐색은 여기에서 비교 기준이 된다.
작은 데이터로 모든 조합을 확인한 결과와 빠른 선택 방법의 결과를 비교하면 어떤 조건에서 두 방법이 달라지는지 발견할 수 있다.
후보를 줄였지만 여전히 조합이 많다면 현재 경로가 최선이 될 가능성이 있는지 계산할 수 있다.
예를 들어 지금까지 선택한 쿠폰의 할인 금액이 3,000원이고, 남은 쿠폰에서 얻을 수 있는 최대 추가 할인이 2,000원이라고 생각해보자.
현재까지 발견한 최선의 할인 금액이 7,000원이라면 이 경로는 최대 5,000원까지밖에 만들 수 없다.
따라서 더 깊이 확인해도 최선의 결과를 바꿀 수 없다.
const maximumPossibleDiscount =
currentDiscount +
estimateRemainingMaximum(
coupons,
index,
remainingSlots
);
if (
maximumPossibleDiscount <=
best.discountAmount
) {
return;
}
이 코드는 현재 경로에서 만들 수 있는 최대 할인 금액이 이미 발견한 최선보다 크지 않으면 탐색을 멈춘다.
중요한 것은 estimateRemainingMaximum이 실제 가능한 할인보다 작게 계산되어서는 안 된다는 점이다.
상한을 너무 낮게 계산하면 실제 정답이 있는 경로를 잘못 제거할 수 있다. 그러면 알고리즘은 빨라지지만 정확성을 잃는다.
가지치기의 핵심은 많이 제거하는 것이 아니다.
정답이 포함되지 않았다고 확실하게 설명할 수 있는 경로만 제거하는 것이다.
쿠폰의 적용 순서에 따라 같은 상태에 여러 번 도달할 수도 있다.
예를 들어 coupon_a를 먼저 선택하고 coupon_b를 선택한 경우와, coupon_b를 먼저 선택하고 coupon_a를 선택한 경우가 실제로 같은 조합이라면 둘을 따로 확인할 필요가 없다.
조합을 만들 때 항상 원래 인덱스보다 뒤에 있는 쿠폰만 선택하면 순서만 다른 중복을 피할 수 있다.
function search(startIndex, selected) {
evaluate(selected);
for (
let index = startIndex;
index < coupons.length;
index += 1
) {
const coupon = coupons[index];
if (!canStackWithSelected(coupon, selected)) {
continue;
}
selected.push(coupon);
search(index + 1, selected);
selected.pop();
}
}
다음 탐색을 index + 1에서 시작하므로 이미 확인한 쿠폰을 다시 선택하지 않는다.
a → b는 확인하지만 b → a는 별도의 조합으로 만들지 않는다.
이 코드는 쿠폰의 적용 순서가 할인 결과에 영향을 주지 않는다는 정책을 전제로 한다.
만약 비율 할인과 정액 할인의 적용 순서에 따라 결과가 달라진다면 순서를 제거해서는 안 된다.
중복 제거 역시 데이터의 모양만 보고 결정할 수 없다. 서비스에서 두 상태를 실제로 같은 것으로 볼 수 있는지 먼저 확인해야 한다.
빠른 알고리즘을 만들었을 때 몇 개의 예제만으로 정확성을 확인하기는 어렵다.
작은 입력에서는 완전 탐색 결과와 비교할 수 있다.
const expected = findBestByExhaustiveSearch(
coupons,
order
);
const actual = findBestOptimized(
coupons,
order
);
expect(actual.discountAmount).toBe(
expected.discountAmount
);
완전 탐색 구현은 느리지만 가능한 모든 조합을 확인한다.
최적화된 구현이 같은 입력에서 같은 결과를 반환한다면 정확성을 확인하는 강한 기준이 된다.
여러 작은 입력을 자동으로 만들어 비교할 수도 있다.
for (let index = 0; index < 1000; index += 1) {
const coupons = createRandomCoupons({
maxCount: 8,
});
const expected =
findBestByExhaustiveSearch(
coupons,
order
);
const actual = findBestOptimized(
coupons,
order
);
expect(actual.discountAmount).toBe(
expected.discountAmount
);
}
이 코드는 쿠폰이 최대 8개인 여러 입력을 만들어 두 알고리즘의 결과를 비교한다.
모든 가능한 서비스 데이터를 증명하는 것은 아니지만 개발자가 직접 작성한 몇 개의 예제보다 다양한 조합을 확인할 수 있다.
완전 탐색은 배포할 최종 구현이 아니더라도 테스트를 위한 정답 생성기로 사용할 수 있다.
빠른 알고리즘을 신뢰하려면 비교할 수 있는 단순하고 명확한 기준이 필요하다.
크리스의 결제 화면에서 가장 좋은 쿠폰 조합을 찾았다고 생각해보자.
const recommendation =
findBestCombination(
eligibleCoupons,
order
);
이 결과는 계산 시점의 쿠폰과 주문 상태를 기준으로 만든 추천이다.
하지만 크리스가 결제를 확정하는 사이에 쿠폰이 만료되거나 다른 요청에서 사용될 수 있다. 장바구니 상품 가격이나 수량이 변경될 수도 있다.
따라서 추천 결과를 그대로 최종 상태에 반영해서는 안 된다.
const result =
await couponRepository.redeemCombination({
couponIds: recommendation.couponIds,
userId: currentUser.id,
orderId: order.id,
redeemedAt: serverNow,
});
저장소는 최신 상태를 기준으로 다음 조건을 다시 확인해야 한다.
함께 사용해야 하는 쿠폰들의 차감과 주문 할인 반영은 하나의 트랜잭션에서 처리해야 한다.
탐색 알고리즘은 최선의 후보를 계산한다. 그러나 공유된 서비스 상태를 안전하게 변경하는 책임까지 자동으로 해결하지는 않는다.
계산 결과와 최종 상태 변경의 책임을 구분해야 한다.
완전 탐색의 대상이 애플리케이션 메모리에 있는 작은 목록이라면 직접 순회해도 된다.
하지만 전체 쿠폰 테이블을 서버로 가져온 뒤 사용자의 후보를 찾는 것은 적절하지 않다.
const allCoupons =
await couponRepository.findAll();
const userCoupons = allCoupons.filter(
(coupon) =>
coupon.recipientId === currentUser.id
);
한 사용자의 쿠폰을 찾기 위해 서비스 전체의 쿠폰을 전송하고 메모리에 저장한다.
먼저 데이터베이스가 검색 조건으로 후보를 줄여야 한다.
SELECT
id,
type,
discount_value,
stack_group,
expires_at
FROM coupons
WHERE recipient_id = $1
AND status = 'ACTIVE'
AND remaining_uses > 0
AND expires_at > $2;
이 쿼리는 소유자, 상태, 남은 횟수와 만료 시각을 기준으로 탐색 후보를 제한한다.
그다음 애플리케이션은 현재 주문의 상품 구성과 쿠폰 조합 규칙처럼 복잡한 판단을 수행할 수 있다.
| 책임 | 적절한 위치 |
|---|---|
| 사용자 소유 쿠폰 조회 | 데이터베이스 조건 검색 |
| 활성 상태와 남은 횟수 필터링 | 데이터베이스 |
| 요청 입력 형식 검증 | API 경계 |
| 상품별 적용 가능 여부 | 서비스 또는 도메인 로직 |
| 쿠폰 조합 탐색 | 서비스 로직 |
| 최종 상태 재검증 | 데이터베이스와 서비스 로직 |
| 쿠폰 차감과 주문 반영 | 트랜잭션 |
| 추천 결과 표시 | UI 상태 |
완전 탐색을 최적화한다는 것은 재귀 함수만 수정하는 일이 아니다.
데이터가 이동하는 전체 흐름에서 어느 단계가 후보를 줄일 수 있는지 찾는 일이다.
실제 서비스에서 가능한 모든 경우를 확인하려 할 때 다음 질문을 점검할 수 있다.
이 질문에 답하면 완전 탐색을 그대로 사용할지, 후보를 먼저 줄일지, 가지치기를 추가할지, 전혀 다른 해결 원리를 찾아야 할지 판단할 수 있다.
완전 탐색은 가능한 경우를 모두 확인하므로 입력이 커질수록 비용이 빠르게 증가할 수 있다.
그렇다고 무조건 피해야 하는 낮은 수준의 방법은 아니다.
입력 크기가 작다면 가장 이해하기 쉽고 정확한 해결책일 수 있다. 더 빠른 방법이 필요하더라도 완전 탐색은 문제의 전체 선택 공간을 이해하게 해준다. 또한 최적화된 알고리즘의 결과를 비교할 수 있는 기준을 제공한다.
실제 개선 과정은 다음과 같이 진행할 수 있다.
완전 탐색은 무식하게 모든 경우를 확인하는 방법이 아니다. 정답이 존재할 수 있는 전체 범위를 드러내고, 정확성의 기준을 만든 뒤 정답이 될 수 없는 경우를 근거 있게 제거하는 문제 해결의 출발점이다.
처음부터 가장 빠른 해결책을 떠올리지 못해도 괜찮다.
먼저 확실하게 맞는 방법을 만들고, 어디에서 비용이 증가하는지 확인하며, 제거해도 되는 경우의 이유를 하나씩 찾는 편이 안전하다. 이 과정에서 문제의 구조와 서비스의 실제 규칙도 더 분명해진다.
다음 글에서는 문제 분해가 단순히 큰 함수를 여러 작은 함수로 나누는 일이 아니라, 복잡한 문제를 독립적으로 판단할 수 있는 작은 결정들로 바꾸는 방법인 이유를 살펴본다.