9osari.log
← All posts

PDF 대량 업로드, 외부 API 호출을 트랜잭션에서 빼내기까지

1. 문제 발견

운영 중인 시스템에는 PDF를 올리면 AI 서버에 분석을 맡기고, 나중에 결과를 가져오는 기능이 있다. 업로드 → AI 제출 → 폴링으로 결과 회수 순서로 동작한다.

이 기능에서 PDF 1,000건이 한꺼번에 접수되면 어떻게 될지 궁금했다.

코드를 따라가 보니 업로드 요청이 커넥션 풀을 오래 점유해, 로그인이나 조회 같은 무관한 요청까지 실패할 수 있는 구조였다.

이 문제를 고치려고 접수와 AI 제출을 분리했다. 그런데 2주 뒤 문서 삭제가 안 된다는 제보를 받고, 같은 문제가 워커에 남아 있다는 것을 알았다.

2. 원인 분석

@Transactional
public DetailResponse create(Long companyId, Create request) {
    Study study = studyService.getStudy(...);              // DB 커넥션 획득
    UploadedPdf pdf = storage.readValidUploadedPdf(...);
    Submission submission = aiClient.submitDraft(pdf.getName(), pdf.getBytes());  // 외부 HTTP. 타임아웃: 연결 3초 + 응답 대기 30초
    storage.moveToStorage(pdf.getFileId());
    return save(study, pdf, submission);                   // 여기서야 커넥션 반납
}

트랜잭션 안에서 외부 HTTP 호출을 하고 있었다. AI 서버가 바로 응답할 때는 드러나지 않지만, 응답이 느려지면 문제가 된다.

AI 서버가 밀려 25초 만에 답하면, 요청 스레드는 그 25초 동안 DB 커넥션을 쥔 채 아무것도 안 한다. 동시에 1,000건이 들어온다면 Tomcat 스레드 200개가 커넥션 풀 10개를 놓고 줄을 서고, 업로드와 무관한 요청까지 커넥션을 기다리다 실패할 수 있다. (Tomcat 기본 스레드 수와 개발 환경의 커넥션 풀 크기를 기준으로 코드를 읽고 예상한 시나리오다. 실제로 부하를 걸어 측정한 결과는 아니다.)

참고로 응답 대기 타임아웃 30초는 “응답이 30초 동안 한 바이트도 안 오면 끊는다”는 뜻이다. PDF를 올리는 시간은 별도라서, 한 건이 커넥션을 쥐는 시간은 30초보다 길어질 수 있다.

DB 커넥션을 쥔 채 AI 서버의 응답을 기다리는 것이 원인이었다.

3. 첫 번째 수정 — 요청에서 AI 제출 분리

별도 브로커가 필요할까?

접수와 AI 제출을 분리하고, 제출하는 작업 수에 상한을 두기로 했다. 처음 설계 문서에는 비동기 작업 큐로 SQS를 적었다.

필요한 조건을 다시 적어봤다. 접수와 제출의 분리, 제출 작업 수 제한, 재시작해도 남아 있는 작업을 이어서 처리하는 것이었다. 그리고 이 시스템에는 이미 작업 상태를 담은 DB 행과 10초 주기 폴링이 있었다. 작업을 DB에 저장하고 주기적으로 조회하는 워커가 이미 있는데 SQS를 넣으면, 두 곳의 작업 상태를 맞춰야 한다. SQS를 쓰면 제출을 즉시 시작할 수 있다는 장점은 있었다. 하지만 AI 작업이 건당 수십 초씩 걸리는데, 제출 시작이 최대 10초 늦어지는 건 체감 차이가 작다고 판단했다. (즉시 제출은 나중에 브로커 없이 추가했다. 4절 끝에서 다룬다.)

Rails 8도 기본 잡 큐를 DB 기반(Solid Queue)으로 바꿨다. 작업 상태의 진실이 DB에 있다면, 큐를 따로 두지 않고 DB를 큐로 쓰는 것도 현실적인 선택지다.

그래서 별도 브로커 대신 DB의 QUEUED 상태를 제출 대기열로 사용했다.

Kafka는?

Kafka도 검토했다. 이 기능에서 필요한 것은 한 제출기에 일감을 전달하는 것이지, 여러 소비자에게 이벤트를 전달하는 것은 아니었다. 이 기능의 소비자는 AI 제출기 하나다. 대량 접수 경로에서는 진행 중인 작업을 5건 안팎으로 제한하고, AI 작업은 건당 수십 초씩 걸린다. 큐만 빨라져도 이 경로의 처리량은 늘지 않는다. 유입이 처리 속도보다 빠르면 QUEUED 행이 쌓인다. 이 규모에서는 먼저 대기 행의 조회 비용과 대기 시간을 관찰하면 된다.

