한 줄 요약: 반품·환불은 주문의 역방향이 아니다. 금액 분배, 재고 복원, 물류 수거, 어뷰징 탐지까지 독립적으로 설계해야 하루 10만 건을 실수 없이 처리할 수 있다.


실제 사고: 쿠팡 대규모 환불 지연 사태

2022년 설 연휴 직후, 국내 최대 이커머스 플랫폼에서 반품 처리 지연이 집단 민원으로 번졌습니다. 명절 기간 폭증한 주문이 연휴 직후 일괄 반품으로 전환되면서 반품 접수 건이 평소의 8배로 튀어올랐습니다. 결과는 세 가지였습니다.

첫째, 환불 지연. 평균 1영업일이던 환불이 9~12영업일로 늘었습니다. 환불 승인 로직이 단일 스레드 배치 잡으로 동작하고 있었고, 적체된 큐가 순차 소화되는 데 열흘 넘게 걸렸습니다.

둘째, 이중 환불. 사용자가 환불이 안 된다고 판단해 재신청했고, 시스템은 멱등성 검증 없이 두 요청을 모두 처리했습니다. “환불을 두 번 받았다”는 제보가 온라인에 수백 건 올라왔습니다.

셋째, 재고 유령 복원. 반품 입고 확인 전에 재고를 미리 복원한 탓에 실제 창고에 없는 상품이 ‘재고 있음’으로 표시되어 재주문됐습니다. 한 SKU에서 고객이 없는 물건을 주문받아 배송 불가 처리하는 일이 반복됐습니다.

이 글은 이 세 가지 사고를 구조적으로 막는 반품·환불 시스템을 WHY 중심으로 설계합니다.


1. 설계 의사결정 로드맵

반품·환불 시스템에서 면접관이 가장 주목하는 것은 “왜 이 구조를 골랐는가”입니다. 결정 하나하나에 트레이드오프 근거가 있어야 합니다.

결정 1 — 환불 방식: 원결제 취소 vs 포인트 vs 혼합

후보 장점 단점 언제 적합
원결제 취소 사용자 만족도 최고, 규제 준수 명확 PG 환불 API 의존, 카드사 영업일 3~5일 소요 법적 의무 환불, B2C 일반 반품
포인트 전환 즉시 지급 가능, PG 수수료 없음 사용자 불만, 금융당국 지적 가능 이벤트 취소·소액 보상
혼합 (카드+포인트) 쿠폰·적립금 원천 구분 환불 계산 복잡도 급증, 소수점 오차 발생 쿠폰·포인트 복합 결제

우리의 선택: 혼합 방식 (카드 결제 금액 → 카드 취소, 포인트 결제 금액 → 포인트 복원)

WHY: 실제 커머스 결제의 60% 이상이 카드+포인트 복합입니다. “5만원 중 1만원은 적립금으로 냈어요”인데 5만원 전액을 카드로 환불하면 사용자는 이득, 플랫폼은 손실입니다. 반드시 원결제 수단별로 분리해 돌려줘야 합니다.


결정 2 — 반품 상태 관리: Saga vs FSM

후보 장점 단점 언제 적합
Saga (분산 트랜잭션) 서비스 간 느슨한 결합, 보상 트랜잭션 명확 디버깅 어려움, 이벤트 순서 보장 필요 MSA에서 물류·재고·PG 분리 운영 시
FSM (유한 상태 머신) 상태 전이 명확, 잘못된 전이 컴파일 타임 차단 서비스 내 강결합 모놀리스·반품 상태가 단일 서비스 내
혼합 (FSM + Saga) 서비스 내 FSM, 서비스 간 Saga 복잡도 높음 대규모 MSA

우리의 선택: FSM + Saga 혼합

WHY: 반품 단계는 REQUESTED → PICKUP_SCHEDULED → IN_TRANSIT → INSPECTING → APPROVED → REFUNDED 처럼 순서가 엄격합니다. 이 전이를 FSM으로 제어하면 “검수 전에 환불” 같은 버그를 컴파일 타임에 차단합니다. 단, 물류사·PG·재고 서비스가 별도 MSA라면 각 단계 보상 트랜잭션을 Saga로 엮어야 합니다. 두 패턴을 계층적으로 쓰는 것이 현실적입니다.


결정 3 — 재고 복원 시점: 입고 확인 후 vs 즉시

후보 장점 단점 언제 적합
입고 확인 후 복원 재고 정합성 보장, 유령 재고 없음 환불 전 재고 공백 발생 고가품, 재고 정확도 중요
즉시 복원 재고 회전 빠름, 품절 줄어듦 반품 거부 시 재고 오염, 유령 재고 저가 소모품, 검수 불필요
조건부 즉시 복원 리스크 낮은 카테고리만 선즉시 카테고리별 로직 분기 필요 하이브리드 운영

우리의 선택: 입고 확인 후 복원 (기본값), 검수 불필요 카테고리는 조건부 즉시 복원

WHY: 쿠팡 사고의 핵심이 여기에 있습니다. 반품 신청만으로 재고를 올리면 고객이 물건을 돌려보내지 않거나, 파손 상태로 보내도 재고가 살아납니다. “있다고 표시된 물건이 없다”는 클레임은 재고 즉시 복원에서 시작됩니다. 기본은 보수적으로 가야 합니다.


결정 4 — 환불 승인: 자동 vs 수동 심사

후보 장점 단점 언제 적합
자동 승인 처리 속도 빠름, CS 비용 없음 어뷰징 취약, 고가품 리스크 소액, 단순 반품, 신뢰 고객
수동 심사 어뷰징 차단, 고가품 보호 처리 지연, CS 비용 증가 고가품, 이상 패턴 감지 시
규칙 기반 자동화 리스크 점수로 자동/수동 분기 규칙 관리 비용, 오탐 중대형 플랫폼 기본값

우리의 선택: 규칙 기반 자동화 (리스크 스코어 < 30이면 즉시 자동 승인, 이상이면 수동 큐)

WHY: 하루 10만 건을 전부 수동 심사하면 CS 인력이 수백 명 필요합니다. 반대로 전자동이면 “한 달에 20번 반품하고 15번 환불받은” 어뷰저를 막을 수 없습니다. 리스크 스코어 기반 분기는 두 문제를 동시에 해결합니다.


결정 5 — 부분 환불 계산: 비례 배분 vs 우선순위

후보 장점 단점 언제 적합
비례 배분 계산 직관적, 구현 단순 쿠폰 할인이 어느 상품에 귀속되는지 불명확 전체 취소, 단일 상품 주문
우선순위 배분 쿠폰/적립금 귀속 명확, 세금 계산 정확 구현 복잡, 예외 케이스 많음 다품목 주문, 쿠폰 혼합 결제
고정 귀속 + 비례 잔여 쿠폰은 1상품 귀속, 나머지 비례 쿠폰 귀속 상품 정의 필요 실무 추천

우리의 선택: 고정 귀속 + 비례 잔여 방식

