안녕하세요, 올리브영에서 주문·배송 도메인의 백엔드 개발을 담당하고 있는 승훈입니다. 👋
온라인 쇼핑에서 주문 취소 버튼이 사라지는 순간이 있습니다. 주문 상태가 '배송준비중'으로 바뀌는 때입니다. 그런데 고객 입장에서 보면 이상합니다. 배송 조회를 눌러봐도 아직 아무 데도 가 있지 않고, 송장 번호도 없습니다. 아직 출발도 안 했는데, 왜 취소가 안 될까요?
이번 글은 그 질문에서 시작해 올리브영의 취소 가능 시점을 실물 출고 직전까지 밀어낸 이야기입니다. 조건문 하나만 바꾸면 될 것 같았던 일이, 왜 이벤트 파이프라인과 분산 락과 장애 격리까지 손대는 일이 되었는지 공유드립니다.
취소를 막는 것이 왜 기본값이었나
'배송준비중 취소 불가'는 특정 회사의 인색함이 아니라, 커머스 시스템이 오래 유지해 온 안전 장치에 가깝습니다. 이유는 단순합니다. 물류가 이미 움직이기 시작했는데 시스템이 취소를 받아버리면, 상품은 상품대로 고객에게 배송되고 결제는 결제대로 취소된 상태가 만들어집니다. 이건 나중에 되돌릴 방법이 마땅치 않은 사고입니다. 그래서 대부분의 시스템은 넉넉하게 앞당겨서 막습니다. 물류가 조금이라도 개입한 흔적이 보이면 그때부터 취소를 차단하는 식입니다.
올리브영도 같은 선택을 하고 있었습니다. 물류센터의 출하 지시(작업 할당) 시점이 기준이었습니다. 문제는 '작업 할당'이 실제로 무엇을 뜻하느냐입니다. 할당은 "이 주문을 어느 설비에서 처리하겠다"는 예약에 가깝습니다. 그 시점에 상품은 여전히 센터 선반에 놓여 있고, 실제 출고까지는 몇 시간이 남아 있는 경우도 있었습니다. 재고가 아직 우리 센터 안에 있는데, 시스템은 이미 취소를 닫아버린 것입니다.
고객에게는 이 간극이 그대로 불편으로 돌아왔습니다. 앱에서 막힌 취소 한 번이 고객센터를 거쳐 한참을 돌아가야 하는 여정으로 바뀌었습니다.
제도적으로도 짚이는 부분이 있었습니다. 전자상거래법이 보장하는 청약철회권과 이용약관 관점에서, 상품이 아직 물류센터를 떠나지 않았다면 고객은 취소할 수 있어야 한다는 것이 자연스러운 해석이었습니다. 결국 저희가 풀어야 할 문제는 이렇게 정리됐습니다. 취소 허들을 '물류가 움직이기 시작한 시점'이 아니라 '되돌릴 수 없게 된 시점'으로 옮기는 것.
되돌릴 수 없는 순간은 언제인가
허들을 뒤로 미루려면 먼저 "되돌릴 수 없는 순간"을 정의해야 했습니다. 저희는 두 가지 기준을 세웠습니다.
1. 물리적으로 되돌릴 수 없는가: 상품이 선반을 떠나 집품·포장 단계에 들어갔는가
2. 외부에 되돌릴 수 없는 요청이 나갔는가: 송장이 발행됐는가, 라이더가 배차됐는가이 기준을 적용하자 배송유형마다 답이 다르게 나왔습니다. 물류센터에서 나가는 주문, 협력사가 직접 발송하는 주문, 매장에서 즉시 출발하는 오늘드림은 되돌릴 수 없는 지점이 서로 달랐습니다. 하나의 시점으로 통일하는 대신, 배송유형별로 가장 늦게까지 미룰 수 있는 지점을 각각 찾는 쪽을 택했습니다.
| 배송유형 | 기존 취소 마감 시점 | 개선 후 취소 마감 시점 |
|---|---|---|
| 센터배송 | 출하 지시(작업 할당) | 집품(피킹) 완료 — 상품이 실제로 선반을 떠난 뒤 |
| 업체배송 | 출하 지시 | 송장 등록 — 택배사에 인계가 확정된 뒤 |
| 오늘드림 | 주문 접수 확정 | 배차 — 라이더가 배정된 뒤 |
센터배송을 예로 들면, 되찾은 구간은 이렇게 그려집니다.
흩어진 판단을 한곳으로 모으기
시점을 정하고 나니 다음 벽이 나타났습니다. 취소 가능 여부를 판단하는 코드가 한 곳에 있지 않았습니다.
온라인몰 주문서, 마이페이지의 취소 버튼, 백오피스의 관리자 취소, 미픽업 건을 정리하는 취소 배치가 각각 자기만의 조건으로 판단하고 있었습니다. 배송유형이 늘어날 때마다 분기가 하나씩 붙어온 결과였습니다. 허들을 뒤로 미룬다는 건 이 분기를 전부 찾아 고친다는 뜻이었고, 하나라도 놓치면 화면에는 취소 버튼이 보이는데 눌러도 실패하는 최악의 경험이 만들어집니다.
그래서 개별 조건을 손보는 대신, 판단 자체를 하나로 모으기로 했습니다. 주문 배송 정보에 '취소 불가 일시'라는 단일 시점 값을 두고, 이 값이 비어 있으면 취소 가능, 값이 채워져 있으면 취소 불가로 판단합니다. 값을 채우는 시점만 배송유형별로 다르게 두고, 값을 읽는 쪽은 모두 같은 규칙을 보게 만든 것입니다.
이 구조에는 예상하지 못한 이점이 하나 더 있었습니다. 취소 가능 상태를 되돌릴 수 있게 됐다는 점입니다. 오늘드림에서는 배차가 잡힌 뒤에도 배달대행사가 배송 취소 콜백을 보내오거나, 대안 배송으로 전환되는 경우가 있습니다. 예전처럼 주문 상태 코드가 앞으로만 흘러가는 구조였다면 이런 주문은 고객이 손댈 수 없는 상태로 남아, 매장 마감 시각까지 기다렸다가 배치가 일괄 취소해 주는 수밖에 없었습니다. 지금은 취소 불가 일시를 다시 비우는 것만으로 고객이 직접 취소할 수 있는 상태로 복원됩니다. 시점 값 하나로 바꿔둔 덕분에 얻은 유연함이었습니다.
물류의 진짜 신호 받아오기
센터배송의 허들을 집품 완료로 옮기려면, 집품이 언제 끝났는지를 주문 시스템이 실시간으로 알아야 합니다. 사실 기존에 허들이 앞당겨져 있던 진짜 이유도 여기에 있었습니다. 물류 시스템의 진행 상황을 제때 알 수 없으니, 안전하게 앞에서 막아둔 것이었습니다.
연동 방식을 놓고 세 가지를 검토했습니다.
| 연동 방식 | 검토 내용 | 채택 |
|---|---|---|
| 주기적 배치 동기화 | 기존 방식의 연장선이라 구현 부담이 가장 적습니다. 그러나 주기가 곧 오차가 됩니다. 주기를 줄일수록 부하가 늘고, 늘릴수록 골든타임을 그만큼 다시 포기해야 합니다. | ❌ |
| 취소 시점 동기 조회 | 고객이 취소를 누를 때마다 물류 시스템에 직접 물어봅니다. 가장 정확하지만 취소 경험이 물류 시스템의 응답 속도와 가용성에 그대로 묶입니다. | ❌ |
|
이벤트 스트리밍(Kafka)
|
물류 시스템이 집품 실적을 발행하고 주문 시스템이 구독합니다. 주문번호를 파티션 키로 두면 같은 주문의 이벤트 순서가 보장되고, 장애가 나도 오프셋으로 재처리할 수 있습니다.
|
✅ 채택
|
결정적이었던 건 결합도였습니다. 동기 조회 구조에서는 취소라는 고객 행동이 물류 시스템의 상태에 직접 매달립니다. 물류가 느려지면 취소도 느려지고, 물류가 멈추면 취소도 멈춥니다. 이벤트 방식에서는 물류가 "집품이 끝났다"는 사실만 던지고 관심을 끊습니다. 나중에 이 신호를 다른 서비스가 함께 쓰게 되더라도 물류 쪽 코드는 손대지 않아도 됩니다. 실제로 취소 판단 외에 다른 용도가 붙기 시작하면서, 이 선택의 값어치가 확인됐습니다.
이벤트는 도착하지 않을 수도 있다
파이프라인을 붙이고 나서 만난 문제는 이벤트 자체의 성질에서 왔습니다. 집품 완료와 출고 확정 사이의 리드 타임이 매우 짧은 주문에서, 집품 완료 이벤트가 누락되고 출고 확정만 도착하는 경우가 있었습니다.
이건 단순한 데이터 누락이 아니라 사고에 직결되는 문제입니다. 집품 완료 이벤트를 못 받으면 취소 불가 일시가 비어 있는 채로 남고, 그 사이 고객이 취소를 누르면 이미 물류센터를 떠난 상품에 대해 취소가 성립해 버립니다. 정확히 저희가 처음부터 두려워하던 그 사고입니다.
그래서 취소를 닫는 장치를 하나 더 두었습니다. 출고 확정 이벤트를 받을 때에도 취소 불가 일시를 채웁니다. 집품 완료 신호가 오지 않았더라도, 출고 확정은 반드시 오기 때문에 취소 문은 늦어도 그 시점에는 닫힙니다. 골든타임을 조금 손해 보더라도 사고 가능성을 남기지 않는 쪽을 택한 것입니다.
반대 방향의 경계 케이스도 있었습니다. 이미 취소된 주문에 대해 출고 실적이 뒤늦게 도착하는 경우입니다. 이런 건은 조용히 버리지 않고 불일치 건으로 남겨 확인 대상으로 만들었습니다. 허들을 늦춘다는 건 결국 이런 경계 케이스가 늘어난다는 뜻이고, 늦춘 만큼 문을 닫는 장치를 더 촘촘히 만들어야 한다는 것이 이 단계에서 얻은 교훈이었습니다.
배차와 취소가 같은 순간에 일어날 때
가장 까다로웠던 영역은 오늘드림이었습니다. 오늘드림은 매장에서 포장이 끝나면 배달대행사에 배차를 요청하고, 라이더가 상품을 픽업해 고객에게 전달하는 구조입니다. 배달대행사와 어떤 방식으로 연동하는지는 이전 글 배달대행사 API 연동과 장애 대응 - 오늘드림 서비스 개발기에서 자세히 다룬 적이 있습니다.
취소 허들이 '접수 확정'에서 '배차'로 밀리면서, 그동안 없던 구간이 새로 생겼습니다. 매장이 포장을 끝내고 배차를 요청하는 그 순간에도 고객이 취소를 누를 수 있게 된 것입니다. 두 요청이 겹치면 이런 일이 벌어집니다.
취소 요청이 조금 먼저 도착하면, 시스템은 아직 배차가 없는 상태에서 배차 취소를 호출합니다. 취소할 대상이 없으니 아무 일도 일어나지 않고, 그 직후 매장의 배차 요청이 도착해 라이더가 정상적으로 배정됩니다. 결과적으로 주문은 취소됐는데 라이더는 매장으로 향합니다. 매장에는 픽업할 상품이 없고, 배차 이력은 그대로 남아 정산과 재고가 어긋납니다. 순서가 반대여도 문제입니다. 배차 요청이 처리되는 중에 취소가 들어오면, 아직 배차 번호가 없어 취소할 대상을 특정하지 못한 채 고객에게는 취소가 완료됐다고 응답하게 됩니다.
핵심은 같은 주문에 대해 배차와 취소가 절대 동시에 진행되면 안 된다는 것이었습니다. 동시성 제어 방식을 놓고 다시 검토했습니다.
| 제어 방식 | 검토 내용 | 채택 |
|---|---|---|
| DB 비관적 락 | 주문 행을 잠가 순서를 강제합니다. 다만 이 구간에는 외부 API 호출이 포함되어 있어, 외부 응답이 지연되는 동안 DB 커넥션과 락이 함께 붙잡힙니다. | ❌ |
| 낙관적 락 + 재시도 | 충돌을 감지한 뒤 되돌리는 방식입니다. 그런데 이미 배달대행사로 나간 호출은 되돌릴 수 없어, 감지 시점에는 이미 늦습니다. | ❌ |
|
Redis 분산 락
|
주문번호 단위로 락을 잡아 외부 호출까지 포함한 구간 전체를 직렬화합니다. DB 자원을 점유하지 않고, 여러 서버에 걸쳐 동작하며, TTL로 교착을 막을 수 있습니다.
|
✅ 채택
|
락을 도입하며 가장 신경 쓴 부분은 락을 잡은 다음에 상태를 다시 읽는 것이었습니다. 락은 순서를 정해줄 뿐, 기다리는 동안 세상이 그대로 있어 주지는 않습니다. 취소 요청이 락을 기다리는 사이 배차가 이미 성립했을 수 있고, 그 반대일 수도 있습니다. 그래서 락 획득 직후 주문의 최신 상태를 다시 조회하고, 그 시점의 사실을 기준으로 다시 판단하도록 했습니다. 배차가 이미 잡혔다면 취소 요청은 "취소 불가"가 아니라 배차 취소라는 보상 동작으로 이어집니다. 외부 시스템에 나간 호출은 롤백되지 않으니, 되돌리는 대신 상쇄하는 것이 유일한 방법이었습니다.
배달대행사가 흔들려도 취소는 완료되어야 한다
배차 취소를 보상 동작으로 삼자, 이번에는 그 동작의 신뢰도가 문제가 됐습니다. 취소의 성패가 외부 시스템의 응답에 달리게 된 것입니다.
첫 번째 걸림돌은 파편화였습니다. 올리브영은 여러 배달대행사와 제휴하고 있는데, 취소 API의 요청 형태도, 상태 코드 체계도, 응답 규약도 회사마다 달랐습니다. 그중에서도 성가셨던 건 '이미 취소된 건'을 어떻게 응답하느냐였습니다. 어떤 곳은 성공으로 응답했고, 어떤 곳은 오류 코드로 응답했으며, 같은 회사 안에서도 사유 코드가 새로 추가되기도 했습니다. 재시도 로직이 이걸 그대로 받아들이면 이미 취소된 건을 계속 재시도하거나, 반대로 실제로는 취소된 건을 실패로 기록하게 됩니다.
그래서 배달대행사 취소를 공통 인터페이스로 감쌌습니다. 표준 요청·응답 모델을 정의하고 회사별 어댑터가 각자의 스펙을 그 모델로 번역합니다. 이때 응답은 딱 세 가지로 정규화했습니다.
1. 취소 완료: 방금 취소됐거나, 이미 취소된 상태 → 성공으로 확정하고 재시도하지 않음
2. 취소 불가: 이미 픽업된 경우 등 확정적 거절 → 재시도하지 않고 예외 처리로 넘김
3. 일시적 실패: 타임아웃, 5xx, 네트워크 오류 → 재시도 대상이 분류가 중요한 이유는, 재시도 여부를 판단하는 코드가 배달대행사의 사정을 전혀 몰라도 되게 만들기 때문입니다. 새 배달대행사가 추가되면 어댑터 하나만 붙이면 되고, 기존 사유 코드가 추가되어도 매핑 테이블만 손보면 됩니다. 취소 로직 본체는 그대로입니다.
두 번째 걸림돌은 장애 전파였습니다. 외부 API가 응답하지 않고 붙잡고 있으면, 그 스레드와 커넥션이 계속 묶입니다. 오늘드림처럼 취소와 배차가 같은 자원을 공유하는 구조에서는 취소가 느려지는 데 그치지 않고 배차까지 함께 밀립니다. 그래서 배달대행사 호출 구간에 서킷 브레이커를 두어, 실패율이 임계치를 넘으면 회로를 열고 즉시 대체 경로로 빠지게 했습니다.
그럼 회로가 열린 동안 들어온 취소는 어떻게 될까요? 여기서 저희가 세운 원칙이 하나 있습니다.
고객의 취소 확정을 외부 시스템의 성공에 종속시키지 않는다.
고객이 취소를 눌렀고 취소 가능 조건을 충족했다면, 그 취소는 우리 시스템 안에서 확정됩니다. 배달대행사 정리는 그 뒤에 따라오는 일입니다. 그래서 회로가 열렸거나 호출이 실패한 건은 SQS 큐로 격리한 뒤, 고객에게는 취소 접수 완료로 응답합니다. 백그라운드 워커가 지수 백오프로 재시도하며 최종적으로 상태를 맞춥니다. 이때 주문번호와 배차 번호를 조합한 멱등 키로 같은 취소가 두 번 실행되지 않도록 막고, 재시도 한도를 넘긴 건은 DLQ로 옮겨 운영 알림을 발생시킵니다.
취소는 한 곳에서 끝나지 않는다
마지막으로 남은 건 취소의 범위였습니다. 오늘드림 주문 하나를 취소하면 매장 POS, 물류 거점, 배달대행사, 재고, 결제가 함께 움직여야 합니다. 이 중 상당수가 외부 시스템이라 하나의 트랜잭션으로 묶을 수 없습니다. 전부 성공하거나 전부 실패하게 만드는 대신, 어디까지 처리됐는지를 건별로 기록하고 보정 배치가 미완료 건만 다시 처리하도록 했습니다.
여기서 의외로 손이 많이 간 부분이 보정 배치의 대상 조건이었습니다. 이미 정상 처리된 건이 배치에 다시 걸리면 이중 취소가 되고, 조건을 너무 좁히면 누락이 남습니다. 픽업 주문처럼 이 흐름을 타면 안 되는 유형을 걸러내고, 결제는 취소됐지만 매장·거점 전송이 남은 건만 정확히 골라내는 작업이 반복됐습니다. 화려하지 않지만 이 조건 하나가 정합성을 지탱합니다.
그리고 한 가지 더, 옮겨놓은 허들을 지키는 일도 필요했습니다. 업체배송의 취소 마감 기준은 송장 등록입니다. 그렇다면 실제 출고 없이 송장만 먼저 등록되는 경우, 고객의 취소 가능 구간은 아무 이유 없이 사라지게 됩니다. 그래서 송장 등록 단계에 취소 가능 여부 확인과 중복 송장 검증을 넣어, 허들이 부당하게 앞당겨지지 않도록 했습니다. 허들을 옮기는 일은 기준선을 새로 긋는 것으로 끝나지 않고, 그 기준선이 지켜지는지 계속 확인하는 일까지 포함한다는 걸 여기서 배웠습니다.
결과: 되찾은 골든타임
세 배송유형 모두에서 취소 마감 시점이 실물 출고 직전까지 밀렸습니다.
- 센터배송: 출하 지시(작업 할당) → 집품 완료
- 업체배송: 출하 지시 → 송장 등록
- 오늘드림: 주문 접수 확정 → 배차
가장 뚜렷한 변화는 고객센터에 쌓이던 문의가 사라졌다는 점입니다. 배송준비중 취소 관련 VOC 인입은 평시 기준 약 90% 감소했고, 문의가 몰리는 세일 기간에도 약 89% 감소했습니다. 그동안 이 문의를 처리하기 위해 상담사가 물류에 수기로 취소 가능 여부를 확인하고, 무상 반품을 안내하고, 배송 완료 여부를 추적하던 업무도 함께 사라졌습니다. 애초에 나가지 않았어도 될 상품이 나갔다가 되돌아오는 반품 물류 비용도 그만큼 줄었습니다.
숫자보다 중요한 건 경로가 바뀌었다는 사실입니다. 예전에는 취소하려면 고객센터를 거쳐야 했고, 그마저도 담당자의 확인 결과에 달려 있었습니다. 지금은 고객이 앱에서 직접 취소 버튼을 누르면 끝납니다. 상품이 아직 우리 손에 있는 한, 취소는 협상의 대상이 아니라 고객의 권리로 보장됩니다.
마치며
처음 이 과제를 받았을 때는 취소 가능 조건을 한 단계 뒤로 미루는 일이라고 생각했습니다. 실제로는 흩어진 판단 기준을 하나로 모으고, 물류의 실시간 신호를 받아오는 파이프라인을 깔고, 새로 생긴 동시성 구간을 분산 락으로 직렬화하고, 외부 장애를 격리하고, 늘어난 경계 케이스마다 문을 닫는 장치를 다는 일이었습니다. 허들을 늦춘 만큼 안전 장치를 더 촘촘히 만들어야 한다는 것이 이 프로젝트를 관통한 원칙이었습니다.
취소는 흔히 실패한 주문으로 취급됩니다. 하지만 마음 편히 취소할 수 있다는 확신이야말로 고객이 다시 주문 버튼을 누르게 만드는 조건이라고 생각합니다. 남은 배송유형과 부분 취소 영역에서도 고객이 되돌릴 수 있는 시간을 조금씩 더 넓혀가려 합니다.
이 과제는 저 혼자 한 일이 아닙니다. 주문·결제, 배송·물류, SCM 등 여러 스쿼드가 각자의 영역에서 같은 문제를 함께 붙잡아 주셨기에 세 배송유형을 한꺼번에 바꿀 수 있었습니다. 이 자리를 빌려 감사드립니다. 긴 글 읽어주셔서 고맙습니다. 🙇
고객의 하루를 지탱하는 주문·배송 시스템을 함께 만들 동료를 찾습니다 💚

