60초 타임아웃에 걸리던 AI 생성을 백그라운드 잡과 폴링으로 옮긴 방법

커버 이미지

이전 글(이벤트 페이지를 AI로 만들며 무엇을 모델에 맡길지 정하기)에서 이벤트 페이지의 배경과 문구를 AI로 생성하는 기능을 소개했습니다. 운영자가 브리프를 적으면 텍스트 초안이 나오고, 그 초안을 바탕으로 배경 이미지를 만들어 페이지 하나를 채우는 흐름입니다.

기능은 동작했지만 개발 서버에 올려 테스트하면서 다른 문제가 드러났습니다. 생성이 자주 실패했고, 실패하는 시점이 늘 같았습니다. 요청을 보내고 1분쯤 지나면 연결이 끊겼습니다.

이 글은 그 1분을 넘기기 위해 생성 구조를 바꾸면서 내린 판단들을 정리한 것입니다.

60초에 끊기는 요청

이벤트 빌더가 있는 관리자 사이트는 Next.js로 만들어져 화면과 서버 코드가 한 애플리케이션 안에 있습니다. 이 글에서는 이쪽을 어드민 서버, 마켓 데이터를 다루는 Kotlin API 서버를 백엔드 서버라고 부릅니다. AI 생성은 어드민 서버에서 일어납니다.

생성 요청 하나가 실제로 하는 일은 모델 호출 여러 번입니다. 텍스트 초안은 한 번 호출에 약 30초, 이미지는 한 장에 50~80초가 걸립니다. 이벤트 배경은 상단 조각과 하단 조각을 따로 만들어 어드민 서버가 이어 붙이는 방식이라, 배경 한 벌을 만들려면 이미지 호출이 여러 번 이어집니다. 그래서 요청 하나가 2~3분을 쓰고 있었습니다.

브라우저 요청은 로드밸런서를 거쳐 어드민 서버로 들어옵니다. 이 로드밸런서에는 유휴 타임아웃(idle timeout)이 걸려 있는데, 연결에 아무 데이터도 흐르지 않는 상태가 그 시간을 넘기면 로드밸런서가 연결을 끊습니다. 설정값은 60초였습니다.

기존 생성 라우트는 모델 호출을 전부 끝낸 뒤 결과를 한 번에 응답하는 구조였습니다. 그동안 연결에는 아무것도 흐르지 않습니다. 어드민 서버는 정상적으로 이미지를 만들고 있는데 60초가 지나는 순간 연결이 끊기고, 브라우저는 이유를 알 수 없는 오류를 받습니다. 생성 호출은 이미 나갔으니 비용은 그대로 나갑니다.

로드밸런서 설정을 늘리지 않은 이유

유휴 타임아웃을 3분이나 5분으로 늘리면 그대로 해결됩니다. 실제로 처음 검토한 선택지이기도 합니다.

그런데 이 로드밸런서에는 어드민의 AI 생성만 물려 있는 게 아닙니다. 사용자 페이지가 쓰는 API들도 같은 경로를 지나갑니다. 유휴 타임아웃은 특정 경로에만 다르게 걸 수 있는 값이 아니라서, 늘리면 그 아래 모든 요청의 연결 유지 시간이 함께 늘어납니다. 응답이 오지 않는 요청이 생겼을 때 정리되는 시점이 늦어지고, 그만큼 연결이 오래 남습니다.

관리자 화면 한 곳의 생성 기능 때문에 사용자 트래픽이 지나는 경로의 기준을 바꾸는 것은 맞바꿈이 좋지 않다고 봤습니다. 영향 범위가 어드민 밖으로 나가고, 문제가 생겼을 때 원인을 이 변경과 연결 짓기도 어렵습니다. 그래서 장비 설정은 그대로 두고 애플리케이션 쪽에서 푸는 것을 전제로 삼았습니다.

연결이 아니라 상태를 옮기기

타임아웃을 애플리케이션에서 푼다고 하면 보통 두 갈래를 떠올립니다. 하나는 연결이 끊기지 않게 중간에 무언가를 계속 흘려보내는 것이고, 다른 하나는 오래 걸리는 작업을 요청과 분리하는 것입니다.