WHY: “3만원 쿠폰으로 A 상품만 할인받았는데 A만 반품하면 3만원 쿠폰 전액을 돌려줘야 하는가?” 이 질문에 답을 못 내면 환불 금액 오류가 발생합니다. 쿠폰은 귀속 상품에 고정하고, 잔여 할인은 남은 상품에 비례 배분하는 것이 법적으로도 안전합니다.


2. 요구사항 분석 및 규모 추정

2-1. 기능 요구사항

기능 상세 면접에서 확인할 것
반품 신청 사유 선택, 사진 첨부, 부분 반품 교환도 포함인가? 해외직구는?
수거 예약 물류사 연동, 수거 날짜 선택 자체 반납 가능한가?
검수 처리 입고 확인, 상품 상태 등록 불량 반품 시 환불 거부 플로우?
환불 처리 PG 취소, 포인트 복원, 쿠폰 복원 부분 환불 지원 여부?
반품 상태 조회 실시간 상태 추적, 알림 발송 배송 추적번호 연동 여부?
어뷰징 탐지 과다 반품자 플래깅, 자동 제재 제재 기준이 정책인가 알고리즘인가?

2-2. 비기능 요구사항

항목 목표 근거
가용성 99.99% 환불 불가 = 금융 사고. 연간 52분 이하
환불 처리 지연 P99 3초 이내 버튼 클릭 후 “환불 신청됨” 피드백 즉시 필요
실제 환불 완료 영업일 1~3일 PG사 정책 준수 (여신금융협회 규정)
멱등성 동일 요청 중복 처리 0건 이중 환불 절대 불가
데이터 정합성 Strong Consistency 금액 오차 0원 목표

2-3. 규모 추정

가정: DAU 200만, 주문 10만 건/일, 반품률 5%
반품 신청 = 10만 × 5% = 5,000건/일
환불 처리 = 4,500건/일 (승인율 90% 가정)
피크 배율 = 명절 연휴 직후 8배 → 피크 3.6만 건/일

초당 평균 환불 처리 = 4,500 / 86,400 ≈ 0.05 TPS
피크 환불 처리 = 3.6만 / (8시간 × 3,600) ≈ 1.25 TPS

환불 금액 레코드 = 4,500건 × 365일 × 5년 ≈ 820만 레코드 → 단일 DB 충분
반품 이미지 = 5,000건 × 3장 × 평균 500KB = 7.5GB/일 → S3 스토리지

비유: 반품 처리는 명절 직후 우체국 창구와 같습니다. 평소엔 조용하다가 특정 시기에 수십 배로 몰립니다. 이 피크를 설계 기준으로 잡지 않으면 반드시 터집니다.


3. 고수준 아키텍처

비유: 반품 시스템은 공항 수하물 분실 처리와 같습니다. 고객이 신고(반품 신청)하면, 수하물 추적팀(물류사)이 짐을 찾고(수거), 검수 부서가 상태를 확인하고(검수), 보상 팀이 보상을 실행합니다(환불). 각 팀이 독립적으로 움직이되 상태는 중앙에서 관리합니다.

graph LR
  A["고객 앱"] --> B["반품 API"]
  B --> C["반품 FSM"]
  C --> D["환불 계산기"]
  C --> E["물류 연동"]
  D --> F["PG 어댑터"]
  C --> G["어뷰징 탐지"]
컴포넌트 역할 핵심 책임
반품 API 진입점, 멱등성 키 발급 중복 요청 차단, 입력 검증
반품 FSM 상태 전이 관리자 불법 전이 차단, 이벤트 발행
환불 계산기 금액 분배 엔진 카드/포인트/쿠폰 원천별 환불액 산출
물류 연동 수거·입고 처리 수거 예약, 입고 확인 웹훅 수신
PG 어댑터 카드사 환불 실행 멱등키 포함 취소 요청, 재시도 로직
어뷰징 탐지 리스크 스코어 산출 과다 반품자 플래깅, 수동 심사 큐 이관

4. 핵심 컴포넌트 상세 설계

4-1. 반품 FSM (유한 상태 머신)

비유: FSM은 엘리베이터 제어기와 같습니다. 2층에서 5층 버튼을 눌러야 올라갑니다. 1층에서 곧바로 5층 호출이 불가능한 게 아니라, 중간 층을 거치는 것이 ‘규칙’입니다. 상태 전이 규칙을 코드로 명문화하면 “검수 전 환불”처럼 순서를 어긴 버그가 컴파일 타임에 차단됩니다.

반품은 총 7개 상태를 가집니다: REQUESTED, PICKUP_SCHEDULED, IN_TRANSIT, INSPECTING, APPROVED, REJECTED, REFUNDED. 이 상태들을 Java enum으로 표현하고, 허용된 전이만 실행되도록 강제합니다.

public enum ReturnStatus {
    REQUESTED,
    PICKUP_SCHEDULED,
    IN_TRANSIT,
    INSPECTING,
    APPROVED,
    REJECTED,
    REFUNDED;

    private static final Map<ReturnStatus, Set<ReturnStatus>> ALLOWED_TRANSITIONS = Map.of(
        REQUESTED,         Set.of(PICKUP_SCHEDULED, REJECTED),
        PICKUP_SCHEDULED,  Set.of(IN_TRANSIT, REJECTED),
        IN_TRANSIT,        Set.of(INSPECTING),
        INSPECTING,        Set.of(APPROVED, REJECTED),
        APPROVED,          Set.of(REFUNDED),
        REJECTED,          Set.of(),
        REFUNDED,          Set.of()
    );

    public ReturnStatus transition(ReturnStatus next) {
        if (!ALLOWED_TRANSITIONS.get(this).contains(next)) {
            throw new IllegalStateTransitionException(this, next);
        }
        return next;
    }
}

FSM의 핵심은 ALLOWED_TRANSITIONS 맵입니다. INSPECTING에서 REFUNDED로 건너뛰는 것이 Map에 없으므로 예외가 발생합니다. “검수 완료 없이 환불”이라는 운영 실수가 코드 레벨에서 차단됩니다.

상태 전이가 발생할 때마다 ReturnStatusChangedEvent를 발행합니다. 물류 연동, 알림 발송, 어뷰징 재평가가 이 이벤트를 구독합니다. 상태 변경 시각도 return_status_history 테이블에 기록해 각 단계 소요 시간을 측정할 수 있게 합니다.


4-2. 환불 금액 계산기 (쿠폰/포인트/카드 분배)

비유: 환불 계산기는 더치페이 정산 앱과 같습니다. “내가 낸 것”만 돌려받아야 합니다. 카드로 낸 4만원은 카드로, 포인트로 낸 1만원은 포인트로 돌아가야 합니다. 그런데 1만원짜리 쿠폰이 A 상품에만 적용됐다면, A만 반품할 때 쿠폰 1만원 전체를 돌려줘야 합니다.

복합 결제(카드 4만원 + 포인트 1만원 + 쿠폰 1만원 = 합계 6만원) 주문에서 A 상품(3만원)만 반품하는 시나리오입니다.

public class RefundCalculator {