DB 큐를 계속 써도 될지 판단하려면 아래 상황을 확인해야 한다. 현재 기능에서는 아직 해당하지 않았다.

신호먼저 할 것그래도 안 되면
제출 대기가 길다폴링 간격 단축, 커밋 직후 이벤트로 워커 깨우기—
워커를 여러 대로 늘린다상한은 서버마다 따로 계산돼 근사치가 된다. 전체 상한이 꼭 필요하면 업로드 전 짧은 트랜잭션으로 자리를 예약—
생산자와 소비자가 다른 DB를 쓴다—SQS 등 별도 큐 검토
같은 이벤트를 소비자 둘 이상이 원한다 · 지난 이벤트 재생이 필요하다—Kafka

나중에 “문서 초안 완료”를 알림·통계·감사 로그가 각각 받아야 한다면 그때 Kafka를 검토할 수 있다. 그 경우에도 DB의 작업 상태를 기준으로 이벤트를 발행하는 경로가 필요하다.

접수와 제출의 분리

Before:  업로드 요청 → (트랜잭션 안) AI 제출 + 저장
After:   업로드 요청 → 검증 + 저장(QUEUED) → 즉시 응답
         워커(10초마다) → AI에 나가 있는 수 < 5 이면 부족분만 꺼내 제출
// 접수. AI 호출 없음. 트랜잭션 종료 시 커넥션 반납.
@Transactional
public DetailResponse create(Long companyId, Create request) {
    UploadedPdf pdf = storage.readValidUploadedPdf(request.getFileId());  // 헤더 1KB만 읽어 검증
    storage.moveToStorage(pdf.getFileId());
    Document doc = save(Document.builder()...build());   // status = QUEUED
    return new DetailResponse(doc);
}

// 워커. "언제, 몇 개"만 안다.
@Scheduled(fixedDelayString = "${...interval-ms:10000}")
public void submitQueuedJobs() {
    long inFlight = repository.countByStatus(DRAFTING);  // 실제 구현은 매핑·실행도 합산
    int capacity = maxInFlight - (int) inFlight;
    if (capacity <= 0) return;
    repository.findQueuedOldestFirst(capacity).forEach(submitter::submit);
}

1,000건을 올려도 요청은 AI 제출을 기다리지 않고 접수 단계에서 끝난다. 워커는 진행 중인 작업을 세어 빈자리만큼만 제출하므로, 서버 한 대 기준으로 대량 접수 경로의 동시 제출이 5건을 넘지 않는다. 서버가 여러 대면 각자 세므로 이 상한은 근사치가 된다. 서버가 재시작해도 QUEUED 행은 DB에 남는다. 워커가 다음 틱에서 다시 조회해 제출한다.

여기까지 만들고 배포했다. 그러다 “삭제가 안 돼요”라는 제보가 왔다.

4. 두 번째 수정 — 워커의 트랜잭션 분리

워커에 남아 있던 외부 호출

워커가 호출하는 제출 메서드는 이렇게 생겼었다.

@Transactional
public void submitQueuedDraft(Long id) {
    Document doc = get(id);
    if (repository.claimDraftSlot(id, QUEUED, DRAFTING) == 0) return;   // 조건부 UPDATE → row 락
    byte[] pdf = storage.readSavedBytes(doc.getPdfFileId());
    Submission submission = aiClient.submitDraft(doc.getFileName(), pdf, ...);  // 외부 HTTP. 타임아웃: 연결 3초 + 응답 대기 30초
    doc.startDraft(submission.getJobId());
}

요청에서는 사라졌지만, 워커에서는 여전히 DB 자원을 쥔 채 AI 서버의 응답을 기다리고 있었다.

첫 수정으로 요청이 커넥션 풀을 점유하는 문제는 줄었다. 하지만 워커의 선점용 조건부 UPDATE가 row 락을 잡았고, 그 락을 AI 업로드가 끝날 때까지 놓지 않았다. 그동안 사용자가 그 문서를 삭제하거나 수정하면 같은 행의 락을 기다리다 lock wait timeout으로 터진다.

첫 수정은 외부 HTTP 호출을 요청 트랜잭션에서 워커 트랜잭션으로 옮긴 셈이었다. 요청 흐름은 확인했지만 워커 메서드의 트랜잭션 범위까지 보지 못했다. 코드 리뷰에서도 놓쳤다.

