📚 목차
[Next.js] CSR에서 SSR로 전환해 LCP를 3.1초에서 0.5초로 줄인 과정
VitalTrip서비스에서 여행경보 페이지는 사용자가 특정 국가의 위험도를 빠르게 확인하기 위해 들어오는 페이지다.
즉, 이 페이지에서 가장 중요한 것은 “최신 정보가 얼마나 빨리 보이느냐”였다.
처음에는 일반적인 CSR(Client-Side Rendering) 방식으로 구현했다.
브라우저가 페이지에 진입한 뒤 JavaScript를 실행하고, 그 다음 외교부 여행경보 API를 호출해 화면을 채우는 구조였다.
이 방식은 구현 자체는 단순했지만, 사용자 경험 측면에서는 분명한 한계가 있었다.
실제로 페이지의 LCP(Largest Contentful Paint)는 3.1초였고, 사용자는 첫 진입 시 의미 있는 콘텐츠를 바로 보지 못했다.
여행경보처럼 즉시 확인이 필요한 정보에서 이 지연은 단순한 수치 이상의 문제였다.
이번 글에서는 이 페이지를 CSR에서 SSR로 전환한 배경,
그리고 Next.js App Router 기반에서 어떤 방식으로 구조를 바꿨는지,
마지막으로 왜 이 페이지에서는 SSR이 더 적합했는지를 정리해본다.
문제 상황: CSR 구조에서는 “최신 정보”가 늦게 보였다
초기 구조는 전형적인 CSR 흐름이었다.
- 사용자가 여행경보 페이지에 진입한다.
- 서버는 거의 비어 있는 HTML을 반환한다.
- 브라우저가 JavaScript 번들을 다운로드하고 실행한다.
- React가 마운트된 뒤 클라이언트에서 API를 호출한다.
- 응답이 도착하면 그제서야 화면에 국가별 경보 정보가 렌더링된다.
즉, 사용자가 실제 콘텐츠를 보기까지는 다음 과정이 모두 끝나야 했다.
- HTML 수신
- JavaScript 다운로드
- JavaScript 파싱 및 실행
- React 마운트
- 외부 API 호출
- 응답 수신 후 렌더링
이 구조의 핵심 문제는 초기 HTML에 콘텐츠가 없다는 점이었다.
브라우저는 페이지에 들어와도 곧바로 보여줄 수 있는 정보가 없었고, 사용자는 로딩 이후에야 실제 데이터를 확인할 수 있었다.
여행경보 페이지는 특성상 일반적인 콘텐츠 페이지와 다르다.
- 사용자는 “지금” 특정 국가가 안전한지 알고 싶다.
- 화면에 보여주는 데이터는 최신성이 중요하다.
- 첫 진입 시점에 핵심 정보가 보여야 한다.
하지만 CSR 구조에서는 “최신 정보”를 가져오긴 해도, 그 정보가 너무 늦게 보이는 문제가 있었다.
왜 이 페이지는 CSR보다 SSR이 더 잘 맞았을까?

모든 페이지가 SSR이어야 하는 것은 아니다.
상호작용이 많고, 진입 직후 꼭 데이터가 필요하지 않은 페이지라면 CSR도 충분히 좋은 선택이다.
하지만 여행경보 페이지는 성격이 달랐다.
1. 첫 화면 자체가 핵심 정보다
이 페이지에서 사용자가 원하는 것은 검색창 자체가 아니라,
“현재 국가별 여행경보 상태”라는 실제 데이터다.
즉, 페이지 진입 직후 보여줘야 하는 것이 명확했다.
이런 경우라면 클라이언트가 나중에 데이터를 가져오는 구조보다,
서버가 먼저 데이터를 포함한 HTML을 만들어 보내는 구조가 더 자연스럽다.
2. 외부 API 응답 이후에만 화면이 완성되는 구조를 줄이고 싶었다
외교부 여행경보 API는 내가 제어할 수 없는 외부 API다.
클라이언트에서 이 API를 호출하면 사용자는 네트워크 상태, 번들 로딩, 실행 타이밍의 영향을 모두 받는다.
반면 서버에서 먼저 데이터를 가져와 HTML을 만들어두면,
브라우저는 완성된 콘텐츠를 더 빠르게 인식할 수 있다.
3. 최신성과 초기 노출 속도를 동시에 챙겨야 했다
이 페이지는 캐시된 정적 정보보다 요청 시점의 최신 정보가 더 중요했다.
따라서 “빠르게 보여주는 것”과 “최신 데이터를 보여주는 것”을 함께 만족시키려면,
요청이 들어올 때마다 서버가 최신 데이터를 가져와 바로 HTML에 담아 반환하는 SSR이 더 적합했다.
해결 방향: 데이터 조회 책임을 클라이언트에서 서버로 옮기기
문제의 본질은 렌더링 기술 자체보다도,
누가 먼저 데이터를 가져오느냐에 있었다.
기존에는 브라우저가 데이터를 가져온 뒤 화면을 완성했다.
이를 서버가 먼저 데이터를 가져오고, 완성된 HTML을 내려주는 방식으로 바꾸면
사용자는 첫 응답에서부터 의미 있는 콘텐츠를 볼 수 있다.
Next.js App Router를 사용하고 있었기 때문에, 이 전환은 비교적 자연스럽게 적용할 수 있었다.
핵심은 페이지를 Server Component 기반의 async 페이지로 두고, 페이지 렌더링 전에 서버에서 외부 API를 먼저 호출하는 것이었다.
구현: App Router의 Server Component를 이용한 SSR

