비동기 웹훅 브리지
발생 상황
궁합 분석 기능을 설계할 때 외부 사주 계산 API의 동작 방식을 확인했더니, 분석 요청을 수신한 뒤 결과를 콜백(웹훅) URL로 비동기 POST하는 구조였습니다. 분석에 수십 초가 소요될 수 있어 즉시 응답이 불가능한 API였습니다.
문제는 우리 서버가 클라이언트에 반환해야 하는 REST API 응답 구조와 충돌한다는 점이었습니다. HTTP 요청을 보낸 클라이언트는 커넥션을 열어 두고 응답을 기다리는데, 외부 API의 분석 결과는 언제 도착할지 알 수 없는 별개의 콜백으로만 받을 수 있었습니다.
문제 상황:
클라이언트 ──요청──▶ 우리 서버 ──분석 요청──▶ 외부 API
클라이언트 ◀ 응답은 언제? ── ←── 결과는 콜백으로만 전달됨
해결 방법
요청이 들어올 때 해당 요청에 대한 CompletableFuture를 생성해 인메모리 레지스트리(PendingRegistry)에 등록합니다. 외부 API에는 분석 요청과 함께 결과를 받을 내부 콜백 URL을 X-Callback-URL 헤더로 전달합니다. 외부 API가 분석을 완료하고 콜백을 호출하면, 레지스트리에서 해당 요청의 Future를 꺼내 완료 처리합니다. 이 시점에 대기 중이던 클라이언트 요청이 결과를 받아 응답합니다.
클라이언트 우리 서버 외부 API
│ │ │
│ GET /compat/{id} │ │
│───────────────────▶│ │
│ │ PendingRegistry.register() │
│ │ ── 분석 요청 + Callback-URL ─▶│
│ │ │ (분석 중...)
│ │◀─── POST /internal/callback ──│
│ │ PendingRegistry.complete() │
│◀──── 결과 응답 ────│ │
타임아웃이 초과되면 PENDING 상태를 반환합니다. 이후 클라이언트가 동일 엔드포인트를 재요청하면 DB에 저장된 결과가 있을 경우 즉시 반환합니다.
| 구성 요소 | 역할 |
|---|---|
PendingRegistry | userId → CompletableFuture 매핑, 요청과 콜백을 연결하는 다리 역할 |
X-Callback-URL 헤더 | 외부 API에 결과를 보낼 내부 URL을 동적으로 전달 |
| 내부 콜백 Controller | 외부 API 웹훅 수신, Future 완료 처리 |
이 방식을 선택한 이유
설계 단계에서 세 가지 방법을 검토했습니다.
① 클라이언트 폴링 클라이언트가 일정 간격으로 조회 API를 반복 호출하는 방식입니다. 구현이 단순하지만, 외부 API 응답이 오기 전까지 클라이언트가 불필요한 요청을 반복해야 합니다. 사용자 수가 늘면 DB 조회 부하가 선형으로 증가하고, 응답 지연이 폴링 간격만큼 생길 수 있습니다.
② SSE(Server-Sent Events) / WebSocket 서버가 결과를 받는 즉시 클라이언트에 푸시하는 방식입니다. 실시간성은 가장 좋지만, SSE/WebSocket 커넥션 관리, 클라이언트 재연결 처리, 프록시/로드밸런서 설정 등 구현 복잡도가 크게 높아집니다.
③ 웹훅-동기 변환 브리지 (채택)
클라이언트 입장에서는 일반 REST 요청을 보내고 응답을 받는 것과 동일합니다. 서버 내부에서 비동기 처리와 동기 변환을 담당합니다. CompletableFuture의 get(timeout) 메서드가 블로킹 대기를 처리하고, 타임아웃 시 PENDING을 반환하는 방식으로 클라이언트 경험을 단순하게 유지할 수 있었습니다.
CompletableFuture를 선택한 이유는 Java 표준 라이브러리이며 타임아웃, 취소, 체이닝 등 비동기 조합 연산이 풍부하고 별도 의존성이 필요 없기 때문입니다.