준비 → 업로드 → 기록

제출 한 건을 세 조각으로 쪼갰다. 가운데 조각에만 외부 I/O가 있고, 거기엔 트랜잭션이 없다.

쪼개면서 실패 처리도 같이 나눴다. 세 조각과 실패 처리를 제출 클래스 하나에 몰아넣으면, catch 블록마다 “이 실패는 셀까, 말까”를 판단하게 된다. 순서를 조율하는 객체가 실패의 의미까지 아는 건 너무 많이 아는 것이다. 그래서 누가 무엇을 아는지부터 정했다.

누가무엇을 아는가
워커언제, 몇 건. 한 건이 터져도 다음 건으로 간다
Submitter준비 → 업로드 → 기록 순서. 실패의 의미는 모른다
UploaderPDF를 읽어 AI로 보낸다. 바깥 예외를 우리 말(SubmitFailure)로 번역한다
SubmitFailure · Document그 실패를 셀지, 몇 번이면 끝낼지
// Submitter — 트랜잭션 없음. 순서만 안다.
void submit(Long id) {
    SubmitOrder order = jobService.prepareQueuedDraft(id);          // ① 짧은 tx, 쓰기 없음
    if (order == null) return;
    try {
        Submission submission = uploader.upload(order);             // ② tx 없음. 느려도 아무도 안 막힘
        jobService.recordDraftSubmission(id, submission);           // ③ 짧은 tx: 선점 + 전이
    } catch (SubmitFailedException e) {
        jobService.recordDraftSubmitFailure(id, e.getFailure(), e.getMessage());  // 어떻게 남길지는 묻지 않는다
    }
}
// Uploader — 바깥 세계의 예외를 우리 말로 바꾸는 곳은 여기 하나다
Submission upload(SubmitOrder order) {
    try {
        byte[] pdf = storage.readSavedBytes(order.getPdfFileId());
        return aiClient.submitDraft(order.getFileName(), pdf, order.getIdempotencyKey());
    } catch (AiUnavailableException e) {
        throw new SubmitFailedException(SubmitFailure.AI_UNAVAILABLE, e);
    } catch (AiRequestFailException e) {
        throw new SubmitFailedException(SubmitFailure.REJECTED, e);
    } catch (FileNotFoundException e) {
        throw new SubmitFailedException(SubmitFailure.PDF_MISSING, e);
    }
}
// 실패의 의미는 실패 자신이 안다
enum SubmitFailure {
    AI_UNAVAILABLE(false),   // AI 다운은 문서 잘못이 아니다. 세지 않고 줄에 남긴다
    REJECTED(true),          // 다시 보내도 또 거절된다
    PDF_MISSING(true);       // 기다려도 파일은 생기지 않는다

    private final boolean countsAsAttempt;
}

// Document — 몇 번이면 끝낼지는 문서가 안다
public void recordSubmitFailure(SubmitFailure failure, String reason) {
    if (!failure.countsAsAttempt()) return;          // QUEUED 그대로, 다음 틱에 다시
    retryCount++;
    if (retryCount >= MAX_RETRY) fail(reason);       // 상한에 닿으면 종료
}
// 워커 — 한 건의 실패가 나머지를 막지 않게 하는 건 반복하는 쪽의 일이다
repository.findQueuedIdsOldestFirst(capacity).forEach(id -> {
    try {
        submitter.submit(id);
    } catch (RuntimeException e) {
        log.error("제출 실패 — 다음 틱에서 재시도. id={}", id, e);
    }
});

이렇게 나누니 Submitter에는 catch가 하나만 남았다. 새 실패 유형이 생겨도 Submitter는 고칠 일이 없다. Uploader에 번역 한 줄, enum에 한 줄이면 된다.

// ① 준비 — 업로드 재료만 뽑고 아무것도 쓰지 않는다
@Transactional
public SubmitOrder prepareQueuedDraft(Long id) {
    Document doc = get(id);
    if (doc.isDeleted() || !doc.isQueued()) return null;
    return new SubmitOrder(doc.getPdfFileId(), doc.getFileName(),
            doc.draftAttemptKey());   // 기준 키 + 시도 횟수로 유도
}

// ③ 기록 — 이제 선점은 업로드 뒤에 한다
@Transactional
public void recordDraftSubmission(Long id, Submission submission) {
    if (repository.claimDraftSlot(id, QUEUED, DRAFTING) == 0) return;   // 삭제됐거나 다른 폴러가 먼저 기록
    get(id).startDraft(submission.getJobId());
}

