사전 개념

이 글에서 반복해서 등장하는 세 가지 개념을 먼저 정리한다.

  • SSR (Server-Side Rendering): 서버에서 HTML을 완성해 브라우저에 보내는 방식. 클라이언트가 빈 화면을 보지 않고 곧바로 콘텐츠를 볼 수 있다. 단, 서버가 데이터를 가져와야 하므로 응답 자체가 느려질 수 있다.
  • ISR (Incremental Static Regeneration): 정적 페이지를 빌드한 뒤 일정 시간(revalidate)이 지나면 백그라운드에서 다시 생성하는 방식. 매 요청마다 서버가 일할 필요 없으므로 응답이 빠르고, 캐시 만료 시에만 갱신된다.
  • Delta 조회: 이미 가지고 있는 데이터는 다시 요청하지 않고, 새로 필요한 데이터만(DELTA) 조회하는 방식. 전체 재조회를 피해 API 호출 횟수를 크게 줄일 수 있다.

문제 상황

주식 전체조회 페이지(/stocks, /overseas-stocks) 접속 시 체감 속도가 느렸다. 페이지 구성에 3개의 API가 호출되는데, 이 호출들이 직렬 워터폴로 동작하고 있었고, 스크롤로 추가 페이지를 로드할 때마다 이미 받은 데이터까지 전부 재조회하는 구조적 문제가 있었다.


원인 분석

1. SSR → Client Hydration 직렬 워터폴

SSR: getStocks(page=0)         ──→ HTML 전송 ──→ Hydration ──→ useEffect ──→ getBatchPrices()
     [200~500ms]                                    [100~300ms]              [200~500ms]

SSR에서 종목 목록만 가져오고, 시세는 클라이언트 마운트 후에야 요청된다. 시세 요청은 SSR 완료 → HTML 전송 → Hydration → useEffect 실행 이후에야 시작되므로 최소 1왕복 시간이 낭비된다.

2. 스크롤 시 전체 재조회 (O(N²) 폭발)

기존에는 SWR 키를 전체 종목코드를 join한 문자열로 생성했다:

const stableKey = stockCodes.slice().sort().join(',');
useSWR(['batch-prices', stableKey], () => getBatchPrices(stockCodes));

페이지가 추가될 때마다 stableKey가 변경 → SWR이 새 요청으로 인식 → 이미 받은 종목까지 전부 재조회:

스크롤 페이지 누적 종목 API 호출 횟수 실제 필요 횟수
1 20 1 1
2 40 2 1
3 60 3 1
5 100 15 5

5페이지까지 로드하면 15번 chunk 요청이 발생하지만, 실제로는 5번이면 충분하다. accumulatedPrices 상태가 있었지만 batchPrices ?? accumulatedPrices에서 batchPrices가 우선하여 재조회를 막지 못했다.

3. BATCH_CHUNK_SIZE = 20이 너무 작음

100개 종목이면 5번의 POST 요청이 동시 발생. 백엔드 부하 시 429 에러 → 지수 백오프 재시도(최대 10.5초)가 연쇄 발생.

4. force-dynamic으로 매 요청마다 백엔드 히트

export const dynamic = 'force-dynamic'으로 ISR 캐시 없이 매번 백엔드 호출.

5. fetcher 재시도 3회 + 403 포함

재시도 상태코드에 403이 포함되어 있었고, 최대 3회 재시도로 10.5초까지 대기 가능.


적용한 개선

P0: Delta 시세 조회

핵심 변경: 이미 가진 시세는 재조회하지 않고, 신규 종목만 API 호출.

// Before: 전체 종목을 매번 조회
useSWR(['batch-prices', stableKey], () => getBatchPrices(stockCodes));

// After: accumulatedPrices에 없는 종목만 delta 조회
const newCodes = stockCodes.filter((code) => !accumulatedPrices[code]);
useSWR(['batch-prices-delta', deltaKey], () => getBatchPrices(newCodes));

상태 관리는 useReducer로 변경하여 filter 변경 시 reset, delta 데이터 도착 시 merge:

type PriceAction =
  | { type: 'reset'; prices: Record<string, MappedStockPrice> }
  | { type: 'merge'; prices: Record<string, MappedStockPrice> };

function priceReducer(state, action) {
  switch (action.type) {
    case 'reset': return action.prices;
    case 'merge':  return { ...state, ...action.prices };
  }
}

효과: 스크롤 시 API 호출이 페이지당 1회로 고정. 5페이지 기준 15회 → 5회 (67% 감소).

P0: SSR에서 시세 함께 프리페치

// Before: SSR에서 카탈로그만 fetch
initialData = await getStocks({ page: 0, size: 20, ... });