연결을 유지하는 방식을 먼저 검토했다가 접었습니다. 하트비트를 흘려보내면 연결은 유지되지만, 작업의 상태가 그 연결 안에만 있다는 사실은 그대로이기 때문입니다. 브라우저를 새로고침하거나 실수로 탭을 닫으면 연결이 사라지고, 서버는 계속 이미지를 만들며 비용을 쓰는데 그 결과를 받을 방법이 없습니다. 개발 서버에서 테스트하며 반복해서 부딪힌 문제가 정확히 이것이었습니다.

그래서 문제를 다시 정의했습니다. 고쳐야 할 것은 연결이 60초에 끊긴다는 사실이 아니라, 작업의 상태를 연결 말고는 어디에서도 찾을 수 없다는 것이었습니다.

바꾼 구조는 단순합니다. 생성을 요청하면 서버가 작업(잡)을 하나 만들고 식별자를 즉시 돌려줍니다. 실제 생성은 응답을 보낸 뒤 백그라운드에서 계속됩니다. 브라우저는 그 식별자로 상태를 물어보고, 완료되면 결과를 받아 갑니다.

배경 3분할 생성 요청 1건을 두 방식으로 비교한 다이어그램. 기존 방식은 한 요청이 세 단계를 끝까지 붙들고 있다가 두 번째 단계에서 60초 선을 넘겨 연결이 끊기고, 바꾼 방식은 시작 요청이 식별자만 즉시 돌려준 뒤 서버가 백그라운드로 같은 세 단계를 진행하는 동안 브라우저가 2.5초 간격으로 상태를 조회한다

// POST /api/builder/generate-image/jobs
export async function POST(request: Request) {
  const body = await request.json();
 
  const job = createJob(body.mode ?? "single");
  // await 하지 않는다. 생성은 응답을 보낸 뒤에도 계속 돈다
  void executeJob(job.jobId, body);
 
  return NextResponse.json(
    { jobId: job.jobId, status: job.status, progress: job.progress },
    { status: 202 },
  );
}

요청 하나가 몇 분을 붙들고 있던 것이, 몇 밀리초 만에 끝나는 시작 요청과 2.5초마다 오가는 짧은 조회 요청으로 나뉩니다. 어느 쪽도 60초 근처에 가지 않으므로 로드밸런서의 기준에 걸릴 일이 없습니다.

SSE 대신 폴링을 고른 이유

상태를 서버에 두고 나면, 그 상태를 브라우저가 어떻게 읽을지는 그다음에 정할 문제입니다. 서버가 진행 상황을 밀어주는 SSE(Server-Sent Events, 서버가 열린 연결로 이벤트를 계속 보내는 방식)를 쓸 수도 있었습니다. 저희는 폴링(브라우저가 주기적으로 물어보는 방식)을 골랐고, 이유는 세 가지였습니다.

진행 이벤트가 드뭅니다. 배경 생성의 단계는 상단 히어로 생성, 제목·하단 생성, 페이지 합성의 세 번뿐입니다. 2~3분 동안 세 번 바뀌는 값을 전달하는 데 2.5초 폴링과 실시간 전송의 체감 차이는 없습니다. 진행률이 초당 갱신되는 작업이었다면 반대로 SSE가 맞습니다.

복구 코드가 단순해집니다. 새로고침 후 이어받기가 폴링에서는 상태 조회 한 번으로 끝납니다. SSE라면 연결이 끊겼을 때 다시 붙는 처리와, 마지막으로 받은 이벤트 다음부터 서버가 다시 보내주는 처리를 따로 만들어야 합니다.

중간 장비에 대한 의존을 없앨 수 있습니다. 애초에 이 문제가 로드밸런서가 긴 연결을 끊어서 생긴 것인데, SSE는 다시 긴 연결이 살아 있어야 한다는 전제 위에 섭니다. 하트비트로 유휴 타임아웃을 피하더라도 경로 중간의 프록시가 응답을 버퍼링하면 이벤트가 흐르지 않는 등 검증할 변수가 늘어납니다. 짧은 요청의 반복은 중간에 무엇이 있든 깨질 여지가 적습니다.

폴링의 비용도 계산해 봤습니다. 2.5초 간격이면 생성 한 건당 조회가 수십 번인데, 오가는 응답은 진행 단계 몇 글자짜리 JSON입니다. 생성은 흐름별로 한 번에 한 건만 돌도록 막아 두었고(비용이 겹쳐 나가는 것을 막으려고 넣은 가드입니다), 이 화면을 쓰는 사람은 운영자 몇 명입니다. 완료 시점에 오는 큰 응답(base64 이미지)은 마지막 한 번뿐이고, 이건 SSE를 써도 같습니다.

