알고리즘을 처음 배울 때 엣지 케이스는 보통 일반적인 입력과 조금 다른 특수한 사례로 배운다.
쿠폰 목록에서 가장 할인 금액이 큰 쿠폰을 찾는다고 생각해보자.
function findBestCoupon(coupons) {
return coupons.reduce((best, coupon) =>
coupon.discountAmount > best.discountAmount
? coupon
: best
);
}
쿠폰이 하나 이상 들어온다면 할인 금액을 비교하여 가장 큰 쿠폰을 반환한다.
이런 기본적인 구현을 이해한 뒤에는 다음과 같은 엣지 케이스를 확인한다.
findBestCoupon([]);
빈 배열에서는 reduce의 초기값이 없기 때문에 오류가 발생한다.
입력의 최솟값이나 빈 데이터를 확인하는 연습은 알고리즘의 안전성을 높이는 데 유용하다.
하지만 실제 서비스에서 엣지 케이스는 빈 배열처럼 문제 끝에 추가하는 특별한 입력만을 의미하지 않는다.
크리스가 쿠폰 서비스를 사용하는 동안에도 다음 상황은 자연스럽게 발생할 수 있다.
각 상황은 한 사용자에게는 가끔 발생할 수 있다. 그러나 많은 사용자가 계속 요청하는 서비스에서는 매일 반복될 수 있다.
실제 서비스에서 엣지 케이스는 현실에서 거의 일어나지 않는 이상한 상황이 아니라, 데이터와 시간과 상태가 경계에 도달할 때 반복해서 발생하는 정상적인 운영 조건이다.
쿠폰 목록이 비어 있는 상황부터 생각해보자.
function findBestCoupon(coupons) {
return coupons.reduce((best, coupon) =>
coupon.discountAmount > best.discountAmount
? coupon
: best
);
}
이 함수는 쿠폰이 반드시 하나 이상 있다고 가정한다.
하지만 신규 가입한 크리스가 아직 쿠폰을 받지 않았다면 빈 목록은 정상적인 데이터다. 쿠폰을 모두 사용했거나 만료된 경우에도 사용할 수 있는 목록은 비어 있을 수 있다.
빈 목록을 예외로만 처리하면 정상적인 사용자에게 오류 화면을 보여주게 된다.
function findBestCoupon(coupons) {
if (coupons.length === 0) {
return null;
}
return coupons.reduce((best, coupon) =>
coupon.discountAmount > best.discountAmount
? coupon
: best
);
}
이제 사용할 수 있는 쿠폰이 없으면 null을 반환한다.
코드 다음 단계도 이 결과의 의미를 알아야 한다.
const bestCoupon = findBestCoupon(coupons);
if (!bestCoupon) {
return {
type: "EMPTY",
message: "현재 사용할 수 있는 쿠폰이 없다.",
};
}
빈 데이터는 계산 실패가 아니다.
서비스가 사용자에게 보여줘야 하는 하나의 정상적인 결과다. 따라서 저장소, 서비스 로직과 UI가 모두 빈 상태를 같은 의미로 처리해야 한다.
| 상황 | 적절한 의미 |
|---|---|
| 조회된 쿠폰이 없음 | 정상적인 빈 목록 |
| 쿠폰 ID가 존재하지 않음 | 요청한 대상을 찾을 수 없음 |
| 데이터베이스 연결 실패 | 일시적인 시스템 오류 |
| 권한 없는 쿠폰을 조회함 | 접근할 수 없는 대상 |
| 데이터 형식이 손상됨 | 저장된 데이터의 무결성 문제 |
모두 “결과가 없다”처럼 보이지만 원인과 다음 행동은 다르다.
엣지 케이스를 처리한다는 것은 모든 상황을 같은 null이나 오류로 바꾸는 것이 아니라, 각각의 의미를 구분하는 일이다.
쿠폰의 만료 여부를 판단하는 코드를 살펴보자.
function isCouponExpired(coupon, now) {
return now > coupon.expiresAt;
}
현재 시간이 만료 시각보다 늦으면 만료된 것으로 판단한다.
그렇다면 현재 시간과 만료 시각이 정확히 같을 때는 어떻게 해야 할까?
서비스 정책이 “만료 시각 전까지만 사용 가능하다”라면 같은 시각부터 사용할 수 없어야 한다.
function isCouponExpired(coupon, now) {
return now >= coupon.expiresAt;
}
>와 >=의 차이는 작은 문법 차이처럼 보인다. 그러나 현실에서는 쿠폰을 사용할 수 있는 마지막 순간을 결정한다.
최소 주문 금액에도 같은 경계가 있다.
function canUseCoupon(orderAmount, minimumAmount) {
return orderAmount >= minimumAmount;
}
최소 주문 금액이 10,000원이라면 다음 세 입력은 서로 다른 의미를 가진다.
canUseCoupon(9999, 10000);
canUseCoupon(10000, 10000);
canUseCoupon(10001, 10000);
9,999원은 기준 미달이고 10,000원은 정책의 경계이며 10,001원은 명확하게 기준을 넘는다.
엣지 케이스는 값이 이상해서 생기는 것이 아니다. 서비스 규칙이 한 상태에서 다른 상태로 바뀌는 지점이기 때문에 중요하다.
쿠폰의 만료일을 다음과 같이 저장했다고 생각해보자.
const expiresAt = "2026-12-31";
사람이 읽으면 12월 31일까지 사용할 수 있는 쿠폰처럼 보인다.
하지만 프로그램에서는 다음 질문에 답할 수 없다.
단순한 날짜 문자열은 현실의 만료 순간을 충분히 표현하지 못한다.
const coupon = {
expiresAt: "2026-12-31T13:00:00.000Z",
displayTimeZone: "Australia/Sydney",
};
서버는 비교 가능한 하나의 시각을 저장하고, 화면은 사용자나 쿠폰 정책에 맞는 시간대로 변환해 보여줄 수 있다.
function isExpired(expiresAt, serverNow) {
return serverNow >= new Date(expiresAt);
}
최종 판단은 서버 시간과 저장된 만료 시각을 기준으로 한다.
사용자 기기의 현재 시간을 Source of Truth로 사용하면 기기 설정이나 시간대에 따라 같은 쿠폰의 결과가 달라질 수 있다.
날짜 경계의 문제는 드문 예외가 아니다. 자정과 월말, 연말은 반드시 도착한다. 시간대를 지원하는 서비스라면 서로 다른 지역의 날짜 경계도 매일 발생한다.
프런트엔드에서 쿠폰 ID와 주문 금액을 올바르게 전달한다고 생각해보자.
const requestBody = {
couponId: "coupon_123",
orderAmount: 10000,
};
정상적인 화면에서는 이런 요청을 보낼 수 있다.
하지만 실제 서버에는 다음과 같은 입력도 도착할 수 있다.
const requestBody = {
couponId: null,
orderAmount: "10000",
};
브라우저의 오래된 버전, 네트워크 중간의 잘못된 변환, 직접 작성한 요청이나 클라이언트 버그 때문에 예상하지 않은 형태가 들어올 수 있다.
값을 바로 사용하면 암묵적인 타입 변환이나 런타임 오류가 발생할 수 있다.
const orderAmount = Number(
request.body.orderAmount
);
if (
!Number.isSafeInteger(orderAmount) ||
orderAmount < 0
) {
throw new Error("주문 금액이 올바르지 않다.");
}
if (
typeof request.body.couponId !== "string" ||
request.body.couponId.length === 0
) {
throw new Error("쿠폰 ID가 올바르지 않다.");
}
이 코드는 주문 금액을 숫자로 변환한 뒤 안전한 정수인지 확인하고, 쿠폰 ID가 비어 있지 않은 문자열인지 검증한다.
하지만 형식이 올바르다고 최종 판단에 사용할 수 있는 것은 아니다.
클라이언트가 보낸 주문 금액은 조작될 수 있으므로 서버가 최신 장바구니와 상품 가격을 기준으로 다시 계산해야 한다.
const cart = await cartRepository.findByUserId(
currentUser.id
);
const orderAmount = calculateOrderAmount(
cart.items
);
외부 요청의 orderAmount는 참고값일 수 있지만 최종 금액의 Source of Truth는 아니다.
엣지 케이스를 생각할 때는 잘못된 값만 확인해서는 부족하다. 형식은 올바르지만 신뢰할 수 없는 값도 함께 고려해야 한다.
크리스가 쿠폰 목록을 열었을 때 남은 사용 횟수가 1회였다고 생각해보자.
const coupon = {
id: "coupon_123",
status: "ACTIVE",
remainingUses: 1,
};
화면은 쿠폰을 사용할 수 있다고 표시한다.
그런데 크리스가 결제 버튼을 누르기 전에 다른 기기에서 같은 쿠폰을 사용할 수 있다. 운영자가 쿠폰을 취소할 수도 있고, 그 사이 만료 시각이 지날 수도 있다.
화면에서 보았던 상태를 그대로 서버에 보내 최종 판단에 사용하면 안 된다.
await redeemCoupon({
couponId: coupon.id,
remainingUses: coupon.remainingUses,
status: coupon.status,
});
remainingUses와 status는 화면이 마지막으로 조회한 시점의 복사본이다.
클라이언트는 사용하려는 쿠폰의 ID만 전달하고 서버가 최신 상태를 다시 확인해야 한다.
await redeemCoupon({
couponId: coupon.id,
requesterId: session.user.id,
redeemedAt: clock.now(),
});
여기에서 중요한 엣지 케이스는 “값이 이상한 쿠폰”이 아니다.
값을 확인한 시점과 사용하는 시점 사이에 상태가 변경되는 상황이다.
실제 서비스의 데이터는 함수가 실행되는 동안에도 다른 사용자와 작업에 의해 바뀔 수 있다. 따라서 상태를 읽었다는 사실이 이후의 변경 권한을 보장하지 않는다.
남은 횟수를 확인한 뒤 차감하는 코드를 생각해보자.
const coupon = await couponRepository.findById(
couponId
);
if (coupon.remainingUses > 0) {
await couponRepository.updateRemainingUses(
couponId,
coupon.remainingUses - 1
);
}
하나의 요청만 처리할 때는 정상적으로 동작한다.
그러나 남은 횟수가 1회일 때 두 요청이 동시에 같은 값을 읽으면 두 요청 모두 쿠폰을 사용할 수 있다고 판단한다.
1회용 쿠폰이 두 번 사용되는 결과가 생길 수 있다.
이 문제를 “사용자가 정확히 같은 순간에 버튼을 누르는 매우 드문 상황”이라고 생각하기 쉽다.
하지만 다음과 같은 이유로 같은 작업은 자연스럽게 겹친다.
확인과 변경을 하나의 조건부 작업으로 처리할 수 있다.
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;
처리 순간에도 모든 조건을 만족하는 경우에만 남은 횟수를 차감한다.
const updatedCoupon =
await couponRepository.redeemIfAvailable({
couponId,
requesterId,
redeemedAt: serverNow,
});
if (!updatedCoupon) {
return {
ok: false,
reason: "COUPON_NOT_REDEEMABLE",
};
}
이제 다른 요청이 쿠폰을 먼저 사용했다면 조건을 만족하는 행이 없으므로 현재 요청은 실패한다.
동시성은 사용자가 특이하게 행동해서 생기는 예외가 아니다. 여러 요청을 함께 처리하는 서비스가 기본적으로 고려해야 하는 실행 환경이다.
쿠폰 사용은 성공했지만 서버의 응답이 크리스에게 도착하지 않았다고 생각해보자.
서버의 데이터베이스에는 쿠폰 사용이 기록되었다. 그러나 앱은 응답을 받지 못했으므로 실패로 판단한다.
앱이 같은 요청을 다시 보내면 어떻게 해야 할까?
await redeemCoupon({
couponId: "coupon_123",
});
요청을 다시 실행하면 쿠폰이 두 번 차감될 수 있다.
이 문제를 막으려면 하나의 사용자 행동을 식별하는 값이 필요하다.
await redeemCoupon({
couponId: "coupon_123",
requestId: "request_456",
});
서버는 같은 requestId로 이미 처리한 결과가 있는지 확인한다.
const previousResult =
await redemptionRepository.findByRequestId(
requestId
);
if (previousResult) {
return previousResult;
}
이미 처리한 요청이면 상태를 다시 변경하지 않고 이전 결과를 반환한다.
이를 멱등성이라고 한다. 같은 작업 요청이 반복되어도 서비스 상태에는 한 번만 반영되도록 만드는 성질이다.
용어를 외우는 것보다 중요한 점은 네트워크 응답 실패와 재시도가 정상적인 운영 흐름이라는 사실이다.
“요청은 한 번만 온다”라는 가정이 오히려 현실에서 벗어난 특별한 조건이다.
쿠폰을 사용하면 여러 상태가 함께 바뀔 수 있다.
다음 코드는 순서대로 작업을 실행한다.
await couponRepository.decreaseRemainingUses(
couponId
);
await redemptionRepository.create({
couponId,
userId,
});
await orderRepository.applyDiscount({
orderId,
discountAmount,
});
첫 번째 작업은 성공했지만 두 번째 작업에서 데이터베이스 오류가 발생할 수 있다.
그러면 쿠폰 횟수는 줄었지만 사용 기록과 주문 할인은 없는 상태가 남는다.
이것은 입력값의 경계가 아니라 실행 과정의 경계에서 생기는 엣지 케이스다.
모든 변경이 같은 데이터베이스에 있다면 하나의 트랜잭션으로 처리할 수 있다.
await database.transaction(async (transaction) => {
await couponRepository.redeem(
couponId,
transaction
);
await redemptionRepository.create(
redemption,
transaction
);
await orderRepository.applyDiscount(
order,
transaction
);
});
세 작업이 모두 성공하면 반영하고, 중간에 하나라도 실패하면 이전 변경도 취소한다.
그러나 외부 알림 서비스처럼 같은 트랜잭션에 넣을 수 없는 작업도 있다.
await redeemCoupon(command);
await notificationService.sendCouponUsed(
userId
);
알림 전송이 실패했다고 쿠폰 사용 자체까지 취소할 필요는 없을 수 있다. 대신 알림 작업을 따로 저장하고 재시도할 수 있다.
| 작업 | 실패 시 필요한 처리 |
|---|---|
| 쿠폰 차감 | 전체 사용 처리 실패 |
| 사용 기록 생성 | 쿠폰 차감과 함께 취소 |
| 주문 할인 반영 | 관련 변경과 함께 취소 |
| 알림 전송 | 쿠폰 사용은 유지하고 별도 재시도 |
| 운영 로그 기록 | 핵심 처리에 미치는 영향에 따라 결정 |
부분 실패를 처리한다는 것은 모든 작업을 무조건 함께 성공시키는 것이 아니다.
어떤 작업이 반드시 함께 성공해야 하며, 어떤 작업은 나중에 다시 처리할 수 있는지 책임을 구분하는 일이다.
어떤 문제가 요청 10만 건 중 한 번 발생한다고 생각해보자.
개발 환경에서 수십 번 실행할 때는 한 번도 보지 못할 수 있다.
그러나 서비스가 하루에 1,000만 건의 요청을 처리한다면 같은 확률의 문제가 하루에 약 100번 발생할 수 있다.
개별 사용자의 관점에서는 드물어도 서비스 전체에서는 반복되는 문제다.
다음과 같은 상황이 여기에 포함될 수 있다.
따라서 엣지 케이스의 우선순위는 단순히 발생 확률만으로 결정하면 안 된다.
다음 요소를 함께 봐야 한다.
| 판단 기준 | 확인할 질문 |
|---|---|
| 발생 가능성 | 얼마나 자주 일어나는가 |
| 요청 규모 | 전체 요청 수가 얼마나 많은가 |
| 피해 크기 | 발생하면 금액, 데이터와 사용자에게 어떤 영향이 있는가 |
| 복구 가능성 | 자동 복구 또는 재처리가 가능한가 |
| 발견 가능성 | 로그와 지표로 빠르게 알아낼 수 있는가 |
| 전파 범위 | 한 사용자에게 끝나는가, 다른 기능에도 영향을 주는가 |
발생 가능성이 낮더라도 결제가 중복되거나 개인정보가 노출되는 문제라면 우선적으로 막아야 한다.
반대로 피해가 작고 쉽게 복구할 수 있는 상황이라면 복잡한 사전 방어보다 명확한 실패와 재시도를 선택할 수 있다.
예외 상황마다 책임지는 위치가 다르다.
| 상황 | 주로 처리할 위치 | 이유 |
|---|---|---|
| 쿠폰 ID 형식이 잘못됨 | API 입력 검증 | 서비스 내부에 잘못된 형식이 들어오는 것을 막는다 |
| 인증되지 않은 요청 | 인증 계층 | 요청자의 신원을 먼저 확인한다 |
| 다른 사용자의 쿠폰 | 서비스 권한 검증 | 소유 관계는 비즈니스 규칙이다 |
| 만료된 쿠폰 | 도메인 로직 | 쿠폰의 사용 정책에 해당한다 |
| 동시 사용 | 데이터베이스 조건부 변경 | 최신 상태를 기준으로 한 번만 변경해야 한다 |
| 같은 요청의 재시도 | 요청 기록과 저장소 | 이미 처리한 작업인지 영구적으로 확인해야 한다 |
| 알림 서비스 장애 | 작업 대기열과 재시도 정책 | 핵심 처리와 분리하여 나중에 다시 실행한다 |
| 빈 쿠폰 목록 | UI 상태 | 사용자에게 정상적인 빈 화면을 보여준다 |
| 대량 요청 | 요청 제한과 인프라 | 전체 시스템의 자원을 보호한다 |
모든 조건을 프런트엔드에서 막으면 요청을 직접 수정하는 사용자를 막지 못한다.
모든 조건을 하나의 서비스 함수에 넣으면 데이터베이스의 동시 상태나 외부 서비스 실패를 제대로 다루기 어렵다.
엣지 케이스를 발견한 다음에는 “어떻게 처리할까?”뿐 아니라 “어디에서 책임져야 할까?”도 물어야 한다.
잘못된 날짜가 있는 쿠폰을 다음과 같이 건너뛸 수 있다.
function isCouponExpired(coupon, now) {
const expiresAt = new Date(coupon.expiresAt);
if (Number.isNaN(expiresAt.getTime())) {
return true;
}
return now >= expiresAt;
}
잘못된 날짜를 만료된 쿠폰처럼 처리하면 사용자에게 사용할 수 없는 쿠폰이 노출되는 문제는 막을 수 있다.
하지만 데이터가 손상되었다는 사실까지 사라져서는 안 된다.
if (Number.isNaN(expiresAt.getTime())) {
logger.error("Invalid coupon expiry date", {
couponId: coupon.id,
expiresAt: coupon.expiresAt,
});
return true;
}
사용자에게는 안전한 결과를 제공하면서 운영 로그에는 원인을 남길 수 있다.
예외 상황을 처리했다는 것은 오류가 발생하지 않게 만들었다는 뜻만이 아니다.
이 질문에 답할 수 있어야 예외 처리가 서비스 운영으로 이어진다.
엣지 케이스를 찾기 위해 무작위로 이상한 상황을 상상할 필요는 없다.
쿠폰이 어떤 상태를 거치는지 따라가면 중요한 경계가 보인다.
발행 예정 → 활성 → 사용 완료
↘ 만료
↘ 취소
각 전환점에서 질문할 수 있다.
데이터 흐름에서도 경계를 찾을 수 있다.
사용자 입력
→ API 검증
→ 인증과 권한 확인
→ 최신 상태 조회
→ 비즈니스 판단
→ 상태 변경
→ 응답
→ 후속 작업
각 단계 사이에는 실패 가능성이 있다.
엣지 케이스는 목록 끝에 추가하는 별도의 항목이 아니다.
상태가 바뀌는 지점과 책임이 이동하는 경계에서 자연스럽게 발견할 수 있다.
새로운 기능을 구현할 때 다음 질문으로 정상 흐름 주변의 경계를 확인할 수 있다.
모든 상황을 복잡한 코드로 미리 막을 필요는 없다.
발생 가능성, 피해 크기와 복구 비용을 기준으로 처리 방식을 선택해야 한다. 어떤 경우에는 사전에 차단하고, 어떤 경우에는 명확하게 실패하며, 어떤 경우에는 기록한 뒤 다시 시도하는 편이 적절하다.
입문 단계에서는 정상적인 입력을 중심으로 알고리즘을 이해하는 것이 좋다.
핵심 절차를 먼저 이해한 뒤 빈 값, 최솟값과 최댓값을 확인하면 코드의 범위를 넓힐 수 있다.
하지만 실제 서비스에서는 정상적인 흐름 자체가 생각보다 단순하지 않다.
사용자는 쿠폰을 보유하지 않을 수 있다. 요청은 중복될 수 있다. 데이터는 처리하는 동안 바뀔 수 있다. 네트워크는 응답만 잃어버릴 수 있다. 여러 작업 중 일부만 실패할 수 있다. 자정은 매일 오고 쿠폰의 만료 시각은 반드시 지나간다.
이런 상황은 서비스 바깥에 있는 특이한 사건이 아니다.
서비스가 사람, 시간, 네트워크와 공유된 데이터를 다루기 때문에 자연스럽게 발생하는 조건이다.
엣지 케이스는 드문 입력을 따로 처리하는 기술이 아니라, 데이터가 비어 있거나 경계에 도달하고 상태가 동시에 바뀌며 작업이 부분적으로 실패할 때도 서비스의 약속을 유지하도록 설계하는 일이다.
따라서 정상 흐름을 구현한 뒤 마지막에 엣지 케이스를 덧붙이는 방식만으로는 부족하다.
문제를 정의할 때부터 빈 상태, 경곗값, 동시 요청, 재시도와 실패 지점을 함께 살펴봐야 한다. 그래야 현실에서 반복되는 상황을 예외가 아니라 서비스가 책임지는 정상적인 동작으로 만들 수 있다.
다음 글에서는 모든 경우를 확인하는 완전 탐색이 왜 무식한 방법이 아니며, 가장 확실한 기준점을 만든 뒤 불필요한 탐색을 제거하는 출발점이 되는지 살펴본다.