    public RefundBreakdown calculate(Order order, List<Long> returnItemIds) {
        // 1. 반품 상품의 정가 합계
        int itemTotal = order.getItems().stream()
            .filter(i -> returnItemIds.contains(i.getId()))
            .mapToInt(OrderItem::getPrice)
            .sum();

        // 2. 쿠폰 귀속 처리: 반품 상품에 귀속된 쿠폰만 복원
        int couponRefund = order.getCoupons().stream()
            .filter(c -> returnItemIds.contains(c.getAppliedItemId()))
            .mapToInt(Coupon::getDiscountAmount)
            .sum();

        // 3. 할인 후 실결제 비율로 카드/포인트 분배
        int discountedTotal = itemTotal - couponRefund;
        double ratio = (double) discountedTotal / order.getActualPaidAmount();

        int cardRefund = (int) Math.floor(order.getCardAmount() * ratio);
        int pointRefund = discountedTotal - cardRefund; // 소수점 차이는 카드에서 조정

        return RefundBreakdown.builder()
            .cardAmount(cardRefund)
            .pointAmount(pointRefund)
            .couponAmount(couponRefund)
            .totalAmount(cardRefund + pointRefund + couponRefund)
            .build();
    }
}

주의점이 세 가지입니다.

첫째, 소수점 처리입니다. Math.floor로 내림 후 나머지를 카드에 할당합니다. 반올림은 총 환불액이 실결제액을 초과할 수 있어 위험합니다.

둘째, 쿠폰 귀속입니다. 쿠폰이 특정 상품에 귀속되지 않은 경우(전체 주문 할인), 반품 상품 금액 비율로 쿠폰 할인액을 나눕니다.

셋째, 배송비 처리입니다. 전체 반품이면 배송비도 환불, 부분 반품이면 배송비는 환불 안 함이 일반적 정책입니다. 이를 별도 파라미터로 받아야 합니다.


4-3. PG 환불 연동

비유: PG 환불 연동은 병원 원무과 청구 취소와 같습니다. 청구를 취소해달라고 했는데 답이 안 오면 취소가 된 건지 안 된 건지 모릅니다. 멱등키로 “이 취소 요청은 딱 한 번만 처리하세요”라고 못 박아야 합니다.

PG 환불에서 가장 위험한 상황은 “요청은 성공했지만 응답이 유실된” 케이스입니다. 이 경우 재시도하면 이중 환불, 재시도 안 하면 환불 누락입니다.

@Service
public class PgRefundAdapter {

    public PgRefundResult requestRefund(RefundRequest request) {
        String idempotencyKey = "refund:" + request.getReturnId();

        try {
            return pgClient.cancelPayment(
                request.getPgTxId(),
                request.getCardAmount(),
                idempotencyKey  // PG사 측에서 동일 키 재요청은 첫 응답 그대로 반환
            );
        } catch (PgTimeoutException e) {
            // 타임아웃 = 성공 여부 불명확 → 조회로 확인
            return pgClient.queryRefundStatus(request.getPgTxId(), idempotencyKey);
        } catch (PgAlreadyRefundedException e) {
            // 이미 처리됨 → 멱등키로 기존 결과 반환
            return pgClient.queryRefundStatus(request.getPgTxId(), idempotencyKey);
        }
    }
}

PG 환불은 세 가지 결과가 나옵니다: 성공, 실패, 불명확(타임아웃). 불명확 케이스를 조회로 해소하는 로직이 없으면 운영자가 수동으로 PG 대사를 맞춰야 합니다. 멱등키 기반 재조회가 이를 자동화합니다.

환불 실패 시 지수 백오프 재시도를 적용합니다. 1초 → 2초 → 4초 → 8초 → 포기 후 Dead Letter Queue 이관. 최대 5회 재시도 이후에도 실패하면 운영자 알림과 함께 수동 처리 큐로 이관합니다.


4-4. 반품 물류 연동 (수거 → 검수 → 입고)

비유: 물류 연동은 택배 도착 알림과 같습니다. 우리가 택배 차량을 통제할 수 없듯, 물류사 시스템도 외부입니다. 우리가 할 수 있는 것은 “물건이 들어왔을 때 알려줘”라는 웹훅 등록뿐입니다. 웹훅이 유실되면 재고가 영원히 복원되지 않습니다.

물류 연동의 핵심은 웹훅 수신의 신뢰성입니다.

@RestController
public class LogisticsWebhookController {

    @PostMapping("/webhook/logistics/arrival")
    @Transactional
    public ResponseEntity<Void> onItemArrived(@RequestBody ArrivalEvent event) {
        // 1. 웹훅 중복 처리 방지
        if (webhookIdempotencyStore.exists(event.getWebhookId())) {
            return ResponseEntity.ok().build(); // 이미 처리됨, 200 반환
        }

        // 2. 반품 FSM 전이: IN_TRANSIT → INSPECTING
        returnService.transitionStatus(event.getReturnId(), ReturnStatus.INSPECTING);

        // 3. 검수 담당자 배정
        inspectionService.assignInspector(event.getReturnId(), event.getWarehouseCode());

        // 4. 멱등성 기록
        webhookIdempotencyStore.mark(event.getWebhookId());

        return ResponseEntity.ok().build();
    }
}

웹훅 처리에서 “200을 반환했지만 DB 저장에 실패”가 발생하면 물류사가 재전송하지 않아 입고 이벤트가 유실됩니다. @Transactional 안에서 멱등성 기록과 상태 전이를 묶어야 합니다.

검수 결과 웹훅도 동일 패턴으로 처리합니다. 검수 결과가 PASSINSPECTING → APPROVED 전이, FAIL이면 INSPECTING → REJECTED 전이가 일어나고 각각 환불 처리 혹은 반품 거부 알림이 발행됩니다.


4-5. 어뷰징 탐지 (과다 반품자)

비유: 어뷰징 탐지는 도서관 연체 관리 시스템과 같습니다. 연체가 3번 누적되면 대출 정지. 기준이 명확하고, 자동 집행됩니다. 다만 처음 위반자를 바로 정지하면 불만이 폭주합니다. 경고 → 수동 심사 → 정지의 단계적 에스컬레이션이 필요합니다.

리스크 스코어는 세 가지 지표를 합산합니다.

public class AbuseDetector {

    public int calculateRiskScore(long userId) {
        UserReturnStats stats = returnStatsRepo.findByUserId(userId);

        // 1. 반품률: 최근 30일 주문 대비 반품 비율 (최대 40점)
        double returnRate = (double) stats.getReturnCount30d() / stats.getOrderCount30d();
        int returnRateScore = (int) Math.min(40, returnRate * 100);

        // 2. 반품 빈도: 월 10건 초과 시 가중 (최대 40점)
        int frequencyScore = Math.min(40, stats.getReturnCount30d() * 4);

        // 3. 반품 사유 패턴: "단순 변심" 비율 (최대 20점)
        double whimRatio = stats.getWhimReturnRatio();
        int whimScore = (int) (whimRatio * 20);

        return returnRateScore + frequencyScore + whimScore;
        // 0~30: 자동 승인, 31~60: 수동 심사, 61+: 자동 거부 후 CS 에스컬레이션
    }
}