전환 후 여행경보 페이지는 서버에서 다음 순서로 동작한다.
- 페이지 요청이 들어온다.
- 서버가 외교부 API를 호출해 최신 여행경보 데이터를 가져온다.
- 국가 목록과 경보 단계별 통계를 계산한다.
- 그 데이터가 포함된 HTML을 생성한다.
- 브라우저는 비어 있는 껍데기가 아니라, 이미 콘텐츠가 담긴 HTML을 받는다.
구조를 단순화하면 아래와 같다.
export default async function AlertsPage() {
const countries = await fetchAllAlertCountries();
const levelCounts = countries.reduce(
(acc, c) => {
const level = c.alarm_lvl;
acc[level] = (acc[level] ?? 0) + 1;
return acc;
},
{} as Record<string, number>,
);
return (
<main>
<AlertSummaryCards levelCounts={levelCounts} />
<CountrySearchClient countries={countries} />
</main>
);
}이 구조에서 중요한 점은 두 가지다.
1. 페이지 컴포넌트 자체가 async다
AlertsPage가 서버에서 실행되기 때문에,
페이지 렌더링 전에 await fetchAllAlertCountries()를 수행할 수 있다.
즉, 데이터가 준비된 뒤에 HTML이 생성된다.
사용자는 빈 페이지를 먼저 받고 기다리는 것이 아니라, 처음부터 데이터가 포함된 화면을 받게 된다.
2. 서버와 클라이언트의 역할을 분리했다
여행경보 데이터 조회와 초기 통계 계산은 서버가 맡고, 사용자 검색 인터랙션은 클라이언트가 맡도록 역할을 나눴다.
- 서버
- 외부 API 호출
- 최신 데이터 확보
- 단계별 통계 계산
- 초기 HTML 생성
- 클라이언트
- 검색어 입력
- 국가 리스트 필터링
- 사용자 상호작용 처리
이렇게 분리하면 “초기 콘텐츠 노출”과 “클라이언트 상호작용”을 동시에 챙길 수 있다.
데이터 최신성을 위해 cache: 'no-store'를 선택한 이유
SSR이라고 해도 항상 같은 방식으로 동작하는 것은 아니다.
Next.js에서는 기본적으로 fetch 결과를 캐싱할 수 있고, 필요하다면 revalidate를 사용해 ISR처럼 운영할 수도 있다.
하지만 여행경보 페이지는 정보 특성상 오래된 데이터를 보여주면 안 되는 페이지였다.
그래서 외부 API 호출 시 아래처럼 cache: 'no-store'를 사용했다.
export async function fetchAllAlertCountries() {
const res = await fetch(url, {
cache: 'no-store',
});
if (!res.ok) return [];
const data = await res.json();
return data.response.body.items?.item ?? [];
}이 설정을 사용하면 요청이 들어올 때마다 서버가 최신 데이터를 직접 가져온다.
왜 이 선택이 중요했냐면,
여행경보는 단순한 콘텐츠가 아니라 상태가 바뀔 수 있는 운영 정보이기 때문이다.
예를 들어 ISR처럼 일정 시간 캐시를 두면,
사용자는 이미 갱신된 경보 상태 대신 몇 분 전 혹은 몇 시간 전 데이터를 볼 수도 있다.
이 페이지에서는 그런 가능성을 줄이는 것이 더 중요했다.
즉, 이 페이지에서 우선순위는 다음과 같았다.
-
- 최신 정보
-
- 첫 화면에서 즉시 보이는 콘텐츠
-
- 그 다음이 캐시 효율
Client Component는 “검색”만 담당하게 만들었다
초기 데이터는 서버에서 다 준비했지만, 검색 기능까지 전부 서버에서 처리할 필요는 없었다.
사용자는 국가명을 입력하며 리스트를 좁혀보는 행동을 많이 하기 때문에, 이 부분은 클라이언트에서 즉시 반응하는 편이 더 자연스럽다.
그래서 검색 UI는 별도의 Client Component로 분리했다.
'use client';
export function CountrySearchClient({ countries }: Props) {
const [query, setQuery] = useState('');
const filtered = query.trim()
? countries.filter(
(country) =>
country.country_nm.includes(query) ||
country.country_eng_nm.toLowerCase().includes(query.toLowerCase()) ||
country.country_iso_alp2.toLowerCase().includes(query.toLowerCase()),
)
: countries;
return (
<>
<SearchInput value={query} onChange={setQuery} />
<CountryList countries={filtered} />
</>
);
}이렇게 하면 초기 렌더링은 서버가 빠르게 책임지고, 이후 사용자 경험은 클라이언트가 부드럽게 이어받는다.
특히 이 구조가 좋았던 이유는,
검색을 위해 다시 API를 호출할 필요 없이 서버에서 받은 최신 데이터를 클라이언트에서 바로 활용할 수 있었다는 점이다.
결과: LCP 3.1초 → 0.5초
Lighthouse 성능 측정 결과
| 전 | 후 |
![]() | ![]() |
이 전환 이후 가장 큰 변화는 사용자가 페이지에 들어왔을 때
실제 콘텐츠를 인식하는 속도가 크게 빨라졌다는 점이었다.
- 변경 전: LCP 3.1초
- 변경 후: LCP 0.5초
단순히 숫자만 좋아진 것이 아니다.
이전에는 페이지 진입 후 한동안 “화면이 준비되는 시간”이 필요했다면,
전환 이후에는 사용자가 거의 즉시 여행경보 관련 핵심 콘텐츠를 확인할 수 있게 됐다.
여행경보 페이지의 목적이 “최신 안전 정보를 빠르게 전달하는 것”이라는 점을 생각하면,
이번 개선은 단순한 렌더링 방식 변경이 아니라 페이지의 목적에 맞게 구조를 바로잡은 작업에 가까웠다.
부수 효과: API 키 노출 위험도 줄일 수 있었다
이번 작업은 성능 개선뿐 아니라 보안 측면에서도 의미가 있었다.
외부 API를 클라이언트에서 직접 호출하면 브라우저 환경으로 내려가는 값과 호출 구조를 더 신경 써야 한다.
반면 서버에서 호출하면 API 키를 서버 환경변수로만 관리할 수 있고, 클라이언트는 가공된 결과만 받게 된다.
즉, SSR 전환은 단순히 “빠르게 보이게 하는 것” 외에도 외부 API 연동 책임을 서버에 두는 더 안전한 구조로 이어졌다.
SSR이 항상 정답은 아니었다
이번 경험에서 중요했던 건
“SSR이 더 좋아서 썼다”가 아니라,
이 페이지의 목적에 SSR이 더 잘 맞았기 때문에 선택했다는 점이다.
만약 이 페이지가
- SEO나 초기 콘텐츠 노출이 중요하지 않거나
- 첫 렌더에 꼭 데이터가 필요하지 않거나
- 인터랙션 중심 페이지였다면
굳이 SSR로 바꾸지 않았을 수도 있다.
하지만 여행경보 페이지는 달랐다.
- 첫 화면에서 정보가 보여야 했고
- 최신성이 중요했고
- 외부 API 호출 이후에만 화면이 완성되는 구조가 병목이었고
실제 사용자 경험 지표도 좋지 않았다
그래서 이 페이지에서는 CSR보다 SSR이 더 적절했다.
마무리
이번 작업을 통해 다시 느낀 점은,
성능 개선은 항상 복잡한 최적화 기법에서 시작되지 않는다는 것이다.
오히려 더 중요한 질문은 이런 것이었다.
- 이 페이지에서 사용자가 가장 먼저 봐야 하는 것은 무엇인가?
- 그 정보는 누가 가져오는 것이 가장 자연스러운가?
- 최신성과 초기 노출 속도 중 무엇을 우선해야 하는가?
여행경보 페이지에서는 그 답이 분명했다.
사용자가 들어오자마자 최신 정보를 볼 수 있어야 했고,
그 책임은 클라이언트보다 서버가 맡는 편이 더 적절했다.
그 결과, CSR 기반 구조에서 발생하던 초기 공백을 줄일 수 있었고
LCP도 3.1초에서 0.5초까지 개선할 수 있었다.
이번 개선은 단순히 렌더링 방식을 바꾼 작업이 아니라,
페이지의 목적에 맞게 데이터 흐름과 렌더링 책임을 다시 설계한 과정이었다.

