← Blog
트러블슈팅

SvelteKit 스트리밍이 배포환경에서만 안 되는 문제 + 페이지 이동 시 이전 API 요청 대기 현상

상황

사내 서비스 배포 환경에서 발생한 두 가지 얽힌 문제

  1. SvelteKit 배포 단계에서 adapter-node 미사용으로 인한 빌드/실행 이슈
  2. 응답이 오래 걸리는 API 요청 중 다른 페이지로 이동 시, 기존 요청이 완료될 때까지 페이지 이동 및 추가 API 요청이 불가능한 현상
  3. SvelteKit 스트리밍 기능이 로컬에서는 동작하지만 배포 환경에서만 동작하지 않는 문제

증상

- 로컬: 스트리밍 정상 동작, 페이지 이동도 즉시 처리됨
- 배포 환경: 첫 API 응답이 도착할 때까지 다음 페이지로 넘어가지 않음
- 백엔드 로그: 두 요청이 거의 동시에 보내져도, 첫 요청 처리 완료 후에야 두 번째 요청이 처리됨

재현 방법:

  1. 응답이 오래 걸리는 페이지(A)로 진입
  2. 로딩 중에 다른 페이지(B)로 이동 시도
  3. A의 응답이 완료되어야 B로 이동됨

환경

  • 프레임워크: SvelteKit
  • 어댑터: adapter-node
  • 리버스 프록시: Nginx
  • 통신 경로: 브라우저 ↔ Nginx ↔ SvelteKit 서버 ↔ Nginx ↔ 백엔드 서버

가설 (탐색 순서대로)

  • 가설 1: 브라우저 ↔ Nginx 간 HTTP/1.1이라 병렬 요청 처리 불가? → 부분 정답
  • 가설 2: SvelteKit 서버 ↔ 백엔드 사이도 HTTP/1.1 문제? → 오답 (이미 HTTP/2였음)
  • 가설 3: SvelteKit이 백엔드로 요청을 직렬로 보내는 건가? → 오답 (handleFetch로 검증)
  • 가설 4: Nginx의 응답 버퍼링이 스트리밍을 막고 있다? → 정답
  • 가설 5: 백엔드 서버 프로세스 개수 문제? → 정답 (병렬 요청 처리 못 함)

시도한 것들

시도결과비고
브라우저 ↔ Nginx HTTP/2로 변경부분 해결페이지 이동 자체는 가능해짐, 단 API 응답은 여전히 직렬
SvelteKit handleFetch 훅에서 임의 timeout 추가원인 아님 확인두 번째 요청이 timeout 기다리지 않고 즉시 발송됨 → SvelteKit은 병렬로 잘 보내고 있었음
백엔드 ↔ Nginx HTTP/2 확인이미 적용 중여기 문제 아님
Nginx proxy_buffering off; 추가✅ 스트리밍 해결단, 버퍼링의 장점을 포기하는 트레이드오프
백엔드 프로세스 다중화 검토✅ 병렬 처리 해결프로세스가 1개라서 직렬 처리되고 있었음

🎯 원인

두 개의 원인이 얽혀 있었음:

원인 1: Nginx의 응답 버퍼링

  • Nginx는 기본적으로 백엔드 응답을 버퍼에 모았다가 한꺼번에 브라우저로 전달
  • SvelteKit의 page.server.ts에서 page로 넘기는 인자에는 두 종류가 있음:
    • Promise 객체 (스트리밍 대상)
    • 일반 객체 (즉시 전달되어야 함)
  • 문제: Nginx 버퍼링 때문에 일반 객체조차도 Promise가 resolve될 때까지 브라우저에 도달하지 않음
  • 결과: API 응답이 오기 전엔 페이지 이동이 안 되는 것처럼 보임

원인 2: 백엔드 서버 프로세스 1개

  • SvelteKit은 백엔드로 요청을 병렬로 잘 보내고 있었음 (handleFetch 검증으로 확인)
  • 하지만 백엔드 서버 프로세스가 단 1개라서 들어온 요청을 직렬로 처리
  • 결과: 첫 요청이 끝나야 두 번째 요청이 처리됨

✅ 해결

1. Nginx 설정에 proxy_buffering off; 추가

location / {
    proxy_pass http://sveltekit_backend;
    proxy_buffering off;   # 스트리밍을 위해 버퍼링 비활성화
    # ... 기타 설정
}

2. 브라우저 ↔ Nginx를 HTTP/2로

  • 페이지 이동 시 이전 요청이 pending되는 현상 해결

