0%

Backend

수 분짜리 외부 AI 호출을 트랜잭션 밖으로 — 체험 처리 Saga 설계기

전유림··수정됨 2026.07.21

수 분짜리 외부 AI 호출을 트랜잭션 밖으로 — 체험 처리 Saga 설계기

QR로 체험에 참여하면 사용자는 프롬프트를 입력하거나, 게임을 완료하거나, 분석 결과를 기다린다. 이후 AI는 네컷만화, 이미지, 영상, 음악, 점수 분석 같은 콘텐츠를 생성하고, 생성된 결과는 랭킹에 반영된다. 마지막으로 현장 단말에는 처리 완료 메시지가 전달되고, 사용자는 결과 화면을 확인한다.

겉으로 보기에는 단순한 흐름이다. 사용자는 버튼을 한 번 누르고 잠시 기다릴 뿐이다. 하지만 백엔드 입장에서는 이 한 번의 체험 안에 여러 성격의 작업이 섞여 있다.

외부 AI API 호출은 수 초 안에 끝날 수도 있지만, 콘텐츠 종류나 외부 서비스 상태에 따라 수 분까지 지연될 수 있다. 랭킹 등록은 별도 모듈과의 연동이 필요하고, 결과 저장은 세션 상태와 함께 일관되게 처리되어야 한다. 마지막으로 현장 단말에는 WebSocket을 통해 완료 메시지를 전달해야 한다. 어느 한 단계라도 실패하거나 지연되면 사용자는 무한 로딩을 보게 되고, 운영자는 해당 체험이 어디까지 처리되었는지 추적하기 어려워진다.

처음에는 이 흐름을 하나의 제출 처리 로직으로 볼 수 있다. AI를 호출하고, 랭킹을 등록하고, 결과를 저장한 뒤, 단말에 알림을 보내면 된다. 그러나 이 전체를 하나의 DB 트랜잭션으로 묶는 순간 문제가 시작된다. 수 분짜리 외부 호출 동안 DB 커넥션을 붙잡게 되고, 외부 API의 지연이 그대로 DB 트랜잭션 지연으로 전파된다. 중간에 워커가 죽거나, AI 호출은 성공했지만 랭킹 등록에서 실패하거나, 결과 저장 직전에 인스턴스가 내려가면 어디까지 성공했고 어디서부터 다시 처리해야 하는지 불분명해진다.

그래서 이 흐름은 긴 트랜잭션 하나로 해결할 수 있는 문제가 아니었다. 필요한 것은 각 단계를 짧은 트랜잭션으로 나누고, 현재 진행 상태를 명시적으로 기록하며, 실패하더라도 다시 회수할 수 있는 구조였다.

이 글은 외부 AI 호출이 포함된 체험 처리 흐름을 하나의 긴 트랜잭션으로 묶지 않고, 단계별 Saga로 분리한 과정에 대한 기록이다. 특히 Saga 상태를 어떻게 정의했는지, 여러 워커가 동시에 처리하려는 상황을 어떻게 막았는지, 재시도와 중복 실행을 어떻게 멱등하게 만들었는지, 그리고 처리 도중 인스턴스가 죽어도 어떻게 최종적으로 종료 상태에 수렴시키는지를 정리한다.

1. 문제: 하나의 트랜잭션으로 묶을 수 없는 흐름

체험 제출이 완료되려면 아래 작업들이 순서대로 처리되어야 한다.

  1. 외부 AI API를 호출해 결과물 생성

  2. 점수를 랭킹에 등록하고 ScoreAttempt 발급

  3. 결과물과 점수를 저장한 뒤 세션을 COMPLETED로 변경

  4. 현장 단말에 결과 완료 메시지 전송

흐름만 보면 하나의 서비스 메서드에서 순서대로 처리하면 될 것처럼 보인다.

하지만 문제는 첫 번째 단계다. 외부 AI 호출은 빠르면 몇 초 안에 끝나지만, 경우에 따라 수 분까지 걸릴 수 있다. 이 호출을 DB 트랜잭션 안에 넣으면 다음 문제가 생긴다.

  • DB 커넥션을 수 분 동안 점유한다.

  • AI API 지연이 DB 트랜잭션 지연으로 전파된다.

  • 락 경합이 길어지고, 커넥션 풀이 고갈될 수 있다.

  • 중간 실패 시 어디까지 처리되었는지 판단하기 어렵다.

