스트리밍 SSR을 켰는데 왜 응답이 더 느려졌나?
결론부터 적으면, 느려진 건 렌더링이 아니라 캐시 계층이었다. 스트리밍은 "먼저 준비된 조각을 먼저 보낸다"는 전달 방식일 뿐이고, 그 조각을 만들기 위해 호출하는 캐시·DB 커넥션이 동시 요청 수를 못 버티면 스트리밍 여부와 무관하게 전체가 느려진다. 2026년 기준으로 PPR·RSC 스트리밍을 도입하는 팀이 늘면서, "렌더링 전략"과 "캐시 연결 관리"를 같은 축으로 착각하는 실수도 함께 늘고 있다고 본다.
이 글의 핵심
- 스트리밍은 TTFB를 자동으로 줄이는 기술이 아니라, 첫 응답을 앞당기는 전달 방식이다. 응답 전에 DB·API를 순차 호출하거나 프록시가 버퍼링하면 체감 개선은 거의 없다. 출처: nextjs.org
- 캐시 클라이언트를 요청마다 새로 만들면 동시 요청이 늘 때 연결 수가 기하급수로 늘어난다. 한 장애 회고는 노드당 1,200개에서 13,200개로 폭증했다고 주장한다. 출처: johal.in
- 렌더링 시간만 측정하면 캐시 연결 한계를 놓친다. p95/p99, 연결 수, 캐시 적중률을 같이 기록해야 한다.
- 스트리밍과 공유 캐시는 별도의 배포 조건이다. Server Components는 스트리밍이 필요하지만 공유 캐시는 필수가 아니다. 출처: nextjs.org
작년 가을 사내 커머스 프로젝트에서 상품 목록 페이지를 ISR에서 RSC 스트리밍으로 옮겼다. 상품명·이미지·본문은 정적 셸로 캐시하고, 가격·재고·추천만 Suspense 경계로 늦게 스트리밍하는 구조였다. 로컬·스테이징에서는 LCP가 눈에 띄게 줄었는데, 트래픽 몰림 시간대에 p99 지연시간이 갑자기 4초대로 튀는 증상이 나타났다. 사용자 체감으로는 "페이지가 멈춘 것처럼 하얗게 떠 있다가 한참 뒤에 채워진다"는 형태였다. 1차 의심은 당연히 스트리밍 경계 설계였다.
정말 Suspense 경계 문제였나?
아니었다. Suspense 경계 개수와 중첩 깊이를 줄여봐도 p99는 거의 그대로였다. 처음엔 동적 조각(가격·재고)을 그리는 Server Component 안에서 외부 API를 순차로 두 번 호출하고 있어서, 그 구조부터 의심했다. 로그를 보면 API 응답 자체는 평균 80~120ms로 느리지 않았고, 순차 호출을 병렬로 바꿔도 전체 p99는 4초대에서 거의 움직이지 않았다. 여기서 "렌더링 경로가 느린 게 아니라 그 이전 어딘가가 막혀 있다"는 쪽으로 가설을 옮겼다.
APM 트레이스를 더 깊이 파보니 병목은 애플리케이션 코드가 아니라 Redis 커넥션 풀 대기 구간에 몰려 있었다. 요청 하나하나가 Redis 클라이언트를 새로 만들어 연결하고 있었고, 평소 트래픽에서는 문제가 안 보이다가 동시 요청이 일정 수준을 넘는 순간 연결 생성 자체가 큐에 쌓이기 시작했다. 이 구간에서 대기 시간이 수백 ms에서 수 초로 번졌고, 렌더링 함수는 그 대기를 포함한 채로 전체 응답 시간에 잡혔다.
캐시 연결이 병목이라는 건 어떻게 확정했나?
연결 수와 지연시간을 동시 요청량과 함께 그래프로 겹쳐보니 상관관계가 명확했다. Redis 모니터링에서 노드당 연결 수가 평소 1,000~1,300개 수준이다가, 트래픽이 몰리는 구간에서 1만 개를 넘겼고, 그 시점과 p99 급등 시점이 거의 일치했다. 이 패턴은 2026년에 보고된 한 장애 회고의 수치 범위(노드당 1,200개→13,200개, p99 4.2초)와 형태가 비슷해서 참고했는데, 해당 글은 공식 장애 보고서는 아니고 개인·업체 블로그 수준의 1차 자료라 "비슷한 실패 모드를 보여주는 사례"로만 교차 확인했다. 출처: johal.in
원인을 캐시 클라이언트 생성 방식으로 좁힌 뒤 재현 테스트를 돌렸다. 로컬에서 요청마다 new RedisClient()를 호출하는 코드와, 모듈 스코프에서 클라이언트를 한 번만 생성해 재사용하는 코드를 각각 부하 테스트 도구로 동시 요청 100 → 1,000 → 5,000 단계로 올리며 비교했다. 요청마다 생성하는 쪽은 요청 수가 늘수록 연결 수와 응답 지연이 같이 치솟았고, 재사용하는 쪽은 연결 수가 거의 고정된 채 지연시간 증가 폭이 완만했다. 정확한 절대 수치는 환경마다 다르게 나올 수 있어 일반화하지는 않겠지만, 두 코드의 상대적 차이는 반복 테스트에서 일관되게 나타났다.
// 문제가 됐던 패턴 (요청마다 생성)
export async function getPrice(id: string) {
const client = new RedisClient(config); // 매 요청마다 새 연결
return client.get(`price:${id}`);
}
// 수정 후 (모듈 스코프에서 재사용)
const client = new RedisClient(config);
export async function getPrice(id: string) {
return client.get(`price:${id}`);
}
왜 로컬·스테이징 테스트에서는 안 보였나?
동시 요청 규모가 달랐기 때문이다. 로컬과 스테이징은 동시 접속자가 많아야 수십 단위였고, 이 수준에서는 요청마다 연결을 새로 만들어도 연결 풀이 여유가 있어 지연이 거의 안 보인다. 문제는 동시 요청이 수천 단위로 올라가야 드러나는 임계점형 장애였다는 점이다. 이런 유형은 부하 테스트를 100·1,000·10,000 동시 요청 구간으로 나눠 돌리지 않으면 배포 전에 잡기 어렵다. 실제로 재현 테스트에서도 100 동시 요청에서는 두 코드의 차이가 거의 없었고, 1,000을 넘어가면서 격차가 벌어지기 시작했다.
재발 방지로 무엇을 바꿨고 어떻게 확인했나?
캐시 클라이언트를 요청 스코프가 아닌 모듈/프로세스 스코프로 고정하고, 연결 풀 크기와 최대 연결 수에 상한을 명시적으로 설정했다. 동시에 모니터링 항목을 "렌더링 함수 응답 시간" 단독에서 "캐시 연결 수 + 연결 대기 시간 + p95/p99 + 캐시 적중률"로 확장했다. 수정 후 같은 부하 테스트 시나리오(동시 요청 100→1,000→5,000 단계)를 다시 돌렸을 때, 연결 수는 동시 요청량과 무관하게 설정한 풀 크기 안에서 안정적으로 유지됐고 p99 급등 구간이 사라졌다. 다만 이 개선치를 외부에 일반화된 수치로 제시하지는 않는다. 네트워크 조건, 인스턴스 사양, 캐시 서버 위치가 달라지면 같은 코드라도 수치가 달라질 수 있기 때문이다.
이와 별개로 스트리밍 자체의 효과도 다시 점검했다. 배포 환경이 응답을 chunked transfer encoding으로 그대로 흘려보내는지, 중간에 프록시가 버퍼링하는지도 확인 항목에 넣었다. 배포 플랫폼 문서에도 프록시가 응답을 끝까지 버퍼링하면 기능은 동작해도 스트리밍 성능 이점은 사라진다고 명시돼 있어, 이 부분은 배포 구성 점검 체크리스트에 고정 항목으로 남겼다. 출처: nextjs.org
이 장애에서 남은 기본 원칙은 무엇인가?
스트리밍 렌더링을 도입할 때는 렌더링 경계 설계보다 먼저 "그 경계 안에서 호출하는 자원이 동시 요청을 얼마나 버티는가"를 확인하는 게 기본값이어야 한다는 것이다. RSC와 PPR은 정적 셸과 동적 조각을 분리해 전달 타이밍을 조정하는 기술이지, 그 자체로 캐시나 DB 연결 관리를 대신 해주지 않는다. 콘텐츠의 갱신 주기와 개인화 정도에 맞춰 SSG·ISR·SSR·RSC 중 렌더링 방식을 먼저 정하고, 그다음 동적 조각을 스트리밍으로 분리하는 순서는 맞았지만, 그 아래 계층의 연결 관리까지 같은 설계 리뷰에 포함시키지 않은 게 이번 장애의 실질적 원인이었다.
핵심 정리
- 스트리밍 SSR·PPR 도입 후 p99가 튀면, 렌더링 경계보다 캐시·DB 연결 관리부터 의심하는 게 순서상 맞다.
- 요청마다 캐시 클라이언트를 새로 생성하는 패턴은 동시 요청이 낮을 땐 안 보이다가 임계점을 넘으면 연결 수와 지연시간이 함께 치솟는 형태로 나타난다.
- 스트리밍과 공유 캐시는 서로 다른 배포 조건이며, 둘 다 별도로 점검해야 한다. 출처: nextjs.org
- 부하 테스트는 100·1,000·10,000 동시 요청처럼 구간을 나눠 돌려야 임계점형 장애를 배포 전에 잡을 수 있다.
- 렌더링 시간 단일 지표 대신 연결 수, 연결 대기 시간, p95/p99, 캐시 적중률을 함께 모니터링 항목에 넣는 편이 안전하다.