SubmitOrder는 ①과 ② 사이의 유일한 운반물이다. 파일 id, 파일명, 멱등키. 워커도 엔티티 대신 id만 넘긴다. 엔티티를 트랜잭션 밖으로 내보내지 않는다. 엔티티가 밖으로 나가면 누군가 거기서 getter를 부르고, LAZY 로딩이 터지고, 다시 트랜잭션을 열고 싶어진다.

선점이 뒤로 가면 중복 제출은?

첫 번째 수정에서는 선점이 업로드 앞에 있었다. “내가 먼저 찍었으니 나만 보낸다.” 이번에는 선점이 업로드 뒤로 갔다. 그러면 폴러가 둘일 때 둘 다 업로드하고 한 쪽만 기록에 성공한다. AI에 job이 두 개 생기는 것 아닌가?

여기서 멱등키가 일한다. 문서를 접수할 때 무작위 기준 키를 DB에 저장한다. 첫 제출에는 그 키를 그대로 쓰고, 다음 시도부터는 기준 키와 시도 횟수로 이름 기반 UUID를 유도한다. 같은 시도를 맡은 두 폴러는 같은 키로 보낸다. 이 AI 서버는 같은 키를 받으면 새 job을 만들지 않고, 보관 중인 같은 job을 돌려준다. 결과적으로 job은 하나다. (이 동작은 AI 서버의 계약이다. 같은 키의 동시 요청에 409를 주는 API라면 진 쪽 처리를 따로 정해야 한다.) 기록에 진 폴러는 그냥 조용히 빠진다.

처음에는 draft-{문서id}-{시도횟수}처럼 DB id로 키를 만들었다. 그런데 로컬과 다른 환경이 같은 AI 서버를 쓰면서 id가 겹쳤고, 실제로 다른 PDF의 결과를 받은 적이 있다. 그래서 요청마다 무작위 기준 키를 저장하는 방식으로 바꿨다.

같은 이유로 “업로드 성공 직후, 기록 직전에 서버가 터지면?”도 해결된다. 시도 횟수가 그대로라 다음 틱이 같은 키로 재제출하고, AI는 같은 job을 준다. 서버 중단이나 폴러 경합 때문에 job이 중복으로 생기지는 않는다. 다만 업로드 직후 사용자가 문서를 삭제하면 기록 선점에 져서 그 job은 기록되지 않는다. 이 job은 AI 서버의 TTL(24시간)로 사라지게 두었다.

중단 시점별 상태

중단 시점남는 상태재시작 후
접수 도중DB 행은 롤백. 이미 옮긴 PDF 파일은 남음 (S3 이동은 롤백되지 않음)사용자가 다시 올림
접수 후QUEUED다음 틱에 잡힘
준비 후, 업로드 중QUEUED (준비는 안 씀)다음 틱에 같은 키로 재제출. job이 생겼더라도 하나로 합쳐짐
업로드 후, 기록 전QUEUED같음
기록 후DRAFTING + job 번호폴러가 결과 회수

어느 시점에 중단돼도 DB에는 QUEUED 또는 DRAFTING 상태가 남는다. 워커나 결과 폴러가 그 상태를 보고 이어서 처리한다.

나중에 더한 것 — 커밋 직후 즉시 제출

처음엔 10초 대기를 감수했지만, 나중에 접수 커밋 직후 워커를 바로 깨우는 경로를 추가했다. 여기에도 브로커는 필요 없었다.

@Async(AI_EXECUTOR)                                      // 요청 스레드에서 돌면 접수 응답이 다시 업로드를 기다린다
@TransactionalEventListener                              // 접수 커밋 뒤에만 실행. 커밋 전엔 QUEUED 행이 안 보인다
@Transactional(propagation = Propagation.NOT_SUPPORTED)  // 끝난 트랜잭션에 끼어들지 않게 밀어낸다
public void onJobQueued(JobQueued event) {
    submitQueuedJobs();
}

세 애너테이션이 모두 필요하다. @TransactionalEventListener만 붙이면 리스너가 요청 스레드에서 동기로 돌아, 1절의 문제가 그대로 돌아온다. @Async도 비동기 실행기가 실제로 켜져 있어야 의미가 있다.

