왜 server fetching을 지워냈을까

해진·2026년 8월 11일
post-thumbnail

App Router에서 서버 패칭의 경계를 다시 그리다

App Router 환경에서 모든 GET 요청을 서버 패칭(RSC + SSR)으로 구성했다가, private API만 클라이언트 패칭으로 전환한 경험을 공유해요.

핵심 포인트:

  • public API → 서버 패칭 유지 (SEO, FCP 이점)
  • private API → 클라이언트 패칭으로 전환 (토큰 관리 단순화)
  • 서버 패칭이 항상 옳은 선택은 아니다 — 데이터의 성격이 기준이 되어야 한다


🤔 배경 — 왜 처음에 모든 GET을 서버에서 패칭했나

Next.js App Router를 도입하면서 RSC의 장점을 최대한 활용하고 싶었어요.

RSC(React Server Component)는 단순한 렌더링 방식이 아니라 컴포넌트 모델 자체가 달라요. 렌더링 방식으로 보면 서버에서 데이터를 채운 HTML을 내려주는 SSR이고, 컴포넌트 모델로 보면 컴포넌트 함수가 JS 번들에 포함되지 않아요. 이걸 잘 설명한 글이 있는데, Different hydration and rendering strategies에서는 RSC를 이렇게 설명해요.

"Server Component는 서버에서만 실행, 코드 자체가 브라우저로 안 감. DB 직접 접근, 파일시스템 접근 가능. 클라이언트에 보내는 건 JS가 아닌 직렬화된 UI 설명(Flight)"

즉, RSC에서 데이터를 패칭하면 컴포넌트 함수도, 패칭 로직도 번들에 포함되지 않고 완성된 HTML만 클라이언트로 내려와요. FCP가 빠르고 SEO에도 유리하죠.

컴포넌트 함수       → 번들 미포함
패칭 로직           → 번들 미포함
DB 드라이버·시크릿  → 서버에서만 실행, 노출 없음
완성된 HTML         → 클라이언트에 전달 → FCP 빠름, SEO 유리

게다가 Suspense를 여러 곳에 배치하면 서버에서 준비된 파편부터 스트리밍할 수 있어서 SPA처럼 부분적으로 화면이 채워지는 경험도 만들 수 있었어요. 구조적으로 꽤 매력적인 선택이었고, 그래서 초기엔 모든 GET 요청을 서버에서 패칭하는 방향으로 설계했어요.



🚧 문제 발견 — private API에서 막혔던 지점

public API는 문제가 없었어요. 그런데 로그인한 유저만 볼 수 있는 데이터를 서버에서 패칭하려는 순간 벽을 만났어요.

서버(RSC)에서 API를 호출할 때 AccessToken이 필요한데, 이를 관리하는 방식에 제한이 생겼어요. 서버는 브라우저 메모리에 접근할 수 없었기에, 서버가 토큰을 알 수 있는 유일한 방법은 쿠키뿐이었죠.

// RSC에서 토큰이 필요한 패칭 (예시)
export default async function Page() {
  const cookieStore = cookies();
  const accessToken = cookieStore.get('access_token')?.value;

  const data = await fetch('/api/private/product', {
    headers: { Authorization: `Bearer${accessToken}` },
  }).then(r => r.json());

  return <ProductDetail data={data} />;
}

쿠키로 해결은 됐어요. 그런데 다음 문제가 기다리고 있었어요.

"로그인·로그아웃했을 때 UI가 즉각 반응하지 않는다"

쿠키는 서버가 읽는 값이에요. 클라이언트 UI가 리렌더링되려면 상태(state) 변경처럼 React가 리렌더를 트리거할 조건이 필요한데, 쿠키 값이 바뀐다고 해서 그 조건이 충족되진 않아요. 그렇다고 쿠키를 차용한 별도 클라이언트 state를 두는 건 동일 출처 원칙에서 벗어나죠. 인증 상태의 출처가 쿠키(서버)와 state(클라이언트) 두 곳이 되어버리거든요.



🔧 해결은 했지만 — 복잡도의 대가

Middleware에서 토큰 갱신
→ x-needs-renewal 커스텀 헤더로 클라이언트에 신호
→ 클라이언트에서 헤더 감지
→ verify API로 서버에 인증 상태 재확인
→ AuthProvider 상태 업데이트

동작은 했어요. 그런데 돌아보면 이 구조를 만든 이유가 "private API를 서버에서 패칭하기 위해서"였어요. 그리고 그 이점은 결국 하나였죠.

번들 크기 절감

그 하나를 위해 감수하고 있던 것들을 나열해봤어요.

  • 쿠키 의존 구조 (서버 패칭을 위한 강제 선택)
  • Middleware + 커스텀 헤더 + verify API 싱크 로직
  • 서버↔클라이언트 인증 상태 불일치 리스크
  • 새로운 팀원이 이해하기 어려운 높은 진입 장벽
  • 디버깅 포인트가 서버·Middleware·클라이언트 전방위로 분산

이 구조를 처음 보는 사람이 "왜 이렇게 되어있지?"를 이해하는 데 드는 비용이 너무 컸어요. 그때 뭔가 잘못 끼워 맞추고 있다는 것을 느꼈어요.



✅ 전환 결정 — public은 서버, private은 클라이언트

그래서 기준을 다시 세웠어요.

데이터의 성격이 패칭 위치를 결정해야 한다

앞서 언급한 글에서는 이런 기준을 제시해요.

"로그인 뒤 페이지, 첫 화면 SEO 불필요 → CSR"