프로젝트의 모듈 통신 원칙도 명확했다.

단일 모듈은 단일 트랜잭션으로 처리하되, 모듈 간 분산 트랜잭션은 피하고 최종 일관성을 수용한다.

따라서 체험 제출 흐름은 하나의 긴 트랜잭션으로 묶지 않는다. 대신 전체 흐름을 여러 단계로 쪼개고, 각 단계를 독립 트랜잭션으로 처리한다. 외부 AI 호출은 트랜잭션 밖에서 수행하고, DB 상태 변경은 짧은 트랜잭션으로만 처리하도록 분리했다.

image.png

2. 상태를 명시한다 — SubmissionStep

가장 먼저 한 일은 “현재 제출이 어디까지 진행되었는지”를 명시적인 상태로 만든 것이다.

Saga는 다음 상태 중 하나를 가진다.

상태

의미

AI_PENDING

AI 결과를 기다리는 중

AI_DONE

AI 결과 확보 완료

RANKING_DONE

랭킹 등록 완료

COMPLETED

정상 종료

ABORTED

실패 또는 타임아웃 종료

전이는 단방향이다.

image.png

중요한 점은 이 전이 규칙을 서비스가 아니라 도메인 모델 안에 넣었다는 것이다.

서비스는 “다음 단계로 넘어가라”고 요청할 뿐이고, 실제로 넘어갈 수 있는지는 Saga 도메인이 판단한다. 현재 상태가 맞지 않거나, 락 토큰이 다르거나, 이미 다른 데이터로 처리된 경우에는 전이를 거부한다.

즉, 상태 전이는 단순한 값 변경이 아니다. Saga의 순서, 소유권, 멱등성, 리스 갱신을 함께 보장하는 도메인 규칙이다.

3. 안전장치 1: DB 조건부 UPDATE로 락을 획득한다

체험 제출은 비동기 작업으로 처리된다. 같은 세션을 여러 워커가 동시에 처리하려고 할 가능성을 배제할 수 없다.

예를 들면 다음 상황이 생길 수 있다.

  • 사용자가 중복 제출을 요청한 경우

  • 재시도 작업과 기존 작업이 겹친 경우

  • 스케줄러가 타임아웃 처리를 시도하는 경우

  • 워커가 지연된 뒤 다시 깨어난 경우

따라서 처리 시작 전에 “이 Saga는 내가 처리한다”는 소유권을 확보해야 한다.

별도의 Redis 분산 락은 두지 않았다. 대신 DB의 조건부 UPDATE로 락을 획득한다.

개념적으로는 다음 조건을 만족할 때만 락을 가져온다.

같은 sessionId이고
처리 가능한 단계이며
락이 없거나 이미 만료된 경우

락 획득에 성공하면 다음 정보를 Saga에 기록한다.

필드

의미

lockedBy

현재 처리 중인 워커

lockToken

해당 락을 식별하는 UUID

lockedUntil

리스 만료 시각

version

동시성 제어용 버전

핵심은 lockedUntil이다. 워커가 죽어서 락을 직접 해제하지 못하더라도, 리스 시간이 지나면 다른 워커가 해당 Saga를 다시 가져올 수 있다.

image.png

이때 중요한 것이 lockToken이다.

워커 A가 오래 멈췄다가 뒤늦게 깨어나더라도, 이미 워커 B가 새 토큰으로 Saga를 잡았다면 A의 토큰은 더 이상 유효하지 않다. 이후 상태 전이는 매번 현재 토큰과 요청 토큰을 비교하고, 다르면 거부한다.

즉, 리스 토큰을 매 전이마다 검증함으로써 오래된 워커가 뒤늦게 상태를 오염시키는 문제를 막는다.

4. 안전장치 2: 모든 전이는 멱등해야 한다

비동기 처리와 재시도가 있는 시스템에서는 같은 작업이 두 번 실행될 수 있다고 가정해야 한다.

따라서 Saga 전이는 멱등하게 설계했다.