// After: 카탈로그 + 시세를 SSR에서 연속 fetch 후 prop 전달
initialData = await getStocks({ page: 0, size: 20, ... });
if (initialData.content.length > 0) {
  const codes = initialData.content.map((item) => item.stockCode);
  initialPrices = await getBatchPrices(codes);
}

initialPrices prop을 클라이언트 컴포넌트에 전달하여, 첫 화면 로드 시 Hydration 대기 없이 시세 표시.

효과: 클라이언트 워터폴 1단계 제거, 첫 화면 시세 로딩 시간 약 500ms~1s 단축.

P1: BATCH_CHUNK_SIZE 20 → 50

// Before
const BATCH_CHUNK_SIZE = 20;
// After
const BATCH_CHUNK_SIZE = 50;

효과: 100개 종목 기준 5회 → 2회 POST 요청 (60% 감소).

P1: ISR 도입

// Before
export const dynamic = 'force-dynamic';
// After
export const revalidate = 30;

30초간 캐시된 페이지를 서브. 종목 목록은 자주 변하지 않으므로 30초면 충분.

효과: 대부분의 사용자가 캐시된 SSR 페이지를 받아 백엔드 호출 생략.

P2: fetcher 재시도 축소

// Before: retries = 3, retryable = [403, 429, 502, 503]
export async function fetcher<T>(url, init, retries = 3) { ... }
const retryable = [403, 429, 502, 503];

// After: retries = 1, retryable = [429, 502, 503]
export async function fetcher<T>(url, init, retries = 1) { ... }
const retryable = [429, 502, 503];

403은 권한 문제이므로 재시도 무의미. 최대 대기 10.5초 → 1.5초로 단축.

P2: Context 리렌더 최적화

BatchPriceProvider의 value와 hook 반환값에 useMemo 적용:

// Provider value 메모이제이션
const value = useMemo(
  () => ({ domestic, overseas, loadingCodes, errorCodes }),
  [domestic, overseas, loadingCodes, errorCodes]
);

// 개별 hook 반환값 메모이제이션
export function useBatchDomesticPrice(stockCode: string) {
  const ctx = useContext(BatchPriceContext);
  const data = ctx.domestic[stockCode];
  const isLoading = ctx.loadingCodes.has(stockCode);
  const error = ctx.errorCodes.get(stockCode);
  return useMemo(() => ({ data, isLoading, error }), [data, isLoading, error]);
}

효과: 다른 종목의 시세 업데이트 시 관련 없는 카드 컴포넌트의 불필요한 리렌더 방지.


Lint 트러블슈팅

초기에 useRef로 accumulated prices를 관리하려 했으나, React 19의 react-hooks/refs 린트 규칙에 의해 “Cannot access refs during render” 에러 발생. 렌더 중 ref 읽기/쓰기를 금지하는 규칙.

useState + useEffect 조합도 react-hooks/set-state-in-effect 규칙에 걸림.

최종적으로 useReducer 를 사용하여:

  • filter 변경 시 → dispatch({ type: 'reset' })
  • delta 데이터 도착 시 → dispatch({ type: 'merge' })

state 업데이트가 reducer에서 이루어지므로 린트 에러 없이 안전하게 동작.


BE 개선이 추가로 필요한 부분

FE 개선만으로 상당한 효과를 얻었지만, 근본적 해결을 위해서는 BE 개선도 필요:

항목 설명
카탈로그 + 시세 통합 응답 GET /api/stocks 응답에 시세 포함 → FE 시세 별도 조회 불필요 (3회 → 1회)
배치 최대 수 증가 POST /api/stocks/prices에서 50~100개 코드 한번에 수용
CORS 헤더 추가 FE에서 백엔드 직접 호출 가능하게 → Next.js 프록시 hop 제거
Redis 시세 캐싱 동일 종목 반복 조회 방지, TTL 5~10초

핵심 교훈

  1. SWR 키 설계가 성능을 결정한다: 전체 데이터를 join한 키는 추가 로드마다 전체 재조회를 유발. Delta 기반 키 설계가 필수.
  2. SSR 프리페치는 체감 속도에 결정적: 클라이언트에서만 fetch하면 Hydration 대기시간이 반드시 발생.
  3. useReducer가 렌더 중 상태 업데이트의 안전한 대안: useRef는 렌더 중 접근 불가, useState + useEffect는 린트 에러. useReducer의 dispatch는 안전.
  4. ISR로 SSR 부하 감소: force-dynamic은 매 요청마다 백엔드 호출. 실시간성이 덜 중요한 목록 페이지는 revalidate로 충분.