이건 private API의 성격이에요. 유저마다 다른 개인화 데이터는 검색 엔진이 수집할 이유도 없고, 수집돼서도 안 되죠. 서버 패칭의 핵심 이점인 SEO와 FCP가 애초에 필요 없는 데이터예요.

데이터 성격패칭 위치이유
public (SEO 필요)서버 (RSC + SSR)FCP, 검색 노출
private (토큰 필요, 개인화)클라이언트토큰 관리 단순화, SEO 불필요

이후 API 클라이언트를 새로 도입하고 인증 상태 관리를 전역 상태(store) 기반으로 전환하면서, 지금은 Middleware 자체가 불필요해져 파일까지 사라졌어요. 토큰 갱신 감지는 서버가, 상태 반영은 클라이언트의 API 클라이언트가 맡는 구조로 정리됐어요.

토큰 관리 방식도 함께 바꿨어요.

기존엔 AccessToken / RefreshToken 모두 쿠키에 담아 서버가 읽는 구조였어요. 서버에서 데이터를 패칭하기 위해 어쩔 수 없이 선택했던 방식이었지만, 그 탓에 로그인·로그아웃 시 UI가 즉각 반응하지 않는 문제가 있었죠.

전환하면서 역할을 이렇게 나눴어요.

  • AccessToken → 로그인 응답으로 받아서 전역 상태(store)로 관리. 상태가 바뀌니 로그인·로그아웃 시 UI가 즉각 반응해요.
  • RefreshToken → 쿠키에만 담아 서버와 통신.

클라이언트는 RefreshToken을 직접 다루지 않아요.
서버는 Authorization헤더에 담긴 AccessToken을 확인하고 만료되었다면, RefreshToken으로 갱신한 뒤, 응답 헤더에 새 AccessToken과 새 RefreshTokene을 실어 보내요. 클라이언트의 API 클라이언트는 모든 응답에서 이 헤더를 감지해 전역 상태를 갱신하는 역할만 해요. RefreshToken까지 만료돼 갱신이 실패하면 그때 401/403 코드가 내려오고, 같은 감지 로직이 이를 세션 만료 신호로 판단해 로그아웃 처리해요.
(단, 로그인·회원가입 요청 자체가 401을 반환하는 경우는 자격 증명 오류일 뿐 세션 만료가 아니라서 이 로직에서 제외했어요.)

덕분에 토큰 로테이션 흐름도 단순해졌어요. 갱신은 서버가 판단해서 실행하고, 클라이언트는 응답에 실려오는 신호(새 토큰 헤더 또는 401)에 반응하기만 하면 돼요. 갱신 시점을 클라이언트가 스스로 판단할 필요가 없어진 거죠. 그리고 이 전체 로직 — 토큰 부착, 갱신 감지, 세션 정리 — 이 API 클라이언트 하나에 응집되면서, Middleware라는 별도 레이어 없이도 인증 흐름이 완결돼요.

// api — private 패칭 함수: API 클라이언트가 인증을 전담
export const getAccountById = async (accountId: number) => {
  const response = await apiClient.get(`/v1/accounts/${accountId}`);
  return response.json();
};

// hooks — react-query로 감싼 커스텀 훅
export function useAccountInfo(accountId: number) {
  return useQuery({
    queryKey: accountKeys.detail(accountId),
    queryFn: () => getAccountById(accountId),
    // 401/403은 API 클라이언트가 이미 세션을 정리하므로 재시도하지 않음
  });
}

// 컴포넌트 — 인증을 전혀 신경 쓰지 않는다
'use client';
export function PrivateAccountInfo({ accountId }) {
  const { data } = useAccountInfo(accountId);
  return <AccountInfo data={data} />;
}

컴포넌트는 훅만 호출하고, 훅은 API 함수만 호출해요. 토큰을 붙이고, 갱신하고, 만료를 판단하는 로직은 전부 API 클라이언트 한 곳에 숨어 있어서 위 세 레이어 중 어디에도 인증 관심사가 드러나지 않아요.

구조를 바꾸고 나니 전반적으로 많은 것들이 단순해졌어요.

  • 쿠키 의존 구조 제거
  • Middleware 자체가 불필요해져서 파일까지 사라짐
  • AccessToken을 전역 상태로 관리해 즉각적인 UI 반응 가능
  • 인증 상태 출처가 단일화 (AccessToken → 전역 상태 / RefreshToken → 서버)
  • 새로운 팀원도 흐름을 직관적으로 파악 가능


📝 정리하며

참고했던 글의 마지막 결론이 이 경험을 잘 요약해줘요.

"서버 렌더링 + hydration으로 느려진 사이트는 없다. 문제는 항상 반대 방향 — 콘텐츠 블로그가 앱 런타임 전체를 싣거나, 측정도 안 한 hydration 비용과 싸우거나"

저도 비슷한 실수를 했어요. "App Router니까 모든 걸 서버에서 패칭하자"는 생각이 처음엔 합리적으로 느껴졌지만, 정작 데이터의 성격을 보지 않았던 거예요.

결국 내린 결론은 간단해요.

public은 서버에서, private은 클라이언트에서.

기술적 선택은 항상 데이터의 성격과 함께 판단해야 한다.

복잡한 구조를 걷어내고 나서야 각 레이어가 자기 역할에 집중할 수 있게 됐어요. 새로운 팀원이 코드를 봤을 때 "왜 이렇게 되어있지?"가 아니라 "아, 이래서 이렇게 했구나"가 될 수 있는 구조가 결국 더 좋은 설계라는 것을 다시한번 느끼게 되었어요.

참고 자료

profile
안녕하세요, Frontend 개발자 윤해진입니다.

0개의 댓글