로딩 중에 다른 메뉴를 눌러도 화면이 넘어가지 않는다
배포 환경에서만 생긴 대기 현상
- 01 · 문제
- 오래 걸리는 화면을 열어 둔 채 다른 메뉴를 누르면, 앞 화면의 로딩이 끝날 때까지 아무것도 바뀌지 않는다
- 02 · 분석
- HTTP/1.1 커넥션 상한리버스 프록시 응답 버퍼링단일 워커의 직렬 처리와 Convoy Effect
- 03 · 04 · 해결 방안과 결론
- 설정 두 줄(proxy_buffering off, HTTP/2)과 데이터 정책 하나로 해결하고, 백엔드 워커 증설은 제안으로 넘겼다
- 결과
- 메뉴 이동이 앞 요청과 무관하게 즉시 반응핵심 정보는 먼저·무거운 표는 나중에 도착하는 스트리밍이 배포 환경에서도 동작
사내 프로젝트라 이름은 A(사용자용 웹), B(어드민)로 표기한다. 이 글은 B에서 있었던 일이다.
문제
어드민 B를 쓰는 운영팀에서 "사이트가 가끔 멈춘다"는 얘기가 들어왔다. 재현해 보니 멈추는 게 아니라 기다리는 것이었다.
- 집계가 오래 걸리는 화면(통계 페이지, 응답 5~8초)에 들어간다.
- 기다리기 싫어서 다른 메뉴를 누른다.
- 아무 일도 일어나지 않는다. 화면도, 로딩 표시도 그대로다.
- 5~8초 뒤 통계 화면이 다 뜨고, 그다음에야 눌렀던 메뉴로 넘어간다.
사용자 입장에서는 "내가 누른 게 먹은 건지 아닌지" 알 수가 없다. 그래서 여러 번 누르고, 그 사이 페이지가 연달아 바뀌면서 더 혼란스러워졌다.
두 번째 증상은 같은 화면에서 나왔다. 통계 페이지는 원래 제목·필터·표 골격은 먼저 뜨고 무거운 표만 나중에 채워지도록 만들어 두었다. 로컬에서는 그렇게 동작했다. 그런데 배포 환경에서는 전부 한꺼번에, 가장 늦은 데이터가 올 때까지 기다린 뒤에 떴다. 사용자는 몇 초 동안 빈 화면을 봤다.
규모를 적어 두면, B는 동시 사용자가 많아야 수십 명인 사내 도구다. 트래픽이 문제가 될 규모는 아니었다. 하지만 매일 종일 쓰는 도구라, 매번 몇 초씩 기다리는 비용은 작지 않았다.
분석
"로컬에서는 되고 배포에서는 안 된다"는 말은 곧 로컬과 배포의 차이에 원인이 있다는 뜻이다. 차이는 요청이 지나는 경로였다.
[로컬] 브라우저 ──▶ SvelteKit(Node) ──▶ 백엔드 API
[배포] 브라우저 ──▶ Nginx ──▶ SvelteKit(Node) ──▶ 백엔드 API
↑ 이 구간이 새로 끼어든다
경로를 구간별로 끊어서 하나씩 의심했다. 어디서 시간이 멈추는지 눈으로 확인할 수 있어야 하기 때문에, 구간마다 "확인 방법"을 먼저 정했다.
구간 1. 브라우저 ↔ Nginx — HTTP/1.1의 커넥션 상한
배포 환경의 Nginx는 HTTP/1.1로만 응답하고 있었다. HTTP/1.1은 커넥션 하나가 한 번에 요청 하나만 처리한다. 응답이 다 올 때까지 그 커넥션은 다음 요청에 쓸 수 없다. 브라우저는 이걸 우회하려고 호스트당 커넥션을 6개까지 열지만, 6개가 전부 응답 대기 중이면 새 요청은 큐에서 기다린다.
통계 페이지는 응답이 오래 걸리는 데이터 요청을 여러 개 동시에 보낸다. 거기에 SvelteKit이 링크 위에 마우스가 올라가면 미리 받아 두는 프리페치 요청까지 합쳐지면, 커넥션 6개가 금방 찬다. 그 상태에서 다른 메뉴를 누르면 그 페이지의 데이터 요청은 앞 요청이 끝나 커넥션이 비기 전까지 출발도 못 한다.
확인 방법. DevTools Network 탭에서 멈춘 요청의 Timing을 보면 Stalled 구간이 길게 잡힌다. Stalled는 "요청을 보낼 커넥션이 없어서 대기 중"이라는 뜻이다. 그리고 Connection ID 컬럼을 켜 보면 앞 요청들이 서로 다른 커넥션을 점유한 채 pending 상태인 것이 보였다.
이건 원인의 일부였다. 다만 이것만으로는 "로컬에서는 왜 안 그런가"가 설명되지 않았다. 로컬 Node 서버도 HTTP/1.1이기 때문이다. 그래서 다음 구간으로 넘어갔다.
구간 2. Nginx ↔ Node — 리버스 프록시의 응답 버퍼링
SvelteKit의 스트리밍은 서버가 응답을 여러 조각으로 나눠 시간차를 두고 내려보내는 기능이다. load 함수가 돌려주는 값 중 일반 객체는 첫 조각(HTML 또는 데이터 응답의 앞부분)에 실려 즉시 나가고, Promise는 resolve될 때 뒤 조각으로 따라간다. 브라우저는 첫 조각을 받는 즉시 화면을 그릴 수 있다.
그런데 Nginx는 기본 설정에서 업스트림(Node)의 응답을 자기 버퍼에 모아 두었다가 버퍼가 차거나 응답이 끝나면 클라이언트로 흘려보낸다(proxy_buffering on). 이건 결함이 아니라 설계다. 느린 클라이언트가 응답을 천천히 읽는 동안 Node가 그 클라이언트에 붙들려 있지 않도록, Nginx가 대신 받아 두는 것이다. 업스트림을 빨리 풀어 주는 것이 프록시의 역할이다.
문제는 스트리밍 응답과 이 설계가 정면으로 충돌한다는 점이다. 첫 조각은 몇 KB짜리 HTML 골격이라 버퍼를 채우지 못한다. Nginx는 더 오기를 기다린다. 마지막 조각인 통계 표 데이터가 5초 뒤 도착하면 그때 응답이 끝나고, 그제야 전부를 한 번에 내려보낸다. 즉시 내려와야 할 일반 객체까지 Promise가 끝날 때까지 붙잡힌다. 이것이 두 번째 증상의 원인이었고, 첫 번째 증상에도 기여했다. 페이지 이동에 필요한 데이터 응답도 같은 방식으로 붙잡히기 때문이다.
확인 방법. 터미널에서 같은 URL을 두 번 요청했다. Nginx를 거치는 주소와, 서버 안에서 Node 포트로 직접 붙는 주소다.
# -N: 버퍼링 없이 도착하는 대로 출력 → 조각이 시간차를 두고 찍히는지 본다
curl -N https://admin.example.com/stats # Nginx 경유: 5초 뒤 한 번에 출력
curl -N http://127.0.0.1:3000/stats # Node 직접: 골격 즉시, 표 데이터 5초 뒤
Node 직접 요청에서는 조각이 두 번에 나눠 찍혔고, Nginx 경유에서는 한 번에 찍혔다. 구간 2가 확정됐다.
구간 3. SvelteKit ↔ 백엔드 — 요청을 직렬로 보내는 건 아닌가
여기까지 고치고 나서도 이상한 것이 하나 남았다. 백엔드 로그를 보면, 서버(SvelteKit)가 거의 동시에 보낸 두 요청이 첫 요청 처리가 끝난 뒤에야 두 번째가 처리되고 있었다.
먼저 "SvelteKit이 순서대로 보내는 게 아닐까"를 의심했다. load 함수 안에서 await를 연달아 쓰면 그렇게 된다. 코드상으로는 Promise.all이었지만 확신이 필요했다. SvelteKit에는 서버가 내보내는 모든 fetch를 가로채는 handleFetch 훅이 있어서, 여기에 인위적으로 지연을 넣어 보았다.
// hooks.server.ts — 검증용. 첫 요청만 3초 붙잡아 둔다
export const handleFetch = async ({ request, fetch }) => {
if (request.url.includes('/api/stats/summary')) {
await new Promise((r) => setTimeout(r, 3000));
}
return fetch(request);
};
첫 요청이 3초 붙잡혀 있는 동안 두 번째 요청은 기다리지 않고 즉시 출발했다. SvelteKit은 병렬로 잘 보내고 있었다. 이 가설은 기각했고, 그러면 직렬화는 백엔드 쪽에서 일어난다는 뜻이 된다.
구간 4. 백엔드 — 워커 하나가 요청을 한 줄로 세운다
백엔드 API는 워커 프로세스 하나로 떠 있었다. 요청 하나를 처리하는 동안 그 워커는 다른 요청을 받지 못하고, 들어온 요청은 앞에서부터 순서대로(FIFO) 처리된다.
이건 운영체제 스케줄링에서 말하는 Convoy Effect와 같은 모양이다. 짧은 작업들이 긴 작업 하나 뒤에 줄을 서서, 전체 대기 시간이 긴 작업 하나에 끌려간다. 집계 5초짜리 요청 뒤에 0.1초짜리 메뉴 요청이 서면, 메뉴 요청은 5.1초가 걸린다. 사용자가 본 "다른 메뉴를 눌러도 앞 화면이 끝나야 넘어간다"는 현상의 마지막 조각이 여기 있었다.
동시 사용자가 수십 명이라 그동안 눈에 띄지 않았다. 하지만 통계 페이지처럼 한 요청이 몇 초를 점유하는 화면이 생기자, 한 사람의 요청이 다른 사람의 요청까지 줄 세우게 됐다.
정리 — 증상 하나, 원인 셋
| 사용자가 본 것 | 실제 원인 | 구간 |
|---|---|---|
| 다른 메뉴를 눌러도 반응이 없다 | 커넥션 6개가 pending 응답에 묶여 새 요청이 Stalled | 브라우저 ↔ Nginx |
| 골격도 표와 함께 늦게 뜬다 | 프록시가 스트리밍 조각을 모아서 한 번에 내려보냄 | Nginx ↔ Node |
| 앞 요청이 끝나야 다음 요청이 처리된다 | 백엔드 워커 1개, 요청 FIFO 처리 | 백엔드 |
같은 증상으로 보였지만 원인은 셋이었다. 하나만 고쳤을 때 "조금 나아졌는데 여전히 느리다"가 된 이유다.
해결 방안
원인이 셋이니 손댈 곳도 셋이다. 각 지점에서 가능한 선택지를 놓고, 동시 사용자 수십 명의 사내 어드민이라는 규모에 맞는지, 지금 가진 것으로 되는지를 기준으로 비교했다.
1. 프록시 버퍼링 — 스트리밍 조각이 즉시 통과하게
| 선택지 | 방법 | 장점 | 단점 | 이 규모에 |
|---|---|---|---|---|
| A. 전역으로 끈다 | location /에 proxy_buffering off; | 설정 한 줄, 즉시 효과 | Node가 느린 클라이언트를 직접 상대. 트래픽 폭주 시 흡수 능력 상실 | 적합 — 사내망, 수십 명 |
| B. 응답 단위로 끈다 | 스트리밍 응답에만 X-Accel-Buffering: no 헤더 | 필요한 응답만 정밀 제어, 나머지는 버퍼링 유지 | SvelteKit 훅에서 헤더를 붙일 조건 판별이 필요. 코드가 늘어난다 | 공개 서비스라면 이쪽 |
| C. 버퍼 크기를 줄인다 | proxy_buffer_size를 작게 | 설정만으로 | 조각이 버퍼보다 크면 여전히 대기. 근본 해결이 아니다 | 부적합 |
2. 커넥션 상한 — 요청이 서로를 막지 않게
| 선택지 | 방법 | 장점 | 단점 | 이 규모에 |
|---|---|---|---|---|
| D. HTTP/2로 올린다 | listen 443 ssl http2; | 커넥션 하나에서 요청을 멀티플렉싱. 상한 문제가 사라진다. 인증서는 이미 있어 추가 비용 0 | Nginx ↔ Node 구간은 HTTP/1.1로 남는다(무방) | 적합 |
| E. 프리페치를 끈다 | data-sveltekit-preload-data="off" | 커넥션 소모를 줄인다 | 페이지 이동 체감 속도를 포기. 원인 회피 | 부적합 |
| F. 도메인 샤딩 | API를 다른 호스트로 분리해 커넥션 12개 확보 | — | HTTP/1.1 시대의 우회책. 인프라(도메인·인증서)가 늘어난다 | 부적합 |
3. 백엔드 직렬 처리 — 긴 요청이 짧은 요청을 막지 않게
| 선택지 | 방법 | 장점 | 단점 | 이 규모에 |
|---|---|---|---|---|
| G. 워커 수를 늘린다 | 같은 서버에서 프로세스 2~4개 | 인프라 추가 없이 병렬 처리. 설정 변경 | 메모리 n배. 프로세스 간 공유 상태가 없는지 확인 필요 | 적합 — 백엔드 팀에 제안 |
| H. 서버 증설 + 로드 밸런서 | 인스턴스 추가 | 처리량 근본 확대 | 수십 명 규모에 과하다. 비용과 운영 부담 | 부적합 |
| I. 무거운 집계를 비동기 작업으로 | 요청 즉시 응답, 결과는 나중에 조회 | 긴 요청 자체를 없앤다 | 백엔드 구조 변경. 지금 문제의 범위를 넘는다 | 장기 과제 |
4. 프론트엔드 데이터 정책 — 무엇을 기다리고 무엇을 흘릴 것인가
버퍼링을 꺼서 스트리밍이 동작하게 되면, 이제 "어떤 데이터를 스트리밍할 것인가"가 문제가 된다. 전부 Promise로 넘기면 화면 골격만 뜬 채 핵심 정보까지 비어 있는 상태가 노출되고, 전부 await하면 스트리밍을 켠 의미가 없다.
기준을 하나 세웠다. 그 데이터가 없으면 화면이 의미가 없는가. 통계 페이지에서 제목·기간·필터 값은 없으면 화면 자체가 성립하지 않으니 await로 받아 HTML에 넣는다. 표 데이터는 없어도 "불러오는 중"이라는 상태가 의미를 가지니 Promise로 넘긴다.
// +page.server.ts
export const load = async ({ fetch }) => {
return {
// 없으면 화면이 성립하지 않는 것 → await. HTML과 함께 즉시 도착
summary: await fetch('/api/stats/summary').then((r) => r.json()),
// 없어도 "로딩 중"이 의미를 갖는 것 → Promise 그대로. 준비되면 따라옴
rows: fetch('/api/stats/rows').then((r) => r.json())
};
};
결론
A(전역 버퍼링 off) + D(HTTP/2) + 데이터 정책을 적용했다. G(워커 증설)는 백엔드 팀에 근거 로그와 함께 제안했고, 워커 2개로 조정됐다.
server {
listen 443 ssl http2; # D — 커넥션 하나에서 멀티플렉싱
location / {
proxy_pass http://127.0.0.1:3000;
proxy_buffering off; # A — 스트리밍 조각을 즉시 통과
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
바뀐 뒤 동작은 이렇다. 통계 페이지에 들어가면 골격이 먼저 뜨고 표는 나중에 채워진다. 그 사이 다른 메뉴를 누르면 즉시 넘어간다. 앞 요청은 뒤에서 취소되거나 무시된다.
감수한 부작용
proxy_buffering off는 프록시가 해 주던 보호를 하나 포기하는 선택이다. 응답을 천천히 읽는 클라이언트가 있으면 이제 Node 프로세스가 그 클라이언트에 직접 붙들린다. 트래픽이 몰릴 때 Nginx가 앞에서 흡수해 주던 완충도 줄어든다.
B는 사내망에서 수십 명이 쓰는 어드민이라 이 위험을 감수할 수 있다고 판단했다. 다만 이 판단은 규모에 종속된다. 같은 설정을 사용자용 웹 A에 그대로 옮기면 안 된다. 그래서 A로 확장할 때의 조건을 문서에 남겼다.
- 공개 서비스에서는 전역 off 대신 B안(
X-Accel-Buffering: no)으로 스트리밍 응답만 골라서 끈다. - 정적 자산(
_app/immutable/*)은 Node를 거치지 않고 Nginx가 직접 서빙하도록 분리한다. 버퍼링을 끈 경로에 정적 파일까지 태울 이유가 없다.
운영에서 보는 것
고친 뒤로 다음 세 가지를 확인한다. 셋 다 새 도구 없이 이미 있는 로그와 DevTools로 본다.
- Nginx access log에
$request_time과$upstream_response_time을 함께 남긴다. 두 값의 차이가 "Nginx가 클라이언트에 응답을 흘려보내는 데 쓴 시간"이다. 버퍼링을 끈 뒤 이 차이가 커지는 요청이 늘어나면 느린 클라이언트가 Node를 붙잡고 있다는 신호이고, 그때가 B안으로 전환할 시점이다. - 백엔드 요청 대기 시간. 워커 2개로 늘린 뒤에도 긴 집계 요청이 겹치면 다시 줄이 생긴다. 로그에서 "요청 도착 시각과 처리 시작 시각의 차이"를 보고, 이 값이 자주 1초를 넘으면 워커를 더 늘리거나 I안(비동기 작업)을 검토한다.
- Network 탭의 Stalled. 배포 후 HTTP/2가 실제로 협상됐는지 Protocol 컬럼(
h2)으로 확인하고, Stalled 구간이 사라졌는지 본다. Nginx 재빌드나 인증서 갱신 뒤 설정이 되돌아가는 일이 있어 배포 체크리스트에 넣었다.
남긴 것
이 문제에서 배운 것을 한 줄로 적으면 이렇다. 증상이 하나여도 원인은 여럿일 수 있고, 경로를 구간별로 끊어 각 구간을 확인할 방법을 먼저 정하는 것이 가장 빨랐다. 코드만 봤으면 SvelteKit을 의심하는 데 시간을 다 썼을 것이다. 프론트엔드 코드는 잘못이 없었고, 세 원인 중 둘은 인프라 설정, 하나는 백엔드 프로세스 모델이었다. 프론트엔드 개발자라도 요청이 지나는 경로 전체를 그릴 수 있어야 한다는 것을 몸으로 배웠다.