API
PostgreSQL 트랜잭션 안에서 외부 API를 기다리지 않도록 바꾼 설계 회고
주문 저장 트랜잭션 안에서 결제 API를 호출해 잠금이 길어졌던 문제를 outbox 방식으로 나눈 회고입니다.
느린 결제 응답이 DB 잠금으로 이어졌다
주문 생성 코드는 주문 row를 insert한 뒤 같은 함수 안에서 외부 알림 API와 결제 준비 API를 호출하고 마지막에 commit하는 구조였다. 결제 테스트 서버가 가끔 5초 이상 응답하지 않자, 그 시간 동안 주문 트랜잭션이 열린 채 있었다. 목록 조회가 모두 느려진 것은 아니지만, 같은 상품 재고를 갱신하는 요청이 줄줄이 대기했다.
처음에는 DB 커넥션 풀을 키우려 했다. 그러나 기다리는 트랜잭션이 늘어날 뿐이고, 외부 API가 실패했을 때 주문 데이터까지 롤백되는 것도 원하는 동작이 아니었다.
DB에 남길 것과 밖에서 할 일을 분리했다
주문 상태와 처리할 작업을 한 트랜잭션에 기록하고, 커밋이 끝난 뒤 워커가 outbox를 읽게 바꿨다.
CREATE TABLE order_events (
id bigserial PRIMARY KEY,
order_id bigint NOT NULL,
kind text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending',
attempts integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
애플리케이션에서는 주문과 이벤트 insert만 짧은 트랜잭션으로 묶는다.
await db.transaction(async tx => {
const order = await tx.order.create({ data: input })
await tx.orderEvent.create({
data: { orderId: order.id, kind: 'payment.prepare', payload: { orderId: order.id } },
})
})
워커는 한 번에 가져온 작업을 processing으로 바꾸고 외부 요청을 수행한다. 재시도할 수 있으므로 결제 제공자에는 주문 ID를 idempotency key로 전달했다. 성공 여부와 별개로 네트워크 timeout을 실패 확정으로 취급하지 않는 규칙도 문서에 남겼다.
바뀐 판단 기준
- 트랜잭션 안에는 DB 읽기와 쓰기만 둔다
- 외부 요청은 timeout과 재시도 횟수를 갖는다
- 재시도해도 같은 결과가 되도록 외부 요청에 멱등 키를 둔다
- outbox row 상태 변경도 중복 워커를 고려한다
- 사용자가 보는 주문 상태와 백그라운드 처리 상태를 구분한다
SELECT ... FOR UPDATE SKIP LOCKED를 적용할 때는 인덱스와 작업량을 같이 확인했다. 행을 잠그는 쿼리만 추가한다고 워커가 안전해지는 것은 아니었다.
회고
이 구조는 코드가 더 길어졌지만, 외부 서비스의 속도가 주문 저장 트랜잭션을 결정하지 않게 됐다. “한 함수에서 끝내면 단순하다”는 느낌보다, 반드시 함께 성공해야 하는 작업과 나중에 성공해도 되는 작업을 분리하는 기준이 운영에서는 더 단순했다.