나중에 생성 과정을 더 잘게 실시간으로 보여주고 싶어지면 그때 전송 방식만 바꾸면 됩니다. 잡 구조는 그대로 두고 읽는 쪽만 교체하는 것이라 전환 비용이 작습니다.

잡 상태를 어디에 둘 것인가

잡을 도입하면 그 상태를 보관할 곳이 필요합니다. 일반적인 답은 Redis 같은 외부 저장소(여러 서버가 함께 읽고 쓰는 별도의 데이터 보관 서버)입니다. 저희는 서버 프로세스의 메모리에 두는 쪽을 택했습니다.

기준은 하나였습니다. 폴링 요청이 처음 생성을 시작한 그 프로세스에 도달하는가입니다. 서버가 여러 대라면 두 번째 요청이 다른 대로 갈 수 있고, 그 프로세스는 잡을 모르니 없는 작업이라고 답합니다. 그래서 어드민 서버가 실제로 몇 대로 떠 있는지부터 확인했습니다. 개발 서버와 운영 서버 모두 컨테이너 한 대로 떠 있고 오토스케일 설정이 없었습니다. 폴링이 다른 프로세스로 갈 일이 없다는 뜻입니다.

작업량도 근거가 됐습니다. 이 화면을 쓰는 사람은 운영자 몇 명이고, 동시에 도는 생성도 많아야 몇 건입니다. 사용자 사이트와 달리 트래픽이 늘어날 일이 없는 관리자 사이트라, 컨테이너를 늘릴 이유도 당분간 생기지 않는다고 봤습니다. 저장소 하나를 새로 붙이고 그 가용성까지 신경 쓰는 비용에 비해 얻는 것이 크지 않다고 판단했습니다.

대신 이 선택이 무엇에 기대고 있는지를 코드 주석에 남겼습니다. 컨테이너를 두 대 이상으로 늘리는 순간 깨지는 구조이고, 그때는 잡 저장소만 외부로 옮기면 나머지는 그대로 쓸 수 있습니다.

30분과 20건

메모리에 두기로 하면 얼마나 오래, 몇 개나 들고 있을지를 정해야 합니다. 잡 하나의 결과는 base64로 인코딩된 이미지라 수 MB입니다. 그대로 쌓이면 서버 메모리를 밀어냅니다.

보관 기간은 30분으로 정했습니다. 이 값은 메모리를 아끼려는 숫자라기보다 새로고침 후에도 결과를 되찾을 수 있는 창입니다. 생성이 완료됐는데 운영자가 자리를 비웠거나 실수로 창을 닫았을 때, 돌아와서 이어 볼 수 있는 시간을 얼마나 줄지의 문제였습니다. 개수 상한은 20건으로 두고, 넘치면 완료된 잡부터 오래된 순으로 지웁니다. 진행 중인 잡은 지우지 않습니다.

정리는 별도 타이머를 돌리지 않고 잡에 접근하는 시점마다 합니다. 몇 분에 한 번 도는 타이머를 두면 서버가 놀고 있을 때도 계속 깨어나야 하는데, 잡을 만들거나 조회할 때 만료된 것을 함께 걷어내면 같은 효과를 얻습니다. 대신 아무도 접근하지 않는 동안에는 만료된 잡이 메모리에 남아 있는데, 상한이 20건이라 그 사이 메모리가 무한정 늘지는 않습니다.

만료를 접근 시점에 처리하기로 한 이 선택은 나중에 결과가 언제 사라지는지를 화면에 표시할 때 다시 문제가 됐습니다.

무엇을 잡으로 옮길지 고르기

AI를 쓰는 모드가 전부 오래 걸리는 것은 아니었습니다. 배경 이미지를 주고 어울리는 텍스트 색을 추천받는 모드는 모델 호출 한 번에 30초 안쪽으로 끝납니다.