스코어 30 이하면 자동 승인, 31~60이면 수동 심사 큐, 61 이상이면 CS 에스컬레이션 큐로 이관됩니다. 스코어 임계값은 운영 데이터로 튜닝해야 합니다. 출시 초기에는 기준을 느슨하게 잡고, 어뷰징 패턴을 수집한 뒤 조정합니다.


5. 극한 시나리오 3개

시나리오 1 — 명절 연휴 직후 반품 폭탄 (피크 8배)

상황: 설 연휴 직후 월요일 오전 9시, 5일치 반품이 동시에 쏟아집니다. 평소 5,000건/일이던 반품이 4만 건으로 치솟습니다. 환불 계산 워커가 큐를 소화하지 못해 적체가 쌓입니다.

실패 메커니즘: 환불 계산 워커가 단일 인스턴스로 동작하고 있으면, 4만 건 큐를 순차 처리하는 데 8시간 이상 걸립니다. 사용자는 “환불 신청했는데 3일째 반응이 없다”는 민원을 올립니다.

대응 구조:

graph LR
  A["반품 신청 큐"] --> B["워커 오토스케일"]
  B --> C["환불 계산기"]
  C --> D["PG 배치 취소"]
  D --> E["상태 업데이트"]

Kafka 파티션을 평소 4개에서 피크 시 32개로 자동 확장합니다. 컨슈머 그룹 역시 HPA(Horizontal Pod Autoscaler)로 파드 수를 4 → 32로 자동 스케일합니다. 단, PG API 호출은 초당 최대 100건 제한이 있으므로 환불 계산과 PG 호출을 별도 큐로 분리하고, PG 호출 워커에 Rate Limiter(토큰 버킷, 초당 80건)를 적용합니다.

결과: 4만 건을 32파드 × 80 TPS = 2,560 TPS로 처리하면 이론상 16초에 소화됩니다. PG Rate Limit 80 TPS 기준으로는 4만 건 / 80 = 500초(약 8분). 실제로 PG도 피크 시 처리 속도가 느려지므로 1~2시간으로 보수적 목표를 잡고 SLA 메시지를 선제 공지합니다.


시나리오 2 — PG사 장애 중 환불 요청 폭주

상황: 오후 2시, PG사 전산 점검으로 30분간 환불 API가 500을 반환합니다. 이 시간 동안 환불 요청 2,000건이 쌓입니다. 30분 후 PG가 복구되면 2,000건이 동시에 재시도됩니다.

실패 메커니즘: 재시도 로직이 지수 백오프 없이 즉시 재시도라면, 2,000건이 동시에 PG를 때립니다. PG가 막 복구된 직후라 부하를 견디지 못하고 재장애가 납니다. 선물 스스로 가져오는 Thundering Herd 문제입니다.

대응 구조:

graph LR
  A["환불 워커"] --> B["Circuit Breaker"]
  B --> C["PG API"]
  B --> D["지연 큐"]
  D --> A

Circuit Breaker를 OPEN 상태로 전환하면 즉시 PG 호출을 중단하고 지연 큐로 이관합니다. Circuit Breaker는 10초마다 Half-Open으로 전환해 헬스체크 요청 1건을 보냅니다. 성공하면 CLOSED로 복구, 실패하면 다시 OPEN. PG 복구 후 재시도는 Jitter(무작위 지연 0~5초)를 추가해 동시 폭격을 방지합니다.

Circuit Breaker 기준: 10초 슬라이딩 윈도우 내 실패율 50% 초과 시 OPEN, OPEN 유지 30초 후 Half-Open.


시나리오 3 — 이중 환불 공격 (멱등성 돌파)

상황: 악의적 사용자가 환불 API에 동일 반품 ID로 100ms 간격으로 3번 요청을 보냅니다. DB 레코드 생성 전에 멱등성 체크를 하면, 3번 모두 “아직 처리 안 됨”으로 통과합니다. 같은 반품에 환불이 3번 실행됩니다.

실패 메커니즘: 애플리케이션 레벨 멱등성 체크와 DB Write 사이에 시간 간격이 있습니다. 이 gap에 Race Condition이 발생합니다.

대응 구조:

graph LR
  A["환불 요청"] --> B["Redis 분산 락"]
  B --> C["멱등성 키 확인"]
  C --> D["환불 처리"]
  D --> E["키 TTL 설정"]

Redis SET NX PX 커맨드로 분산 락을 겁니다. 키: refund:lock:{returnId}, TTL: 10초. 락 획득 실패 시 즉시 409 Conflict 반환. 환불 완료 후 refund:done:{returnId} 키를 24시간 TTL로 설정합니다. 이후 동일 returnId 요청은 “이미 처리됨” 응답을 반환합니다.

DB 레벨 안전망도 추가합니다. return 테이블에 (return_id, status) 유니크 인덱스를 추가하면, 동시 트랜잭션 중 하나가 DB 유니크 제약 위반으로 실패합니다. Redis + DB 이중 방어로 이중 환불 확률을 실질적으로 0으로 만듭니다.


6. 실무 실수 Top 5

순위 실수 원인 결과 방지책
1 이중 환불 멱등성 체크 없이 재시도 동일 건 2회 이상 환불 Redis NX 락 + DB 유니크 인덱스
2 재고 유령 복원 반품 신청 즉시 재고 +1 없는 상품 재주문 발생 입고 확인 후 재고 복원
3 소수점 환불 오차 int 나눗셈 truncation 합계 ±1원 불일치 Math.floor + 나머지 단일 항목 조정
4 쿠폰 미복원 환불 시 포인트만 고려, 쿠폰 누락 사용자 쿠폰 소실 민원 RefundBreakdown에 쿠폰 복원 명시
5 물류 웹훅 유실 웹훅 수신 실패 시 재전송 없음 입고됐지만 상태 미전이, 환불 대기 무한 웹훅 멱등 처리 + 미수신 주기 폴링 보완

7. Phase 1 → 4 진화

Phase 1 — 단순 반품 (월 ~50만원 인프라)

기능: 전체 반품·환불만 지원. 부분 반품 없음. 자동 승인 일괄 적용.

구성: 단일 Spring Boot 인스턴스, RDS MySQL (db.t3.medium), Redis 캐시 (cache.t3.micro).

한계: 부분 반품 불가, 어뷰징 탐지 없음, 피크 대응 수동 스케일.


Phase 2 — 부분 환불 + 어뷰징 탐지 (월 ~200만원)

기능: 다품목 부분 반품, 쿠폰 귀속 계산, 리스크 스코어 기반 승인 분기.

구성: 반품 API / 환불 계산기 / 어뷰징 서비스 분리 (3 마이크로서비스), Kafka 이벤트 버스, RDS + 읽기 레플리카.

추가: 수동 심사 관리 화면, 알림 발송 (카카오알림톡).


Phase 3 — 물류 완전 연동 + 자동 스케일 (월 ~600만원)

기능: 물류사 실시간 웹훅, 검수 담당자 배정, 입고 사진 등록, HPA 기반 오토스케일.

