AI 응답을 90초 동안 기다리게 할 수는 없으니까 — SSE로 전환한 이야기
반려견 성격 분석 서비스를 운영하면서, 하나의 문제가 계속 마음에 걸렸습니다. 사용자가 AI 분석을 요청하면 90초 가까이 아무런 피드백 없이 로딩 화면만 보고 있어야 한다는 점이었습니다.
이 글은 동기식 HTTP 요청을 SSE(Server-Sent Events)로 전환한 과정과, 그 과정에서 이해하게 된 HTTP 연결의 본질에 대해 정리한 것입니다.
문제: 90초의 침묵
서비스의 AI 진단 플로우는 이렇습니다. 사용자가 반려견 정보와 행동 패턴을 입력하면, 백엔드가 Google Gemini API를 호출해서 MBTI 기반 성격 분석을 수행합니다. 문제행동이 있는 경우에는 MBTI 분석과 문제행동 분석을 병렬로 실행합니다.
이 과정이 빠르면 45초, 느리면 120초까지 걸립니다. 그 동안 사용자가 보는 화면은 이것뿐이었습니다.
🐕 킁킁... 뭔가 냄새가 나요! 조금만 기다려주세요.
(로딩 애니메이션만 계속...)진짜 분석 중인지, 에러가 난 건지, 얼마나 남았는지 알 수 없습니다. 저라도 이 화면에서 90초를 기다리기는 어려울 것 같다는 생각이 들었습니다.
왜 90초 동안 연결이 열려 있어야 하는가
이 문제를 이해하려면 먼저 현재 구조를 들여다볼 필요가 있었습니다.
Browser ── POST /api/gemini/analyze-mbti ──→ NestJS ──→ Gemini API
│ │
│ HTTP 연결이 열린 채 대기 (pending) │ await로 응답 대기
│ │
│◄──────────── HTTP 200 JSON ──────────────┘
연결 종료브라우저가 POST 요청을 보내면, NestJS 컨트롤러의 return이 실행될 때까지 HTTP 응답이 나가지 않습니다. 그 return은 Gemini API 응답을 받아야 가능합니다. 결국 브라우저와 백엔드 사이의 HTTP 연결이 90초 동안 열려있어야 합니다.
Node.js 자체는 블로킹되지 않습니다. await는 스레드를 블로킹하는 게 아니라 이벤트 루프에 제어를 넘기고 나중에 돌아오는 것이니까요. 하지만 HTTP 연결은 열려있고, 그 연결을 유지하기 위해 각 레이어에 timeout 설정이 필요합니다.
타임아웃 체인: 가장 짧은 곳에서 끊긴다
요청이 거치는 레이어마다 timeout 설정이 있고, 가장 짧은 값에서 먼저 연결이 끊깁니다.
레이어 | timeout | 초과 시 현상 |
|---|---|---|
Gemini API (httpOptions) | 90초 | NestJS에서 exception → 재시도 |
axios (브라우저) | 150초 | ECONNABORTED → 에러 UI 표시 |
NestJS keepAliveTimeout | 175초 | 서버가 TCP 연결 강제 종료 |
Nginx proxy_read_timeout | 60초 / 180초 | 502 Bad Gateway |
여기서 중요한 점은 timeout과 TTL은 다른 개념이라는 것입니다. timeout은 "응답을 기다리는 최대 시간"이고, TTL(Time To Live)은 캐시나 DNS에서 "데이터의 유효 기간"을 뜻합니다.
실제로 문제가 됐던 건 Nginx였습니다. 로컬 Docker 환경의 nginx.conf는 기본값 60초를 쓰고 있었고, Gemini 분석이 60초를 넘기면 Nginx가 먼저 연결을 끊어버려서 502 에러가 발생했습니다. 프로덕션용 Coolify 템플릿에서만 180초로 설정되어 있었기 때문에 프로덕션에서는 문제가 안 됐지만, 근본적인 해결은 아니었습니다.
대안 검토: 어떤 방식이 있는가
장시간 HTTP 연결을 처리하는 패턴은 크게 4가지가 있습니다.
방식 | 연결 유지 | 진행률 표시 | 구현 복잡도 |
|---|---|---|---|
동기식 (기존) | 90초+ 내내 | 불가 | 낮음 |
Polling | 1~2초씩 여러 번 | 단계별 가능 | 중간 (Redis/Bull 필요) |
WebSocket | 세션 전체 | 실시간 | 높음 (sticky session 필요) |
SSE | 90초+ (데이터 흐름) | 실시간 | 중간 (NestJS 네이티브 지원) |
Polling은 Redis나 Bull 같은 작업 큐 인프라가 필요합니다. 개인 서비스에 오버엔지니어링이라는 생각이 들었습니다. WebSocket은 양방향 통신이 필요 없는 상황에서 sticky session까지 고려해야 하므로 역시 과했습니다.
결국 SSE(Server-Sent Events)가 이 프로젝트에 가장 적합하다는 결론에 도달했습니다. NestJS가 @Sse 데코레이터로 네이티브 지원하고, HTTP 기반이라 Nginx 설정만 약간 조정하면 되고, 추가 인프라가 필요 없습니다.
SSE가 타임아웃 문제를 근본적으로 해결하는 이유
동기식에서는 90초 동안 데이터 전송이 전혀 없습니다. 연결은 열려있지만 침묵 상태입니다. 이 침묵이 timeout을 유발합니다.
동기식: ── 요청 ──── 90초 침묵 ──── 응답 ──
↑ 이 구간에서 timeout
SSE: ── 요청 ── event ── event ── event ── 완료 ──
↑ 데이터 보낼 때마다 timer 리셋SSE에서는 15초마다 heartbeat 이벤트를 보냅니다. Nginx의 proxy_read_timeout이나 Node.js의 setTimeout은 마지막 데이터 전송 이후부터 카운트하기 때문에, 주기적으로 이벤트를 보내면 timer가 계속 리셋됩니다. 어떤 레이어든 60초 이상 침묵이 없으니 timeout에 걸리지 않습니다.
구현: 2단계 요청 구조
SSE 구현에서 한 가지 고려할 점이 있었습니다. 브라우저의 EventSource API는 GET 요청만 지원합니다. 하지만 분석에 필요한 반려견 정보(이름, 견종, 행동 패턴 등)는 request body로 보내야 하는 데이터입니다.
이를 해결하기 위해 2단계 요청 구조를 설계했습니다.
1단계: POST /api/gemini/analyze-mbti/stream/init
→ 데이터를 서버 메모리에 저장하고 analysisId 발급
2단계: GET /api/gemini/analyze-mbti/stream/:analysisId
→ EventSource로 SSE 스트림 연결, 진행 이벤트 수신백엔드: NestJS @Sse 데코레이터
NestJS에서 SSE 엔드포인트는 Observable<MessageEvent>를 반환하는 형태입니다.
@Sse('analyze-mbti/stream/:analysisId')
streamAnalysis(@Param('analysisId', ParseUUIDPipe) analysisId: string): Observable<MessageEvent> {
return new Observable<MessageEvent>(subscriber => {
// 15초마다 heartbeat
const heartbeat = interval(15000).pipe(takeUntil(done$)).subscribe(() => {
subscriber.next({ data: { event: 'heartbeat', timestamp: Date.now() } });
});
// MBTI + 문제행동 병렬 분석
const mbtiPromise = this.geminiService.analyzeDogPersonalityOnly(dto).then(result => {
subscriber.next({ data: { event: 'mbti_done', data: result } });
return result;
});
const problemPromise = this.geminiService.analyzeDogProblemBehaviorOnly(dto).then(result => {
subscriber.next({ data: { event: 'problem_done', data: result } });
return result;
});
Promise.all([mbtiPromise, problemPromise]).then(([mbti, problem]) => {
subscriber.next({ data: { event: 'complete', data: merged } });
subscriber.complete(); // 스트림 종료
});
});
}기존의 Promise.all() 병렬 처리 패턴을 그대로 유지하면서, 각 Promise가 resolve될 때 SSE 이벤트를 보내는 구조입니다. 기존 동기 API 엔드포인트도 그대로 남겨두어 하위 호환성을 보장했습니다.
프론트엔드: EventSource API
// 1단계: init POST (axios 사용 — 인증 헤더 포함)
const { analysisId } = await axios.post('/gemini/analyze-mbti/stream/init', requestData);
// 2단계: SSE 스트림 연결 (EventSource — 커스텀 헤더 불필요)
const eventSource = new EventSource(`/api/gemini/analyze-mbti/stream/${analysisId}`);
eventSource.onmessage = (event) => {
const parsed = JSON.parse(event.data);
switch (parsed.event) {
case 'start': setProgressStep('analyzing'); break;
case 'mbti_done': setProgressStep('mbti_complete'); break;
case 'complete': navigateToResult(parsed.data); eventSource.close(); break;
}
};EventSource는 커스텀 헤더를 지원하지 않지만, init POST에서 이미 인증을 처리하고 analysisId가 일종의 일회용 토큰 역할을 하기 때문에 보안 문제는 없습니다.
Nginx: 버퍼링 비활성화
SSE가 Nginx를 통과하려면 버퍼링을 꺼야 합니다. Nginx는 기본적으로 백엔드 응답을 버퍼에 모았다가 한꺼번에 클라이언트에 보내는데, SSE에서는 이벤트가 발생할 때마다 즉시 전달되어야 합니다.
location /api/gemini/analyze-mbti/stream {
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection ''; # SSE는 upgrade 아님
proxy_read_timeout 300s;
}결과: 사용자가 보는 화면의 변화
이전에는 90초 동안 같은 메시지가 표시되었지만, 이제는 실제 분석 진행에 맞춰 단계별로 업데이트됩니다.
이전:
🐕 킁킁... 뭔가 냄새가 나요! 조금만 기다려주세요.
(90초 동안 변화 없음...)
이후:
● 성격을 분석하고 있어요 ← start 이벤트
✅ MBTI 분석 완료 ← mbti_done 이벤트
○ 문제행동 분석 완료 ← 대기 중
→ 결과 페이지로 이동 ← complete 이벤트기술적으로는 같은 시간이 걸리지만, 사용자가 느끼는 체감은 전혀 다릅니다.
돌이켜 생각해보면
이번 작업을 하면서 "HTTP 연결이 열려 있다"는 것의 의미를 좀 더 깊이 이해하게 되었습니다. 동기식이든 SSE든 연결 자체는 유지되지만, 침묵하는 연결과 데이터가 흐르는 연결은 본질적으로 다르다는 점이 핵심이었습니다.
또 하나 배운 것은 timeout 설정이 단순히 "숫자를 크게 늘리면 되는" 문제가 아니라는 것입니다. Browser, Nginx, NestJS, 외부 API까지 이어지는 체인에서 각 레이어의 timeout이 어떤 역할을 하고, 어떤 순서로 작동하는지를 이해해야 합니다. 그래야 timeout 숫자를 키우는 대신 heartbeat라는 근본적인 해결책을 떠올릴 수 있었습니다.
개인 서비스라 트래픽이 많지 않고, 당장은 동기식으로도 문제없이 돌아가고 있었습니다. 하지만 사용자 경험을 생각하면 분명 개선이 필요한 부분이었고, SSE가 추가 인프라 없이 해결할 수 있는 적절한 선택지였다는 생각이 듭니다.
관련 글
AI 기반 강아지 MBTI 서비스 개발기: 느린 응답 시간, UI/UX로 풀어낸 성찰
AI 기반 강아지 MBTI 서비스 개발 중 느린 응답 시간 난관을 UI/UX 개선으로 극복한 과정을 소개합니다. 사용자 경험을 최우선으로 기술적 한계를 돌파한 인사이트를 얻어가세요.
NestJS 서버로 추적하는 이벤트 루프 — 이론에서 실전으로 (5편)
이벤트 루프 이론을 실제 NestJS 서버 코드에 대입합니다. 서버 시작부터 HTTP 요청 처리까지, 6개 페이즈가 실제로 어떤 역할을 하는지 추적합니다.
k6와 실시간 Pool 모니터링으로 시스템 한계점 찾기
k6로 시스템 한계점을 찾는 Breakpoint 테스트와 NestJS Connection Pool 실시간 모니터링 시스템을 구현한 경험. 최적 RPS를 찾기까지의 과정을 정리했습니다.