이런 모드까지 잡으로 바꾸면 얻는 것 없이 흐름만 길어집니다. 결과를 바로 받으면 될 것을 시작 요청과 폴링으로 나누게 되고, 호출부는 완료를 기다리는 코드를 하나 더 갖게 됩니다. 그래서 60초에 걸릴 수 있는 모드만 잡으로 옮기고, 안전하게 끝나는 모드는 기존 동기 방식 그대로 두었습니다. 두 경로가 공존하지만, 판단 기준이 소요 시간 하나라 어느 쪽에 속하는지 헷갈릴 일은 없습니다.

기술적인 제약도 하나 있었습니다. Next.js의 라우트 파일은 HTTP 메서드 핸들러 외의 export를 허용하지 않습니다. 백그라운드 실행 함수가 생성 로직을 그대로 다시 써야 하는데, 라우트 파일 안에서는 그 로직을 꺼내 공유할 수가 없었습니다. 그래서 생성 코어를 라우트 밖의 모듈로 먼저 분리하고, 동기 라우트와 잡 라우트가 같은 함수를 호출하게 했습니다. 진행 단계를 알리는 콜백을 인자로 받게 해서, 동기 라우트는 넘기지 않고 잡 라우트만 넘기도록 했습니다.

화면에서 사라져도 살아 있게 만들기

서버 쪽이 바뀌었다고 문제가 다 풀린 것은 아니었습니다. 생성 상태가 모달 컴포넌트 안에 있으면, 모달을 닫는 순간 컴포넌트가 사라지면서 폴링도 함께 끊깁니다. 서버는 계속 만들고 있는데 받을 사람이 없어지는 것은 처음 문제와 같습니다.

그래서 생성 상태를 컴포넌트 밖으로 꺼냈습니다. 브리프 입력값, 진행 중 여부, 생성된 후보를 전역 스토어에 두고 모달은 그 값을 보여주기만 하게 바꿨습니다. 모달을 닫아도 폴링은 계속 돌고, 다시 열면 진행 중이던 상태가 그대로 있습니다.

흐름이 세 갈래여서 스토어도 세 개로 나눴습니다. AI 초안, 배경 생성 모달, 빌더 안의 버튼·장식 이미지 생성입니다. 하나로 합칠까 고민했지만 단계 구조와 복구 규칙이 서로 달라서, 합치면 조건 분기가 스토어 안으로 들어올 것 같았습니다. 대신 잡을 시작하고 폴링하고 위젯에 등록하는 공통 코드는 헬퍼 한 곳에 두고 세 스토어가 모두 그것을 부르게 했습니다.

닫기 확인창은 상태를 스토어로 옮기면서 함께 없앴습니다. "닫으면 생성 중인 내용이 사라집니다"라는 경고였는데, 더 이상 사라지지 않으니 전제가 없어진 문구였습니다.

새로고침 뒤 이어받기

모달을 닫는 것까지는 전역 상태로 해결되지만 새로고침은 다릅니다. 페이지가 다시 로드되면 메모리에 있던 전역 상태도 초기화됩니다.

복구에 필요한 것은 어떤 잡이 돌고 있었는지뿐입니다. 결과는 서버가 30분 동안 들고 있으니까요. 그래서 진행 중인 잡의 식별자와 최소한의 입력 맥락을 sessionStorage에 남기고, 앱이 로드될 때 한 번 확인해서 잡이 살아 있으면 폴링을 이어받게 했습니다.

interface PersistedDraftJob {
  jobId: string;
  kind: "text" | "background"; // 어느 단계의 잡인지
  templateId: string;
  brief: string;
  period: string;
  hadReferences: boolean; // 참조 이미지를 썼는지 여부만
  draft?: EventDraftSpec;
}
배경 생성을 요청하면 오른쪽 아래에 위젯이 뜨며 진행 단계를 표시하고, 도중에 브라우저를 새로고침해도 그 작업을 다시 잡아 이어서 보여주는 과정

처음에는 배경 생성 잡이 시작될 때만 이 기록을 남겼습니다. 흐름의 첫 단계인 텍스트 초안 생성은 30초 남짓이라 그 사이 새로고침할 일이 없다고 본 것인데, 실제로는 검색이 붙으면 1분에 가까워지고 그 구간에서 새로고침하면 진행 중이던 생성을 전부 잃었습니다. 기록에 단계 구분을 넣어 텍스트 잡도 복구 대상에 포함시켰습니다.