구성: EKS 클러스터 (노드 6~32 자동), Kafka 파티션 동적 조정, S3 이미지 저장, CloudFront CDN.

추가: Grafana 실시간 모니터링 대시보드, PagerDuty 경보 연동.


Phase 4 — AI 검수 + 글로벌 PG (월 ~1,500만원)

기능: 반품 이미지 AI 분석으로 검수 자동화, 해외 PG 멀티 연동 (Stripe/PayPal), 다국어 반품 정책 관리.

구성: SageMaker 이미지 분류 모델 (파손 판정 정확도 95% 목표), Stripe Connect for Marketplace, Aurora Global Database.

추가: A/B 테스트 프레임워크로 자동 승인 임계값 지속 튜닝.


8. 핵심 메트릭

메트릭 목표 경보 임계값 측정 방법
반품 신청 P99 응답시간 < 500ms > 1초 APM (Datadog)
환불 처리 P99 시간 < 3초 > 10초 Kafka 컨슈머 lag
이중 환불 발생 건수 0건/일 1건 즉시 P0 알림 DB 집계 쿼리
환불 성공률 > 99.9% < 99% PG 응답 코드 집계
어뷰징 감지율 수동 목표 없음 탐지율 급감 시 모델 점검 리스크 스코어 분포
재고 유령 복원 건수 0건/일 1건 즉시 P1 알림 재고-입고 정합성 쿼리
물류 웹훅 유실률 < 0.01% > 0.1% 웹훅 수신 vs 물류사 발송 대사

9. 실제 장애 사례 분석

사례 A — 쿠팡 2022 설 연휴 적체 (재현)

앞서 소개한 사고의 구조적 원인을 재정리합니다. 환불 배치 잡이 Spring Batch 단일 쓰레드로 동작했고, 명절 연휴 5일치 적체를 순차 처리하는 데 열흘이 걸렸습니다. 해결책은 Kafka 기반 이벤트 스트리밍으로 전환해 컨슈머를 수평 확장 가능하게 만드는 것입니다. 배치 잡이 아닌 스트림 처리가 피크 탄력성의 핵심입니다.

사례 B — 환불 API 타임아웃 후 이중 처리

네트워크 불안정으로 환불 API 응답이 20초 타임아웃됐습니다. 클라이언트는 실패로 판단해 재요청했고, 실제로는 첫 요청이 지연 처리돼 총 2회 환불됐습니다. PG사 멱등키 지원을 확인하지 않고 단순 txId를 키로 썼던 것이 원인이었습니다. 해결책: PG사별 멱등키 지원 스펙 확인 필수, 미지원 시 pgTxId + timestamp 조합 키를 별도 관리.

사례 C — 부분 반품 금액 계산 오류

3개 상품 주문에서 2개만 반품할 때, 할인 쿠폰 3,000원을 어느 상품에 귀속시킬지 로직이 없었습니다. 결과적으로 모든 반품에 쿠폰 3,000원이 더해져 실제보다 3,000~6,000원을 더 환불했습니다. 이 오류가 6개월간 감지되지 않아 누적 손실이 수억 원에 달했습니다. 환불 금액 합계가 원결제 금액을 초과하는지 DB 레벨 검증을 반드시 추가해야 합니다.


10. 확장 포인트

교환 플로우 통합: 반품 FSM에 EXCHANGE_REQUESTEDEXCHANGE_SHIPPED 경로를 추가하면 교환을 별도 시스템 없이 흡수할 수 있습니다. 교환은 “반품 + 재주문”이므로 FSM 분기를 추가하는 것이 가장 단순합니다.

해외 직구 반품: 관세 환급 로직이 추가됩니다. 국내 PG 환불 외에 관세청 API 연동이 필요하고, 반품 운송장 발행 국가별 분기가 생깁니다. 이를 Strategy 패턴으로 RefundStrategy 인터페이스 아래 DomesticRefundStrategyCrossBorderRefundStrategy로 분리하면 확장이 용이합니다.

구독 서비스 환불: 월정액 구독은 일할 계산이 필요합니다. “15일에 해지하면 나머지 15일치 환불”이라는 계산이 기본 환불 계산기에 없습니다. 별도 SubscriptionRefundCalculator를 구현하고 주문 타입에 따라 분기합니다.

B2B 반품: 기업 고객은 세금계산서 수정이 필요합니다. 국세청 API 연동으로 수정 세금계산서를 자동 발행하는 모듈을 추가합니다. 이는 B2B 대규모 주문에서 필수이며, B2C와 완전히 분리된 플로우가 권장됩니다.


11. 이 설계의 한계와 대안

비유: 설계도는 이상적인 집을 그리지만, 실제 공사는 지반이 흔들리고 자재가 안 오고 인부가 파업합니다. 시니어 엔지니어가 주니어 엔지니어와 다른 점은 “잘 될 때”가 아니라 “안 될 때”를 먼저 생각한다는 것입니다.


11-1. PG 환불 API 장애 시 — 재시도 큐 + 멱등키 + 수동 환불 fallback

PG가 30분간 500을 반환하는 상황에서 우리 시스템이 어디까지 자동 복구할 수 있는가를 명확히 해야 합니다.

graph LR
  A["PG 환불 요청"] --> B["Circuit Breaker"]
  B -->|OPEN| C["지연 재시도 큐"]
  C --> D["최대 5회 재시도"]
  D -->|여전히 실패| E["Dead Letter Queue"]
  E --> F["수동 처리 화면"]

단계별 대응 전략

상황 자동 대응 한계
PG 일시 장애 (< 10분) Circuit Breaker + 지수 백오프 재시도 복구 후 Thundering Herd 방지에 Jitter 필수
PG 장기 장애 (> 30분) DLQ 이관 + 운영자 알림 이후는 사람이 처리해야 함
PG 응답 불명확 (타임아웃) 멱등키로 상태 조회 API 재호출 PG사가 멱등키 조회를 지원하지 않으면 불가
PG 전산 폐업 포인트로 즉시 환불 후 카드 분은 수동 처리 정책적 결정이 코드보다 선행되어야 함

수동 환불 fallback 화면이 반드시 필요한 이유: DLQ에 쌓인 건을 처리하는 운영 화면이 없으면, 운영자가 DB에 직접 SQL을 치게 됩니다. 이것이 더 위험합니다. manual_refund_queue 테이블과 CS 관리 화면은 시스템 설계에 포함해야 합니다.


11-2. 이중 환불 방지 — DB 유니크 vs Redis SET NX vs 분산 락 비교

시나리오 3에서 Redis 분산 락 + DB 유니크 이중 방어를 소개했는데, 실제로는 규모에 따라 적합한 방어 수준이 다릅니다.

방어 방법 구현 복잡도 비용 실패 시나리오 적합한 규모
DB 유니크 제약 (return_id UNIQUE) 낮음 없음 DB 레플리카 지연 중 동일 키 2개 삽입 가능 일 10만 건 이하 모든 규모
Redis SET NX PX 중간 Redis 운영 비용 Redis 장애 시 락 없이 처리, TTL 만료 전 처리 지연 시 중복 가능 초당 수십 건 이상 동시 요청
분산 락 (Redlock) 높음 Redis 3대 이상 네트워크 파티션 시 두 노드가 동시에 락 획득 가능 (이론적) 극히 드문 경우

