엣지 케이스는 특이한 상황만을 의미하지 않는다

vx_developer·2026년 9월 10일

코테보다가

목록 보기
9/25
post-thumbnail

알고리즘을 처음 배울 때 엣지 케이스는 보통 일반적인 입력과 조금 다른 특수한 사례로 배운다.

쿠폰 목록에서 가장 할인 금액이 큰 쿠폰을 찾는다고 생각해보자.

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일까지 사용할 수 있는 쿠폰처럼 보인다.

하지만 프로그램에서는 다음 질문에 답할 수 없다.

  • 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;
}

이미 처리한 요청이면 상태를 다시 변경하지 않고 이전 결과를 반환한다.

이를 멱등성이라고 한다. 같은 작업 요청이 반복되어도 서비스 상태에는 한 번만 반영되도록 만드는 성질이다.

용어를 외우는 것보다 중요한 점은 네트워크 응답 실패와 재시도가 정상적인 운영 흐름이라는 사실이다.

“요청은 한 번만 온다”라는 가정이 오히려 현실에서 벗어난 특별한 조건이다.

작업의 일부만 성공하는 상황을 설계해야 한다

쿠폰을 사용하면 여러 상태가 함께 바뀔 수 있다.

  1. 쿠폰의 남은 횟수를 차감한다.
  2. 쿠폰 사용 기록을 생성한다.
  3. 주문에 할인 금액을 반영한다.

다음 코드는 순서대로 작업을 실행한다.

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 검증
→ 인증과 권한 확인
→ 최신 상태 조회
→ 비즈니스 판단
→ 상태 변경
→ 응답
→ 후속 작업

각 단계 사이에는 실패 가능성이 있다.

  • 요청은 도착했지만 인증에 실패한다.
  • 조회는 성공했지만 변경 전에 상태가 바뀐다.
  • 변경은 성공했지만 응답 전송에 실패한다.
  • 응답은 성공했지만 알림 작업이 실패한다.

엣지 케이스는 목록 끝에 추가하는 별도의 항목이 아니다.

상태가 바뀌는 지점과 책임이 이동하는 경계에서 자연스럽게 발견할 수 있다.

엣지 케이스를 설계할 때 물어봐야 할 질문

새로운 기능을 구현할 때 다음 질문으로 정상 흐름 주변의 경계를 확인할 수 있다.

  1. 입력 데이터가 하나도 없을 때도 정상적인 상태인가?
  2. 목록에 항목이 하나만 있을 때 결과는 어떻게 되는가?
  3. 최소값, 최댓값과 정확한 경곗값에서는 어떤 정책이 적용되는가?
  4. 날짜와 시간이 같은 순간에는 어느 상태로 판단하는가?
  5. 시간대와 일광 절약 시간에 따라 결과가 달라질 수 있는가?
  6. 필수 입력이 누락되거나 잘못된 타입으로 들어오면 어떻게 되는가?
  7. 형식은 올바르지만 신뢰할 수 없는 외부 값이 있는가?
  8. 최종 판단의 Source of Truth는 어디에 있는가?
  9. 데이터를 읽은 뒤 사용하는 사이에 상태가 바뀔 수 있는가?
  10. 두 요청이 동시에 같은 데이터를 변경하면 어떻게 되는가?
  11. 같은 요청이 반복되어도 결과가 한 번만 반영되는가?
  12. 요청은 성공했지만 응답 전달이 실패하면 재시도를 안전하게 처리할 수 있는가?
  13. 여러 작업 중 일부만 성공하면 어떤 상태가 남는가?
  14. 함께 성공하거나 함께 실패해야 하는 작업은 무엇인가?
  15. 나중에 다시 처리할 수 있는 후속 작업은 무엇인가?
  16. 오래된 앱이나 서로 다른 버전의 클라이언트도 요청할 수 있는가?
  17. 평소보다 많은 요청이 동시에 들어오면 어떤 자원이 먼저 부족해지는가?
  18. 한 사용자에게 드문 상황이 전체 요청 규모에서는 얼마나 자주 발생하는가?
  19. 예외를 처리한 뒤에도 원인을 찾을 수 있는 로그와 지표가 남는가?
  20. 사용자에게 보여줄 실패와 내부에 기록할 실패를 구분했는가?
  21. 이 상황을 어느 계층에서 책임지는 것이 가장 안전한가?
  22. 복구할 수 없는 피해가 발생하는 경우를 우선적으로 막았는가?

모든 상황을 복잡한 코드로 미리 막을 필요는 없다.

발생 가능성, 피해 크기와 복구 비용을 기준으로 처리 방식을 선택해야 한다. 어떤 경우에는 사전에 차단하고, 어떤 경우에는 명확하게 실패하며, 어떤 경우에는 기록한 뒤 다시 시도하는 편이 적절하다.

엣지 케이스는 현실이 코드의 가정을 벗어나는 순간이다

입문 단계에서는 정상적인 입력을 중심으로 알고리즘을 이해하는 것이 좋다.

핵심 절차를 먼저 이해한 뒤 빈 값, 최솟값과 최댓값을 확인하면 코드의 범위를 넓힐 수 있다.

하지만 실제 서비스에서는 정상적인 흐름 자체가 생각보다 단순하지 않다.

사용자는 쿠폰을 보유하지 않을 수 있다. 요청은 중복될 수 있다. 데이터는 처리하는 동안 바뀔 수 있다. 네트워크는 응답만 잃어버릴 수 있다. 여러 작업 중 일부만 실패할 수 있다. 자정은 매일 오고 쿠폰의 만료 시각은 반드시 지나간다.

이런 상황은 서비스 바깥에 있는 특이한 사건이 아니다.

서비스가 사람, 시간, 네트워크와 공유된 데이터를 다루기 때문에 자연스럽게 발생하는 조건이다.

엣지 케이스는 드문 입력을 따로 처리하는 기술이 아니라, 데이터가 비어 있거나 경계에 도달하고 상태가 동시에 바뀌며 작업이 부분적으로 실패할 때도 서비스의 약속을 유지하도록 설계하는 일이다.

따라서 정상 흐름을 구현한 뒤 마지막에 엣지 케이스를 덧붙이는 방식만으로는 부족하다.

문제를 정의할 때부터 빈 상태, 경곗값, 동시 요청, 재시도와 실패 지점을 함께 살펴봐야 한다. 그래야 현실에서 반복되는 상황을 예외가 아니라 서비스가 책임지는 정상적인 동작으로 만들 수 있다.

다음 글에서는 모든 경우를 확인하는 완전 탐색이 왜 무식한 방법이 아니며, 가장 확실한 기준점을 만든 뒤 불필요한 탐색을 제거하는 출발점이 되는지 살펴본다.

profile
Vision eXperience Developer

0개의 댓글