3. SvelteKit 스트리밍 전략 정립

  • await로 받을 것 (HTML에 포함되어 즉시 내려옴): 페이지의 주요 정보 (이름, 주소 등)
  • 스트리밍으로 받을 것 (Promise로 넘김): 부가 정보 (리뷰, 추천 등)
// +page.server.ts 예시
export const load = async ({ fetch }) => {
  return {
    // 핵심 정보: await로 받아서 HTML과 함께 즉시 전달
    main: await fetch('/api/restaurant').then(r => r.json()),
    // 부가 정보: Promise 그대로 넘겨 스트리밍
    reviews: fetch('/api/reviews').then(r => r.json())
  };
};

4. 백엔드 프로세스 다중화 (또는 worker 추가)

💭 배운 점 / 다음에는

디버깅에서 얻은 교훈

  1. 증상이 같아 보여도 원인은 여러 개일 수 있다 — "페이지 이동 안 됨"이 사실 두 가지 다른 원인이었음
  2. 통신 경로를 단계별로 끊어서 검증 — 브라우저 ↔ Nginx ↔ SvelteKit ↔ Nginx ↔ 백엔드, 각 구간을 의심하며 좁혀나간 게 결정적
  3. handleFetch 훅 같은 디버깅 도구를 알아두면 강력함 — "SvelteKit이 정말 병렬로 보내나?"를 직접 검증할 수 있었음
  4. 인프라(Nginx) 동작 원리도 프론트엔드 엔지니어가 알아야 함 — 코드 문제가 아니라 인프라 설정 문제일 수 있음

Nginx 버퍼링 트레이드오프

  • proxy_buffering off의 단점:
    • Nginx의 강점인 버퍼링/캐싱 기능을 잃음
    • 백엔드 서버가 느린 클라이언트에 직접 노출됨 (slow client 공격에 취약해질 수 있음)
    • 트래픽 폭주 시 백엔드 부하 흡수 능력 감소
  • 모든 location이 아니라 스트리밍 필요한 경로에만 off 적용하는 게 더 안전할 수 있음

스트리밍 설계 원칙

페이지 렌더링에 필수적인 데이터는 await, 부가적 데이터는 Promise 그대로 넘겨 스트리밍

🔗 참고

  • SvelteKit Streaming with promises 공식 문서
  • Nginx proxy_buffering 공식 문서

📌 후속 (2026-07-31) — 공개 서비스로 가면 이 해결책은 부족하다

위 해결책은 사내 서비스라서 통했다. 세 가지 전제가 깔려 있었다.

  • 동시 사용자가 수십 명 수준 → 커넥션 하나 오래 잡혀도 티가 안 남
  • 사내망이라 클라이언트가 빠르고 신뢰 가능 → slow client 공격 시나리오가 없음
  • 캐시 미스가 나도 감당 가능한 트래픽 → 버퍼링·캐싱을 통째로 꺼도 체감 없음

공개 서비스에서는 셋 다 깨진다. 당시 글 말미에 "스트리밍 필요한 경로에만 off 적용하는 게 안전할 수 있다"고 적어뒀는데, SSR에서는 경로로 나누는 게 안 된다는 걸 이번에 알았다. 모든 페이지가 다 문서 응답이라 location /을 쪼갤 기준이 없다. 답은 경로 단위가 아니라 응답 단위였다.

여전히 남아 있던 문제

#문제왜 그때는 안 보였나심각도
1정적 자산까지 버퍼링·캐싱 상실사용자가 적어서 Node가 번들 서빙해도 버팀높음
2proxy_http_version 미지정 → upstream이 HTTP/1.0요청량이 적어 TCP 재연결 비용이 안 드러남높음
3스트리밍 수명 상한 없음사내망이라 API가 멈추는 일이 드묾높음
4proxy_intercept_errors on에러 페이지를 볼 일이 별로 없었음중간
5X-Forwarded-Proto 미전달도메인이 하나뿐이라 리다이렉트가 안 꼬임중간
6BODY_SIZE_LIMIT 불일치업로드 기능을 안 썼음중간
7HTTP/2가 고쳤다고 생각한 것이 사실은 다른 원인증상이 사라져서 검증을 안 함개념 정정

문제 1 — location /에 건 proxy_buffering off가 정적 자산까지 덮는다