핵심 판단 기준: 환불은 사용자가 의도적으로 동시에 두 번 클릭하는 행위가 대부분입니다. 이 케이스는 DB 유니크 제약만으로도 충분히 막힙니다. Redis 락을 추가하는 이유는 DB 트랜잭션이 열리기 전 애플리케이션 레벨에서 조기 차단해 DB 부하를 줄이는 것입니다. Redis가 없는 소규모 서비스라면 DB 유니크 하나로 시작해도 됩니다.

경고: Redis 락 없이 DB 유니크만 쓰면 UniqueConstraintViolationException이 애플리케이션까지 올라옵니다. 이를 409 Conflict로 깔끔하게 처리하는 예외 핸들러가 반드시 필요합니다.


11-3. Saga 보상 트랜잭션이 또 실패하면 — Dead Letter Queue + 수동 개입 큐

Saga의 장점은 서비스 간 분산 트랜잭션을 보상 트랜잭션으로 처리한다는 것입니다. 그런데 보상 트랜잭션 자체가 실패하면 어떻게 됩니까?

graph LR
  A["환불 Saga 시작"] --> B["PG 취소 성공"]
  B --> C["재고 복원 실패"]
  C --> D["보상: PG 재청구 시도"]
  D -->|보상도 실패| E["Saga 불일치 상태"]
  E --> F["Dead Letter Queue"]
  F --> G["수동 개입 큐 + 알림"]

보상 트랜잭션 실패의 현실: PG 취소는 됐는데 재고 복원이 실패한 상태에서, 보상으로 PG를 재청구하려 해도 PG가 “이미 취소된 결제”라고 거부합니다. 시스템은 논리적으로 불일치한 상태가 됩니다.

이 케이스를 완전 자동화하는 것은 사실상 불가능합니다. 설계에 포함해야 할 것은 다음 세 가지입니다.

  1. 보상 실패 감지: Saga orchestrator가 보상 트랜잭션 실패를 catch하고 DLQ에 이관합니다.
  2. 운영자 알림: P0 수준 알림으로 온콜 담당자에게 즉시 통보합니다.
  3. 수동 조정 화면: 운영자가 “PG는 취소됐음, 재고는 수동 +1, Saga 상태 강제 종료”를 실행할 수 있는 관리 기능이 필요합니다.

비유: Saga 보상 실패는 외과 수술 중 출혈을 막으려다 다른 혈관을 건드린 상황입니다. 자동화로는 한계가 있고, 전문가(운영자)가 직접 개입해야 합니다. 이 화면을 만들지 않으면 장애 때 DB에 직접 손을 대는 더 위험한 상황이 벌어집니다.


11-4. 부분 환불 소수점 오차 — BigDecimal vs 정수 센트

4-2에서 Math.floor + 나머지 카드 조정을 썼는데, 이게 왜 double을 쓰면 위험한지 설명합니다.

// 위험한 코드: double 부동소수점 오차
double ratio = 30000.0 / 60000.0; // 0.5 → 실제로 0.4999999999... 가능
int cardRefund = (int) (50000 * ratio); // 24999원이 나올 수 있음

// 안전한 코드: 정수 센트 연산
long itemTotalCents = 3000000L; // 3만원 = 3,000,000 센트
long totalCents = 6000000L;
long cardAmountCents = 5000000L;
long cardRefundCents = cardAmountCents * itemTotalCents / totalCents; // 정수 나눗셈, 버림
long remainder = (cardAmountCents * itemTotalCents) % totalCents; // 나머지
// 나머지를 마지막 항목(카드)에 1센트씩 배분

BigDecimal vs 정수 센트 비교

방법 장점 단점 추천 상황
double 코드 짧음 부동소수점 오차로 ±1원 불일치 금액 계산에 절대 사용 금지
BigDecimal 정밀도 보장, Java 표준 코드 장황, 성능 약간 느림 Java 환경 범용 추천
정수 센트 (원 × 100) 가장 빠름, 오차 없음 화면 출력 시 100으로 나눠야 함 고성능 환경, DB 컬럼도 센트로 저장

나머지 금액 할당 전략: 비율 배분에서 발생하는 나머지 1~2원은 항상 마지막 항목(보통 카드)에 몰아줍니다. 여러 항목에 나눠서 배분하면 총액 검증 로직이 복잡해집니다. “마지막 항목이 차액을 흡수”하는 규칙을 팀 내 명문화하세요.


11-5. 쿠폰 복원 문제 — 만료된 쿠폰을 돌려줘야 하나?

환불 시 쿠폰을 복원해야 하는데, 반품 처리가 30일 걸리는 동안 해당 쿠폰이 만료됐습니다. 이 케이스는 코드로 해결할 수 없는 정책적 결정입니다.

선택지 사용자 경험 운영 비용 법적 이슈
만료된 쿠폰 그대로 복원 (유효기간 연장) 최상 쿠폰 남용 우려 없음
동일 금액의 신규 쿠폰 발급 좋음 쿠폰 재발급 비용 없음
쿠폰 할인액을 포인트로 대체 환불 보통 낮음 없음
쿠폰 복원 없이 나머지 수단만 환불 나쁨 없음 분쟁 가능성

코드 설계에 미치는 영향: 정책이 결정되면 CouponRefundPolicy 인터페이스로 분리해 정책 변경 시 코드 수정 범위를 최소화합니다. 정책이 결정되기 전에 특정 방식으로 하드코딩하면 나중에 전면 수정이 필요합니다.

면접 팁: 이 질문이 나왔을 때 “정책팀과 먼저 결정해야 합니다”라고 말하는 것이 올바른 시니어 답변입니다. 기술 혼자 결정할 수 없는 영역입니다.


12. 동시성 문제 상세

비유: 같은 주문에 환불 버튼을 두 명이 동시에 누르는 상황은, 두 사람이 동시에 ATM에서 같은 계좌의 잔액을 인출하려는 것과 같습니다. 먼저 잠금을 거는 쪽이 이기고, 나머지는 “처리 중”이라는 안내를 받아야 합니다.


12-1. 이중 환불 Race Condition 시나리오

T=0ms: 요청A 도착 → return_id=123 조회 → "처리 안 됨" 확인
T=5ms: 요청B 도착 → return_id=123 조회 → "처리 안 됨" 확인 (A가 아직 커밋 안 함)
T=10ms: 요청A → 환불 레코드 INSERT
T=10ms: 요청B → 환불 레코드 INSERT (동시)
결과: 두 건 모두 성공 → 이중 환불

이 Race Condition은 “조회 → 처리” 사이의 gap에서 발생합니다. 막는 방법은 세 가지이고, 규모에 따라 선택이 달라집니다.


12-2. 락 전략 비교 — 환불 도메인에 맞는 선택