이미 목표 상태에 도달했고, 저장된 데이터도 동일하다면 다시 호출되어도 성공으로 본다. 반대로 이미 같은 단계에 도달했지만 데이터가 다르다면 잘못된 재시도 또는 경합으로 보고 실패 처리한다.

예를 들어 AI_DONE 상태에서는 다음과 같이 판단한다.

현재 상태

요청 데이터

결과

아직 AI_PENDING

새 AI 결과

AI_DONE으로 전이

이미 AI_DONE

기존과 같은 AI 결과

성공 처리

이미 AI_DONE

기존과 다른 AI 결과

전이 거부

영속화 계층도 같은 원칙을 따른다. 결과 점수나 콘텐츠 저장 시 이미 존재하는 데이터가 있으면 다시 저장하지 않는다.

이렇게 하면 Saga가 어느 단계에서 재실행되더라도 부작용이 두 번 발생하지 않는다.

특히 랭킹 등록처럼 이미 외부 모듈에 반영된 작업은 되돌리기보다, 발급된 scoreAttemptId를 Saga에 남기고 이후 단계를 이어서 재시도할 수 있게 했다.

이 설계의 방향은 “실패 시 모든 것을 되돌린다”가 아니다. 이미 완료된 단계는 멱등하게 인정하고, 다음 단계로 안전하게 진행할 수 있게 만드는 것이다.

image.png

5. 안전장치 3: 실패 사유를 명확히 분류한다

성공 경로보다 더 중요한 것은 실패 경로다.

오케스트레이터는 각 단계를 실행하고, 실패하면 원인을 분류해 Saga를 ABORTED로 종료한다.

실패 사유는 다음처럼 명시했다.

실패 사유

의미

AI_GENERATION_FAILED

AI 생성 실패

INPUT_REJECTED

부적절 입력 차단

RANKING_REGISTER_FAILED

랭킹 등록 실패

SAGA_TRANSITION_FAILED

Saga 상태 전이 실패

SUBMISSION_FAILED

제출 완료 처리 실패

SUBMITTING_TIMEOUT

타임아웃

성공 경로는 단순하다.

image.png

각 단계는 성공하면 다음 단계로 넘어간다. 실패하면 즉시 abort 처리 후 흐름을 중단한다.

여기서 중요한 정책이 하나 있다.

부적절 입력은 시스템 오류가 아니다. 사용자의 입력이 콘텐츠 모더레이션에 의해 차단된 정상적인 실패 케이스다.

따라서 부적절 입력은 일반 장애와 분리해 INPUT_REJECTED로 종료한다. 운영 로그에서도 ERROR가 아니라 WARN 수준으로 남긴다.

이렇게 해야 운영자가 로그를 볼 때 진짜 장애와 정상 차단을 구분할 수 있다. 또한 단말에도 “처리 실패”가 아니라 “부적절한 입력으로 차단됨”과 같은 구체적인 사유를 전달할 수 있다.

6. 마지막 안전망: 종료되지 않은 Saga를 회수한다

인라인 abort만으로는 충분하지 않다.

단계 처리 중 예외가 발생하면 오케스트레이터가 Saga를 ABORTED로 종료한다. 하지만 abort 처리 자체가 실패할 수도 있다.

예를 들면 다음 상황이다.

  • 워커 인스턴스가 갑자기 종료된다.

  • OOM으로 프로세스가 죽는다.

  • abort 트랜잭션이 롤백된다.

  • DB 장애로 종료 상태 저장에 실패한다.

이 경우 Saga는 AI_PENDING, AI_DONE, RANKING_DONE 중간 상태에 머물 수 있다. 사용자는 단말 앞에서 무한 로딩을 보게 된다.

그래서 실패 처리는 한 곳에서 끝나지 않는다. abort에 실패하면 오케스트레이터는 무리하게 붙잡지 않고 안전망에 넘긴다.

안전망은 60초마다 실행되는 cleanup 스케줄러다.

스케줄러는 다음 조건을 만족하는 Saga를 찾는다.

아직 COMPLETED 또는 ABORTED가 아니고
lockedUntil이 현재 시각보다 이전인 Saga