복구가 실패하는 경우도 화면에 알리게 했습니다. 30분이 지나 되살릴 것이 없으면 기록만 조용히 지우고 끝났는데, 그러면 운영자는 아무 설명 없는 빈 화면을 보게 됩니다. 만료됐는지, 생성이 실패했는지, 서버가 재시작됐는지를 구분해 사유를 남기도록 바꿨습니다.

참조 이미지는 세션에 남길 수 없다

복구를 만들면서 한 가지를 포기했습니다. 운영자가 캐릭터 이미지를 참조로 올려 초안을 만든 경우입니다.

참조 이미지는 base64로 수 MB인데 sessionStorage에 그만한 데이터를 넣을 수 없습니다. 그렇다고 참조를 뺀 채로 배경 생성을 자동으로 이어가면, 운영자가 의도하지 않은 결과를 유료로 만들어 버리게 됩니다.

그래서 참조를 썼다는 사실만 남기고, 복구 시점에 그 값이 참이면 배경 생성을 자동으로 시작하지 않습니다. 대신 참조 이미지가 사라져 자동 생성을 멈췄다는 안내와 함께 다시 생성 버튼을 남겨 둡니다. 자동으로 해줄 수 없는 상황에서는 임의로 진행하는 것보다 멈추고 설명하는 편이 낫다고 봤습니다. 비용이 붙는 동작이면 특히 그렇습니다.

진행 위젯과 진입점

생성이 백그라운드로 돌게 되면서 새로운 문제가 생겼습니다. 모달을 닫으면 화면에 아무 흔적이 없어서, 지금 뭔가 돌고 있는지 아닌지를 알 수 없었습니다.

화면 오른쪽 아래에 진행 위젯을 붙였습니다. 루트 레이아웃에 마운트해서, 생성을 걸어 두고 다른 관리자 화면으로 옮겨 다녀도 위젯이 그대로 따라옵니다. 어느 화면에 있든 진행 단계와 경과 시간을 볼 수 있어, 오래 걸리는 생성을 띄워 놓고 그동안 다른 일을 할 수 있습니다. 3분할 배경이면 1/3 단계 · 상단 히어로 생성 중처럼 표시됩니다.

위젯을 만들고 나서 하나를 더 고쳤습니다. 처음에는 잡 시작 요청의 응답을 받은 뒤에 위젯 항목을 등록했는데, 서버가 느릴 때는 그 응답까지 몇 초가 걸립니다. 그 사이에 모달을 닫으면 화면에 아무것도 없어서 요청이 씹힌 것처럼 보였습니다. 그래서 버튼을 누르는 즉시 임시 항목을 먼저 띄우고, 응답이 오면 실제 잡으로 교체하게 했습니다.

완료된 뒤에 어디로 가야 하는지도 알려줘야 했습니다. 배경 생성은 완료 알림까지만 있고 결과를 보러 가는 길이 없어서, 알림을 받고도 화면에 아무 변화가 없었습니다. 후보는 스토어에 잘 들어와 있는데 빌더에서 배경 요소를 선택해 모달을 다시 열어야만 보이는 상태였습니다.

위젯의 완료 항목에 "생성 결과 보러 가기" 버튼을 붙였습니다. 다만 이 버튼은 열 대상이 화면에 있을 때만 의미가 있습니다. 다른 페이지를 보고 있다면 열 모달 자체가 없으니, 그때는 버튼 대신 어디로 가면 볼 수 있는지 경로를 문장으로 안내합니다. 빌더 안의 버튼·장식 이미지 생성도 마찬가지입니다. 그 패널은 요소를 선택해야 열리는 구조라 자동으로 열어줄 수 없어서, 해당 요소를 선택하면 후보가 보인다는 안내만 남겼습니다.

완료된 배경 생성 결과가 오른쪽 아래 위젯에 표시되고, '생성 결과 보러 가기' 버튼을 누르면 배경 만들기 모달이 열려 결과를 보여주는 과정

같은 작업에서 함께 손본 것들

여기부터는 잡 구조를 만드는 일과 성격이 다른 것들입니다. 결과가 언제 사라지는지 알리는 문제는 이번 구조에서 새로 생겼고, 실패 사유와 로그인 확인은 전부터 있던 문제가 이 화면을 오래 들여다보는 동안 눈에 들어온 것입니다. 같은 작업에서 함께 고쳤기에 여기 묶어 둡니다.

결과의 수명을 화면에 드러내기

