7,600줄 Lambda 오케스트레이터 이전기: 복잡함의 정체는 15분 타임아웃이었다

LLM 리포트 생성 파이프라인의 완료 이벤트가 조용히 유실되는 버그를 추적하다가, 7,600줄짜리 오케스트레이터의 복잡함 대부분이 Lambda 15분 타임아웃을 우회하기 위한 코드였음을 알게 됐습니다. 버그를 고치는 대신 실행 기반을 Temporal로 옮기고, 91곳의 정규식 사후 정제를 Spring AI structured output 계약으로 대체한 과정입니다.

Sample code

이전 글에서 진단 리포트 파이프라인 하나를 SQS·Lambda에서 Temporal로 옮긴 이야기를 했습니다. 사실 그때 시스템에는 파이프라인이 하나 더 남아 있었습니다. 더 오래됐고 더 큰, 오케스트레이터 단일 파일만 7,600줄쯤 되는 Node/NestJS Lambda 종합 리포트 생성기입니다. 이번 글은 이 두 번째 레거시를 옮기는 이야기입니다. 첫 이전의 동기가 “구조를 한눈에 보이게”였다면 이번에는 시작이 달랐습니다. 버그 하나를 쫓다가, 코드 대부분이 플랫폼 제약을 우회하려고 존재한다는 걸 알게 됐거든요.

버그 하나에서 시작된 이전

운영 중인 진단 리포트 서비스에 이상한 제보가 들어왔습니다. 리포트 생성이 분명히 성공했는데 사용자 화면에는 계속 “생성 중”으로 남는 케이스가 있다는 것이었습니다.

리포트 생성 파이프라인은 이렇게 생겼습니다.

백엔드(Kotlin/Spring) ──SQS──> 생성 워커(Node/NestJS Lambda)
     ↑                              │
     └────── 완료 이벤트 ←──────────┘

백엔드가 생성 요청을 SQS로 발행하면 Lambda 워커가 LLM 호출을 오케스트레이션하고, 끝나면 완료 이벤트를 다시 발행합니다. 백엔드는 이 이벤트를 받아 리포트 상태를 갱신합니다.

로그를 따라가 보니 완료 이벤트는 발행되고 있었고 수신도 되고 있었습니다. 문제는 백엔드의 검증 로직이었습니다.

// 백엔드: 완료 이벤트가 "지금 활성인 생성 작업"의 것인지 검증
if (event.jobId != null && event.jobId != activeJob.id) {
    // 내 작업의 완료가 아님 → 스킵
    return
}

가드 자체는 정당합니다. 재시도나 지연 전달로 과거 작업의 완료 이벤트가 뒤늦게 도착할 수 있으니 지금 활성인 작업의 완료만 반영해야 하니까요. 진짜 문제는 워커 쪽이었습니다.

// 워커: 완료 이벤트 발행 시
const jobId = payload.diagnosisId; // ← 생성 작업 ID가 아니라 진단 ID를 실어 보냄

워커가 완료 이벤트의 jobId 필드에 생성 작업(GenerationJob) ID가 아니라 도메인 엔티티(진단) ID를 넣고 있었습니다. 둘 다 같은 형식의 ULID라 어디서도 에러가 나지 않았고, 백엔드 가드는 “유효한 ULID지만 내 작업 ID는 아님”으로 판단해 이벤트를 조용히 버렸습니다.

한 줄짜리 버그입니다. 워커의 한 줄만 고치면 끝나는 문제처럼 보였습니다. 그런데 이걸 계기로 워커 코드를 처음부터 끝까지 읽다가 다른 질문에 부딪혔습니다. 이 파이프라인은 왜 이렇게 복잡한가?

복잡함의 정체: Lambda 15분 타임아웃 우회 3종

이 워커의 오케스트레이터는 단일 파일이 약 7,600줄입니다. LLM으로 섹션들을 생성하고 챕터로 재구성한다는 일 자체는 개념적으로 단순한데 코드는 그렇지 않았습니다. 전수 분석을 해 보니 복잡함의 대부분은 비즈니스 로직이 아니었습니다. “Lambda는 15분 안에 끝나야 한다”는 제약을 우회하는 코드였습니다. 크게 세 덩어리였습니다.

우회 1: 수동 체크포인트/재개 시스템

리포트 하나는 수십 번의 LLM 호출로 만들어지고 전체 소요 시간이 15분을 넘을 수 있습니다. 그래서 워커는 진행 상태를 스스로 저장하고 복원하는 시스템을 직접 들고 있었습니다.

// 챕터 하나 끝날 때마다 부분 결과를 외부 스토리지에 기록
await onCheckpoint({
  completedChapters,
  partialResultLocation,
});

// 재기동 시: 완료된 챕터는 건너뛰고 이어서 생성
const reusable = reusableChaptersById[chapter.id];
if (reusable) continue;

