다운로드 진행 상태 피드백 패턴
개요
파일/엑셀 다운로드처럼 서버 처리 시간이 긴 작업에서 사용자에게 “진행 중”임을 알리는 4가지 패턴(인디케이터, 단계별 진행률, Polling, SSE)을 정리한다. 각 방식의 장단점과 작업 시간 기준 업계 관행을 비교하여, 어떤 상황에 어떤 방식을 채택할지 의사결정 기준을 제공한다.
배경: 다운로드 버튼 클릭 후 아무 피드백이 없으면 사용자가 (1) 오류로 인식하거나 (2) 중복 클릭하는 문제가 발생한다. 이를 해결하는 것이 진행 상태 피드백의 목적이다.
핵심 개념: “진짜 진행률”이 가능한가?
진행률(%)을 표시하려면 서버가 중간 진행 상태를 전달할 수 있는 구조여야 한다.
[진짜 % 불가능한 구조 — 대부분의 단순 API]
요청 → (서버가 통째로 처리) → JSON 한 번에 응답 → 클라이언트에서 파일 생성
└ 이 구간 동안 진행 정보를 받을 방법이 없음 (0%였다가 갑자기 100%)
[진짜 % 가능한 구조]
요청 → 서버가 작업을 쪼개서 진행 상태를 별도 채널(Redis/스트림)로 노출
→ 클라이언트가 그 상태를 주기적/실시간으로 수신즉, 진짜 진행률은 프론트엔드만으로는 불가능하고 백엔드가 진행 상태를 외부에 노출해야 한다.
4가지 패턴 비교
방식 1: 인디케이터만 (Indeterminate)
버튼 비활성화 + 스피너 + 완료 Toast만 표시. 진행률 숫자는 없음.
- 장점: 구현 가장 단순, BE 변경 0, 기존 코드 영향 없음, 중복 클릭 방지
- 단점: 진행 상태 정보 없음, 대용량 시 “멈춘 건지” 불안감
- 업계 사례: GitHub 파일 업로드, Google Drive 소용량 다운로드
- 적합 조건: 대부분 작업이 3초 이내로 끝날 때
// indeterminate 로딩 바는 CSS 애니메이션만으로 구현 → BE 무관
<Button label={isLoading ? '다운로드 중...' : '엑셀 다운로드'}
disabled={isLoading} loading={isLoading} onClick={handleDownload} />방식 2: 단계별 진행률 (Staged / Fake Progress)
코드 실행 단계에 진행률을 수동 매핑. 0 → 30 → 70 → 100%.
- 장점: BE 변경 없음, 100% = 진짜 완료 보장, 구현 단순
- 단점: “가짜 진행률” (실제 처리량 반영 X), 단계 사이에서 멈춰 보일 수 있음
- 업계 사례: Figma 내보내기, Notion PDF 내보내기 — 실제로 매우 널리 쓰임
- 적합 조건: 작업 시간이 예측 가능하고 단계가 명확할 때
버튼 클릭 → 0% "데이터 요청 중..."
API 응답 → 60% "파일 생성 중..." ← 실제로 데이터 도착한 시점
XLSX 생성 → 90% "저장 준비 중..."
writeFile → 100% "완료!" ← 실제로 파일 저장된 시점각 단계가 실제 코드 실행에 1:1 대응하므로, 사용자가 100%를 보는 순간 파일은 이미 저장돼 있다.
방식 3: Polling (주기적 상태 조회)
BE가 비동기 Job을 만들고 진행 상태를 Redis 등에 저장. FE가 일정 간격(예: 0.5초)으로 상태를 조회.
① FE 다운로드 요청 → BE가 즉시 { jobId } 반환
② BE 백그라운드 처리 → 진행률을 Redis에 저장 (TTL 설정)
③ FE 주기적 polling → GET /jobs/{jobId} → { progress: 40, status: PROCESSING }
④ status: DONE → 결과 데이터/파일 URL로 다운로드- 장점: 진짜 진행률, 서버 상태 정확 반영, 연결 끊겨도 재연결 쉬움(stateless), 기존 인증 구조 그대로 사용
- 단점: BE 신규 개발(Job 관리 + Redis), FE 폴링 로직, 네트워크 요청 증가
- 업계 사례: AWS S3 대용량 업로드, Stripe 정산 배치, Shopify 대량 내보내기 — 대용량 비동기 작업의 사실상 업계 표준
- 적합 조건: 작업 시간 10초 이상이거나 실패 재시도/백그라운드 처리가 중요할 때
방식 4: SSE (Server-Sent Events)
BE가 스트리밍 응답으로 진행 상태를 push, FE는 EventSource로 수신.
- 장점: 가장 실시간, 서버 push 방식, 연결 1개로 효율적
- 단점: 아래 “SSE의 함정” 참고. HTTP/1.1에서 브라우저 동시 연결 수 제한(6개)
- 업계 사례: ChatGPT 스트리밍 응답, GitHub Actions 로그 실시간 표시 — 실시간성이 핵심일 때
- 적합 조건: 1초 이하 실시간성이 필요하거나, 서버가 작업 중 중간 결과를 계속 생성할 때
SSE의 함정: 인증 토큰
SSE는 Spring Boot의 SseEmitter로 서버 구현은 쉽지만, 브라우저 측 인증 처리가 까다롭다.
// 표준 EventSource는 커스텀 헤더 설정이 불가능
const es = new EventSource('/api/stream');
// ❌ Authorization: Bearer 헤더를 붙일 방법이 없음 (GET only, 헤더 설정 불가)Keycloak/OAuth2처럼 모든 요청에 Bearer 토큰이 필요한 시스템에서는 우회가 필요하다:
| 우회 방법 | 문제점 |
|---|---|
토큰을 쿼리스트링으로 ?token=xxx | URL/로그에 토큰 노출 → 보안 위반 |
fetch + ReadableStream 직접 구현 | EventSource 대체 코드를 직접 작성 |
| 프록시(BFF) 서버에서 SSE 통과 처리 | 프록시를 스트리밍 모드로 대폭 수정 |
핵심: SSE가 “불가능”한 게 아니라, 인증 게이트웨이/프록시 구조를 쓰는 프로젝트에서는 FE 구현 비용이 크다. Polling은 기존 HTTP API 클라이언트를 그대로 쓰므로 토큰 처리가 자동으로 해결된다. 이것이 비동기 다운로드에서 Polling이 더 자주 선택되는 실질적 이유다.
의사결정 기준 (작업 시간 기준)
~3초 → 방식 1 (인디케이터)
3~10초 → 방식 2 (단계별) 또는 방식 3 (Polling)
10초+ → 방식 3 (Polling) 강력 권장
실시간성이 핵심 / 중간 결과 생성 → 방식 4 (SSE)종합 비교표:
| 항목 | 인디케이터 | 단계별 | Polling | SSE |
|---|---|---|---|---|
| 진짜 진행률 | ❌ | △ (가짜) | ✅ | ✅ |
| BE 변경 | 없음 | 없음 | 큼(Job+Redis) | 중간(스트림) |
| FE 변경 | 작음 | 작음 | 중간(폴링) | 큼(인증/프록시) |
| 인증 처리 | 자동 | 자동 | 자동 | 까다로움 |
| 실시간성 | - | - | 0.5초 수준 | 최고 |
| 유지보수 | 쉬움 | 쉬움 | 보통 | 어려움 |
실무 트레이드오프 결론
- 단순/소용량: 방식 1, 2가 비용 대비 효과 최고. Notion·Figma 같은 B2B SaaS도 실제로 단계별 방식을 많이 쓴다.
- 대용량/비동기/재시도 필요: 방식 3 Polling. Redis가 이미 인프라에 있으면 자연스러운 선택.
- SSE는 채팅·로그 스트리밍처럼 실시간성이 본질인 경우에만. 인증 프록시 환경에서 단순 다운로드 진행률 용도로는 과한 경우가 많다.
- 처음부터 과설계하지 말고, 작업 시간이 실제로 길어질 때 방식 2 → 방식 3으로 업그레이드하는 점진적 접근이 안전하다.