마이리얼트립 SSR 최적화
SSR 도입으로 SEO·초기 로딩을 개선하려다 react-query 중복 호출로 성능이 악화된 과정을 분석하고, 구조적 해결책·지표 향상 결과를 공유합니다. SSR 최적화 핵심 포인트와 react-query 캐시 설정 노하우까지 담았습니다.
마이리얼트립 고객경험개발팀은 통합 숙소 도메인의 성능 개선을 위해 다양한 페이지에 SSR(Server Side Rendering)을 도입하고 있습니다. 이를 통해 SEO 최적화와 초기 로딩 속도 향상을 기대하고 있으며, 특히 상품 상세, 객실 상세, 옵션 리스트, 검색 결과 페이지에 SSR을 적용하고 있습니다.
하지만 적용 과정에서 예기치 못한 성능 저하와 중복 API 호출 문제가 발생하였고, 이를 해결하기 위한 원인 분석과 구조적인 개선이 필요했습니다.
이 포스트에서는 단순히 개선 결과를 소개하는 것을 넘어
문제를 어떻게 발견했고, 어떤 방식으로 검증했으며, 어떻게 구조적으로
해결했는지에 대해 상세히 기록하고자 합니다.
이를 통해 SSR 도입을 고민 중인 다른 개발자분들께도 실질적인 인사이트를 드릴 수 있기를 바랍니다.
(참고) 이 글에서는 Next.js v13.5.9와 TanStack Query(react-query) v4.29.12를 기준으로 작성되었습니다.
목차
- SSR 도입 배경과 문제 인식
- 실제 개선 사례와 성능 변화 측정
- SSR 개발 시 반드시 고려해야 할 설계 포인트
- 결론 - 기술적 디테일을 넘은 구조적 학습
1. SSR 도입 배경과 문제 인식
도입 배경
마이리얼트립의 통합 숙소 페이지는 사용자가 검색을 시작해 상품을 탐색하고 최종적으로 예약을 결정하는 핵심 여정의 중심에 위치한 페이지입니다.
이 페이지의 성능과 사용자 경험은 전환율에 직접적인 영향을 미치기 때문에 초기 렌더링 속도와 SEO(검색 엔진 최적화)를 개선하기 위한 전략으로 SSR(Server Side Rendering)을 도입하게 되었습니다.
적용 대상 페이지는 다음과 같습니다.
- 숙소 상세 페이지
- 객실 상세 페이지
- 숙소 옵션리스트 페이지
- 숙소 검색 결과 페이지
문제 인식
SSR을 적용한 이후, 다음과 같은 문제가 나타나기 시작했습니다.
- 페이지 진입 시 초기 로딩 시간이 예상보다 길다는 자체 진단
- CSR로 hydration이 완료된 이후에도 로딩 스피너가 계속 노출됨
- FCP(First Contentful Paint), LCP(Largest Contentful Paint) 등 주요 성능 지표가 오히려 악화
- 서버 로그 상 동일한 API가 여러 차례 호출되는 현상 발생
특히, SSR을 적용한 의도가 클라이언트에서 API 요청을 줄이기 위한 목적이었기 때문에 중복 호출은 치명적인 문제가 될 수밖에 없었습니다.
2. 실제 개선 사례와 성능 변화 측정
SSR 도입 후 발생한 중복 API 호출과 성능 저하 문제를 해결하기 위해 각 페이지별로 원인을 분석하고 구조를 개선했습니다. 여러 페이지에서 공통적으로 발견된 문제와 그 근본 원인은 다음과 같았습니다.
공통 원인 분석
1. 구조적 중복 호출
: 데이터 요청 로직 설계상 불필요한 중복 호출 발생
- 변경 전 흐름 예시
① fetchQuery로 접근 권한 등 초기 데이터 검증
② prefetchQuery로 동일한 데이터를 다시 호출 (불필요한 중복)
③ 또 다른 fetchQuery로 데이터를 받아 특정 정보 가공 (또 다른 중복)
→ 이 구조는 동일 API가 여러 번 호출되는 결과를 낳았습니다.
2. 캐시 관리 미흡
:react-query의 캐시 메커니즘을 SSR 환경에 맞게 활용하지 못함
- resetQueries() 오용: 특정 조건에서 queryClient.resetQueries()를 호출하여, 유효한 다른 캐시까지 의도치 않게 삭제하는 경우가 있었습니다. 이로 인해 후속 로직에서 캐시를 사용하지 못하고 다시 API를 호출했습니다.
// 문제가 된 코드 예시
if (!isValid) {
await queryClient.resetQueries(); // 유효성 검사 실패 시 모든 캐시 삭제
}
- SSR staleTime 누락: SSR 환경에서는 매 요청마다 새로운 QueryClient 인스턴스가 생성됩니다. 이때 defaultOptions로 staleTime을 명시적으로 설정하지 않으면 기본값인 ‘0’이 적용됩니다. 이는 fetchQuery나 prefetchQuery로 가져온 데이터가 즉시 stale 상태가 되어, 동일 데이터 요청 시 캐시를 활용하지 못하고 다시 API를 호출하게 만듭니다. CSR의 <QueryClientProvider> 설정은 SSR에 영향을 주지 않습니다.
// 해결책: SSR용 QueryClient 생성 시 staleTime 명시적 설정
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 15000, // 예: 15초간 fresh 상태 유지
},
},
});
- SSR/CSR 간 queryKey 불일치: 동일한 데이터를 가리키더라도 SSR과 CSR에서 queryKey를 생성하는 방식(특히 객체 내 값의 타입이나 기본값 처리)이 다르면, SSR에서 생성된 캐시를 CSR에서 활용하지 못하고 다시 API를 호출하게 됩니다. (이는 특히 검색결과 페이지에서 두드러졌습니다.)
3. fetchQuery vs prefetchQuery 선택
: 메서드의 특성을 고려하지 않고 사용한 경우가 있었음
- fetchQuery: 데이터를 반드시 가져와야 하고, 에러 처리나 데이터 기반의 분기가 필요할 때 사용합니다. 실패 시 에러를 던집니다(throw).
- prefetchQuery: 데이터를 미리 로드해두는 것이 목적일 때 사용하며, 실패하더라도 에러를 던지지 않고 조용히 넘어갑니다. 따라서 데이터 기반의 분기 로직에는 부적합 합니다.
// fetchQuery 사용 예시 (데이터 기반 분기)
try {
const data = await queryClient.fetchQuery(key, fetchFn);
if (!data.valid) {
// 데이터 검증 후 리다이렉트 등 분기 처리
return { redirect: { destination: “/404” } };
}
// data 사용 로직
} catch (err) {
// 에러 처리
logger.error(err);
return { redirect: { destination: “/error” } };
};
데이터 검증이나 에러 처리가 필수적인 로직에서 prefetchQuery를 사용하면, 실패 케이스를 적절히 처리하지 못하는 문제가 발생할 수 있습니다.
이러한 원인들을 바탕으로 각 페이지의 특성에 맞게 개선 작업을 진행했습니다.
2.1 숙소상세, 객실상세, 옵션리스트 페이지 개선
이 페이지들은 유사한 데이터 요청 흐름을 가지고 있어 공통적인 문제점과 해결책을 적용할 수 있었습니다.
문제 분석
- 숙소상세: 최초 권한 검증(
fetchQuery), 데이터 프리페칭(prefetchQuery), 판매자 정보 가공(fetchQuery) 과정에서 동일 API가 총 3회 호출되었습니다. 이는 앞서 언급된 ‘구조적 중복 호출’ 패턴의 전형적인 예시였습니다. - 객실상세/옵션리스트: 데이터 조회 및 프리페칭 과정에서 동일 API가 총 2회 호출되었습니다.
- 공통 원인:
- 구조적 문제: 불필요한prefetchQuery또는 중복fetchQuery호출이 로직 내에 존재했습니다.
- 캐시 문제: SSRQueryClient에staleTime이 설정되지 않아 캐시가 즉시 만료되었고, 일부 조건부 로직에서resetQueries()가 호출되어 유효한 캐시가 삭제되었습니다.
- 메서드 선택: 데이터 검증이나 에러 분기가 필요한 부분에서prefetchQuery가 사용된 경우도 있었습니다.
개선 방향 및 결과
1. API 호출 최소화 (구조 개선): 최초 한 번의 fetchQuery로 해당 페이지에서 필요한 핵심 데이터를 모두 가져오도록 로직을 통합했습니다. 이후 다른 로직에서는 queryClient.getQueryData(queryKey)를 사용하여 이미 가져온 데이터를 캐시에서 재사용하도록 변경했습니다.
// 개선된 구조 예시
// 1. 최초 한 번만 fetchQuery 실행
const data = await queryClient.fetchQuery(queryKey, () =>
fetchData(params)
);
// 2. 필요한 데이터 검증 및 처리
if (!data || !data.isAccessible) {
return { redirect: { destination: “/error” } };
}
// 3. 이후 로직에서는 캐시 데이터 활용
const processData = (queryClient) => {
const cachedData = queryClient.getQueryData(queryKey);
// cachedData를 활용한 로직
};
2. fetchQuery/prefetchQuery 적절히 사용: 데이터 검증 및 에러 분기가 필요한 로직은 반드시 fetchQuery를 사용하고 try…catch로 감싸도록 수정했습니다. 불필요하거나 잘못 사용된 prefetchQuery는 제거했습니다.
3. 캐시 설정 최적화:
- SSR에서 QueryClient 생성 시 defaultOptions에 적절한 staleTime (예: 10~15초)을 설정했습니다.
- resetQueries() 대신, 캐시 초기화가 꼭 필요한 경우 removeQueries(specificQueryKey)를 사용하거나 queryClient.invalidateQueries(specificQueryKey)를 사용하는 등 영향을 최소화하는 방식으로 변경했습니다.
이를 통해 각 페이지의 API 중복 호출 문제를 해결하고 SSR 응답 속도를 크게 개선할 수 있었습니다.
✅ 숙소상세 페이지
성능 개선 지표
API 호출 횟수
개선 전: 3회 → 개선 후: 1회
SSR 속도 평균
개선 전: 2076ms → 개선 후: 1052ms (개선율: 약 50%)
Lighthouse 점수
개선 전: 64점 → 개선 후: 65점
✅ 객실상세 페이지
성능 개선 지표
API 호출 횟수
개선 전: 2회 → 개선 후: 1회
SSR 속도 평균
개선 전: 775ms → 개선 후: 328ms (개선율: 약 57%)
Lighthouse 점수
개선 전: 55점 → 개선 후: 80점
✅ 옵션리스트 페이지
성능 개선 지표
API 호출 횟수
개선 전: 2회 → 개선 후: 1회
SSR 속도 평균
개선 전: 2033ms → 개선 후: 534ms (개선율: 약 73%)
Lighthouse 점수
개선 전: 67점 → 개선 후: 80점
2.2 검색결과 페이지 개선
검색결과 페이지는 숙소 관련 페이지와는 다른 고유한 문제점을 가지고 있었습니다.
문제 분석
1. SSR과 CSR에서 queryKey 불일치:
- 가장 큰 문제였습니다. SSR과 CSR에서 queryKey를 구성할 때 사용하는 검색 파라미터(searchParam)의 기본값 처리 방식이 달랐습니다. 예를 들어, SSR에서는 search: searchParam || undefined 로, CSR에서는 search: searchParam || “” 와 같이 처리하여, 동일한 검색 조건임에도 불구하고 다른 queryKey 배열이 생성되었습니다.
// 문제 발생 예시
// SSR key: [“search”, { search: undefined, page: 1 }]
// CSR key: [“search”, { search: “”, page: 1 }]
// -> 두 키는 다르게 취급됨
- 이로 인해 SSR 단계에서 fetchQuery로 데이터를 성공적으로 가져와 HTML에 포함시켰음에도 불구하고, 클라이언트에서 Hydration이 완료된 후 useQuery가 실행될 때 캐시를 찾지 못해 (CSR 기준의 queryKey로는 캐시가 없으므로) 동일한 API를 다시 호출하는 현상이 발생했습니다. 이것이 Hydration 후 로딩 스피너가 보이거나 LCP 지표가 나빠지는 현상의 주된 원인이었습니다.
2. 불필요한 맵 API의 SSR 호출:
- 검색 결과 페이지의 지도 표시는 클라이언트 측 인터랙션에 의해 이루어지므로, 관련 지도 데이터 API(fetchMapData)를 SSR 단계에서 미리 호출할 필요가 없었습니다. getServerSideProps에서 await queryClient.fetchQuery([“map”], fetchMapData)를 실행함으로써 초기 HTML 생성 시간을 불필요하게 늘리고 서버 리소스를 낭비하고 있었습니다.
개선 방향 및 결과
1. queryKey 통일:
- SSR과 CSR에서 queryKey를 생성하는 로직을 완전히 동일하게 수정했습니다. 특히 파라미터의 기본값 처리 방식을 일치시켜, 동일한 검색 조건에 대해서는 항상 동일한 queryKey 배열이 생성되도록 보장했습니다.
// 개선 후: 통일된 queryKey 생성 로직 (예시)
const getSearchQueryKey = (params) => {
const { search, page, …rest } = params;
// 항상 동일한 기본값 처리 (예: 빈 문자열)
const normalizedSearch = search || “”;
const normalizedPage = page || 1;
return [
“search”,
{ search: normalizedSearch, page: normalizedPage, …rest },
];
};
// SSR
const queryKey = getSearchQueryKey(context.query);
await queryClient.fetchQuery(queryKey, () => fetchData(context.query));
// CSR
const router = useRouter();
const queryKey = getSearchQueryKey(router.query);
const { data } = useQuery(queryKey, () => fetchData(router.query));
2. 맵 API 호출 위치 변경:
- 지도 데이터 API 호출 로직을 getServerSideProps에서 완전히 제거했습니다. 대신, 실제 지도를 렌더링하는 클라이언트 컴포넌트(MapComponent) 내부에서 useQuery를 사용하되, 지도가 실제로 화면에 표시되어야 할 때만 API를 호출하도록 enabled 옵션을 활용했습니다.
// 개선 전: SSR에서도 맵 데이터 호출
export async function getServerSideProps() {
// …
await queryClient.fetchQuery([“map”], fetchMapData); // 불필요
}
// 개선 후: 클라이언트 컴포넌트에서만 호출
function MapComponent() {
const { data } = useQuery([“map”], fetchMapData);
// …
}
성능 변화
이 개선을 통해 검색결과 페이지의 주요 성능 지표가 눈에 띄게 향상되었습니다.
성능 개선 지표
First Contentful Paint
개선 전: 2.8s → 개선 후: 0.5s (개선율: 82.14%)
Speed Index
개선 전: 8.3s → 개선 후: 4.0s (개선율: 51.81%)
LCP
개선 전: 5.48s → 개선 후: 3.10s (개선율: 43.43%)
메인 데이터 요청
개선 전: 4.41s → 개선 후: 1.76s (개선율: 60.09%)
3. SSR 개발 시 반드시 고려해야 할 설계 포인트
3.1 SSR 관련 핵심 포인트
✅ QueryClient 인스턴스 생성과 설정
(필수)매 요청마다 새 인스턴스 생성
- SSR에서는 CSR과 달리 매 요청에 대해 독립적인 QueryClient 인스턴스가 필요합니다.
(필수) defaultOptions 명시적 설정
- SSR에서는 staleTime이 기본값 0으로 설정되어 있어 중복 요청의 원인이 됩니다. 적절한 staleTime 값을 명시적으로 설정하세요.
// 헬퍼 함수로 래핑하여 재사용
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 10000, // 최소 10초는 fresh 상태 유지
retry: 1, // 재시도 횟수 제한
},
},
});
⚠️ 주의 ⚠️
CSR에서 QueryClientProvider에 지정한 기본 옵션이 SSR에서는 절대 적용되지 않습니다.
✅ 구조적 설계가 핵심
SSR은 단순히 prefetch를 붙이는 작업이 아닌, 데이터 흐름 자체를 새롭게 설계해야 합니다
- 한 번의 API 호출로 모든 데이터 획득: fetchQuery는 최초 한 번만 실행하고, 이후에는 getQueryData()로 재활용합니다.
- 필요한 데이터만 가져오기: 사용자 경험에 즉시 필요하지 않은 데이터(예: 지도 데이터)는 클라이언트 측으로 이동합니다.
- 정확한 메서드 선택: 데이터 검증이나 에러 처리가 필요한 경우 prefetchQuery 대신 fetchQuery를 사용합니다.
✅ 캐시 관리 주의사항
- resetQueries() 신중히 사용: API는 모든 쿼리 캐시를 삭제하므로, 필요한 경우 removeQueries()나 조건부 초기화를 사용하세요.
- SSR/CSR 간 queryKey 통일: 동일한 데이터에 대해 SSR과 CSR에서 정확히 같은 구조와 기본값을 가진 queryKey를 사용하세요.
3.2 queryKey와 성능 최적화
✅ queryKey 일관성 유지
객체 형태의 queryKey는 참조 문제가 발생할 수 있으므로 다음 패턴을 따르세요
// ✅ 추천 패턴
// 1. 원시값 사용
const queryKey = [“product”, productId, checkIn, checkOut];
// 2. useMemo로 안정적 참조 유지
const queryKey = useMemo(() => [“product”, productId], [productId]);
// ❌ 피해야 할 패턴
// 1. 날짜 객체 등 새로운 참조 생성
const queryKey = [“product”, { id: productId, date: new Date() }];
// 2. 스프레드 연산자로 객체 복사
const params = { …baseParams, id: productId };
const queryKey = [“product”, params];
✅ 성능 측정 및 디버깅
- 실행 시간 측정: 캐시 상태를 확인하는 가장 간단한 방법은 실행 시간을 측정하는 것입니다. 캐시된 데이터는 접근 시간이 거의 0ms입니다.
const start = Date.now();
await queryClient.fetchQuery(key, fetchFn);
const end = Date.now();
console.log(`쿼리 실행 시간: ${end — start}ms`)
- DevTools 활용: 콘솔 로그보다 DevTools를 활용해 쿼리 상태를 정량적으로 확인하세요.
이러한 설계 포인트들을 준수하면 SSR 환경에서 react-query를 효율적으로 활용하여 중복 API 호출 없이 성능을 최적화 할 수 있습니다.
4. 결론 - 기술적 디테일을 넘은 구조적 학습
이번 SSR 최적화 작업은 단순한 성능 개선을 넘어,
기술을 다룰 때 원리와 구조를 정확히 이해하고 설계하는 것의 중요성을 일깨워주는 경험이었습니다.
핵심 인사이트
SSR은 단순히 prefetch를 붙이는 작업이 아닙니다
- 구조적으로 중복 호출이 발생하지 않는 설계를 먼저 해야 합니다.
- react-query에 대한 표면적인 이해로는 SSR을 다루기 어렵습니다.
- SSR과 CSR은 서로 다른 context이며, 각각에 맞는 세팅이 필요합니다.
- 성능 개선은 결국 낭비를 줄이는 과정입니다.
마이리얼트립 프론트엔드 팀은 앞으로도 SSR과 CSR을 유기적으로 조화시키며 구조적으로 최적화된 사용자 경험을 제공하기 위한 다양한 시도를 계속해나갈 예정입니다.
이번 포스팅이 SSR 설계와 성능 개선을 고민하는 모든 개발자분들께 실질적인 도움이 되었기를 바랍니다.