이제 같은 서버 안에서 즉시 제출과 10초 틱이 동시에 돌 수 있다. 둘 다 빈자리를 세고 통과하면 상한을 넘기고 같은 PDF를 두 번 올리게 된다. 그래서 워커 진입을 ReentrantLock.tryLock()으로 한 번에 하나만 들어가게 했고, 못 들어간 쪽은 기다리지 않고 건너뛴다. 10초 틱은 이제 즉시 제출이 놓친 것(AI 다운, 상한 초과)을 받아 주는 안전망이다.

5. 테스트

중단 시점마다 따로 복구하는 코드 없이 남은 상태만으로 이어서 처리하는 구조다. 그래서 복구 시나리오를 테스트하는 대신, 설계가 기대는 성질을 단위 테스트로 확인했다.

접수는 AI를 부르지 않는다. 요청 스레드에서 AI를 부르던 문제의 재발 방지.

@DisplayName("접수는 PDF를 보관 경로로 옮기고 AI 호출 없이 QUEUED로 저장한다")
verify(aiClient, never()).submitDraft(any(), any(), any());

준비는 아무것도 쓰지 않고, 기록만 선점한다. 두 번째 수정의 핵심.

prepareQueuedDraftBuildsOrderWithDerivedIdempotencyKey   // 재료 + 요청 기준 키·시도로 유도한 멱등키
prepareQueuedDraftIgnoresDeletedDocument                  // 삭제된 문서의 PDF는 AI로 안 나간다
recordDraftSubmissionClaimsSlotAndTransitions             // 기록 = 선점 + 전이
recordDraftSubmissionSkipsWhenClaimLost                   // 선점에 지면 조용히 통과

AI 다운이면 아무것도 안 쓰였으므로 줄에 남는다. 트랜잭션 없는 업로드 단계가 실패해도 DB가 깨끗하다는 성질.

@DisplayName("AI 서버 다운이면 실패를 세지 않는다 — 아무것도 안 쓰였으므로 문서는 줄에 남아 AI 복구 후 이어서 제출된다")

실패의 의미는 한 곳에서 정한다. 번역은 Uploader, 셀지 말지는 SubmitFailure, 언제 끝낼지는 Document.

uploaderTranslatesMissingPdfToPdfMissing   // 바깥 예외 → SubmitFailure
aiUnavailableIsNotCounted                  // 세지 않으면 QUEUED 그대로
countedFailureEndsAtRetryLimit             // 상한에 닿으면 종료

한 건이 터져도 워커는 다음 건으로 간다. 한 건의 실패가 나머지를 막지 않는 계약.

workerContinuesWhenOneSubmissionThrows

워커는 빈자리만큼만, 접수 순서로.

submitsQueuedDraftsOnlyUpToCapacity
skipsQueuedSubmissionWhenCombinedInFlightAtCapacity

두 폴러의 실제 경합, 중단 뒤 재제출, 1,000건 부하는 이번 테스트 범위 밖이다. 조건부 UPDATE는 쿼리가 실제 스키마에서 실행되는지만 확인했고, 원자성은 DB에 맡겼다. 조건(QUEUED이고 삭제되지 않은 행만 전이) 자체를 실제 DB로 검증하는 테스트는 아직 없다.

정리

원래첫 번째 수정두 번째 수정
외부 I/O 위치요청 트랜잭션 안워커 트랜잭션 안트랜잭션 밖
쥐고 있던 자원커넥션 (동시 요청이 풀을 경쟁)row 락 (제출 중인 문서)외부 호출 중 DB 락·커넥션 없음
증상다른 요청까지 커넥션 대기 위험해당 문서 삭제·수정 타임아웃—
추가 인프라—00

이번에는 접수와 AI 제출을 분리하는 것만으로 문제가 끝나지 않았다. 워커가 여전히 트랜잭션 안에서 AI 응답을 기다리고 있었고, 그동안 잡은 문서 행의 락이 삭제와 수정을 막았다.

기억할 것은 네 가지다.

  1. 작업 상태가 DB에 있고 워커가 있다면, 별도 브로커 없이도 커밋 직후 이벤트와 주기 폴링으로 제출을 이어갈 수 있다.
  2. 외부 호출을 다른 스레드로 옮긴 뒤에도, 그 호출을 감싼 트랜잭션이 있는지 확인해야 한다.
  3. 멱등키를 요청별 기준 키와 시도 횟수에서 유도하면 같은 제출 시도를 다시 해도 AI에서 같은 job을 받는다.
  4. 실패 유형별로 재시도 횟수를 셀지 결정하는 규칙은 제출 순서를 조율하는 코드에서 분리했다.
···
← PREV AI 활용 — 나만의 개발 워크플로우