60초 타임아웃에 걸리던 AI 생성을 백그라운드 잡과 폴링으로 옮긴 방법
Summary
Summary
이미지 한 장에 50~80초가 걸리는데 로드밸런서의 유휴 타임아웃은 60초였습니다. 타임아웃을 늘리는 대신, 생성을 백그라운드 잡으로 돌리고 폴링으로 상태를 받아 요청을 비동기 처리로 바꾼 과정입니다.

이전 글(이벤트 페이지를 AI로 만들며 무엇을 모델에 맡길지 정하기)에서 이벤트 페이지의 배경과 문구를 AI로 생성하는 기능을 소개했습니다. 운영자가 브리프를 적으면 텍스트 초안이 나오고, 그 초안을 바탕으로 배경 이미지를 만들어 페이지 하나를 채우는 flow입니다.
기능 자체는 동작했지만 개발 서버에 올려 테스트하는 동안 다른 문제가 드러났습니다. 생성이 자주 실패했는데, 실패하는 시점이 매번 비슷했습니다. 요청을 보내고 1분쯤 지나면 연결이 끊겼습니다.
이 글은 그 1분을 넘기기 위해 생성 구조를 바꾸면서 내린 판단들을 정리한 것입니다.
60초에 끊기는 요청
이벤트 빌더가 있는 관리자 사이트는 Next.js로 만들어져 화면과 서버 코드가 한 애플리케이션 안에 있습니다. 이 글에서는 이 애플리케이션을 어드민 서버, 마켓 데이터를 다루는 Kotlin API 서버를 백엔드 서버라고 부릅니다. AI 생성은 어드민 서버에서 일어납니다.
생성 요청 하나는 내부에서 모델을 여러 번 호출합니다. 텍스트 초안은 한 번 호출에 약 30초, 이미지는 한 장에 50~80초가 걸립니다. 이벤트 배경은 상단 조각과 하단 조각을 따로 만들어 어드민 서버가 이어 붙이는 방식이라, 배경 한 벌을 만들려면 이미지 호출이 여러 번 이어집니다. 그래서 요청 하나가 2~3분을 쓰고 있었습니다.
브라우저 요청은 로드밸런서를 거쳐 어드민 서버로 들어옵니다. 이 로드밸런서에는 유휴 타임아웃(idle timeout)이 걸려 있는데, 연결에 아무 데이터도 흐르지 않는 상태가 그 시간을 넘기면 로드밸런서가 연결을 끊습니다. 설정값은 60초였습니다.
기존 생성 라우트는 모델 호출을 전부 끝낸 뒤 결과를 한 번에 응답하는 구조여서, 호출이 도는 동안 연결에는 아무것도 흐르지 않았습니다. 어드민 서버는 정상적으로 이미지를 만들고 있는데 60초가 지나는 순간 연결이 끊겼고, 브라우저는 이유를 알 수 없는 오류를 받았습니다. 생성 호출은 이미 나간 뒤라 비용은 그대로 나갔습니다.
로드밸런서 설정을 늘리지 않은 이유
유휴 타임아웃을 3분이나 5분으로 늘리면 그대로 해결되는 문제였고, 실제로 처음 검토한 선택지도 그 방법이었습니다.
그런데 이 로드밸런서에는 어드민의 AI 생성만 물려 있는 것이 아니라 사용자 페이지가 쓰는 API들도 같은 경로를 지나갑니다. 유휴 타임아웃은 특정 경로에만 다르게 걸 수 있는 값이 아니어서, 늘리면 그 아래 모든 요청의 연결 유지 시간이 함께 늘어난다는 특징이 있었습니다. 응답이 오지 않는 요청이 생겼을 때 정리되는 시점이 늦어지고, 그만큼 연결이 오래 남습니다.
관리자 화면 한 곳의 생성 기능 때문에 사용자 트래픽이 지나는 경로의 기준까지 바꾸는 것은 얻는 것에 비해 영향이 크다고 판단했습니다. 영향 범위가 어드민 밖으로 나가고, 나중에 문제가 생겨도 원인을 이 변경과 연결 짓기 어렵기 때문입니다. 그래서 장비 설정은 그대로 두고 애플리케이션에서 푸는 것을 전제로 삼았습니다.
오래 걸리는 작업을 요청에서 분리하기
타임아웃을 애플리케이션에서 푸는 방법은 크게 두 가지입니다. 하나는 연결이 끊기지 않게 중간에 무언가를 계속 흘려보내는 것이고, 다른 하나는 오래 걸리는 작업을 요청과 분리하는 것입니다.
연결을 유지하는 방식을 먼저 검토했다가 채택하지 않았습니다. 하트비트를 흘려보내면 연결은 유지되지만, 작업의 상태가 그 연결 안에만 있다는 사실은 그대로이기 때문입니다. 브라우저를 새로고침하거나 실수로 탭을 닫으면 연결이 사라지고, 서버는 계속 이미지를 만들며 비용을 쓰는데 그 결과를 받을 방법이 없습니다. 개발 서버에서 테스트하는 동안 이 상황을 여러 번 겪었습니다.
그래서 문제를 다시 정의했습니다. 연결이 60초에 끊기는 것보다, 작업의 상태를 그 연결 말고는 어디에서도 찾을 수 없다는 쪽이 먼저 고쳐야 할 문제라고 생각했습니다.
구조는 이렇게 바꿨습니다. 생성을 요청하면 서버가 작업(잡)을 하나 만들고 식별자를 즉시 돌려줍니다. 실제 생성은 응답을 보낸 뒤 백그라운드에서 계속되고, 브라우저는 그 식별자로 상태를 물어보다가 완료되면 결과를 받아 갑니다.
// 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입니다. 생성은 flow별로 한 번에 한 건만 돌도록 막아 두었고(비용이 겹쳐 나가는 것을 막으려고 넣은 가드입니다), 이 화면을 쓰는 사람은 운영자 몇 명입니다. 완료 시점에 오는 큰 응답(base64 이미지)은 마지막 한 번뿐이고, 이 점은 SSE를 써도 같습니다.
나중에 생성 과정을 더 잘게 실시간으로 보여주고 싶어지면 그때 전송 방식만 바꾸면 됩니다. 잡 구조는 그대로 두고 읽는 쪽만 교체하는 것이라 전환 비용이 작습니다.
잡 상태를 어디에 둘 것인가
잡을 도입하면 그 상태를 보관할 곳이 필요합니다. 일반적인 답은 Redis 같은 외부 저장소(여러 서버가 함께 읽고 쓰는 별도의 데이터 보관 서버)입니다. 저희는 서버 프로세스의 메모리에 두는 쪽을 택했습니다.
판단 기준은 폴링 요청이 처음 생성을 시작한 그 프로세스에 도달하는지 하나였습니다. 서버가 여러 대라면 두 번째 요청이 다른 대로 갈 수 있고, 그 프로세스는 잡을 모르니 없는 작업이라고 답합니다. 그래서 어드민 서버가 실제로 몇 대로 떠 있는지부터 확인했습니다. 개발 서버와 운영 서버 모두 컨테이너 한 대로 떠 있었고 오토스케일 설정도 없어서, 폴링이 다른 프로세스로 갈 일은 없었습니다.
작업량도 근거가 됐습니다. 이 화면을 쓰는 사람은 운영자 몇 명이고, 동시에 도는 생성도 많아야 몇 건입니다. 사용자 사이트와 달리 트래픽이 늘어날 일이 없는 관리자 사이트라, 컨테이너를 늘릴 이유도 당분간 생기지 않는다고 생각했습니다. 저장소 하나를 새로 붙이고 그 가용성까지 신경 쓰는 비용에 비해 얻는 것이 크지 않다고 판단했습니다.
대신 이 선택이 무엇에 영향 받는지를 코드 주석에 남겼습니다. 컨테이너를 두 대 이상으로 늘리는 순간 깨지는 구조이고, 그때는 잡 저장소만 외부로 옮기면 나머지는 그대로 쓸 수 있다고 명시했습니다.
30분과 20건
메모리에 두기로 하면 얼마나 오래, 몇 개나 들고 있을지를 정해야 합니다. 잡 하나의 결과가 base64로 인코딩된 이미지라, 상한을 두지 않으면 아무도 다시 보지 않는 것까지 프로세스가 살아 있는 내내 남기 때문입니다.
보관 기간은 30분으로 정했습니다. 메모리를 아끼려고 잡은 숫자는 아니고, 새로고침한 뒤에도 결과를 되찾을 수 있는 시간을 얼마나 줄지의 문제였습니다. 생성이 완료됐는데 운영자가 자리를 비웠거나 실수로 창을 닫았을 때 돌아와서 이어 볼 수 있어야 했습니다. 개수 상한은 20건으로 두고, 넘치면 완료된 잡부터 오래된 순으로 지우되 진행 중인 잡은 지우지 않게 했습니다. 20건은 운영자 몇 명이 쓰는 화면에서 넉넉한 수라고 보고 정한 값입니다. 잡 하나가 들고 있는 결과는 이어 붙인 배경 한 벌이라 최대 1MB여서 20건이 모두 차도 서버 메모리 2GB의 1% 남짓이라, 메모리 쪽에서 상한을 더 조일 이유는 없었습니다.
정리는 별도 타이머를 돌리지 않고 잡에 접근하는 시점마다 합니다. 몇 분에 한 번 도는 타이머를 두면 서버가 놀고 있을 때도 계속 깨어나야 하는데, 잡을 만들거나 조회할 때 만료된 것을 함께 걷어내면 같은 효과를 얻습니다. 대신 아무도 접근하지 않는 동안에는 만료된 잡이 메모리에 남아 있는데, 상한이 20건이라 그 사이 메모리가 무한정 늘지는 않습니다.
어떤 모드까지 잡으로 옮길 것인가
AI를 쓰는 모드가 전부 오래 걸리는 것은 아니었습니다. 배경 이미지를 주고 어울리는 텍스트 색을 추천받는 모드는 모델 호출 한 번에 30초 안쪽으로 끝납니다.
이런 모드까지 잡으로 바꾸면 얻는 것 없이 flow만 길어집니다. 결과를 바로 받으면 될 것을 시작 요청과 폴링으로 나누게 되고, 호출부는 완료를 기다리는 코드를 하나 더 갖게 됩니다. 그래서 60초에 걸릴 수 있는 모드만 잡으로 옮기고, 안전하게 끝나는 모드는 기존 동기 방식 그대로 두었습니다. 두 경로가 공존하게 됐지만, 어느 모드가 어느 쪽인지는 소요 시간 하나로 갈립니다.
기술적인 제약도 하나 있었습니다. Next.js의 라우트 파일은 HTTP 메서드 핸들러 외의 export를 허용하지 않습니다. 백그라운드 실행 함수가 생성 로직을 그대로 다시 써야 하는데, 라우트 파일 안에서는 그 로직을 꺼내 공유할 수가 없었습니다. 그래서 생성 코어를 라우트 밖의 모듈로 먼저 분리하고, 동기 라우트와 잡 라우트가 같은 함수를 호출하게 했습니다. 진행 단계를 알리는 콜백을 인자로 받게 해서, 동기 라우트는 넘기지 않고 잡 라우트만 넘기도록 했습니다.
모달을 닫아도 생성이 이어지게 하기
서버를 바꿨다고 문제가 다 풀린 것은 아니었습니다. 생성 상태가 모달 컴포넌트 안에 있으면 모달을 닫는 순간 컴포넌트가 사라지면서 폴링도 함께 끊깁니다. 서버는 계속 만들고 있는데 받을 사람이 없어지는 것이라, 처음 겪은 문제와 같은 상황입니다.
그래서 생성 상태를 컴포넌트 밖으로 꺼냈습니다. 브리프 입력값, 진행 중 여부, 생성된 후보를 전역 스토어에 두고 모달은 그 값을 보여주기만 하게 바꿨습니다. 모달을 닫아도 폴링은 계속 돌고, 다시 열면 진행 중이던 상태가 그대로 있습니다.
생성 flow가 세 가지여서 스토어도 세 개로 나눴습니다. AI 초안, 배경 생성 모달, 빌더 안의 버튼·장식 이미지 생성입니다. 하나로 합칠까 고민했지만 단계 구조와 복구 규칙이 서로 달라서, 합치면 조건 분기가 스토어 안으로 들어올 것 같았습니다. 대신 잡을 시작하고 폴링하고 위젯에 등록하는 공통 코드는 헬퍼 한 곳에 두고 세 스토어가 모두 그것을 부르게 했습니다.
닫기 확인창은 상태를 스토어로 옮기면서 함께 없앴습니다. "닫으면 생성 중인 내용이 사라집니다"라는 경고였는데, 더 이상 사라지지 않으니 전제가 없어진 문구였습니다.
새로고침 뒤 이어받기
모달을 닫는 것까지는 전역 상태로 해결되지만 새로고침은 다릅니다. 페이지가 다시 로드되면 메모리에 있던 전역 상태도 초기화됩니다.
복구에 필요한 것은 어떤 잡이 돌고 있었는지뿐입니다. 결과는 서버가 30분 동안 들고 있기 때문입니다. 그래서 진행 중인 잡의 식별자와 최소한의 입력값을 sessionStorage에 남기고, 앱이 로드될 때 한 번 확인해서 잡이 살아 있으면 폴링을 이어받게 했습니다.
interface PersistedDraftJob {
jobId: string;
kind: "text" | "background"; // 어느 단계의 잡인지
templateId: string;
brief: string;
period: string;
hadReferences: boolean; // 참조 이미지를 썼는지 여부만
draft?: EventDraftSpec;
}
처음에는 배경 생성 잡이 시작될 때만 이 기록을 남겼습니다. flow의 첫 단계인 텍스트 초안 생성은 30초 남짓이라 그 사이 새로고침할 일이 없다고 판단한 것인데, 실제로는 검색이 붙으면 1분에 가까워지고 그 구간에서 새로고침하면 진행 중이던 생성을 전부 잃었습니다. 기록에 단계 구분을 넣어 텍스트 잡도 복구 대상에 포함시켰습니다.
복구가 실패하는 경우도 화면에 알리게 했습니다. 30분이 지나 되살릴 것이 없으면 기록만 지우고 끝나서, 운영자는 아무 설명 없는 빈 화면을 보게 됩니다. 만료됐는지, 생성이 실패했는지, 서버가 재시작됐는지를 구분해 사유를 남기도록 바꿨습니다.
세션에 남길 수 없는 참조 이미지
복구를 만들면서 한 가지를 포기할 수밖에 없었습니다. 운영자가 캐릭터 이미지를 참조로 올려 초안을 만든 경우입니다.
참조 이미지는 서버에 올리지 않고 브라우저 안에서 base64 문자열로만 다루는데, 여러 장을 올리면 합쳐서 20MB를 넘기도 합니다. sessionStorage에 담을 수 있는 용량은 약 5MB라 이 데이터는 애초에 들어가지 않습니다. 그렇다고 참조를 뺀 채로 배경 생성을 자동으로 이어가면, 운영자가 의도하지 않은 결과를 유료로 만들어 버리게 됩니다.
그래서 참조를 썼다는 사실만 남기고, 텍스트 초안을 만드는 도중에 새로고침한 경우에는 초안 텍스트까지만 복구한 뒤 배경 생성을 자동으로 시작하지 않게 했습니다. 대신 참조 이미지가 사라져 자동 생성을 멈췄다는 안내와 함께 다시 생성 버튼을 남겨 둡니다. 생성은 누를 때마다 비용이 나가는 동작이라, 임의로 진행하는 것보다 멈추고 설명하는 편이 낫다고 판단했습니다.
배경 생성 단계에서 새로고침한 경우는 다릅니다. 그 시점에는 참조 이미지가 이미 잡의 입력으로 서버에 넘어가 있어 브라우저가 값을 잃어도 생성은 그대로 진행되고, 새로고침한 뒤에도 결과를 정상적으로 되찾습니다. 복구하지 못하는 것은 참조를 아직 서버로 보내지 않은 텍스트 초안 단계뿐입니다.
진행 위젯과 진입점
생성이 백그라운드로 돌게 되면서 새로운 문제가 생겼습니다. 모달을 닫으면 화면에 아무 흔적이 없어서, 지금 생성이 돌고 있는지 아닌지를 알 수 없었습니다.
그래서 화면 오른쪽 아래에 진행 위젯을 붙였습니다. 루트 레이아웃에 마운트해서, 생성을 걸어 두고 다른 관리자 화면으로 옮겨 다녀도 위젯이 그대로 따라옵니다. 어느 화면에 있든 진행 단계와 경과 시간을 볼 수 있어, 오래 걸리는 생성을 띄워 놓고 그동안 다른 일을 할 수 있습니다. 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();
}AI 생성 라우트는 이 전제에서 벗어나 있었습니다. 브라우저가 부르는 경로는 /api/builder/generate-image/jobs로 다른 API와 똑같이 생겨 미들웨어를 그대로 지나가는데, 이 라우트는 요청을 백엔드 서버로 넘기지 않고 어드민 서버가 보관한 키로 외부 모델 API를 직접 호출합니다. 인증을 판단할 곳이 양쪽 어디에도 없어, 로그인하지 않은 요청이 그대로 실행돼 비용이 나갈 수 있는 상태였습니다.
그래서 미들웨어의 예외를 푸는 대신 해당 라우트에서 로그인 여부를 직접 확인하고 401 JSON을 돌려주게 했습니다. 리다이렉트로 막으면 브라우저의 fetch가 그것을 따라가 로그인 페이지 HTML을 받아 버립니다. 완료된 잡의 조회 응답에는 생성 결과가 그대로 실려 나가므로 상태 조회 라우트에도 같은 확인을 붙였고, 폴링은 401을 일시적인 네트워크 오류와 구분해 재시도하지 않고 바로 실패로 처리하게 했습니다. 로그인 페이지로 자동 이동시키지는 않았습니다. 빌더에서 편집 중이던 내용이 날아가기 때문입니다.
남은 한계
이 구조는 세 가지의 한계점이 존재합니다. 이것들은 코드 주석과 운영자 안내에 함께 남겼습니다.
- 배포·재시작하면 진행 중이던 잡이 사라집니다: 잡 상태가 프로세스 메모리에 있어 프로세스가 바뀌면 함께 없어집니다. 동기 방식일 때도 배포 순간의 요청은 끊겼으니 새로 생긴 한계는 아니지만, 백그라운드로 도는 만큼 운영자가 다른 화면에 있다가 뒤늦게 알게 될 수 있습니다.
- 보관 한도 20건을 운영자 전체가 함께 씁니다: 여러 명이 잇달아 생성하면 남의 완료 결과를 밀어낼 수 있습니다. 지금은 이 화면을 쓰는 사람이 적어 문제가 되지 않지만, 사용자가 늘면 사람별로 나누거나 한도를 올려야 합니다.
- 컨테이너가 두 대 이상이면 폴링이 어긋납니다: 잡을 모르는 프로세스로 조회가 갈 수 있어, 잡 저장소를 두 프로세스가 함께 읽고 쓰는 Redis로 옮겨야 합니다. 바꿀 곳은 잡을 만들고 읽고 지우는 모듈 하나뿐이고, 기존의 라우트와 화면 코드는 그대로 사용할 수 있습니다.
마치며
처음에 이 문제는 로드밸런서 설정값 이야기로 보였습니다. 유휴 타임아웃을 60초에서 3분으로 늘리면 끝나는 일 같았습니다.
실제로 바꾼 것은 작업의 상태를 어디서 처리하느냐였습니다. 상태가 HTTP 연결 안에만 있으면 연결이 끊기는 모든 상황이 그대로 작업의 실패가 됩니다. 로드밸런서의 타임아웃, 모달 닫기, 새로고침, 다른 페이지로 이동이 전부 같은 원인의 사고였습니다. 상태를 서버로 꺼내고 식별자로 되찾을 수 있게 만들자 이 네 가지 문제점이 한꺼번에 해결됐습니다.
이번 작업에서 코드보다 오래 고민한 것은 화면 문구였습니다. 30분 뒤에 결과를 불러올 수 없다는 사실, 실패한 이유, 복구하지 못한 사정은 코드에 이미 있었지만 운영자에게는 표시되지 않았습니다. 생성을 백그라운드로 돌리고 나니 운영자가 진행 상황을 직접 볼 수 없게 됐고, 그러한 내용을 화면에 표시해야 했습니다.
수정 내역
- 2026-09-08: 보관 상한 20건의 메모리 근거 추가
- 2026-09-08: 참조 이미지를 쓴 경우의 복구 범위를 단계별로 구분해 정정