무슨 일이 일어나는가 /_app/immutable/*는 해시가 붙은 불변 번들이다. 원래는 nginx가 버퍼링해서 메모리에 들고 있다가 느린 클라이언트에게 천천히 내려주고, proxy_cache로 재사용해야 한다. 그런데 location / 하나에 proxy_buffering off를 걸면 이 경로도 전부 적용된다. 번들 하나 내려받을 때마다 Node 프로세스가 파일을 읽어 클라이언트 속도에 맞춰 흘려보내고, 캐시도 안 된다.

해결 — 빌드 산출물은 nginx가 디스크에서 직접 서빙

location ^~ /_app/ {
    root /home/web/example.com/build/client;
    try_files $uri @sveltekit;

    expires 1y;
    add_header Cache-Control "public, immutable";
    access_log off;
    gzip_static on;
}

location @sveltekit {
    include snippets/proxy_sveltekit.conf;
    proxy_buffering on;
}

왜 해결되는가 요청이 애초에 Node에 도달하지 않는다. 버퍼링을 켤지 말지를 고민할 필요 자체가 사라진다. sendfile로 커널이 직접 소켓에 밀어넣으므로 유저스페이스 복사도 없고, 해시 파일명이라 immutable 캐시 헤더를 안전하게 붙일 수 있어 재방문 시 요청 자체가 발생하지 않는다.


문제 2 — nginx는 upstream으로 HTTP/1.0으로 말한다

무슨 일이 일어나는가 http2 on브라우저 ↔ nginx 구간 얘기다. nginx ↔ Node 구간은 proxy_http_version을 지정하지 않으면 기본이 HTTP/1.0이고, 여기엔 keepalive 개념이 없다. Node는 응답을 끝낼 때마다 연결을 닫는다. 요청마다 TCP 핸드셰이크가 새로 붙고, upstream 블록에 keepalive를 써도 무시된다.

해결

proxy_http_version 1.1;
proxy_set_header Connection "";

왜 해결되는가 HTTP/1.1은 기본이 persistent connection이다. 그리고 Connection 헤더를 빈 값으로 덮어쓰는 게 핵심인데, 이걸 안 하면 nginx가 클라이언트에서 받은 Connection: close를 그대로 upstream에 전달해 keepalive가 무력화된다. 두 줄이 세트로 있어야 upstream { keepalive 64; }가 실제로 동작한다.


문제 3 — 스트리밍에 수명 상한이 없다 (이번 건의 핵심)

무슨 일이 일어나는가 proxy_buffering off에서 백엔드 연결이 늦게 끝나는 문제의 크기는 스트리밍이 얼마나 오래 열려 있느냐로 정해진다. 그런데 기존 코드에는 상한이 없다. 리뷰 API 하나가 응답하지 않으면 커넥션이 proxy_read_timeout까지 그대로 매달린다. 당시 설정은 proxy_read_timeout 600이었으니 10분이다.

해결 — 앱에서 초 단위로 묶는다

// $lib/server/stream.ts
export function stream<T>(p: Promise<T>, ms = 3000): Promise<T | null> {
  return Promise.race([
    p,
    new Promise<null>((r) => setTimeout(() => r(null), ms))
  ]).catch(() => null);
}
// +page.server.ts
import { stream } from '$lib/server/stream';

export const load = async ({ fetch }) => {
  return {
    main: await fetch('/api/restaurant').then((r) => r.json()),
    reviews: stream(fetch('/api/reviews').then((r) => r.json()))
  };
};

그리고 nginx 타임아웃을 600초에서 60초로 조인다.

proxy_read_timeout 60s;
proxy_send_timeout 60s;
send_timeout 60s;

왜 해결되는가 Promise.race가 3초 안에 안 오는 Promise를 null로 resolve시키면 SvelteKit의 스트리밍 응답이 즉시 종료되고, 그 시점에 Node가 소켓을 닫는다. 커넥션 점유 시간의 상한이 인프라 타임아웃(60초)이 아니라 앱이 정한 값(3초) 이 된다. 동시 접속이 늘어도 미완료 스트림이 쌓이지 않는다.

.catch(() => null)도 중요하다. 스트리밍으로 넘긴 Promise가 reject되면 SvelteKit은 페이지 전체를 에러로 처리한다. 부가 정보 하나 때문에 페이지가 죽으면 안 되므로 반드시 흡수해야 한다.

클라이언트 폴백을 같이 둔다

<script lang="ts">
  export let data;
  let retried: Review[] | null = null;

  async function retry() {
    retried = await fetch('/api/reviews').then((r) => r.json());
  }
</script>

{#await data.reviews}
  <ReviewSkeleton />
{:then reviews}
  {#if reviews}
    <ReviewList {reviews} />
  {:else if retried}
    <ReviewList reviews={retried} />
  {:else}
    <button on:click={retry}>리뷰 불러오기</button>
  {/if}
{/await}

서버가 3초를 넘기면 커넥션은 놓아주고 브라우저가 다시 가져온다. 서버 자원을 붙잡는 대신 사용자에게 선택권을 준다.


문제 4 — proxy_intercept_errors on이 앱 에러 페이지를 사장시킨다

무슨 일이 일어나는가 이 옵션은 upstream이 보낸 4xx·5xx를 가로챈다. SvelteKit이 +error.svelte를 렌더해서 500으로 내려보내면, nginx가 그걸 버리고 /50x.html 정적 파일로 갈아끼운다. 정성 들여 만든 에러 페이지가 배포 환경에서만 안 보인다.

해결

proxy_intercept_errors off;

왜 해결되는가 끄면 Node의 응답이 그대로 브라우저에 전달된다. 잃는 건 없다. Node 프로세스가 죽어서 나는 502·504는 nginx가 직접 만드는 응답이라 intercept_errors와 무관하게 error_page 지시자가 계속 동작하기 때문이다. 즉 "앱이 살아 있을 때는 앱 에러 페이지, 앱이 죽었을 때는 nginx 정적 페이지"라는 원하는 동작이 자동으로 나온다.


문제 5 — X-Forwarded-Proto가 없으면 앱이 https를 모른다

무슨 일이 일어나는가 Node는 평문 HTTP로 요청을 받으므로 event.urlhttp://로 잡힌다. redirect(), canonical URL, OG 태그, 쿠키의 secure 판정이 전부 어긋난다.

해결 — nginx와 앱 양쪽을 맞춘다

proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host  $host;
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
# adapter-node 환경변수
PROTOCOL_HEADER=x-forwarded-proto
HOST_HEADER=x-forwarded-host
ADDRESS_HEADER=x-forwarded-for
XFF_DEPTH=1

왜 해결되는가 adapter-node는 보안상 이 헤더들을 기본적으로 신뢰하지 않는다. 임의의 클라이언트가 헤더를 위조해 앱을 속일 수 있기 때문이다. 환경변수로 "이 헤더는 내 프록시가 붙인 것이니 믿어라"라고 명시해야 비로소 event.urlgetClientAddress()가 실제 값을 반환한다. nginx 설정만 해서는 아무 일도 안 일어난다.


문제 6 — client_max_body_sizeBODY_SIZE_LIMIT의 불일치

nginx에 client_max_body_size 10M을 걸어도 adapter-node의 BODY_SIZE_LIMIT 기본값은 512KB다. nginx는 통과시키는데 앱이 413을 뱉는다. 로그를 nginx에서만 보면 원인을 못 찾는다.

BODY_SIZE_LIMIT=10M   # nginx 값과 일치시킬 것

문제 7 — HTTP/2가 고쳤다고 생각한 것

당시 "브라우저 ↔ Nginx를 HTTP/2로" 바꾸니 페이지 이동 pending이 사라졌고, 그래서 이걸 해결책 2번으로 적었다. 다시 보니 증상을 가린 쪽에 가깝다.

  • 진짜 직렬화 원인은 원인 2(백엔드 프로세스 1개)였다
  • HTTP/2는 브라우저 쪽 요청 큐잉만 풀어줬을 뿐, 백엔드의 직렬 처리는 그대로였다
  • 백엔드를 다중 프로세스로 만들면 HTTP/1.1에서도 해결된다

HTTP/2는 공개 서비스에서 어차피 기본이니 유지하되, 인과관계는 정정해둔다. 증상이 사라졌다고 원인을 찾은 게 아니다.


최종 설정

http 블록:

limit_conn_zone $binary_remote_addr zone=perip:10m;

upstream sveltekit {
    server 127.0.0.1:3000;
    keepalive 64;
}

snippets/proxy_sveltekit.conf:

proxy_pass http://sveltekit;

proxy_http_version 1.1;
proxy_set_header Connection "";

proxy_set_header Host              $host;
proxy_set_header X-Real-IP         $remote_addr;
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host  $host;

proxy_redirect off;
proxy_intercept_errors off;

server 블록:

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    client_max_body_size 10M;
    root /home/web/example.com/public;
    include mime.types;
    charset utf-8;

    limit_conn perip 20;

    location ^~ /_app/ {
        root /home/web/example.com/build/client;
        try_files $uri @sveltekit;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
        gzip_static on;
    }

    location @sveltekit {
        include snippets/proxy_sveltekit.conf;
        proxy_buffering on;
    }

    location / {
        include snippets/proxy_sveltekit.conf;

        proxy_buffering on;              # 기본은 켠다
        proxy_buffer_size 8k;
        proxy_buffers 8 8k;
        proxy_busy_buffers_size 16k;

        proxy_read_timeout 60s;
        proxy_send_timeout 60s;
        send_timeout 60s;
    }

    error_page 502 504 /502.html;
    location = /502.html {
        root /home/web/example.com/public;
        internal;
    }
}

proxy_buffering off가 어디에도 없다. 스트리밍은 아래 훅에서 응답 단위로 켠다.

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';

const STREAMING_ROUTES = new Set<string>([
  '/restaurant/[id]',
  '/search'
]);

export const handle: Handle = async ({ event, resolve }) => {
  const response = await resolve(event);

  if (event.route.id && STREAMING_ROUTES.has(event.route.id)) {
    response.headers.set('X-Accel-Buffering', 'no');
    response.headers.set('Cache-Control', 'no-cache, no-transform');
  }

  return response;
};

왜 이 방식이 해결책인가

X-Accel-Buffering: no는 nginx가 응답별로 해석하는 표준 opt-out 헤더다. upstream이 이 헤더를 붙여 보내면 nginx는 해당 응답만 버퍼링을 끄고, 헤더는 클라이언트로 전달하기 전에 제거한다. 덕분에 정적 자산·API·비스트리밍 페이지는 버퍼링과 캐싱을 그대로 누리고, 실제로 스트리밍하는 라우트만 즉시 flush된다. 인프라 전역 설정을 앱이 필요할 때만 국소적으로 뒤집는 구조다.

라우트 화이트리스트를 쓴 이유는 두 가지다. 스트리밍을 안 쓰는 페이지까지 버퍼링을 끌 이유가 없고, 이 헤더는 버퍼링뿐 아니라 해당 응답의 proxy_cache까지 무력화하기 때문이다. Next.js 공식 문서 예시를 /:path* 전역에 그대로 적용했다가 모든 리소스가 캐시를 못 타게 된 사례가 실제로 보고돼 있다.

event.route.id/restaurant/1이 아니라 /restaurant/[id] 형태다. 여기서 자주 틀린다.

검증

nginx -t && systemctl reload nginx

# 1) 정적 자산이 Node를 안 거치는지 (앱 헤더가 없어야 함)
curl -sI https://example.com/_app/immutable/entry/start.js | head -20

# 2) upstream keepalive — 요청 수만큼 연결이 늘어나면 안 됨
ss -tn state established '( dport = :3000 )' | wc -l

# 3) 스트리밍 — 셸이 먼저, 부가 데이터가 나중에 찍혀야 함
curl -N --trace-time -s -o /dev/null https://example.com/restaurant/1

3번에서 전부 마지막에 몰려 찍히면 STREAMING_ROUTES 등록을 먼저 확인하고, 그래도 안 되면 gzip 필터가 chunk를 다시 뭉치는 경우이므로 해당 location에서 gzip off로 예외 처리 후 재측정한다.

다시 정리한 원칙

  1. 인프라 스위치는 전역이 아니라 응답 단위로 뒤집는다proxy_buffering off(전역) 대신 X-Accel-Buffering: no(응답별)
  2. 스트리밍은 수명에 상한을 건다 — 인프라 타임아웃이 아니라 앱이 정한 초 단위로
  3. 정적 자산은 앱 서버를 거치지 않게 한다 — 프록시 튜닝 이전에 트래픽 자체를 줄인다
  4. 증상이 사라진 것과 원인을 찾은 것은 다르다 — HTTP/2 건이 그 사례였다
  5. 프록시 설정과 앱 설정은 짝으로 맞춘다client_max_body_sizeBODY_SIZE_LIMIT, X-Forwarded-*PROTOCOL_HEADER

🔗 참고 (추가)

  • Nginx X-Accel-Buffering — ngx_http_proxy_module 문서
  • Nginx proxy_busy_buffers_size — 버퍼링을 켠 채로 부분 flush가 일어나는 원리
  • SvelteKit adapter-node 환경변수 문서 (PROTOCOL_HEADER, BODY_SIZE_LIMIT)