즉, 종료되지 않았고 리스도 만료된 Saga는 더 이상 처리 주인이 없는 것으로 본다.

image.png

각 Saga는 독립 트랜잭션으로 종료한다. 한 건의 종료 실패가 다른 건에 영향을 주지 않게 하기 위해서다.

또 하나의 예외 케이스도 고려했다. Saga 레코드가 만들어지기 전에 세션이 멈춘 경우다. 예를 들어 세션은 SUBMITTING으로 들어갔지만, Saga 생성 직전에 인스턴스가 죽을 수 있다.

이 경우에는 Saga 기준 cleanup으로는 잡을 수 없다. 그래서 Saga가 없는 타임아웃 세션을 종료하는 경로도 별도로 둔다.

정리하면 실패를 회수하는 안전망은 세 겹이다.

image.png
  1. 단계 실패 시 즉시 ABORTED로 종료하는 인라인 abort

  2. 멈춘 워커의 락을 회수하기 위한 리스 만료

  3. 종료되지 않은 Saga와 세션을 주기적으로 정리하는 cleanup 스케줄러

7. 도메인은 시간을 직접 알지 않는다

이 Saga는 시간에 따라 동작이 달라진다.

  • 리스가 아직 유효한가?

  • 리스가 만료되었는가?

  • 한 단계 전진할 때 리스를 언제까지 연장할 것인가?

만약 도메인 모델 안에서 현재 시간을 직접 조회하면 이런 분기를 테스트하기 어렵다.

그래서 모든 비결정적 요소는 외부에서 주입한다.

now, leaseDuration, lockToken 모두 도메인 내부에서 생성하지 않고 애플리케이션 계층에서 전달한다.

도메인은 현재 시간이 실제로 몇 시인지 알 필요가 없다. 테스트에서는 고정된 시간을 사용해 “리스 만료 전”, “리스 만료 후”, “리스 연장 후” 같은 시나리오를 검증할 수 있다.

도메인은 DB도, 스레드도, 외부 AI도 모른다. 오직 전달받은 값과 현재 상태만 보고 전이 가능 여부를 판단한다.

image.png

이렇게 하면 도메인 모델은 순수하게 유지되고, 시간 기반 로직도 단위 테스트하기 쉬워진다.

8. 정리

이번 설계의 핵심은 외부 AI 호출을 DB 트랜잭션 안에 넣지 않는 것이었다.

긴 외부 호출은 트랜잭션 밖에서 처리하고, 상태 변경은 짧고 명확한 트랜잭션으로 나눴다. 각 단계의 진행 상태는 Saga에 기록하고, 중복 실행과 지연된 워커를 견딜 수 있도록 락 토큰, 리스, 멱등 전이를 적용했다.

문제

해법

수 분짜리 외부 AI 호출을 트랜잭션에 넣을 수 없음

단계별 독립 트랜잭션으로 분리

여러 워커의 동시 처리 가능성

DB 조건부 UPDATE로 원자적 락 획득

지연된 워커의 뒤늦은 상태 변경

리스 토큰 + 매 전이 검증

재시도로 인한 중복 부작용

도메인 전이와 영속화 처리의 멱등성

처리 중 인스턴스 사망

리스 만료 기반 회수 + 주기적 cleanup

실패 원인 파악 어려움

실패 사유 enum으로 명시

시간 기반 로직 테스트 어려움

시간과 토큰을 외부에서 주입

가장 크게 느낀 점은 성공 경로보다 실패 경로가 훨씬 중요하다는 것이다.

정상 완료 흐름은 비교적 짧다. 하지만 실제 시스템을 안정적으로 만드는 코드는 실패를 분류하고, 중간 상태를 기록하고, 오래된 락을 회수하고, 끝나지 않은 Saga를 다시 종료시키는 부분에 있다.

결국 고가용성은 “잘 될 때 얼마나 빠른가”보다 “안 될 때 어디까지 무너지고, 어떻게 회수되는가”로 결정된다.

이번 Saga 설계는 그 회수 경로를 코드가 아니라 상태와 규칙으로 명시하려는 시도였다.

읽기 도구

13분 읽기

이 글이 도움이 되었나요?

다음으로 읽기