Home > AI > Backend > 동기 30초 타임아웃을 webhook 비동기로

동기 30초 타임아웃을 webhook 비동기로
AI Claude Code Spring 비동기 API설계

30초로는 부족했다

문서를 OCR로 파싱하는 기능이 있다. 내부 OCR 서비스에 HTTP로 요청하고 결과를 받아 응답하는 단순한 동기 구조였다.

두 가지가 겹쳐 30초 타임아웃에 계속 걸렸다.

  • PaddleOCR 콜드스타트 — 모델 로딩에 시간이 걸린다. 한동안 요청이 없으면 첫 요청이 특히 느리다
  • 페이지당 처리 지연 — 문서가 길수록 선형으로 늘어난다

타임아웃을 늘리는 방법도 있지만, 페이지 수에 비례해 늘어나는 작업이라 상한을 정할 수가 없다. 요청 스레드와 커넥션을 그동안 붙잡고 있는 것도 문제다. ba916774(PR #768)에서 webhook 콜백 구조로 바꿨다.

바뀐 흐름

[이전] 클라이언트 → 서버 → OCR 서비스 (동기 대기 30초+) → 응답
[이후] 클라이언트 → 서버 → OCR 서비스 async 제출 → jobId 즉시 반환
                 OCR 서비스 완료 → 서버 /callback → 결과 저장
       클라이언트 → GET /requests/{id} 로 조회

jobId를 매핑한 엔티티를 두어 상태와 결과 JSON(MEDIUMTEXT)을 저장한다. 클라이언트는 즉시 jobId를 받고, 조회 엔드포인트로 진행 상태를 확인한다.

이 구조를 만들면서 다룬 논점이 세 가지다.

(1) 콜백 멱등성

콜백은 반드시 중복해서 온다고 가정해야 한다. OCR 서비스가 재시도할 수도 있고, 네트워크 문제로 응답이 유실돼 상대가 실패로 판단하고 다시 보낼 수도 있다.

jobId를 기준으로 이미 처리된 요청이면 무시하도록 했다. jobId는 OCR 서비스가 발급하는 자연스러운 멱등 키다 — 별도로 멱등 키를 설계할 필요가 없었다.

후속 커밋(dfcc927a)에서 여기에 jobId null 가드를 추가했다. findByJobId(null)이 호출되면 의도치 않은 행에 매칭될 수 있다. 콜백 페이로드는 외부에서 들어오는 값이라 필드가 비어 있을 가능성을 전제해야 한다.

콜백 경로는 세션 인증 대상이 아니므로 SecurityConfig에서 인증 예외로 두되, X-Internal-API-Key 헤더 검증을 붙였다. 인증 예외로 열어둔 엔드포인트에는 반드시 다른 형태의 검증이 따라와야 한다.

(2) 트랜잭션 경계

이 부분은 처음에 잘못 잡았다가 리뷰에서 고쳤다.

원래는 서비스 클래스에 @Transactional이 붙어 있었다. 그러면 외부 OCR 서비스 호출이 트랜잭션 안에서 실행된다. 외부 HTTP 호출은 응답 시간을 예측할 수 없는데, 그동안 DB 커넥션을 점유한 채 잡고 있게 된다. 커넥션 풀이 마르는 전형적인 경로다.

dfcc927a에서 이렇게 정리했다.

대상 변경
클래스 @Transactional 제거
제출(submit) OCR 호출 먼저 → jobId 받아 1회 INSERT
콜백(handleCallback) @Transactional 명시

제출 경로의 순서를 바꾼 게 핵심이다. 먼저 외부를 호출해 jobId를 받고, 그 결과를 가지고 트랜잭션 안에서 한 번만 INSERT한다. 외부 호출은 트랜잭션 밖이다.

콜백 쪽에는 반대로 @Transactional을 명시했다. 조회한 엔티티의 상태와 결과를 더티 체킹으로 반영하는데, 트랜잭션이 없으면 영속성 컨텍스트가 요청 범위를 벗어나 변경이 저장되지 않을 수 있다.

“외부 호출은 트랜잭션 밖, 저장은 트랜잭션 안” 이 원칙이 결국 두 메서드에 서로 다른 처방으로 나타난 셈이다. 클래스 레벨 @Transactional은 이 구분을 지워버린다.

(3) 점진적 마이그레이션

기존 동기 엔드포인트를 그대로 남겼다. 비동기 엔드포인트(/mds/parse/async, /process/async)를 새 경로로 추가하고, 조회·콜백 엔드포인트를 함께 뒀다.

이유는 단순하다. 프런트엔드가 준비되기 전에 서버가 응답 형태를 바꾸면 그 순간부터 화면이 깨진다. 비동기 전환은 클라이언트 입장에서 응답 계약이 완전히 달라지는 변경이다 — 결과를 받던 자리에 jobId가 오고, 폴링 로직이 새로 필요하다.

병존시키면 서버 배포와 클라이언트 전환을 분리할 수 있다. 짧은 문서는 동기로 두고 긴 문서만 비동기로 보내는 선택도 가능해진다. 동기 경로를 언제 걷어낼지는 클라이언트가 전부 옮겨간 뒤에 정하면 된다.

남는 교훈

동기를 비동기로 바꾸는 작업의 실제 난이도는 “비동기로 만드는 것”에 있지 않다. 즉시 jobId를 반환하는 건 쉽다. 어려운 건 그 뒤에 생기는 새로운 경계들이다.

  • 응답이 두 번(제출 응답 + 콜백)으로 나뉘면서 그사이의 상태를 어딘가 저장해야 한다
  • 콜백은 네트워크 너머에서 오므로 중복·유실·인증을 전부 다뤄야 한다
  • 외부 호출과 DB 트랜잭션이 뒤섞이면 커넥션을 오래 잡는다
  • 클라이언트 계약이 바뀌므로 한 번에 갈아탈 수 없다

이번 작업에서 실제로 리뷰에 걸린 것도 비동기 전환 자체가 아니라 트랜잭션 경계와 null 가드였다. 구조를 바꾸는 커밋보다 그 구조가 만드는 경계를 다듬는 후속 커밋이 더 촘촘했다.