진단 리포트 생성을 SQS·Lambda에서 Temporal 워크플로로 옮긴 이유

진단 리포트 생성을 SQS+Lambda 이벤트 파이프라인으로 돌렸습니다. 동시성 한도, 큐 백로그 지연, 흩어진 오케스트레이션 세 가지가 걸렸고 이걸 Temporal 워크플로로 옮기면서 생성 로직을 한 메서드 안으로 모았습니다. 2부작 중 1편입니다.

Sample code

s-class-backend에는 학생부를 받아 진단 리포트를 생성하는 흐름이 있습니다. 제출이 들어오면 리포트를 한 건 만들어 S3에 저장하고 완료되면 알림을 보내는 작업입니다. 이 생성 파이프라인을 SQS + Lambda 이벤트 기반에서 Temporal 워크플로로 옮겼습니다.

이 글은 2부작 중 1편입니다. 여기서는 왜 옮겼고 구조가 어떻게 바뀌었는지를 다룹니다. 2편에서는 그 과정에서 붙인 Spring AI를 내부까지 들여다봅니다 — Anthropic 네이티브 구조화 출력이 실제로 어떻게 나가는지, 프롬프트 캐싱이 왜 이 워크로드에선 득이 안 됐는지, 토큰·비용을 어떻게 Advisor로 거뒀는지.

SQS + Lambda 이벤트 파이프라인이던 이전 구조

기존 흐름은 전형적인 이벤트 주도 구조였습니다. 제출이 들어오면 도메인 상태와 outbox 메시지를 만들고 별도 relay가 주기적으로 폴링해 SQS로 발행합니다. 실제 생성은 외부 시스템(Lambda/별도 서비스)이 큐를 소비해 처리하고 완료·실패는 SNS를 거쳐 다시 SQS로 돌아옵니다.

graph LR
    Submit["SubmitDiagnosisUseCase"]
    Service["DiagnosisSubmissionService<br/>Diagnosis · GenerationJob · Outbox"]
    Relay["OutboxRelay<br/>@Scheduled 2s"]
    Q1["SQS<br/>request-events"]
    Gen["외부 생성기<br/>Lambda · 코드 밖"]
    SNS["SNS"]
    Q2["SQS<br/>event-queue (maxConcurrent=1)"]
    Consumer["SqsConsumer"]
    Handler["EventHandler<br/>processorByType"]
    P["4개 Processor<br/>started · completed · report.completed · report.failed"]
    SvcDone["CompletionService / FailureService"]

    Submit --> Service --> Relay --> Q1 --> Gen --> SNS --> Q2 --> Consumer --> Handler --> P --> SvcDone

발행과 소비 사이의 설정값은 대략 이랬습니다.

  • outbox relay 폴링: 기본 2초 간격(@Scheduled(fixedDelayString = "...delay-ms:2000"))
  • 요청 큐 visibility timeout: 900초(15분), maxReceiveCount=3 후 DLQ
  • 이벤트 소비 리스너: maxConcurrentMessages=1, maxMessagesPerPoll=1, pollTimeoutSeconds=10
  • 실패 재시도 백오프: 1회 30초 → 2회 2분 → 3회 10분 → 4회 30분 → 5회+ 1시간

이 구조 자체가 잘못된 건 아닙니다. 다만 진단 리포트 생성처럼 여러 단계 + 외부 유료 호출 + 상태 전이가 얽힌 작업에는 세 가지가 계속 걸렸습니다.

세 가지 문제

1. 동시성 한도

리스너가 maxConcurrentMessages=1이라 완료·실패 이벤트를 한 번에 하나씩만 처리했습니다. 여기에 생성 자체가 외부 Lambda였는데, Lambda는 동시성(reserved concurrency)에 한도가 있습니다. 동시성을 키우면 키우는 대로 비용과 모델 호출 rate limit에 부딪힙니다. 결국 “한 번에 얼마나 처리할지”가 인프라 설정에 묶여 있었고 코드에서 보이지도 않았습니다.

2. 큐 백로그 지연

문제는 평소가 아니라 몰릴 때였습니다. outbox는 2초마다 폴링하고 소비는 직렬(maxMessagesPerPoll=1)입니다. 큐에 작업이 쌓이면 뒤에 들어온 요청은 앞의 것이 다 빠질 때까지 기다립니다. 단순화해서 큐에 100건이 쌓이고 건당 처리+폴링이 10초 남짓이면, 마지막 건은 십수 분 뒤에야 시작됩니다. 사용자 입장에서는 “어떤 리포트는 금방 나오고 어떤 리포트는 한참 뒤에 나온다”가 됩니다.

3. 오케스트레이션이 안 보인다