graph LR
  A["환불 요청 진입"] --> B{"트래픽 규모?"}
  B -->|"일 1만 건 이하"| C["DB SELECT FOR UPDATE"]
  B -->|"일 10만 건 이하"| D["Optimistic Lock @Version"]
  B -->|"초당 수십 건 동시"| E["Redis SET NX"]

SELECT FOR UPDATE (비관적 락)

@Transactional
public void processRefund(long returnId) {
    // 트랜잭션 내에서 행을 잠금 → 다른 트랜잭션은 대기
    Return returnEntity = returnRepo.findByIdForUpdate(returnId);
    if (returnEntity.getStatus() == REFUNDED) {
        throw new AlreadyRefundedException();
    }
    // 환불 처리...
}
  • 장점: 구현 단순, 별도 인프라 없음
  • 단점: 락 대기 중 커넥션 점유 → 동시 요청 많으면 커넥션 풀 고갈
  • 적합: 일 환불 1만 건 이하, 동시 요청이 드문 환경

Optimistic Lock (@Version)

@Entity
public class Return {
    @Version
    private Long version; // 업데이트 시 version 불일치 → OptimisticLockException
}

@Transactional
public void processRefund(long returnId) {
    Return returnEntity = returnRepo.findById(returnId);
    returnEntity.refund(); // version 증가
    // 동시 요청은 OptimisticLockException → 재시도 또는 409 반환
}
  • 장점: 락 없음, 커넥션 점유 없음, 성능 좋음
  • 단점: 충돌 시 예외 처리 + 재시도 로직 필요
  • 적합: 충돌이 드물지만 성능이 중요한 경우

Redis SET NX (분산 락)

  • 장점: 여러 서버 인스턴스에서도 단일 락 보장
  • 단점: Redis 장애 시 락 없이 처리될 위험, TTL 설정 실수 시 데드락
  • 적합: 다중 서버 환경에서 초당 수십 건 이상 동시 요청

12-3. 트래픽 적은 환불 도메인에서 Redis 락이 오버엔지니어링인 이유

환불의 특성을 먼저 생각해야 합니다.

환불은 주문·결제와 달리 다음 특성을 가집니다.

  • 사용자 한 명이 동일 주문에 동시 요청하는 경우는 버튼 더블클릭이 전부
  • 실제 Race Condition은 초당 1건 미만 (일 4,500건 / 86,400초 = 0.05 TPS)
  • 환불은 “빠른 응답”보다 “정확한 처리”가 훨씬 중요

이 도메인에서 Redis를 추가하면 다음 비용이 발생합니다.

  1. Redis 클러스터 운영 비용 (월 수십만 원)
  2. Redis 장애 시 락 없이 처리되는 fallback 로직 필요
  3. TTL 설정이 짧으면 처리 중 락 만료, 길면 장애 후 복구 지연
  4. 코드 복잡도 증가 → 신규 개발자 온보딩 비용

결론: 환불 도메인에서는 DB 유니크 제약 + SELECT FOR UPDATE가 99%의 케이스를 커버합니다. Redis 락은 초당 수백 건 이상의 동시 환불 요청이 실측으로 확인된 이후에 추가하세요. “미리 준비한 복잡함”은 기술 부채입니다.


13. 오버엔지니어링 경고

비유: 편의점 계산대에 대형마트 전산 시스템을 붙이는 격입니다. 기능은 완벽하지만, 유지비가 매출보다 많이 나옵니다. 시스템 설계에서 “얼마나 정교한가”보다 “지금 이게 필요한가”가 더 중요한 질문입니다.


13-1. 규모별 적정 아키텍처

graph LR
  A["일 100건 이하"] --> B["동기 처리 + DB 트랜잭션"]
  C["일 1000건"] --> D["비동기 큐 도입 시점"]
  E["일 10000건+"] --> F["Saga + 이벤트 드리븐"]

일 환불 100건 이하: 동기 처리 + DB 트랜잭션이면 충분

Spring Boot 단일 인스턴스, MySQL, Redis 없음. 환불 요청 → 동기 처리 → 즉시 응답. 100건을 Kafka + Saga + 이벤트 드리븐으로 처리하면 시스템이 더 불안정해집니다. 컴포넌트가 많을수록 장애 포인트가 많습니다.

일 환불 1,000건: 비동기 큐 도입 시점

PG API가 간헐적으로 느릴 때 동기 처리는 사용자 응답을 막습니다. 이 시점에 RabbitMQ나 DB 기반 outbox 패턴으로 비동기화합니다. 아직 Kafka는 불필요합니다.

일 환불 10,000건 이상: Saga + 이벤트 드리븐 필요

이 규모에서는 물류·PG·재고가 독립 팀과 서비스로 분리돼 있을 가능성이 높습니다. 서비스 간 분산 트랜잭션 처리를 위해 Saga가 필요해집니다.


13-2. “환불은 속도보다 정확성” — 이중 환불이 100배 위험한 이유

빠른 환불이 좋은 것은 맞습니다. 하지만 이중 환불과 비교하면 차원이 다른 문제입니다.

문제 사용자 반응 비즈니스 영향 법적 영향
환불 1시간 지연 불만 접수 민원 증가 없음
이중 환불 발생 일부 사용자는 신고, 일부는 침묵 직접 금전 손실 + 금융당국 조사 전자금융거래법 위반 가능

이중 환불 1건의 비용 = 환불 금액 + 조사 비용 + 브랜드 손상. 환불 1시간 지연의 비용 = CS 인력 비용 + 리뷰 1건.

정확성 보장 로직(유니크 제약, 멱등키, 조회 후 처리)을 “성능 최적화”라는 이유로 생략하는 것은 잘못된 트레이드오프입니다. 느리더라도 정확하게가 환불 도메인의 제1원칙입니다.


14. Kafka 심층 — 언제 필요하고, 어떻게 써야 하는가


14-1. 환불 이벤트 순서 보장 — 파티션 키 = 주문 ID

Kafka는 기본적으로 파티션 내 순서만 보장합니다. 같은 주문의 이벤트가 다른 파티션에 들어가면 순서가 뒤바뀝니다.

// 잘못된 설계: 랜덤 파티셔닝
kafkaTemplate.send("refund-events", refundEvent);

// 올바른 설계: 주문 ID를 파티션 키로
kafkaTemplate.send("refund-events", String.valueOf(orderId), refundEvent);
// 같은 orderId → 항상 같은 파티션 → 순서 보장

파티션 키를 주문 ID로 쓰는 이유: 한 주문에서 APPROVEDREFUNDED 순서가 뒤바뀌면, REFUNDED를 처리하는 컨슈머가 아직 APPROVED 상태가 아닌 주문을 처리하려다 실패합니다. 파티션 키 = 주문 ID로 설정하면 같은 주문의 이벤트는 항상 같은 파티션에 들어가 순서가 보장됩니다.


14-2. Consumer 처리 실패 시 재시도 + DLQ

graph LR
  A["refund-events 토픽"] --> B["Consumer"]
  B -->|처리 성공| C["offset commit"]
  B -->|처리 실패| D["retry-refund-events"]
  D -->|3회 재시도 후 실패| E["dlq-refund-events"]
  E --> F["운영자 알림 + 수동 처리"]