체크포인트 기록, 부분 결과 저장 위치 관리, 재개 시 완료분 스킵, 중복 실행 방지까지. 워크플로 엔진이 해 주는 일을 애플리케이션 코드로 다시 만든 셈입니다. 이 로직만 수백 줄인데, 상태 저장이 애플리케이션 책임이 되는 순간 “체크포인트 직후에 죽으면?”, “부분 결과가 깨져 있으면?” 같은 엣지 케이스가 전부 애플리케이션의 버그 표면이 됩니다.

우회 2: 별도의 Batch API 폴링 경로

15분을 확실히 피하는 다른 방법으로, LLM 제공사의 Batch API에 작업을 던져 놓고 별도 스케줄러가 주기적으로 결과를 폴링하는 두 번째 실행 경로도 있었습니다. 같은 리포트를 만드는 코드가 두 벌이 된 겁니다. 기능 플래그로 갈라지는 두 경로는 미묘하게 다르게 동작했고 유지보수 비용도 두 배였습니다.

우회 3: 91곳의 정규식 사후 정제

LLM 출력을 JSON으로 파싱하는 계층은 방어 코드의 박물관이었습니다.

// JSON.parse 실패 → trailing comma 제거 후 재시도 → 그래도 실패하면 fallback
try {
  return JSON.parse(raw);
} catch {
  const repaired = raw.replace(/,\s*([}\]])/g, '$1');
  try {
    return JSON.parse(repaired);
  } catch {
    return fallbackSection; // 빈 섹션으로 degrade
  }
}

이런 패턴이 오케스트레이터 전체에 91곳 있었습니다. 출력 정제, 금칙어 검사, 응답이 너무 짧으면 초안으로 간주하고 다시 부르는 휴리스틱까지. 게다가 파싱이 끝내 실패하면 빈 결과로 조용히 degrade하는 fallback이 도처에 깔려 있어서, 리포트가 실패하는 대신 군데군데 비어 있는 채로 성공하는 경우가 생겼습니다. 실패가 명시적이지 않으니 운영에서 발견도 늦었습니다.

세 우회의 공통 원인

세 덩어리를 나란히 놓고 보면 공통점이 보입니다.

우회 코드실제로 풀려던 문제
수동 체크포인트/재개장시간 실행의 내구성(durability)
Batch API 폴링 경로15분 제한 자체의 회피
정규식 정제 + fallback신뢰할 수 없는 LLM 출력의 계약화

앞의 둘은 실행 기반이 장시간 워크플로를 지원하지 않아서 생긴 코드고, 마지막은 LLM 출력에 스키마 계약이 없어서 생긴 코드입니다. 버그 한 줄을 고쳐도 이 구조적 비용은 그대로 남는다는 뜻입니다. 그래서 고치는 대신 전제를 바꾸기로 했습니다.

왜 Temporal이었나

첫 이전 덕분에 같은 시스템에서 이미 Temporal 워크플로 하나를 운영하고 있었습니다. 새 기술 도입이 아니라 검증된 구조로의 수렴이라는 점이 결정을 쉽게 만들었습니다. 일반적인 도입 이유(오케스트레이션 가시성, 동시성·재시도의 코드화)는 이전 글에서 다뤘으니, 여기서는 이번 파이프라인의 문제가 어떻게 사라지는지만 봅니다.

1. 완료 이벤트 계약 자체가 사라집니다. 발단이 된 jobId 버그의 근본 원인은 완료를 비동기 이벤트로 통지하고 수신자가 그 정합성을 검증해야 하는 계약 구조입니다. Temporal에서 완료는 워크플로 함수의 반환값입니다. 호출자는 자기가 시작한 워크플로의 반환을 받는 것이라 “이 완료가 내 작업의 완료인가?”라는 질문이 성립하지 않습니다. 버그가 고쳐지는 게 아니라 버그가 살 자리가 없어집니다.

2. 체크포인트/재개가 event history로 대체됩니다. Temporal은 모든 activity의 완료를 event history에 기록하고, 워커가 죽으면 history를 replay해서 정확히 그 지점부터 이어갑니다. 수동 체크포인트 시스템 수백 줄이 삭제 대상이 됩니다. Batch 폴링 경로도 존재 이유(15분 회피)가 사라지므로 함께 삭제됩니다.

// 워크플로: 재시도·재개는 선언으로 끝난다
private val activities = Workflow.newActivityStub(
    ReportActivities::class.java,
    ActivityOptions.newBuilder()
        .setStartToCloseTimeout(Duration.ofMinutes(10))
        .setRetryOptions(
            RetryOptions.newBuilder().setMaximumAttempts(3).build()
        )
        .build(),
)