가장 크게 걸린 건 이거였습니다. “리포트 한 건을 만든다”는 하나의 일이 다음과 같이 흩어져 있었습니다.

  • SubmitDiagnosisUseCaseDiagnosisSubmissionService (요청 + outbox 생성)
  • OutboxRelayOutboxProcessorRequestPublisher (발행)
  • 외부 Lambda (생성, 레포 밖)
  • SqsConsumerEventHandlerprocessorByType (수신·라우팅)
  • DiagnosticStarted/Completed/ReportCompleted/ReportFailed 4개 Processor
  • DiagnosticReportCompletionService / DiagnosticReportFailureService (완료·실패 처리)

상태 전이(PENDING → PROCESSING → COMPLETED/FAILED), 멱등성(중복 이벤트 skip), 재시도(backoff 재예약)가 이 12~15개 클래스에 나뉘어 있었습니다. 한 군데를 고치려면 흐름 전체를 머릿속에서 다시 이어 붙여야 했습니다. 코드를 처음 보는 사람은 “리포트가 어떻게 만들어지는가”를 한 파일에서 읽을 수 없었습니다.

Temporal로 옮기며 흐름을 코드 안으로

Temporal로 옮기면서 가장 크게 바뀐 건 이 가시성이었습니다. 워크플로 한 메서드가 흐름 전체를 보여줍니다.

override fun generate(command: ReportCommand): DiagnosticReport {
    // 1) 결정적 분석 — 숫자는 여기서 확정된다.
    val analysis = activities.analyzeScores(command)

    // 2) 서술 섹션은 서로 독립이므로 병렬로 시작한다(fan-out).
    val summary = Async.function { activities.writeSummary(command, analysis) }
    val strength = Async.function { activities.writeStrength(command, analysis) }
    val advice = Async.function { activities.writeAdvice(command, analysis) }

    // 3) 배리어에서 모두 모은 뒤 조립한다.
    Promise.allOf(summary, strength, advice).get()
    return DiagnosticReport(
        reportId = command.reportId,
        analysis = analysis,
        summary = summary.get(),
        strength = strength.get(),
        advice = advice.get(),
    )
}

이벤트 핸들러도, processorByType 맵도, SNS 언래핑도 없습니다. 분석을 먼저 하고 독립적인 서술을 병렬로 돌리고 모아서 조립한다 — 이게 코드 순서 그대로입니다. 실제 진단 리포트 워크플로는 섹션이 더 많지만(내신·세특·활동·역량 등), 읽는 방식은 같습니다.

graph TB
    Cmd["ReportCommand"]
    WF["ReportGenerationWorkflow.generate()"]
    A["analyzeScores (결정적)"]
    S1["writeSummary"]
    S2["writeStrength"]
    S3["writeAdvice"]
    R["DiagnosticReport"]

    Cmd --> WF --> A
    A --> S1
    A --> S2
    A --> S3
    S1 --> R
    S2 --> R
    S3 --> R

숫자는 코드, 문장은 AI

옮기는 김에 생성을 Spring AI(Anthropic)로 통합했습니다. 이때 지킨 원칙이 하나 있습니다. 점수·통계 같은 숫자는 결정적 분석 코드가 소유하고 AI는 서술·근거 같은 문장만 쓴다. 액티비티 경계가 이 원칙을 강제합니다.

override fun analyzeScores(command: ReportCommand): ScoreAnalysis {
    val scores = command.rawScores
    require(scores.isNotEmpty()) { "rawScores must not be empty" }
    return ScoreAnalysis(average = scores.average(), max = scores.max(), min = scores.min())
}

override fun writeSummary(command: ReportCommand, analysis: ScoreAnalysis): NarrativeSection =
    NarrativeSection(
        title = "총평",
        body = ai.write(
            instruction = "${command.targetMajor} 지원자의 성적 총평을 2문장으로",
            context = "평균 ${analysis.average}, 최고 ${analysis.max}, 최저 ${analysis.min}",
        ),
    )

AI는 이미 계산된 숫자를 받아 문장만 만듭니다. 점수를 모델에게 만들게 하지 않습니다. 이 경계 덕분에 모델이 흔들려도 리포트의 수치는 흔들리지 않습니다. Spring AI를 어떻게 붙였고 내부에서 무슨 일이 일어나는지는 2편에서 자세히 다룹니다.

동시성·재시도·내구성

