구매상품 배송완료 추적
작성: 0chil구매 배송추적 코드는 dev.vingle.order.delivery.tracking 아래의 controller·domain·infra·job·service로 구성한다.
옷장정리와 독립된 PurchaseDeliveryWebhookClient 인터페이스와 서명 검증 구현을 사용한다.
운영에서는 PurchaseDeliveryApiWebhookClient, 테스트에서는 메모리에 구독을 저장하는 FakePurchaseDeliveryWebhookClient를 사용한다.
설정 파일은 preferences/delivery-api.yml 하나로 관리하고 각 사용처에 @Value로 직접 주입한다. base-url·api-key·secret-key·webhook-secret은
delivery-api 루트에서 공유하고 기존 secret 값은 유지한다. 옷장정리 endpoint는
delivery-api.closet-cleanout.webhook-endpoint-id, 구매 endpoint는 delivery-api.purchase.webhook-endpoint-id로 구분한다.
구매 추적은 모든 환경에서 등록하며 endpoint ID 기본값은 0이다. endpoint 발급 후 실제 ID를 설정한다.
송장 입력은 OrderDeliveryService에서 배송 정보·추적 객체·등록 Job을 같은 트랜잭션에 저장한다.
PurchaseDeliveryTrackingService가 webhookRequestId=null인 추적 객체를 먼저 생성하고,
등록 Job에는 주문 ID·구매 ID·추적 ID를 전달한다.
추적 테이블은 자동 생성하는 id를 PK로 사용하고 purchase_id와 enabled로 활성 이력을 조회한다.
구매당 활성 추적은 최대 한 건이다. 주문 잠금 안에서 이전 추적 행을 enabled=false로 변경하고 새 추적 행을 enabled=true로 생성한다.
유효한 송장이 입력될 때마다 기존 추적을 비활성화하고, 구매 ID를 담은 취소 Job을 저장한다.
같은 배송사·정규화 송장을 다시 입력하더라도 새 추적 행과 등록 Job을 생성한다.
비활성화는 Querydsl의 일괄 UPDATE로 enabled와 수정 시각을 함께 변경한다.
호출부는 같은 트랜잭션 안에서 비활성화 → 구매별 취소 Job 저장 순서로 처리하며 구독을 조회하지 않는다.
취소 Job은 purchaseId를 받아 해당 구매의 비활성 추적 중 requestId가 남은 구독들을 조회하여 취소한다. 활성 추적은 취소하지 않는다.
취소에 성공한 구독은 requestId를 비워 후속 Job에서 다시 취소하지 않는다. 별도 취소 상태·시각 컬럼을 추가하지 않으며 추적 이력은 보존한다.
취소 실패는 해당 requestId를 유지한 채 기존 Job 재시도 정책으로 처리한다. 앞서 취소에 성공한 구독은 재시도 대상에서 제외된다.
변경 전 추적의 지연 웹훅은 비활성 이력으로 판별하여 무시한다.
관리자 입력, 일괄 입력, 셀러 배송 수집 모두 이 경로를 사용한다.
외부 API 호출은 등록 Job에서 수행하며, 외부 등록 실패로 이미 저장한 송장을 롤백하지 않는다.
구매별로 가장 최근에 입력한 배송사·정규화 송장의 추적 건을 활성 상태로 유지한다. 등록 Job은 추적 ID로 객체를 조회한다. 객체가 없으면 관리 로그를 남기고 종료한다. 외부 API에는 현재 배송사·송장과 주문·구매·추적 ID를 전달한다. API 호출 중에는 트랜잭션과 주문 잠금을 유지하지 않는다. 등록 Job은 구매별 scope 제한 없이 전체 동시 실행을 50개로 제한한다. 등록 Job은 주문을 조회하거나 잠그지 않는다. 응답의 requestId는 추적 ID·기존 requestId를 조건으로 한 UPDATE로 저장한다. 이미 다른 requestId가 저장돼 있으면 기존 값을 유지하고 새로 생성된 중복 구독은 클라이언트로 즉시 취소한다. 등록 도중 비활성화된 추적에도 requestId를 저장한다. 등록 Job에서 취소 Job을 발행하지 않는다. 구매별 취소 Job은 송장 변경·주문 취소 호출부에서 발행한다. 새 추적의 등록이 먼저 끝나더라도 취소 Job이 비활성 이력만 조회하므로 활성 구독은 유지한다. 주문·상품 상태에 따른 배송완료 반영 여부는 웹훅 처리 Job에서 판단한다. 비활성 구독의 취소와 실패 재시도는 취소 Job에서 처리한다. 응답 저장 실패는 클라이언트로 생성된 구독을 즉시 취소한 뒤 예외를 전파하여 등록 Job을 재시도하며, 추적 객체를 다시 생성하지 않는다. 등록 실패 시 requestId가 없는 추적 행을 유지하고 같은 추적 ID로 재시도한다. 외부 등록 성공 후 응답 자체가 유실되면 requestId를 알 수 없어 재시도에서 외부 구독이 중복 생성될 수 있다.
배송사 정규화는 OrderDeliveryService에서 기존 DeliveryCarrier로 결정한다.
배송사 enum이 null이면 추적 서비스를 호출하지 않으며, 추적 서비스는 non-null enum만 받는다.
CJ·로젠·우체국·롯데·GS편의점·CU편의점·한진을 지원한다.
추적 엔티티의 delivery_carrier와 Job payload에는 내부 enum을 저장한다.
deliveryapi 코드로의 변환은 infra HTTP 클라이언트에서, 수신 코드의 enum 변환은 웹훅 수신 서비스에서 수행한다.
수신 DTO는 service.dto 패키지에 두며 배송사 변환 로직을 포함하지 않는다.
추적 시 송장의 공백과 하이픈을 제거하며 저장된 원문은 변경하지 않는다.
미지원 배송사 또는 빈 송장은 관리 로그를 남기고 추적 등록을 건너뛴다.
빈 송장은 정규화 직후 관리 로그만 남기고 반환한다.
송장 변경 시 기존 활성 이력을 비활성화하고 구매별 취소 Job을 저장한다.
주문 전체 취소 시에는 활성 추적 객체를 비활성화하고 이력을 보존한다.
상품 취소 시에는 해당 구매에 배송중 상품이 더 없으면 활성 추적 객체를 비활성화한다.
OrderServiceV3와 OrderOperationAdminService에서 주문 취소 후 직접 Repository로 비활성화하고,
구매 ID를 담은 외부 구독 취소 Job을 같은 트랜잭션에 저장한다. OrderCancellation은 배송 추적에 의존하지 않는다.
주문 전체 취소는 구매 ID 목록을 조건으로 한 일괄 UPDATE를 사용한다.
주문 전체 취소의 취소 Job은 payload 목록을 모아 한 번에 enqueue한다.
외부 취소 실패는 새 송장 저장·등록에 영향을 주지 않으며 관리 로그와 Job 재시도로 처리한다.
취소되거나 수동 완료된 상품은 웹훅으로 다시 변경하지 않는다.
POST /order/webhook/delivery, Content-Type: application/json을 사용한다.
X-Webhook-Id, X-Webhook-Timestamp, X-Webhook-Signature 헤더가 필요하다.
delivery-api.webhook-secret으로 원본 본문의 HMAC-SHA256 서명과 현재 시각 ±300초를 검증한다.
서명 또는 필수 헤더 오류는 401, 수신 secret 미설정은 503이다.
컨트롤러는 서명 검증·JSON 파싱·HTTP 응답을 맡는다. PurchaseDeliveryWebhookService는
이벤트·metadata 검증, 배송완료 항목 선별, 수신 송장 정규화·중복 제거와 처리 Job 등록을 맡는다.
등록 metadata의 orderId, purchaseId, purchaseDeliveryTrackingId는 문자열이다. tracking.polled와 tracking.completed에서
isDelivered=true 또는 currentStatus=DELIVERED인 항목을 처리한다. hasChanged 값이나
이벤트 이름만으로 완료를 판단하지 않는다. 잘못된 payload와 미지원 이벤트는 관리 로그를 남기고
200으로 무시한다. 처리할 항목은 Job을 영속화한 뒤 200을 반환하며 enqueue 실패는 성공 처리하지 않는다.
완료 Job은 주문을 잠그고 metadata의 추적 ID로 조회한 활성 추적 건의 구매 ID·배송사·송장을 대조한다.
일치하는 완료 항목이 있으면 해당 구매의 배송중 상품 전체를 완료한다. 구매 내 상품별 송장은 대조하지 않는다.
추적이 없거나 비활성 상태이거나 배송사·송장이 다르면 웹훅을 무시하고, 중복 수신과 취소·수동 완료 상품은 재처리하지 않는다.
requestId 저장 여부는 처리 조건이 아니므로 등록 응답 저장 전 수신한 웹훅도 처리할 수 있다.
실제로 완료된 상품마다 actorMemberId=null인 변경 로그를 한 번 기록한다.
주문의 isDeliveryWritable 기준으로 완료 처리를 허용하며 실제 구매상품은 배송중일 때만 완료한다.
결제대기·주문취소 주문은 제외한다. 배송완료 주문도 주문 기준에서는 허용하지만 실제 처리 가능 여부는 상품 상태로 검사한다.
수동 완료도 같은 도메인 규칙을 사용한다. 취소를 제외한 모든 상품이 완료되면 주문 전체를 배송완료로 변경한다.
운영 관리자 주문 상세 화면에서는 배송 등록 시 문자 발송 여부를 선택한다.
배송 시작 문자는 기본 선택이며, 기존과 같이 구매 건의 첫 배송 등록 시에만 발송한다.
관리자 화면은 배송 등록 요청의 sendTextMessage에 선택값인 true 또는 false를 명시한다.
수동 처리와 웹훅 처리 모두 배송완료 문자를 발송하지 않는다.
배송중 상품은 주문 상세 화면의 배송 정보 수정에서 기존 택배사와 송장번호를 확인하고 변경할 수 있다.
수정 시 배송중 상태를 유지하고, 지원 배송사인 경우 변경한 송장으로 구매 건의 배송 추적을 다시 등록한다.
배송 정보 수정 요청은 sendTextMessage=false로 전송하며 배송 시작 문자를 재발송하지 않는다.
- 운영·개발은
ddl-auto=validate이므로 DDL을 먼저 적용한다. - 수신 endpoint를 먼저 배포한다.
기존 환경별
delivery-api.webhook-secret값은 그대로 유지한다. - deliveryapi의
POST /v1/webhooks/endpoints로 각 환경의 공개 HTTPS URL과 기존delivery-api.webhook-secret값을webhookSecret으로 전달하여 endpoint를 생성한다. 생성 시 테스트 POST가 전송되므로 수신 URL이 먼저 열려 있어야 한다. 테스트 이벤트는 서명 검증 후 미지원 이벤트로 200을 반환한다. - 반환된 ID를
delivery-api.purchase.webhook-endpoint-id에 설정하여 배포한다. API 인증은 기존delivery-api.api-key,delivery-api.secret-key를 사용한다. 옷장정리의 기존 endpoint ID는delivery-api.closet-cleanout.webhook-endpoint-id로 옮기고, 웹훅 컨트롤러는 기존 구현과delivery-api.webhook-secret참조를 유지한다.
운영 로그에서 추적 등록 성공·실패를 확인한다. 실패 Job은 기존 Job 재시도 정책(1회, 5초)을 따르며, 이후 실패는 관리 로그를 확인한다.
deliveryapi 공식 문서에 따라 구독은 최대 14일, 정기 폴링은 1시간 간격이며 22:00~07:00 KST에는 중단된다. 마지막 완료 웹훅은 전송 실패 시 재전송되지 않는다. 만료 구독 재등록과 유실 결과 정기 조회는 이번 구현에 포함하지 않는다. 미지원 배송사·구독 만료·완료 웹훅 유실 건은 기존 수동 배송완료 기능으로 처리한다.