RSC는 무조건 빠른 기술이 아니라 읽기 비중·갱신 주기·개인화 정도에 맞춰 CSR/SSR/SSG/ISR과 섞어 쓰는 선택지다. 우리 팀은 상품 상세 페이지를 App Router 기반 RSC로 전환했다가 LCP가 개선은커녕 오히려 늘어나는 걸 겪었다. 그 원인은 서버 컴포넌트 자체가 아니라 클라이언트에 남겨야 할 조각을 서버로 잘못 옮긴 경계 설정 문제였다.
RSC로 바꾼 뒤 왜 화면이 더 늦게 보였나?
결론부터 말하면 원인은 RSC라는 기술 선택이 아니라 컴포넌트 경계를 잘못 그은 것이었다. 상품 상세 화면에서 사용자 로그인 상태와 최근 본 상품 목록을 서버 컴포넌트 안에서 함께 조합하다 보니, 매 요청마다 개인화 데이터 조회가 LCP 대상 이미지 렌더링을 막는 구조가 됐다.
- RSC 전환 자체보다 어떤 컴포넌트를 서버로 옮겼는지가 체감 성능을 좌우한다
- 개인화 데이터를 서버 컴포넌트에 섞으면 CDN 캐시 이점이 줄어든다(React 공식 문서 기준 RSC는 사전 렌더링에 강한 구조이지 개인화 처리에 최적화된 구조는 아니다)
- 전환 후 LCP가 나빠지면 십중팔구 서버로 옮긴 컴포넌트가 사실은 클라이언트에 남아야 했을 가능성이 크다
- Vercel Commerce 사례처럼 초기 JS 67KB(Pages Router 210KB 대비 약 68% 감소, Vercel 템플릿 참고)는 경계를 제대로 그었을 때 나오는 수치이지 RSC를 쓴다고 자동으로 따라오는 수치가 아니다
- 재발 방지의 핵심은 린트 규칙과 라우트 단위 leaf-up 마이그레이션이었다
서버 컴포넌트로 옮긴 게 진짜 문제였나?
절반은 맞고 절반은 틀렸다. 문제는 서버 컴포넌트라는 선택 자체가 아니라, 그 안에 개인화 로직과 순수 표시 로직을 한 파일에 섞어 넣은 설계였다. 처음 가설은 "RSC가 서버 왕복을 늘려서 느려졌다"였는데, 네트워크 탭을 보니 스트리밍 자체는 문제없이 진행되고 있었다.
로그를 뜯어보니 상품 이미지(LCP 후보)를 감싸는 서버 컴포넌트 안에서 로그인 세션 조회, 최근 본 상품 조회, 재고 API 호출이 순차적으로 실행되고 있었다. 세 호출 중 재고 API의 P95 응답이 평균 420ms였고, 이 지연이 그대로 스트리밍 청크를 늦춰 LCP 이미지가 그려지는 시점까지 밀려났다. 즉 서버 컴포넌트가 "느린 걸" 만든 게 아니라, 원래 클라이언트에서 지연 로딩해도 됐을 개인화 조각을 LCP 경로에 끼워 넣은 설계 실수였다.
캐싱 전략을 먼저 안 짜서 생긴 문제 아니었나?
맞다, 이게 근본 원인 중 하나였다. 개인화가 강할수록 RSC의 서버 렌더 결과는 사용자별로 달라지고, 이는 곧 CDN 정적 캐시 이점을 줄이는 방향으로 작동한다는 게 여러 자료에서 반복적으로 지적되는 부분이다. 우리는 전환 전에 "이 페이지는 어디까지 캐시 가능한 조각이고, 어디부터 개인화 조각인가"를 나누지 않은 채 화면 전체를 서버 컴포넌트로 밀어넣었다.
결과적으로 캐시 가능한 상품 설명·이미지·리뷰 요약과, 캐시 불가능한 로그인 상태·최근 본 상품이 같은 렌더 트리 안에서 뒤섞였다. 이렇게 되면 캐시 히트율 자체가 의미 없어지고, 매 요청이 개인화 조회를 포함한 풀 렌더링으로 처리된다. 정적/읽기 중심/상호작용 중심을 먼저 분류하라는 실무 가이드가 강조되는 이유를 여기서 체감했다.
무엇으로 원인을 확정했나?
재현은 어렵지 않았다. 로컬에서 재고 API 호출부에 인위적으로 400ms 지연을 주입하고 동일 라우트를 다시 요청했더니, Lighthouse 기준 LCP가 기존 1.6초에서 2.4초 근처로 늘어나는 걸 재현할 수 있었다. 반대로 재고 조회를 서버 컴포넌트 트리 밖으로 빼고 클라이언트에서 별도 fetch로 늦게 채우도록 바꾸자, 동일 지연 조건에서도 LCP 이미지는 재고 데이터를 기다리지 않고 먼저 그려졌다.
이 실험으로 "RSC가 느리다"가 아니라 "LCP 경로에 걸린 데이터 의존성이 문제"라는 게 확정됐다. 서버 컴포넌트 트리 안에서 어떤 fetch가 렌더링을 블로킹하는지 React DevTools의 서버 컴포넌트 트레이스와 네트워크 워터폴을 같이 봐야 이런 패턴이 잡힌다.
무엇을 바꿔서 재발을 막았나?
두 가지를 바꿨다. 첫째, leaf-up 방식으로 라우트를 다시 쪼갰다. 상품 이미지·설명·가격처럼 순수 표시용이고 캐시 가능한 조각만 서버 컴포넌트로 남기고, 로그인 상태·최근 본 상품·재고처럼 요청마다 달라지는 조각은 별도 Suspense 경계 안에 넣어 클라이언트에서 늦게 채우도록 분리했다.
둘째, use client 파일이 아닌 곳에서 훅이나 브라우저 API를 쓰지 못하게 막는 린트 규칙을 팀 컨벤션에 추가했다. 이 규칙 하나로 이후 두 번의 유사한 회귀를 코드 리뷰 단계에서 미리 걸렀다. 변경 후 스테이징 환경에서 측정한 LCP는 평균 1.5초대로 돌아왔고, 재고 API 지연이 발생해도 LCP 지표에는 영향을 주지 않는 구조가 됐다. 다만 이 수치는 우리 트래픽·인프라 조건에서 나온 값이라 다른 환경에 그대로 적용된다고 보장할 수는 없다.
같은 함정을 피하려면 뭘 기본값으로 삼아야 하나?
전환 전에 화면을 "캐시 가능한 정적 조각", "읽기 중심 서버 조각", "개인화·상호작용 조각" 세 갈래로 먼저 나누는 걸 기본값으로 삼는 게 가장 안전했다. 콘텐츠 갱신 주기가 낮고 SEO가 중요하며 개인화가 낮다면 SSG나 ISR 쪽이 여전히 유리하고, 개인화가 중간 수준이면서 읽기가 많다면 SSR과 RSC를 섞는 편이 맞았다. 상호작용이 화면의 핵심이라면 그 부분만 클라이언트 컴포넌트로 남겨두는 게 결국 더 단순한 설계였다.
업계 설문에서 응답자의 약 45%가 서버 컴포넌트를 써봤지만 그중 긍정적 경험은 3분의 1 수준이라는 결과가 나온 것도 이 학습 곡선을 반영한다고 본다. 2026년 기준으로도 "전체를 한 번에 갈아엎지 말고 라우트 하나를 완전히 전환해 측정한 뒤 다음으로 넘어가라"는 원칙은 여전히 유효했다.
핵심 정리
- RSC 전환 후 LCP가 나빠졌다면 기술 선택보다 컴포넌트 경계 설정을 먼저 의심하는 게 순서였다
- 개인화 조각과 캐시 가능한 조각을 한 서버 컴포넌트 트리에 섞으면 캐시 히트율과 LCP를 동시에 잃을 수 있다
- leaf-up 마이그레이션과
use client경계 린트 규칙이 재발 방지에 실질적으로 작동했다 - 콘텐츠 갱신 주기·개인화 정도·SEO 요구를 먼저 나누고 나서 SSG/ISR, SSR+RSC, CSR 비중을 정하는 게 설계를 단순하게 만들었다
- 특정 프레임워크나 렌더링 전략을 절대적으로 우월하다고 단정할 근거는 없었고, 우리 환경의 트래픽·인프라 조건 안에서만 유효한 결론이었다