구조를 옮기면서 따라온 것들입니다.

  • 동시성은 인프라 설정이 아니라 워커 용량과 워크플로 구조로 정해집니다. 독립적인 섹션은 Async.function으로 병렬 fan-out하고 배리어에서 모읍니다. “한 번에 얼마나”가 코드에 보입니다.
  • 재시도는 액티비티 옵션으로 명시합니다. 그런데 Temporal은 RetryOptions를 안 주면 기본이 무제한 재시도입니다. 유료 AI 호출에서는 이게 위험합니다. 비재시도성 오류나 지속 실패가 나면 토큰·비용이 그대로 새어 나갑니다. 그래서 시도 횟수에 상한을 둡니다.
ActivityOptions.newBuilder()
    .setStartToCloseTimeout(Duration.ofMinutes(2))
    .setRetryOptions(
        RetryOptions.newBuilder()
            .setInitialInterval(Duration.ofSeconds(2))
            .setBackoffCoefficient(2.0)
            .setMaximumInterval(Duration.ofSeconds(30))
            .setMaximumAttempts(3) // 무제한 재시도 방지 — 비용 상한
            .build(),
    )
    .build()
  • 내구성은 Temporal이 워크플로 상태를 이벤트 히스토리로 들고 있어서 따라옵니다. 워커가 중간에 죽어도 작업이 사라지지 않고 마지막 지점부터 다시 이어집니다. 이전 구조에서 상태·재시도·멱등성을 직접 관리하던 코드가 줄어듭니다.

한 가지 옮기면서 만난 구체적인 함정도 적어 둡니다. Temporal 기본 DataConverter는 Kotlin data class를 역직렬화하지 못합니다. 워크플로·액티비티의 입출력으로 data class를 쓰려면 DataConverter에 Jackson Kotlin module을 등록해야 합니다. 샘플 코드의 테스트에 그대로 들어 있습니다.

숫자로 본 것

정직하게 적으면, 이 글은 “프로덕션 버스트 부하에서 A/B로 몇 % 빨라졌다”를 들고 있지 않습니다. 새 워크플로는 신규 파이프라인이고 이전 구조와 같은 조건의 운영 비교 샘플을 아직 충분히 쌓지 않았습니다. 그래서 정량보다 구조에서 온 이득이 더 큽니다.

확실히 말할 수 있는 건 이쪽입니다.

  • 이전: 이벤트 소비 maxConcurrentMessages=1, outbox 폴링 2초, 백로그가 쌓이면 뒤 작업이 직렬로 밀림. 오케스트레이션이 12~15개 클래스 + 외부 Lambda + 2개 SQS hop + SNS에 분산.
  • 이후: 리포트 한 건의 생성 흐름이 워크플로 한 메서드. 독립 섹션은 병렬, 재시도는 명시적 상한, 상태는 Temporal이 보존.

생성 비용은 측정해 두고 있습니다. 실제 진단 리포트 한 건의 end-to-end 생성은 claude-sonnet-4-6 기준 약 $0.98이고 토큰·비용은 호출마다 자동 집계됩니다. 이 비용을 어떻게 거두는지가 2편의 주제입니다.

SQS·Lambda가 틀렸다는 뜻은 아닙니다

SQS와 Lambda는 여전히 좋은 도구입니다. 단발성 비동기 처리, 단순한 fan-out, 트래픽 흡수에는 오히려 더 가볍고 잘 맞습니다. 이번에 옮긴 건 그런 작업이 아니라, 여러 단계가 이어지고 · 상태 전이가 있고 · 유료 외부 호출이 끼고 · 흐름이 한눈에 보여야 하는 오케스트레이션이었습니다. 이 조건이 겹치면 Temporal 워크플로가 현실적인 선택지가 됩니다.

라이브러리나 인프라 교체 자체가 목적은 아닙니다. 다만 그 변경이 “리포트가 어떻게 만들어지는가”를 다시 한 파일 안에서 읽게 만든다면, 그건 단순한 교체 이상의 의미가 있습니다.

다음 편

2편에서는 이 워크플로 안에서 Spring AI가 Anthropic과 실제로 어떻게 동작하는지를 내부까지 봅니다.

  • useProviderStructuredOutput()이 만드는 요청이 tool이 아니라 Anthropic 네이티브 구조화 출력(outputConfig)으로 나가는 경로
  • 프롬프트 캐싱을 켜봤지만 이 워크로드(섹션마다 다른 프롬프트, 리포트당 한 번)에서는 cache write만 쌓이고 read가 0이라 오히려 손해였던 이야기
  • .entity()ChatResponse를 돌려주지 않아 토큰·비용을 직접 못 읽는 문제와, 이를 CallAdvisor로 우회해 거둔 방법

샘플 코드는 seeun-blog-sample/sqs-lambda-to-temporal에 있습니다. ./gradlew :sqs-lambda-to-temporal:test로 워크플로 동작을 확인할 수 있습니다.