3. 실패가 명시적이 됩니다. activity 실패는 정책만큼 재시도되고 최종 실패는 워크플로 실패로 드러납니다. 빈 섹션으로 조용히 degrade하는 대신, 어느 activity가 왜 실패했는지가 Temporal UI에 남습니다.

먼저 이전한 파이프라인의 실제 실행 타임라인을 보면 이 가시성이 어떤 의미인지 바로 보입니다.

Temporal UI 워크플로 실행 타임라인 — 결정적 분석 후 섹션 병렬 fan-out, 대학별 코멘트 fan-out, 비용 집계·저장까지 한 화면

맨 아래 결정적 분석 activity들이 먼저 끝나고, 그 위로 서술 섹션들이 병렬로 퍼졌다가, 추천 결과를 받아 항목별 코멘트가 다시 fan-out되고, 마지막에 비용 집계와 저장으로 수렴합니다. 리포트 한 건(약 3분 30초, LLM 호출 수십 번)의 실행 구조와 각 단계의 소요 시간이 코드를 열지 않고도 한 화면에서 읽힙니다. Lambda 시절 이 정보는 CloudWatch 로그를 시간순으로 짜맞춰야 나오는 것이었습니다.

4. 동시성 제어의 축이 맞는 곳으로 옮겨집니다. Lambda의 스케일링 자체는 훌륭하지만 이 워크로드에서는 제어의 축이 어긋나 있었습니다. 리포트 하나가 실행 환경 하나를 최대 15분간 점유하는데 그 시간 대부분이 LLM 응답 대기입니다. 대기에 GB-초 비용을 내면서 동시성 슬롯을 태우는 구조입니다. 더 근본적인 문제는 손잡이의 단위입니다. 정작 제어해야 하는 값은 LLM 제공사 rate limit에 대응하는 “전역 동시 LLM 호출 수”인데, Lambda가 주는 손잡이는 “함수 인스턴스 수”뿐입니다. 코드 안에 인스턴스 내부 동시성 상수를 박아도 전체 호출 수는 인스턴스 수 × 내부 동시성이라 전역 제어가 안 되고, reserved concurrency로 인스턴스를 묶으면 SQS 메시지가 스로틀 → 재시도 → visibility timeout 순환으로 밀려납니다. 백프레셔가 우아한 대기가 아니라 재시도 폭탄이 되는 겁니다. Temporal에서는 폭주가 태스크 큐에 쌓이고, 워커는 슬롯 수만큼만 꺼내 가며, activity 단위 rate limit이 전역 동시 호출 수를 손잡이 하나로 제어합니다. 트래픽 스파이크가 스로틀 에러가 아니라 대기열 깊이로 나타납니다.

정규식 91곳을 대체한 것: Spring AI structured output

세 번째 우회(출력 정제)는 워크플로 엔진이 아니라 LLM 출력 계약의 문제입니다. 여기는 Spring AI의 structured output으로 접근했습니다.

Node 시절의 계약은 “프롬프트로 JSON을 부탁하고, 나온 텍스트를 파싱하다 실패하면 정규식으로 고쳐 본다”였습니다. 새 구조에서는 타입이 곧 스키마입니다.

// 출력 계약 = Kotlin data class
data class LearningTypeSection(
    val mainType: String,
    val subType: String,
    val diagnosis: String,
    val subjectBottlenecks: List<SubjectBottleneck>,
)

// 호출: responseType을 넘기면 스키마 강제 + 역직렬화까지 프레임워크 책임
val section = aiChatClient.generate(
    system = prompt.system,
    user = prompt.user,
    responseType = LearningTypeSection::class.java,
)

provider의 structured output 기능으로 스키마를 강제하면 trailing comma 복구, 파싱 재시도 루프, “너무 짧은지” 길이 검사 같은 코드가 필요 없어집니다. 스키마로 표현하지 못하는 검증(응답 값이 통제 어휘 목록 안에 있는지 같은 것)만 명시적 검증 코드로 남기고, 그 검증의 실패는 Temporal의 activity 재시도로 흘려보냅니다.

정제 로직 91곳의 운명을 정리하면 이렇습니다. 대부분은 스키마가 대체해서 삭제, 진짜 도메인 규칙인 것만 순수 후처리 함수로 축소, 파싱 재시도 루프는 RetryOptions 선언 한 줄로.

분석에서 발견한 덤: 죽은 코드였던 정규식들

이전 계획을 세우려고 정규식을 전수 분석하다가 재미있는 걸 발견했습니다. 학생 답변 텍스트에서 성적 정보를 긁는 정규식 중에 some_key: 1.5 같은 key=value 리터럴 토큰을 찾는 패턴들이 있었는데, 정작 이 토큰을 만들어 내는 코드가 production 어디에도 없었습니다. 테스트 픽스처에만 있었습니다.