생성 결과가 30분만 남고 20건까지만 보관된다는 사실이 코드에만 있고 화면 어디에도 없었습니다. 운영자에게는 그런 규칙이 있다는 것을 알 방법이 없고, 나중에 결과를 다시 열려다 실패해도 왜 안 되는지 알 수 없습니다. 20건은 개인이 아니라 서버 전체가 함께 쓰는 한도라, 여러 건을 잇달아 생성하면 오래된 결과부터 밀려납니다.

남은 시간을 카운트다운으로 보여주려다 접었습니다. 만료된 잡을 접근 시점에 걷어내고 20건이 넘으면 오래된 것부터 지우기 때문에, "28분 뒤 만료"라고 떠 있어도 이미 밀려나 사라졌을 수 있습니다. 대신 19:42 완료 · 30분이 지나면 새로 불러올 수 없습니다처럼 완료 시각을 보여줍니다. 몇 시에 끝났다는 사실은 틀릴 일이 없고, 고정값이라 1초마다 도는 타이머도 필요 없습니다. 이 시각은 서버가 내려주는 값을 씁니다. 브라우저가 관측한 시각을 쓰면 새로고침으로 복구했을 때 복구한 시각이 완료 시각으로 표시되기 때문입니다.

문구는 "사라집니다"가 아니라 "새로 불러올 수 없습니다"로 적었습니다. 30분이 지나도 화면에 떠 있는 후보는 계속 쓸 수 있고 서버에서 다시 불러오지 못하게 될 뿐이라, 실제 동작에 맞춘 표현입니다. 여기에 더해 물음표 아이콘으로 열리는 도움말 모달을 만들어 보관 한도와 함께, 적용하거나 보관을 눌러야 실제로 저장된다는 점을 설명했습니다. 마음에 드는 결과를 보고도 아무 동작을 하지 않으면 30분 뒤에 사라지고 생성 비용만 쓴 것이 되기 때문입니다.

실패 사유를 운영자의 언어로

특정 참조 이미지를 넣으면 생성이 계속 거부되는데 화면에는 "이미지 생성 API 오류 (400)"만 떴습니다. 상태 코드만으로는 브리프를 고치면 되는 문제인지, 기다리면 풀리는 문제인지, 개발팀에 문의할 문제인지 구분할 수 없어 같은 요청을 다시 누르게 됩니다. 생성은 누를 때마다 비용이 나갑니다.

응답 본문에는 안전 정책에 걸렸고 입력을 바꾸라는 사유가 들어 있었습니다. 이 사유를 뽑아 상황별 문구로 바꿨습니다. 안전 정책 차단은 운영자가 직접 해결할 수 있는 유일한 경우라 브리프나 참조 이미지를 바꾸라는 조치까지 안내하고, 한도 초과는 잠시 후 재시도, 인증 실패는 개발팀 문의로 넘깁니다. 그 밖의 오류는 상태 코드와 원문을 붙여 문의할 때 그대로 전달할 수 있게 했습니다. 사유 문구가 길어지고 나서야 같은 실패가 위젯과 토스트와 알림 세 곳에 동시에 뜨고 있다는 것도 눈에 띄어, 알림만 걷어냈습니다.

인증에서 빠져 있던 라우트

이 프로젝트의 미들웨어는 로그인 여부를 검사하면서 /api로 시작하는 경로를 제외하고 있었습니다. 어드민 서버의 /api 경로는 대부분 브라우저 요청을 백엔드 서버로 넘기고 응답을 돌려주는 중계 역할(프록시)만 해서, 로그인은 요청을 최종적으로 처리하는 백엔드 서버가 판단하기 때문입니다. 미들웨어가 검사해 로그인 페이지로 리다이렉트를 돌려주면 그 응답을 기다리던 호출부가 깨지기도 합니다.

// middleware.ts
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // API 라우트는 인증 검사에서 제외한다
  if (pathname.startsWith("/api/")) {
    return NextResponse.next();
  }
 
  // 페이지 요청은 로그인 쿠키가 없으면 로그인 확인 페이지로 보낸다
  if (!hasSessionCookies(request)) {
    return NextResponse.redirect(new URL("/auth/check", request.url));
  }
 
  return NextResponse.next();
}

