← Cases
Case 06어드민 B2026.05severity high

로딩 중에 다른 메뉴를 눌러도 화면이 넘어가지 않는다

배포 환경에서만 생긴 대기 현상

01 · 문제
오래 걸리는 화면을 열어 둔 채 다른 메뉴를 누르면, 앞 화면의 로딩이 끝날 때까지 아무것도 바뀌지 않는다
02 · 분석
HTTP/1.1 커넥션 상한리버스 프록시 응답 버퍼링단일 워커의 직렬 처리와 Convoy Effect
03 · 04 · 해결 방안과 결론
설정 두 줄(proxy_buffering off, HTTP/2)과 데이터 정책 하나로 해결하고, 백엔드 워커 증설은 제안으로 넘겼다
결과
메뉴 이동이 앞 요청과 무관하게 즉시 반응핵심 정보는 먼저·무거운 표는 나중에 도착하는 스트리밍이 배포 환경에서도 동작
SvelteKitNode.jsNginx

사내 프로젝트라 이름은 A(사용자용 웹), B(어드민)로 표기한다. 이 글은 B에서 있었던 일이다.

문제

어드민 B를 쓰는 운영팀에서 "사이트가 가끔 멈춘다"는 얘기가 들어왔다. 재현해 보니 멈추는 게 아니라 기다리는 것이었다.

  1. 집계가 오래 걸리는 화면(통계 페이지, 응답 5~8초)에 들어간다.
  2. 기다리기 싫어서 다른 메뉴를 누른다.
  3. 아무 일도 일어나지 않는다. 화면도, 로딩 표시도 그대로다.
  4. 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;커넥션 하나에서 요청을 멀티플렉싱. 상한 문제가 사라진다. 인증서는 이미 있어 추가 비용 0Nginx ↔ 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로 본다.

  1. Nginx access log에 $request_time과 $upstream_response_time을 함께 남긴다. 두 값의 차이가 "Nginx가 클라이언트에 응답을 흘려보내는 데 쓴 시간"이다. 버퍼링을 끈 뒤 이 차이가 커지는 요청이 늘어나면 느린 클라이언트가 Node를 붙잡고 있다는 신호이고, 그때가 B안으로 전환할 시점이다.
  2. 백엔드 요청 대기 시간. 워커 2개로 늘린 뒤에도 긴 집계 요청이 겹치면 다시 줄이 생긴다. 로그에서 "요청 도착 시각과 처리 시작 시각의 차이"를 보고, 이 값이 자주 1초를 넘으면 워커를 더 늘리거나 I안(비동기 작업)을 검토한다.
  3. Network 탭의 Stalled. 배포 후 HTTP/2가 실제로 협상됐는지 Protocol 컬럼(h2)으로 확인하고, Stalled 구간이 사라졌는지 본다. Nginx 재빌드나 인증서 갱신 뒤 설정이 되돌아가는 일이 있어 배포 체크리스트에 넣었다.

남긴 것

이 문제에서 배운 것을 한 줄로 적으면 이렇다. 증상이 하나여도 원인은 여럿일 수 있고, 경로를 구간별로 끊어 각 구간을 확인할 방법을 먼저 정하는 것이 가장 빨랐다. 코드만 봤으면 SvelteKit을 의심하는 데 시간을 다 썼을 것이다. 프론트엔드 코드는 잘못이 없었고, 세 원인 중 둘은 인프라 설정, 하나는 백엔드 프로세스 모델이었다. 프론트엔드 개발자라도 요청이 지나는 경로 전체를 그릴 수 있어야 한다는 것을 몸으로 배웠다.

사례 전체와 이력 보기