추적해 보니 예전에 있던 “AI 대화형 입력 모드”가 답변을 그 형태로 정규화해 주던 시절의 잔재였습니다. 모드가 제거되면서 토큰을 심는 쪽은 사라졌는데 긁는 쪽 정규식만 남아서, 테스트만 통과시키는 죽은 코드가 된 겁니다.

이 발견으로 이전 비용 산정이 달라졌습니다. 처음에는 이 정규식 뭉치를 Kotlin으로 전부 포팅해야 한다고 봤는데, 분석 후 방침을 정정했습니다.

대상처리
key=value 토큰 정규식삭제 (죽은 코드)
이미 구조화된 객관식 답변을 정규식으로 재파싱하던 코드답변 키 직접 조회로 대체
자유 서술에서 수치를 뽑는 정규식LLM structured output으로 이관
진짜 결정적 로직 (환산표, 예측 입력 조립)순수 함수로 포팅

포팅 대상이라고 생각했던 것의 상당 부분이 실은 삭제 대상이었습니다. 마이그레이션의 첫 단계는 코드 읽기가 아니라 “이 코드가 아직 살아 있는가”의 판별이어야 합니다. 죽은 코드를 판별하지 않으면 죽은 코드를 정성껏 포팅하게 되니까요.

이전 전략: 빅뱅 대신 수직 슬라이스

7,600줄을 한 번에 옮기는 건 검증 불가능한 도박이라 순서를 이렇게 잡았습니다.

  1. 스켈레톤 — 전용 태스크 큐, 트리거(outbox), 결과 발행자, 워크플로/activity 인터페이스. 기존 파이프라인과 대칭 구조.
  2. 순수 로직 선행 포팅 — LLM 무관한 결정적 로직(환산표, 예측 입력 조립)부터. 저위험이고 파리티 테스트가 쉬움.
  3. 첫 수직 슬라이스 — 가장 단순한 섹션 하나를 제출 컨텍스트 적재 → 분석 → 리포트 조립 → 완료 발행까지 끝에서 끝까지 관통. 인메모리 TestWorkflowEnvironment로 커맨드와 리포트가 Temporal 경계를 왕복하는 것까지 E2E 검증.
  4. 첫 AI 섹션 — Spring AI structured output 재작성 패턴을 섹션 하나로 확립. 나머지 섹션은 이 패턴의 반복.
  5. 섀도우 diff → 컷오버 — 신·구 파이프라인을 병행 실행해 결과를 대조한 뒤 트래픽 전환, Lambda 은퇴.

수직 슬라이스에서 얻은 교훈 하나만 꼽자면 직렬화 경계입니다. 동적 구조(사용자 답변 맵)를 Temporal 경계로 그대로 넘기면 payload 직렬화의 세부 동작에 의존하게 됩니다. 그래서 동적 답변은 JSON 원문 String으로 운반해 byte-exact를 보장하고, 경계를 통과하는 건 명시적 타입의 DTO만으로 제한했습니다. 워크플로 경계는 API 계약과 같은 급으로 엄격하게 다루는 게 맞다고 봅니다.

이전은 파리티 복제가 아니라 개선의 기회이기도 했습니다. 분석 중에 기존 파이프라인이 대상 사용자군의 상태에 따라 조언 모드를 바꿔야 하는데 분기 자체가 없다는 갭을 발견했고, 기존 Temporal 파이프라인에 이미 있던 정책 객체를 조건부로 주입하는 방식으로 이전 과정에서 함께 해결했습니다.

정리

  • 증상은 한 줄 버그, 원인은 계약 구조. jobId 불일치는 워커 한 줄을 고치면 사라지지만, 완료를 이벤트로 통지하고 수신자가 검증하는 계약이 있는 한 같은 부류의 버그는 다시 생깁니다. 완료가 함수 반환값이 되는 구조에서는 그 부류 전체가 사라집니다.
  • 복잡한 코드를 만나면 “무엇이 이 복잡함을 강제했나”를 먼저 물어야 합니다. 이번에는 답이 비즈니스가 아니라 플랫폼 제약(15분)이었고, 그렇다면 해법은 리팩토링이 아니라 플랫폼 교체입니다.
  • LLM 출력 정제 코드가 늘어난다면 계약이 없다는 신호입니다. structured output으로 스키마를 강제하면 정제 코드의 대부분은 삭제되고 남는 것은 진짜 도메인 검증뿐입니다.
  • 마이그레이션 분석의 첫 산출물은 포팅 목록이 아니라 삭제 목록입니다.

이전은 현재 진행 중입니다. 나머지 섹션/챕터 생성기의 structured output 재작성과 섀도우 diff, 컷오버가 끝나면 삭제된 줄 수와 생성 성공률·소요 시간의 before/after를 들고 후속 글로 돌아오겠습니다.