두 종류의 API 요청이 어디까지 가고 누가 로그인을 판단하는지 비교한 다이어그램. 일반 API 요청은 브라우저에서 어드민 서버를 거쳐 백엔드 서버로 가고, 백엔드 서버가 로그인을 확인한 결과가 다시 브라우저로 돌아온다. AI 생성 요청은 어드민 서버가 보관한 키로 외부 모델 API를 직접 호출해, 생성 결과만 돌아오고 로그인은 어디서도 확인되지 않는다

AI 생성 라우트는 이 전제에서 벗어나 있었습니다. 브라우저가 부르는 경로는 /api/builder/generate-image/jobs로 다른 API와 똑같이 생겨 미들웨어를 그대로 지나가는데, 이 라우트는 요청을 백엔드 서버로 넘기지 않고 어드민 서버가 보관한 키로 외부 모델 API를 직접 호출합니다. 인증을 판단할 곳이 양쪽 어디에도 없어, 로그인하지 않은 요청이 그대로 실행돼 비용이 나갈 수 있는 상태였습니다.

그래서 미들웨어의 예외를 푸는 대신 해당 라우트에서 로그인 여부를 직접 확인하고 401 JSON을 돌려주게 했습니다. 리다이렉트로 막으면 브라우저의 fetch가 그것을 따라가 로그인 페이지 HTML을 받아 버립니다. 완료된 잡의 조회 응답에는 생성 결과가 그대로 실려 나가므로 상태 조회 라우트에도 같은 확인을 붙였고, 폴링은 401을 일시적인 네트워크 오류와 구분해 재시도하지 않고 바로 실패로 처리하게 했습니다. 로그인 페이지로 자동 이동시키지는 않았습니다. 빌더에서 편집 중이던 내용이 날아가기 때문입니다.

남은 한계

이 구조는 세 가지를 안고 있습니다. 전부 알면서 받아들인 것이라 코드 주석과 운영자 안내에 함께 남겼습니다.

  • 배포·재시작하면 진행 중이던 잡이 사라집니다: 잡 상태가 프로세스 메모리에 있어 프로세스가 바뀌면 함께 없어집니다. 동기 방식일 때도 배포 순간의 요청은 끊겼으니 새로 생긴 한계는 아니지만, 백그라운드로 도는 만큼 운영자가 다른 화면에 있다가 뒤늦게 알게 될 수 있습니다.
  • 보관 한도 20건을 운영자 전체가 함께 씁니다: 여러 명이 잇달아 생성하면 남의 완료 결과를 밀어낼 수 있습니다. 지금은 이 화면을 쓰는 사람이 적어 문제가 되지 않지만, 사용자가 늘면 사람별로 나누거나 한도를 올려야 합니다.
  • 컨테이너가 두 대 이상이면 폴링이 어긋납니다: 잡을 모르는 프로세스로 조회가 갈 수 있어, 잡 저장소를 두 프로세스가 함께 읽고 쓰는 Redis로 옮겨야 합니다. 바꿀 곳은 잡을 만들고 읽고 지우는 모듈 하나이고, 라우트와 화면 코드는 그대로 둘 수 있습니다.

마치며

처음에 이 문제는 로드밸런서 설정값 이야기로 보였습니다. 60초를 3분으로 늘리면 되는 문제처럼요.

실제로 바꾼 것은 작업의 상태가 어디에 있는가였습니다. 상태가 HTTP 연결 안에만 있으면 연결이 끊기는 모든 상황이 곧 작업의 실패가 됩니다. 로드밸런서의 타임아웃, 모달 닫기, 새로고침, 다른 페이지로 이동이 전부 같은 종류의 사고였습니다. 상태를 서버로 꺼내고 식별자로 되찾을 수 있게 만들자 이 네 가지가 한꺼번에 해결됐습니다.

그리고 이번 작업에서 코드보다 오래 붙들고 있던 것은 화면 문구였습니다. 30분 뒤에 사라진다는 사실, 실패한 진짜 이유, 복구하지 못한 사정처럼 코드에는 이미 있지만 운영자에게는 보이지 않던 것들입니다. 백그라운드로 도는 작업은 사용자가 그 진행을 볼 수 없으니, 무엇이 일어나고 있고 무엇이 사라질 예정인지를 화면이 대신 말해줘야 했습니다. 비동기로 만드는 일에는 보이지 않게 된 것을 다시 보이게 만드는 작업이 함께 따라왔습니다.