스토리보드 대체 프로바이더를 도입하며 정한 오류 분류, 시간 예산, 취소의 경계

스토리보드 생성의 구원투수로 Qwen을 준비했어요.
안녕하세요, 하루들입니다!
지난 글에서는 서로 다른 이야기를 그려도 하루들만의 그림체를 유지하기 위해 어떤 시도를 했는지 이야기했어요. 원하는 그림이 나오도록 입력을 다듬는 일만큼, 사용자가 생성 버튼을 눌렀을 때 결과를 끝까지 돌려주는 일도 중요했어요.
아무리 프롬프트를 잘 만들어도 외부 AI가 응답하지 않으면 만화는 완성되지 않으니까요.
2026년 10월 14일의 관리 화면에도 생성 실패가 여러 건 남아 있었어요. 화면에는 AI_PROVIDER_ERROR와 AI_PROVIDER_RATE_LIMITED가 반복해서 보였습니다.

2026년 10월 14일 관리자 화면의 실패 이력. 사용자 이름과 생성 ID는 마스킹했습니다.
이 화면은 실패 이력만 모아 본 것이어서 전체 요청의 실패율을 보여주지는 않아요. 또 이 코드만으로 스토리보드와 이미지 중 어느 단계에서, 어떤 원인으로 실패했는지까지 알 수는 없어요. 다만 사용자에게 결과를 돌려주지 못한 요청이 실제로 남아 있다는 점은 분명했어요.
이번 글에서는 그중 스토리보드 생성이 실패했을 때 Qwen에 한 번 더 맡길 수 있도록 만든 과정을 소개하려고 해요. 위 만화처럼 구원투수를 마련한 셈이지만, 매번 등판시키는 것은 아니에요. 어떤 상황에 맡기고 얼마나 기다릴지까지 정해야 했던 이야기예요.
하루들은 사용자가 쓴 이야기를 곧바로 이미지 생성 모델에 전달하지 않아요. 먼저 이야기를 네 컷의 스토리보드로 바꾸고, 그 결과를 바탕으로 이미지를 만들어요.
사용자가 쓴 이야기
→ 스토리보드 생성: 네 컷의 장면과 대사 구성
→ 이미지 생성: 스토리보드와 스타일 정보를 그림으로 표현
→ 이미지 저장
두 단계는 하는 일도, 결과물도 달라요. 스토리보드는 이미지가 아니라 장면·등장인물·대사 등이 담긴 구조화된 데이터예요. 같은 데이터 계약을 만족한다면, 스토리보드를 만든 AI가 달라져도 뒤의 이미지 생성 흐름으로 전달할 수 있어요.
그래서 이번 변경은 스토리보드 단계에 집중했어요. Gemini를 메인으로, Qwen을 보조로 설정하면 Gemini에서 대체 가능한 실패가 발생했을 때 Qwen이 같은 입력으로 스토리보드 생성을 맡는 구조예요. 이미지 생성은 기존 Gemini 경로를 유지합니다.
즉, Qwen이 만화 이미지를 대신 그리는 기능은 아니에요. 이미지 단계가 실패했다고 스토리보드부터 다시 만드는 것도 아니고요.
처음부터 자동 전환을 붙인 것은 아니었어요. 먼저 기존 StoryboardGenerator를 구현하는 Qwen 어댑터를 추가하고, 설정으로 Gemini와 Qwen 중 하나를 선택할 수 있게 했어요.
여기서 중요했던 것은 두 AI가 같은 형태의 결과를 반환하게 만드는 일이었어요. Gemini와 Qwen의 응답 변환과 검증을 공통화해서, 어느 쪽이 만들었든 서비스가 같은 Storyboard를 받도록 했습니다. 예를 들어 정확히 네 개의 패널이 있는지, 각 컷의 이야기 역할이 정해진 순서에 맞는지 같은 기준을 함께 적용했어요.
다만 공통 형식을 만족한다고 이야기의 품질까지 같아지는 것은 아니에요. 형식 검증은 이미지 생성으로 전달할 수 있는 결과인지 확인하는 기준이고, 장면이나 대사의 품질은 별도로 살펴봐야 해요.
그다음, 제공자를 선택하는 기능 위에 자동 대체 정책을 올렸어요. 이때 각 어댑터 안에 서로를 호출하는 코드를 넣기보다, 역할을 나누기로 했습니다.
StoryboardGenerator: 각 제공자에게 한 번의 생성을 요청해요.
StoryboardGenerationService: 메인을 먼저 호출하고, 실패하면 보조를 호출할지 결정해요.
StoryboardAttemptRunner: 한 번의 실행에 제한 시간과 취소 처리를 적용해요.
이렇게 나누면 Qwen 어댑터가 Gemini의 실패를 알 필요가 없어요. 어느 순서로 호출할지와 어떤 실패를 대체할지는 조합 서비스 한 곳에서 확인할 수 있어요.
다른 제공자가 있다고 모든 예외에서 호출하면 될까요?
인증 키가 잘못되었거나 내부 코드에 문제가 생긴 경우까지 자동 대체하면, 원인을 해결하지 못한 채 외부 호출만 하나 더 할 수 있어요. 이미 취소된 작업인데 보조 제공자가 새로 생성을 시작하는 것도 원하지 않았고요.
그래서 현재 스토리보드 정책에서는 다음 실패에 대체 호출을 허용했어요.
제한 시간 안에 끝나지 않은 경우
제공자의 요청 제한에 걸린 경우
출력이 잘렸거나 응답을 처리하지 못한 경우
네트워크 오류 또는 일시적 서버 오류로 분류된 경우
반대로 인증·설정 오류, 요청 거절, 명시적 취소와 인터럽트, 내부 오류는 그대로 종료해요. 원인을 알 수 없는 오류도 대체 가능한 장애라고 추측하지 않습니다.
특히 PROVIDER_ERROR라는 큰 분류만으로 대체 여부를 정하지 않았어요. 그 안에서도 상세 원인이 네트워크 오류인지, 일시적 서버 오류인지 확인해요. 관리 화면에 같은 실패 코드가 남았다고 해서 모든 요청이 같은 전환 대상인 것은 아닌 거예요.
호출 횟수에도 끝을 정했어요.
메인 성공 → 다음 단계로
메인이 대체 가능한 오류로 실패 → 보조 한 번 호출
메인이 대체할 수 없는 오류로 실패 → 종료
보조도 실패 → 종료
보조가 실패한 뒤 다시 메인으로 돌아가지 않아요. 이번 정책에서 각 제공자 호출은 최대 한 번이에요. 같은 제공자를 계속 재시도하는 대신, 정해진 조건에서 다른 제공자에게 한 번의 기회를 주는 방식으로 만들었습니다.
보조 제공자를 추가하면 성공할 경로가 하나 더 생기지만, 사용자가 기다리는 시간도 길어질 수 있어요. 메인을 오래 기다린 뒤 보조에서도 다시 오래 기다리게 해서는 안 됐어요.
그래서 스토리보드 생성에는 제공자별 제한 시간과 두 시도가 공유하는 전체 시간 예산을 뒀어요. 기본 제한 시간은 메인 60초, 보조 60초이며, 보조를 활성화하면 전체 예산은 둘을 합한 120초예요. 보조를 사용하지 않으면 메인의 제한 시간만 적용합니다.
각 시도는 자기 제한 시간과 전체 예산의 남은 시간 중 더 짧은 시간을 사용해요. 작업 대기와 요청 준비, 응답 수신, 결과 검증도 이 시간 안에 포함돼요.
예를 들어 메인이 5초 만에 대체 가능한 오류로 실패하면, 60초를 채우지 않고 바로 보조를 호출해요. 이 경우 보조에 주어지는 시간은 최대 60초예요. 메인이 60초를 모두 쓰고 실패했다면 보조는 남은 전체 예산 안에서 실행돼요. 이미 전체 예산이 소진됐다면 보조 호출도 시작하지 않아요.
이 시간을 계산할 때는 시스템 시각 대신 System.nanoTime()을 사용했어요. 현재 날짜와 시간을 알기 위한 것이 아니라, 요청을 시작한 뒤 얼마나 지났는지를 계산하기 위해서예요.
여기서 120초는 스토리보드 단계의 기본 시간 예산이에요. 뒤에 이어지는 이미지 생성과 저장까지 포함한 전체 요청 시간을 보장하는 값은 아닙니다.
설정도 명시적으로 선택하도록 했어요. 아래는 Gemini를 메인, Qwen을 보조로 사용하는 예시예요.
STORYBOARD_PROVIDER=gemini
STORYBOARD_FALLBACK_PROVIDER=qwen
STORYBOARD_PRIMARY_TIMEOUT=60s
STORYBOARD_FALLBACK_TIMEOUT=60s보조 제공자의 기본값은 none이라 설정하지 않으면 자동 대체는 동작하지 않아요. 메인과 보조에 같은 제공자를 지정한 설정은 시작할 때 거절하고, Qwen을 선택하면 Qwen에 필요한 설정도 검증합니다.
제한 시간이 지났을 때 서버가 결과를 기다리지 않기만 하면 충분할까요?
메인 요청이 계속 실행되는 상태에서 보조를 시작하면 두 작업이 겹칠 수 있어요. 나중에 도착한 메인의 결과를 어떻게 다룰지도 정해야 하고요.
그래서 각 시도에 취소 처리를 등록할 수 있는 컨텍스트를 전달했어요. 시간 초과가 발생하면 해당 HTTP 요청의 취소 처리를 실행하고, 실행 작업에도 취소와 인터럽트를 요청해요. 그리고 결과가 돌아왔더라도 제한 시간이 지났다면 성공으로 받아들이지 않습니다.
보조 호출에는 새 컨텍스트를 주되, 전체 시간 예산은 그대로 공유해요. 메인에서 발생한 취소 상태를 보조가 물려받지 않으면서도 요청 전체의 제한은 유지하기 위해서예요.
다만 클라이언트에서 취소했다고 외부 AI 서버의 계산이나 과금까지 반드시 중단된다고 보장할 수는 없어요. 여기서 제어하는 것은 우리 서비스의 실행과 응답 수용 범위예요.
숨은 재시도도 함께 확인했어요. 조합 서비스에서는 제공자마다 한 번씩 호출하더라도 SDK나 HTTP 계층이 내부에서 다시 요청하면, 실제 시도 횟수와 대기 시간이 늘어날 수 있으니까요.
스토리보드 경로에서는 SDK·HTTP 내부 재시도를 비활성화했어요. 또 Gemini의 스토리보드 클라이언트를 이미지 클라이언트와 분리해서, 이번 정책이 이미지 생성의 기존 제한 시간과 재시도 설정까지 바꾸지 않도록 했습니다.
메인이 실패했더라도 Qwen이 성공했다면, 스토리보드 생성 전체는 다음 단계로 이어질 수 있어요. 그래서 메인의 중간 실패를 곧바로 생성 작업의 최종 실패로 저장하지 않아요. 성공한 제공자의 스토리보드와 토큰 사용량을 이후 이미지 생성과 완료 처리로 전달하고, 최종적으로 실패한 경우에 실패 상태를 저장합니다.
대신 중간 시도는 로그로 남겨요. storyboard_attempt_completed에는 제공자와 모델, 메인·보조 역할, 성공 여부, 소요 시간과 실패 분류를 기록하고, 대체가 시작되면 storyboard_fallback_triggered를 남깁니다.
최종 성공 건수만 보면 Qwen이 어떤 실패를 이어받았는지 놓칠 수 있어요. 시도별 기록이 있어야 보조가 얼마나 자주 호출되는지, 어떤 원인으로 넘어오는지, 성공까지 얼마나 더 기다리는지 살펴볼 수 있어요.
대체 제공자를 붙였다고 해서 항상 두 곳을 호출하면 안 돼요. 그래서 테스트에서도 보조를 호출해야 하는 경우와 호출하지 않아야 하는 경우를 함께 확인했어요.
메인이 성공하면 보조는 호출하지 않는가
대체 가능한 메인 실패에서만 보조를 한 번 호출하는가
인증 오류나 취소, 미분류 오류는 그대로 종료하는가
보조도 실패하면 추가 호출 없이 종료하는가
메인 시간 초과 시 취소 처리를 실행한 뒤 보조를 시작하는가
전체 예산이 끝났다면 보조를 호출하지 않고, 늦은 결과도 거절하는가
이 테스트는 호출 순서와 시간·취소 경계를 확인하는 근거예요. 실제 서비스의 성공률이 얼마나 좋아졌는지, Qwen의 스토리보드 품질이 충분한지까지 증명하는 것은 아니에요. 그 부분은 운영 기록과 생성 결과를 함께 보며 확인해야 해요.
Qwen을 연결하면서 추가한 것은 다른 AI로 향하는 호출 경로였어요. 하지만 실제로 정해야 했던 것은 그 경로를 사용할 조건이었습니다.
어떤 실패라면 한 번 더 맡길지, 사용자를 얼마나 기다리게 할지, 포기한 요청의 늦은 응답은 어떻게 다룰지까지 함께 정해야 했어요. 보조가 있다는 이유로 모든 실패를 감추거나 무한히 시도하지 않도록요.
이번 변경은 스토리보드 단계의 대체 경로를 마련한 것이에요. 이미지 생성이나 저장의 실패까지 해결한 것은 아닙니다. 앞으로는 대체 호출의 성공 비율과 추가 대기 시간, 결과물의 품질을 함께 살펴보며 지금의 기준을 다듬어가려고 해요.
사용자가 자신의 이야기를 입력하고 기다린 끝에 만화를 받아볼 수 있도록, 하루들의 생성 흐름을 조금씩 더 단단하게 만들어가겠습니다.