외부 파트너(브랜드/POS/주문 시스템)와 안정적으로 주문을 연동하기 위한 주문 연동 플랫폼입니다. 고객 주문을 표준(Canonical) 주문 모델로 저장한 뒤, 파트너별 어댑터를 통해 주문을 전송하고 파트너의 상태 업데이트(Webhook)를 받아 주문 상태를 동기화합니다. 이 프로젝트는 단순한 주문 앱이 아니라, 연동/운영 관점에서 안정성을 갖춘 주문 연동 시스템을 구현하는 것입니다.
- 표준 주문 모델(Canonical Model) 기반 주문 저장
- 파트너 시스템으로 주문 전송(파트너별 변환/전송 어댑터)
- 파트너 Webhook으로 상태 동기화
- 멱등성(Idempotency) / 중복 방지
- Outbox 패턴 기반 이벤트 발행(DB 트랜잭션 정합성)
- 실패 시 재시도 / 백오프 / DLQ(실패 적재)
- 운영을 위한 로그/모니터링(추후 예정)
- Java 17.0.12
- Spring Boot 3.5.9
- Spring Web (REST API)
- Spring JDBC
- Flyway 11.7.2
- k6 1.5.0
- MySQL 8.0.43 (Docker)
- Redis 7.4-alpine (Docker)
- Docker Compose
- Store 생성/조회 API 구현
- 요청 검증 및 기본 예외 처리(404 등)
- README에 Store API 문서화
📝 Store API
- Method:
POST - Path:
/api/stores - Description: 매장을 생성합니다.
Content-Type: application/json
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | O | 매장명 (공백/빈 문자열 불가) |
partner |
string | X | 연동 파트너명(예: PartnerA) |
partnerStoreId |
string | X | 파트너 시스템의 매장 식별자 |
curl -i -X POST http://localhost:8080/api/stores \
-H "Content-Type: application/json" \
-d '{"name":"홍대점","partner":"PartnerA","partnerStoreId":"A-101"}'- 주문 생성 API 구현
- Idempotency-Key 기반 중복 방지 정책 적용
- 공통적으로 예외 처리를 위해 GlobalExceptionHandler 추가
- README에 멱등 정책 및 주문 API 문서화
📝 Idempotency 정책 (Idempotency-Key)
- 클라이언트는 주문 생성 요청마다 고유한
Idempotency-Key값을 생성하여 요청 헤더에 포함해야 합니다. - 동일 주문에 대해 재시도 요청을 보낼 때는 반드시 동일한 Idempotency-Key를 재사용해야 합니다.
Idempotency-Key: 멱등키 값
-
Idempotency-Key가 누락/공백인 경우
400 Bad Request- 응답:
{ "code": "IDEMPOTENCY_KEY_MISSING", "message": "..." }
-
동일 Idempotency-Key로 이미 생성된 주문이 존재하는 경우
- 주문을 새로 생성하지 않고 기존 주문을 반환합니다. (중복 생성 방지)
-
동시 요청으로 UNIQUE 충돌이 발생하는 경우
- DB의
UNIQUE(idempotency_key)제약으로 중복 삽입을 방지합니다. - 충돌 시 기존 주문을
idempotency_key로 재조회하여 기존 주문을 반환합니다.
- DB의
📝 Order API
- Method:
POST - Path:
/api/orders
Content-Type: application/jsonIdempotency-Key: 멱등키 값
| Field | Type | Required | Description |
|---|---|---|---|
storeId |
number | O | 매장 ID |
totalPrice |
number | O | 총 결제 금액 |
curl -i -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-H "Idempotency-Key: key-001" \
-d '{"storeId":1,"totalPrice":50000}'- Status:
201 Created
Body Example
{
"id": 1,
"storeId": 1,
"status": "CREATED",
"totalPrice": 50000,
"createdAt": "2026-01-09T06:00:00Z"
}{
"code": "IDEMPOTENCY_KEY_MISSING",
"message": "멱등키 헤더가 필요합니다."
}{
"code": "STORE_NOT_FOUND",
"message": "Store 아이디가 1인 Store는 찾을 수 없습니다."
}{
"code": "INTERNAL_SERVER_ERROR",
"message": "Unexpected error"
}- Method:
GET - Path:
/api/orders/{id}
curl -i http://localhost:8080/api/orders/1- Status:
200 OK
Body Example
{
"id": 1,
"storeId": 1,
"status": "CREATED",
"totalPrice": 50000,
"createdAt": "2026-01-09T06:00:00Z"
}{
"code": "ORDER_NOT_FOUND",
"message": "orderId: 999에 대한 주문 정보를 찾을 수 없습니다."
}- 주문 생성 트랜잭션에 outbox 이벤트 저장
- 이벤트 스키마/페이로드 형식 정의
- README에 Outbox 패턴 및 흐름 문서화
📝 Outbox 패턴 적용
주문 생성 과정에서 발생하는 후속 작업(파트너 전송, VAN 승인 요청 등)을 트랜잭션 정합성을 유지하면서 안정적으로 처리하기 위해 Outbox 패턴을 적용했습니다.
- 이벤트 유실 방지: 주문 저장(DB commit)과 이벤트 저장(outbox)을 같은 트랜잭션으로 묶어 “주문은 저장됐는데 이벤트가 없는” 상황을 방지합니다.
- 외부 연동 장애 격리: 파트너/VAN 등 외부 시스템 장애가 있어도 주문 생성 API를 안정적으로 유지하고, 후속 작업은 별도 디스패처가 재시도하며 처리합니다.
- 운영 가능성: 이벤트 처리 상태(PENDING/PROCESSED/FAILED)와 재시도 횟수(retry_count)가 DB에 남아 모니터링 및 복구가 가능합니다.
- 주문 생성 트랜잭션에 outbox 이벤트 저장
- 주문 생성 성공 시
outbox_events테이블에ORDER_CREATED이벤트를 함께 저장 - 주문 INSERT와 outbox INSERT가 동일 트랜잭션에서 커밋/롤백되도록 구성
- 멱등키(Idempotency-Key)로 인해 “기존 주문 반환”인 경우 outbox 이벤트를 재적재하지 않도록 처리
- 주문 생성 성공 시
- 이벤트 스키마/페이로드 형식 정의
event_type,aggregate_type,aggregate_id,payload(JSON)기반으로 표준화- payload는 이벤트별 DTO로 정의하여 스키마를 명확히 유지
- status:
PENDING→PROCESSED/FAILED - retry_count: 실패 시 재시도 횟수 증가
- processed_at: 성공/최종 실패 시점 기록
- 클라이언트가 주문 생성 요청(멱등키 포함)
- 서버가 주문을 저장
- 같은 트랜잭션에서 outbox 이벤트(
ORDER_CREATED)를PENDING상태로 저장 - 커밋 완료 후 outbox 디스패처가 이벤트를 처리(전송/후속 작업)
- outbox 이벤트 폴링 워커 구현
- 전송 성공/실패 처리 및 재시도 기본 정책 적용
- README에 outbox 상태 전이/워커 동작 문서화
📝 Outbox 워커 + 전송 처리
Outbox 테이블에 쌓인 PENDING 이벤트를 Dispatcher가 폴링하여 처리하고,
결과를 PROCESSED / FAILED로 기록해 결국 처리되도록 합니다.
- outbox 이벤트 폴링 워커 구현
- 일정 주기마다
status='PENDING'이벤트를 배치로 조회(findPending(limit)) - 이벤트 타입 기반으로 처리 로직 분기(현재는 mock 처리, 추후 파트너/VAN 호출로 확장할 예정)
- 일정 주기마다
- 전송 성공/실패 처리 및 재시도 기본 정책 적용
- 성공 시:
status='PROCESSED',processed_at=NOW()업데이트 - 실패 시:
retry_count증가 - 최대 재시도 횟수 초과 시:
status='FAILED'로 전환(자동 처리 대상에서 제외)
- 성공 시:
PENDING→PROCESSED: 처리 성공PENDING→PENDING: 처리 실패(재시도 횟수 증가 후 재시도 대기)PENDING→FAILED: 재시도 한계 초과(수동 조치 필요)
PENDING이벤트를 일정 개수(batch) 조회- 이벤트별 처리 수행(전송/후속 작업)
- 성공 시
markProcessed(id)로 상태 갱신 - 실패 시
retry_count증가, 한계 초과 시FAILED전환
- 임시로 Partner Mock 구현
- 파트너별 어댑터/클라이언트 구조 도입
- README에 파트너 계약(Contract) 및 Adapter 구조 문서화
📝 Partner Mock + Adapter 구조
- OutboxDispatcher가
ORDER_CREATED이벤트를 처리할 때, 파트너 시스템으로 주문을 전송하는 흐름을 검증합니다. - 실제 외부 파트너 API 대신, 프로젝트 내부에 Partner Mock 엔드포인트를 두어 안정적으로 통합 테스트가 가능하도록 했습니다.
- 파트너별 계약이 달라질 수 있으므로, Adapter(변환) + Client(전송)로 책임을 분리했습니다.
실제 외부 파트너 서버 대신, 로컬에서 동작하는 Mock 엔드포인트로 주문 전송을 검증합니다. OutboxDispatcher는 PartnerClient를 통해 Mock URL로 주문을 전송합니다.
- Method:
POST - Path:
/mock/partner-a/orders - Description: PartnerA가 주문을 수신하는 상황을 로컬에서 Mock합니다.
curl -i -X POST http://localhost:8080/mock/partner-a/orders \
-H "Content-Type: application/json" \
-d '{"externalOrderId": 10, "partnerStoreId":"A-101", "amount": 15000}'- Partner Contract 변화 대응: 파트너별 요청 필드/형식이 바뀌어도 Adapter만 수정하면 됩니다.
- 전송 로직 격리: HTTP 호출/에러 처리/타임아웃 등은 Client가 책임집니다.
- Outbox 로직 단순화: Dispatcher는 이벤트 처리와 라우팅에 집중하고, 변환/전송은 하위 컴포넌트에 위임합니다.
- Adapter: Canonical(Order/Store) → Partner Contract DTO로 변환
- Client: Partner Contract DTO를 실제 전송(HTTP)
- Dispatcher: Outbox 이벤트 폴링 후, partner에 맞는 Adapter/Client로 라우팅
| Field | Type | Required | Description |
|---|---|---|---|
externalOrderId |
number | O | 내부 시스템의 주문 ID(외부 전송용) |
partnerStoreId |
string | O | 파트너 시스템의 매장 식별자(stores.partner_store_id) |
amount |
number | O | 주문 금액(예: orders.total_price) |
- 주문 생성 트랜잭션에서
outbox_events에ORDER_CREATED이벤트 저장 - OutboxDispatcher가
PENDING이벤트 폴링 - 이벤트의
aggregate_id(orderId)로 주문 조회 - 주문의
store_id로 매장 조회 - 매장의
partner값에 따라 Adapter/Client 선택 - Adapter가 PartnerA 요청 DTO로 변환
- Client가 Partner Mock(또는 실제 파트너 API)로 HTTP 전송
- 성공 시 outbox
PROCESSED, 실패 시 재시도 후FAILED
- 파트너 webhook 수신 엔드포인트 구현
- 토큰 검증 방식 추가
- webhook 중복 처리 방지 적용
- README에 webhook 처리/검증/중복 방지 문서화
📝 Webhook 수신 + 중복 방지
- 파트너(PartnerA)가 주문 처리 결과(상태 변경)를 우리 시스템에 알리기 위해 Webhook을 호출합니다.
- Webhook 수신 시
orders.status를 업데이트하여 파트너와 주문 상태를 동기화합니다. - 같은 Webhook 이벤트가 여러 번 전달되어도(재전송/중복) 한 번만 처리하도록 중복 방지를 적용합니다.
- 간단한 토큰 검증으로 파트너 인증을 수행합니다.
- Method:
POST - Path:
/webhooks/partner-a/orders
| Header | Required | Description |
|---|---|---|
X-Event-Id |
O | Webhook 이벤트의 고유 ID (중복 방지 키) |
X-Partner-Token |
O | 파트너 인증 토큰 (간단 검증 방식) |
| Field | Type | Required | Description |
|---|---|---|---|
orderId |
number | O | 우리 시스템 주문 ID (orders.id) |
status |
string | O | 파트너가 통지하는 주문 상태 값 |
curl -i -X POST http://localhost:8080/webhooks/partner-a/orders \
-H "Content-Type: application/json" \
-H "X-Event-Id: evt-100" \
-H "X-Partner-Token: partner-a-secret" \
-d '{"orderId": 1, "status": "ACCEPTED"}'
Webhook 엔드포인트는 외부에서 호출 가능하므로, 무분별한 호출로 주문 상태가 변경되는 것을 막기 위해 임시로
X-Partner-Token 기반 검증을 적용했습니다.
- 요청의
X-Partner-Token값이 서버 설정의 토큰과 일치하지 않으면401 Unauthorized를 반환합니다. - 토큰은 로컬에서
.env에 환경변수로 관리할 수 있도록 구성했습니다.
# .env
PARTNER_A_WEBHOOK_TOKEN=partner-a-secret
파트너 시스템은 네트워크 장애/타임아웃 발생 시 동일 이벤트를 재전송할 수 있습니다.
따라서 X-Event-Id를 중복 방지 키로 사용하고, DB 레벨에서 UNIQUE 제약으로 중복 처리를 방지합니다.
webhook_events.event_id에 UNIQUE 제약 적용- 새로운
X-Event-Id는 INSERT 성공 → 최초 이벤트로 처리 - 이미 존재하는
X-Event-Id는 INSERT 실패(Duplicate) → 중복 이벤트로 판단하고 종료
- PartnerA가 Webhook 호출:
POST /webhooks/partner-a/orders - Controller에서
X-Partner-Token검증 (불일치 시 401) - Service에서
X-Event-Id를webhook_events에 INSERT 시도 - INSERT 성공: 최초 이벤트 → 주문 상태 업데이트 수행
- INSERT 실패(중복): 중복 이벤트 → 상태 업데이트 생략 후 종료
- 존재하지 않는 주문이면 404 반환
| Status | When |
|---|---|
200 OK |
정상 처리 또는 중복 이벤트 처리 |
401 Unauthorized |
토큰 검증 실패 |
404 Not Found |
존재하지 않는 주문 ID |
- 재시도 정책 고도화(백오프/최대 횟수)
- DLQ(실패 적재) 설계/적용
- 운영용 조회/재처리 API 추가
📝 재시도/백오프/DLQ + 운영 기능
Outbox 패턴은 이벤트를 DB(outbox_events)에 저장한 뒤, 별도의 워커(OutboxDispatcher)가 주기적으로 폴링하여 외부 시스템(파트너)으로 전송합니다. 이 과정에서 네트워크 장애, 파트너 응답 실패 등이 발생할 수 있으므로 재시도 정책, 백오프, DLQ(실패 적재), 운영용 조회/재처리 기능이 필요합니다.
-
OutboxDispatcher는
PENDING상태의 이벤트를 주기적으로 조회하여 처리합니다. -
전송 실패 시
retry_count를 증가시키고, 재시도 스케줄을 위해next_run_at을 설정합니다. -
최대 재시도 횟수(
MAX_RETRY_COUNT)를 초과하면 이벤트는FAILED로 전환되어 더 이상 자동 재시도되지 않습니다.
status: PENDING / PROCESSED / FAILEDretry_count: 재시도 횟수next_run_at: 다음 재시도 가능 시각 (백오프 적용)last_error: 최근 실패 원인(예외 메시지)
재시도 횟수에 따라 다음 실행 지연 시간이 증가합니다.
- 0회 → 5초
- 1회 → 15초
- 2회 → 30초
- 3회 → 60초
- 4회 이상 → 120초
본 프로젝트에서는 별도의 DLQ 테이블/큐를 두지 않고, outbox_events의 status를 FAILED로 유지하는 방식으로 DLQ를 구성합니다.
- FAILED 이벤트는 자동 재시도 대상에서 제외됩니다.
- 운영자는 Ops API를 통해 FAILED 이벤트를 확인하고 원인을 파악할 수 있습니다.
-
필요 시 강제로
PENDING으로 되돌려 재처리할 수 있습니다.
운영자는 Outbox 이벤트를 조회하고, 실패한 이벤트를 수동으로 재처리할 수 있습니다. (관리자용 API로 가정)
- Method:
GET - Path:
/ops/outbox - Query:
status(default: FAILED)limit(default: 50, max: 200)
curl -i "http://localhost:8080/ops/outbox?status=FAILED&limit=50"- Method:
POST - Path:
/ops/outbox/{id}/retry - Description: 특정 outbox 이벤트를
PENDING으로 되돌려 즉시 재처리 대상으로 등록합니다.
curl -i -X POST "http://localhost:8080/ops/outbox/10/retry"status→ PENDINGnext_run_at→ NOW()processed_at→ NULL
주문 생성 API의 Idempotency-Key(중복 방지) 구현을 다음 두 방식으로 구현하고, k6 부하 테스트로 성능 차이를 직접 측정했습니다.
Redis 멱등 처리 구현 및 성능 측정은 feature/idempotency-redis 브랜치에서 진행했습니다.
orders.idempotency_key컬럼에 UNIQUE 제약을 부여- 중복 요청 시
DuplicateKeyException발생 → 기존 주문을 조회하여 동일 응답 반환
장점
- DB가 원자적으로 중복을 막으므로 정확성과 일관성이 매우 높음
- 인프라 단순
단점
- 중복 요청이 많아질수록 DB insert 충돌 및 조회로 DB 부하가 증가할 수 있음
- Redis에 멱등키를
SETNX(setIfAbsent)로 선점(IN_PROGRESS 락) - 처리 완료 시
DONE:<responseJson>형태로 결과 저장 - 중복 요청은 Redis에서 빠르게 차단 및 동일 응답 제공 가능
장점
- 중복 요청 폭주 상황에서 DB로 유입되는 트래픽을 줄일 수 있음
- 분산환경에서 멱등 처리 확장에 유리
단점
- Redis 장애/TTL/상태 불일치 관리 등으로 운영 복잡도가 증가
- 정상 처리 흐름은 결국 DB insert가 필요하여 체감 성능 개선 폭이 제한적일 수 있음
- DB 기반과 Redis 기반의 성능 차이는 거의 없었음
- 정상 주문은 결국 DB INSERT가 발생하므로 병목은 DB 트랜잭션 비용 영향이 큼
중복 요청 테스트에서는 일부 조건에서 Redis 방식이 더 좋아 보이기도 했지만,
테스트 스크립트의 sleep(0.1) 존재 여부에 따라 TPS가 크게 달라지는 문제가 있었고,
최종적으로 동일한 조건(100 VUs / 20s / sleep 제거)으로 맞춘 결과는 아래와 같았습니다.
-
DB 기반 중복 요청
- avg: 18.38ms
- p(90): 20.23ms
- p(95): 33.71ms
-
Redis 기반 중복 요청
- avg: 18.29ms
- p(90): 19.9ms
- p(95): 33.52ms
결론적으로 중복 요청에서도 Redis 도입에 따른 유의미한 성능 차이는 거의 없었음
(로컬 환경 / 단일 서버 / 단일 DB 구성에서는 병목이 네트워크/서버 처리 비용에 더 크게 좌우됨)
현재 프로젝트 구성(단일 인스턴스 + 단일 DB + 로컬 규모)에서는
DB Unique 기반 멱등 처리만으로도 안정성과 단순성을 충분히 확보할 수 있어 기본 방식으로 유지하는 것이 합리적인 것으로 보인다. 다만, Redis 방식은 다음 상황에서 효과가 커질 수 있으므로 확장 옵션으로 유지하는 것으로 결정했다.
✅ Redis 방식이 의미 있는 경우
- 중복 요청이 폭증하는 트래픽 패턴(모바일 재시도, 네트워크 불안정)
- 애플리케이션 인스턴스가 여러 대인 분산 환경
- DB 부하를 줄이기 위해 중복 처리 응답을 Redis에서 캐싱하고 빠르게 반환해야 하는 경우
Outbox Dispatcher의 폴링 쿼리(findAndLockPending)는 아래 조건으로 지금 처리 가능한 이벤트를 id 오름차순으로 50개 가져오고 있다.
status='PENDING'next_run_at IS NULL OR next_run_at <= NOW()ORDER BY id LIMIT 50
인덱스가 없으면 이러한 이벤트가 테이블 뒤쪽(id가 큰 쪽)에 몰려 있는 상황에서
50개를 찾기 위해 PK(=PRIMARY) 스캔으로 수십만 ~ 수백만 행을 스캔하는 일이 발생한다.
폴링 패턴에 맞춘 복합 인덱스를 추가해, 조건 필터링 + 정렬/limit을 인덱스에서 처리하도록 유도했다. 측정 결과 인덱스 도입 전의 걸린 시간 평균 340ms에서 인덱스 도입 후 0.09ms로 대폭 줄일 수 있었다.
CREATE INDEX idx_outbox_pending_pick
ON outbox_events (status, next_run_at, id);