기술

새로고침 한 번에 진행률이 사라지는 SSE, 폴링 폴백으로 안정화했습니다

2026.05.1412분 읽기

7단계 파이프라인의 진행 상태가 필요했습니다

이메일을 지식그래프로 변환하는 파이프라인이 있습니다. PST 파일이나 EML ZIP을 업로드하면 백그라운드에서 7단계가 순차적으로 실행됩니다.

1. PST/EML 전처리
2. 스레드 빌드
3. 엔티티 추출 (LLM)
4. Neo4j 임포트
5. Qdrant 임베딩
6. 커뮤니티 탐지 + 요약 (LLM)
7. node2vec

전체 처리에 길게는 수십 분이 걸립니다. LLM을 두 번 호출하는 3, 6단계가 특히 오래 걸려요. 사용자가 화면 앞에 앉아서 마냥 기다리는 것도, 백그라운드에 던져놓고 끝났는지 모르는 상태로 두는 것도 모두 좋은 UX가 아니었습니다.

해결책은 명확했습니다. 실시간 진행률을 화면에 보여주는 것. 7단계 중 어디까지 갔는지, 각 단계의 세부 진행률(예: "53/212")은 어느 정도인지, LLM 토큰을 얼마나 썼는지를 화면에서 확인할 수 있어야 했습니다.


SSE 한 가지로 가려다 함정을 봤습니다

처음에는 SSE(Server-Sent Events)만으로 충분해 보였습니다. 브라우저가 서버와 연결을 유지하면서 서버가 push하는 이벤트를 받는 단방향 스트림. 진행률 표시에 잘 맞는 기술이에요.

구현 자체는 단순했습니다.

1. 프론트에서 POST /import → jobId 수신
2. EventSource로 GET /stream/{jobId} SSE 연결
3. 서버가 500ms마다 DB 변경 감지 → progress 이벤트 push
4. 완료 시 done 이벤트 → 연결 종료

테스트 환경에서는 잘 동작했습니다. 그런데 한 가지 시나리오에서 무너졌어요.

사용자가 페이지를 새로고침하면 어떻게 되는가?

SSE 연결이 끊깁니다

새로 페이지가 로드되면서 SSE를 다시 연결할 수도 있지만, 그 사이의 진행 상태는 어떻게 복원할 것인가?

어느 단계까지 갔는지, 현재 진행률은 얼마인지를 처음부터 다시 알아야 합니다

이건 일시적 네트워크 끊김에서도 똑같이 발생하는 문제였어요. SSE 연결이 끊기는 모든 순간에 진행 상태가 "사라진" 것처럼 보입니다. 백엔드는 멀쩡히 돌고 있는데 화면에는 보이지 않는 거예요.


정상 경로 + 폴백 경로를 같이 깔았습니다

해결책은 SSE를 정상 경로로 두고, 폴링을 폴백 경로로 같이 까는 것이었습니다.

정상 경로 (SSE):
  임포트 직후 → EventSource 연결 → 500ms마다 push 수신

폴백 경로 (폴링):
  페이지 로드 시 → GET /jobs → running 상태 감지
  → 10초 간격 GET /status/{id} → DB에서 현재 상태 조회

두 경로의 역할이 명확하게 갈렸어요.

SSE: 임포트를 시작한 그 세션 안에서 부드러운 실시간 업데이트

폴링: 페이지 새로고침 / 다른 탭에서 복귀 / 네트워크 일시 끊김 같은 비정상 상황의 회복

핵심은 진행 상태가 어디에 저장되어 있는가입니다. 두 경로 모두 같은 DB 테이블(email_import)을 읽습니다. SSE는 그 테이블의 변경을 push로 흘리고, 폴링은 그 테이블을 직접 조회합니다. 저장소가 한 곳이라서 어느 경로로 접근하든 같은 상태를 봅니다.

CREATE TABLE email_import (
  id INT PRIMARY KEY,
  status VARCHAR(20),           -- pending, running, completed, failed
  current_step VARCHAR(100),
  completed_steps INT DEFAULT 0,
  step_progress VARCHAR(50),    -- "53/212"
  email_count INT DEFAULT 0,
  total_tokens INT DEFAULT 0,
  estimated_cost FLOAT
);

이 테이블에 진행 상태가 매번 기록됩니다. SSE 연결이 끊겨도 상태는 DB에 남아 있어요. 새로 페이지를 열면 폴링이 그 상태를 읽어와서 화면을 복원합니다.

1.jpeg


subprocess의 진행 상태는 stdout으로 흘렸습니다

진행 상태가 DB에 기록되어야 한다고 했는데, 정작 7단계 작업을 실행하는 건 subprocess입니다. 각 단계는 별도의 파이썬 스크립트로 실행되고, 그 안에서 LLM 호출이나 임베딩 같은 작업이 돌아갑니다.

여기서 작은 설계 결정이 있었어요. subprocess가 부모 프로세스에 진행 상태를 어떻게 전달할 것인가?

가능한 방식들이 있었습니다.

방식

설명

문제

DB 직접 쓰기

subprocess가 DB connection 갖고 직접 update

subprocess마다 DB 의존성

HTTP 콜백

subprocess가 부모 서버에 HTTP 요청

부모 서버 의존성 + 인증

파일 polling

파일에 진행 상태 쓰고 부모가 polling

I/O 잡음 + 동기화

stdout 프로토콜