재시도 토픽 설계: 즉시 재시도 대신 별도 retry 토픽에 넣고 일정 시간 후 재처리합니다. 이렇게 하면 원본 토픽의 다른 메시지 처리가 블록되지 않습니다.

@KafkaListener(topics = "refund-events")
public void consume(RefundEvent event) {
    try {
        refundService.process(event);
    } catch (RetryableException e) {
        // 재시도 가능한 에러 (일시적 DB 장애, PG 타임아웃)
        retryProducer.send("retry-refund-events", event.withRetryCount(event.getRetryCount() + 1));
    } catch (NonRetryableException e) {
        // 재시도 불가 에러 (이미 환불됨, 잘못된 데이터)
        dlqProducer.send("dlq-refund-events", event);
    }
}

DLQ 설계 원칙: DLQ는 쌓기만 하고 보지 않으면 의미가 없습니다. DLQ 메시지 수를 메트릭으로 수집하고, 임계값(예: 10건 이상) 초과 시 알림을 보내야 합니다.


14-3. Kafka 없이 DB 폴링으로 충분한 규모

Kafka는 강력하지만 운영 부담이 큽니다. Broker 관리, 파티션 리밸런싱, 컨슈머 lag 모니터링 등 전담 인력이 필요합니다. 다음 기준 이하라면 Transactional Outbox + DB 폴링으로 충분합니다.

기준 Kafka 불필요 Kafka 필요
처리량 초당 100건 이하 초당 수백 건 이상
지연 허용 수 초 이내 OK 실시간 (< 1초) 필요
컨슈머 수 3개 이하 수십 개 이상
팀 규모 소규모 (Kafka 운영 어려움) Kafka 전담 운영 가능

Transactional Outbox 패턴: 이벤트를 Kafka 대신 DB의 outbox 테이블에 저장하고, 별도 워커가 주기적으로 폴링해 처리합니다. DB 트랜잭션과 이벤트 저장이 같은 트랜잭션 안에 있어 이벤트 유실이 없습니다.

-- 환불 처리와 이벤트 저장을 같은 트랜잭션으로
BEGIN;
UPDATE returns SET status = 'REFUNDED' WHERE id = 123;
INSERT INTO outbox (event_type, payload, created_at)
VALUES ('REFUND_COMPLETED', '{"returnId": 123}', NOW());
COMMIT;

-- 워커가 5초마다 폴링
SELECT * FROM outbox WHERE processed = false ORDER BY created_at LIMIT 100;

일 환불 5,000건 기준으로 5초 폴링이면 충분히 소화됩니다. Kafka 없이 이 패턴으로 Phase 2까지 운영하고, 처리량이 한계에 도달하면 Kafka로 이관하세요.


15. 면접 포인트

Q1. 이중 환불을 막는 방법을 단계적으로 설명해주세요. 3단계 방어를 사용합니다. 1단계는 애플리케이션 레벨 Redis 분산 락입니다. 환불 요청 진입 즉시 `SET NX PX`로 returnId 기반 락을 겁니다. 락 획득 실패 시 409를 반환합니다. 2단계는 DB 유니크 인덱스입니다. `refund_transactions` 테이블에 `(return_id)` 유니크 인덱스를 걸어 동시 트랜잭션 중 하나가 DB 레벨에서 실패하게 합니다. 3단계는 PG 멱등키입니다. PG 요청 시 returnId를 멱등키로 전달하면 PG사 측에서도 중복 처리를 차단합니다. 세 레이어가 모두 통과해야만 실제 환불이 실행됩니다.
Q2. 반품 재고 복원을 즉시 하지 않고 입고 확인 후 하는 이유는? 반품 신청과 실제 입고 사이에는 평균 2~3일이 걸립니다. 이 기간에 재고를 미리 올리면 두 가지 문제가 생깁니다. 첫째, 고객이 물건을 보내지 않거나 파손 상태로 보내도 재고가 복원됩니다. 둘째, 복원된 재고에 새 주문이 들어왔는데 실제 입고 거부가 나면 재주문을 취소해야 합니다. 보수적으로 입고 확인 후 복원하는 것이 재고 정합성을 보장하는 유일한 방법입니다. 다만 소모품·저가품은 검수 없이 신청 즉시 복원해 재고 회전을 높이는 조건부 즉시 복원 전략을 병행할 수 있습니다.
Q3. 복합 결제(카드+포인트+쿠폰) 부분 환불 계산을 어떻게 하나요? 3단계로 계산합니다. 1단계: 반품 상품에 직접 귀속된 쿠폰 할인액을 먼저 분리합니다. 이 금액은 해당 쿠폰 수단으로 전액 환불합니다. 2단계: 쿠폰을 제외한 반품 상품의 실결제 기여 비율을 구합니다. (반품 상품 원가 - 귀속 쿠폰) / 전체 실결제액. 3단계: 이 비율을 카드 결제액, 포인트 결제액에 각각 곱합니다. 소수점은 Math.floor로 내리고 차이는 카드 환불액에 합산합니다. 이렇게 하면 환불 합계가 실결제액을 절대 초과하지 않습니다.
Q4. PG 환불 타임아웃 시 어떻게 처리하나요? 타임아웃은 "성공인지 실패인지 모르는" 상태입니다. 두 가지 전략을 씁니다. 1단계: 타임아웃 발생 즉시 PG 환불 상태 조회 API를 호출합니다. 멱등키로 조회하면 PG사가 처리 결과를 반환합니다. 성공이면 DB를 REFUNDED로 업데이트합니다. 2단계: 조회도 실패하면 반품 상태를 `REFUND_PENDING`으로 유지하고 지수 백오프 재시도 큐에 넣습니다. 최대 5회 재시도 후에도 실패하면 Dead Letter Queue로 이관하고 운영자에게 알림을 보냅니다. 절대 타임아웃을 즉시 실패로 처리하면 안 됩니다. 그러면 실제 성공한 환불을 재시도해 이중 환불이 발생합니다.
Q5. 하루 10만 건 환불 피크를 어떻게 처리하나요? 세 가지 레이어로 대응합니다. 첫째, Kafka 기반 비동기 처리입니다. 환불 신청 즉시 사용자에게는 "환불 신청 완료"를 반환하고, 실제 PG 호출은 Kafka 컨슈머가 비동기로 처리합니다. 둘째, HPA(수평 파드 자동 확장)입니다. Kafka Consumer Lag를 메트릭으로 HPA를 설정하면 큐가 쌓일수록 파드 수가 자동으로 늘어납니다. 셋째, PG Rate Limiter입니다. PG 호출 워커에 Token Bucket(초당 80건)을 적용해 PG API를 보호합니다. 명절 피크 기준 4만 건 / 80 TPS ≈ 500초(약 8분)에 처리됩니다. 선제 공지로 "최대 10분 소요"를 안내하면 민원을 줄일 수 있습니다.

함께 읽으면 좋은 글

댓글

이 글이 도움이 됐다면?

같은 카테고리의 다른 글도 확인해보세요

더 많은 글 보기 →