약속된 prefix로 stdout 출력 → 부모가 파싱

자유도가 단순함

선택은 stdout 프로토콜이었습니다. subprocess는 자기 stdout에 ##으로 시작하는 약속된 라인을 출력하고, 부모 프로세스가 그걸 파싱해서 DB에 기록합니다.

##STEP:1:7:PST 파싱           → current_step = "PST 파싱", completed_steps 진행
##PROGRESS:53:212              → step_progress = "53/212"
##STEP_DONE:1                  → completed_steps = 1
##TOKENS:1500:200              → total_tokens 누적, estimated_cost 계산
##EMAIL_COUNT:212              → email_count 업데이트

이 방식의 장점이 몇 가지 있었습니다.

subprocess가 DB나 HTTP에 의존하지 않음: 단순히 print만 함

테스트가 쉬움: subprocess 단독 실행해도 stdout만 보면 진행 상태가 보임

## 아닌 일반 출력은 그대로 로그: 디버깅에 유리

핵심은 약속된 인터페이스 하나로 전체 상태 흐름이 통일됐다는 점이었어요. subprocess가 stdout으로 보낸 정보가 → 부모 프로세스가 DB에 기록 → SSE/폴링이 DB를 읽어서 → 화면에 표시되는 일관된 흐름이 만들어졌습니다.

2.jpeg


비용 추적도 같은 구조에 얹었습니다

7단계 중 LLM을 쓰는 단계(엔티티 추출, 커뮤니티 요약)는 토큰 비용이 발생합니다. 이걸 화면에 같이 표시하고 싶었는데, 다행히 위 구조에 자연스럽게 얹을 수 있었어요.

# subprocess 내부
input_tokens, output_tokens = response.usage_metadata
print(f"##TOKENS:{input_tokens}:{output_tokens}", flush=True)

LLM 호출마다 ##TOKENS: 라인을 출력하면, 부모 프로세스가 누적해서 DB에 기록합니다. 화면에는 "LLM 누적: 15,000토큰 · ~$0.0030" 같은 식으로 표시돼요.

비용 추적을 위해 새로운 기반 시설을 만들 필요가 없었어요. 진행 상태와 같은 통로로 흐르게 했더니 거의 무료로 비용 표시 기능이 붙었습니다.


화면에서 본 진행률은 이렇게 동작했습니다

최종적으로 사용자가 보는 화면은 이런 식이었어요.

│ 파이프라인 진행 상태 (Job #4)                      
│                                                    
│ ● PST 파싱                                   OK  
│ ● 스레드 빌드                                  OK  
│ ● 엔티티 추출                                  OK  
│ ◉ Qdrant 임베딩                           53/212  
│ ○ 커뮤니티 탐지                                    
│ ○ node2vec                                        
│                                                    
│              LLM 누적: 15,000토큰 · ~$0.0030      

completedSteps 이하: 녹색 (done)

completedSteps 번째: 파란색 애니메이션 (running) + stepProgress 표시

나머지: 회색 (pending)

페이지를 새로고침해도 이 화면이 그대로 복원됩니다. 폴링이 DB에서 상태를 읽어 와서 같은 화면을 그리니까요. 사용자 입장에서는 "새로고침해도 진행률이 유지된다"고 느낍니다.


정상 경로와 폴백 경로 둘 다 깔아야 합니다

이 작업을 하면서 한 가지 패턴을 다시 확인했어요.

"한 가지 메커니즘만 깔지 않는다" 가 운영 안정성의 핵심이었습니다.

SSE는 정상 상황에서 가장 좋은 UX를 제공합니다. 부드러운 실시간 업데이트가 가능하니까요. 하지만 SSE 하나만 깔아두면 비정상 상황(새로고침, 끊김, 다른 탭 복귀)에서 진행률이 사라집니다. 폴링은 그 빈 곳을 메웁니다.

비슷한 결의 패턴은 다른 곳에서도 보였어요. LLM 라우팅 정확도가 100%가 아니라는 걸 인정하고 보조 시그널(엔티티 감지)을 같이 까는 구조도 같은 발상이었습니다.

핵심은 단일 메커니즘에 모든 책임을 지우지 않는 것입니다. 정상 경로 + 폴백 경로를 같이 까는 게 운영에서 가장 안정적인 구조였어요.


마무리

긴 작업의 실시간 진행률을 표시할 때 SSE만 쓰면 페이지 새로고침이나 일시적 끊김에서 진행률이 사라집니다. SSE를 정상 경로로 두고, 폴링을 폴백 경로로 같이 까니 비정상 상황에서도 화면이 일관되게 복원됐습니다.

핵심은 진행 상태를 DB 한 곳에 저장해서 어느 경로로 접근하든 같은 상태를 보게 만든 것이었습니다. 그리고 subprocess의 진행 상태는 stdout 프로토콜(##STEP:, ##PROGRESS:, ##TOKENS:)로 흘려서, 비용 추적까지 같은 통로로 통일했습니다.

긴 작업의 진행률 표시를 만들고 있다면, 정상 경로와 폴백 경로를 같이 까는 걸 추천합니다. 단일 메커니즘에 모든 책임을 지우면 비정상 상황에서 무너집니다. 정상 경로 + 폴백 경로 조합이 운영 안정성의 기본기였습니다.

#SSE#폴링#진행률표시#파이프라인#subprocess#stdout프로토콜#운영안정성#AI전환기