앞선 글에서는 Next.js와 TypeScript 프로젝트를 운영 관점에서 어떻게 구성해야 하는지를 다뤘습니다. 프로젝트 구조가 잡히면, 그 다음으로 가장 빠르게 복잡해지는 영역이 바로 데이터 접근입니다.
실무에서는 이런 상황을 자주 겪게 됩니다.
- fetch 호출이 컴포넌트마다 흩어져 있다
- 에러 처리가 화면마다 제각각이다
- 어떤 요청은 캐시되고, 어떤 요청은 항상 최신인지 기준이 없다
- 서버 컴포넌트와 클라이언트 컴포넌트에서 데이터 흐름이 섞인다
“데이터를 어떻게 가져오느냐”보다, 어디에서, 어떤 규칙으로 가져와야 운영이 안정되는지에 대해 알아보겠습니다.
- fetch를 직접 쓰지 않고 래퍼를 두는 이유
- 에러를 표준화해야 하는 실무적인 이유
- Next.js 캐시 옵션을 언제, 어떻게 써야 하는지
- 서버/클라이언트 데이터 접근 경계를 어떻게 유지할지
개념/배경 설명
Next.js는 기본 fetch에 강력한 기능을 얹어 제공합니다. 캐시, revalidate, 서버 컴포넌트 연동까지 모두 가능하지만, 이 기능들이 “자유롭게” 열려 있다는 점이 오히려 문제를 만들기도 합니다.
데이터 접근 계층이 정리되지 않으면 다음과 같은 문제가 생깁니다.
- 동일한 API를 여러 방식으로 호출해 캐시가 꼬인다
- 에러 처리 방식이 통일되지 않아 장애 시 대응이 어렵다
- 서버 전용 요청이 클라이언트 번들로 새어 나간다
- API 변경 시 화면 여러 곳을 동시에 수정해야 한다
그래서 실무에서는 fetch를 “편의 함수”가 아니라 계약이 명확한 데이터 접근 계층으로 다루는 것이 중요합니다.
핵심 설계 1: fetch를 직접 쓰지 말고 공통 래퍼를 둔다
컴포넌트나 페이지에서 fetch를 바로 호출하면, 다음 세 가지가 순식간에 무너집니다.
- 에러 처리 기준
- 헤더/인증 처리
- 캐시 전략
권장 방식은 “서버 전용 fetch 래퍼”를 하나 두고, 모든 데이터 접근이 이를 통과하게 만드는 것입니다.
// src/server/lib/http.ts
import { env } from '@/config/env';
export type HttpResult<T> =
| { ok: true; data: T }
| { ok: false; error: { code: string; message: string; status?: number } };
type RequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
headers?: Record<string, string>;
body?: unknown;
cache?: RequestCache;
next?: { revalidate?: number };
};
export async function http<T>(
url: string,
options: RequestOptions = {},
): Promise<HttpResult<T>> {
const res = await fetch(url, {
method: options.method ?? 'GET',
headers: {
'content-type': 'application/json',
...options.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
cache: options.cache,
next: options.next,
});
if (!res.ok) {
return {
ok: false,
error: {
code: 'HTTP_ERROR',
message: `status=${res.status}`,
status: res.status,
},
};
}
const data = (await res.json()) as T;
return { ok: true, data };
}
이 래퍼의 핵심은 두 가지입니다.
- 성공/실패를 유니온 타입으로 고정해 호출부에서 분기 처리를 강제한다
- fetch 옵션(cache, revalidate)을 한 곳에서 통제할 수 있다
실무 포인트 정리
- fetch를 직접 쓰는 순간, 규칙은 무너진다
- HTTP 결과 타입을 표준화하면 에러 처리가 통일된다
- 데이터 접근은 반드시 server 폴더 아래에서만 수행한다
핵심 설계 2: 에러는 “던지지 말고” 반환한다
프론트엔드 코드에서 try/catch로 에러를 처리하는 방식은 규모가 커질수록 유지보수가 어려워집니다.
실무에서는 다음 방식이 더 안정적입니다.
- 네트워크/HTTP 에러 → 정상적인 결과 타입으로 반환
- 정말 복구 불가능한 경우만 throw
이렇게 하면 화면 로직에서는 항상 같은 패턴으로 처리할 수 있습니다.
const result = await fetchSessions(userId);
if (!result.ok) {
return { error: result.error.message };
}
return { sessions: result.data };
이 패턴은 장애 상황에서 특히 강력합니다. 에러가 예외로 터지지 않고 “데이터 흐름의 일부”로 남기 때문에, 화이트 스크린 같은 2차 장애를 줄일 수 있습니다.
핵심 설계 3: 캐시 전략을 API 단위로 명시한다
Next.js의 fetch는 기본적으로 캐시를 사용합니다. 문제는 “어떤 요청이 캐시되어야 하는지”를 명시하지 않으면, 의도와 다른 결과가 나오기 쉽다는 점입니다.
실무에서 권장되는 기준은 다음과 같습니다.
- 인증/개인 데이터 → cache: 'no-store'
- 자주 바뀌지 않는 설정/메타 → revalidate 사용
- 정적 성격 데이터 → 기본 캐시
// 개인 데이터
http<SessionListItem[]>(`${env.API}/sessions`, {
cache: 'no-store',
});
// 공용 설정 데이터
http<AppConfig>(`${env.API}/config`, {
next: { revalidate: 60 },
});
중요한 점은 “화면 기준”이 아니라 “데이터 성격 기준”으로 캐시 전략을 정해야 한다는 것입니다.
핵심 설계 4: 서버 → 클라이언트 데이터 흐름을 단방향으로 유지한다
Next.js App Router에서는 서버 컴포넌트에서 데이터를 가져와 클라이언트 컴포넌트에 전달하는 구조가 기본입니다.
실무에서 반드시 지켜야 할 원칙은 다음입니다.
- 클라이언트에서 서버 API를 직접 호출하지 않는다
- 데이터 접근은 서버 계층에서만 수행한다
- 클라이언트는 “결과를 표현”하는 역할에 집중한다
이 원칙이 깨지면 인증, 캐시, 에러 처리 기준이 화면마다 달라지기 시작합니다.
운영/실무에서 자주 겪는 문제
- fetch 옵션 누락으로 개인정보가 캐시되는 사고
- 에러 throw로 인해 특정 상황에서만 화면이 깨지는 문제
- 서버 API를 클라이언트에서 직접 호출해 CORS/보안 이슈 발생
- API 변경 시 화면 여러 곳을 동시에 수정해야 하는 구조
이 문제들의 공통 원인은 “데이터 접근 규칙이 없었다”는 점입니다.
실무 권장 체크리스트
- fetch 호출이 server 계층으로 모여 있는가
- HTTP 결과 타입이 표준화되어 있는가
- 에러가 throw가 아닌 결과로 처리되는가
- API별 캐시 전략이 명시되어 있는가
- 서버 → 클라이언트 데이터 흐름이 단방향인가
Next.js에서 데이터 접근 계층을 잘 설계한다는 것은 “편하게 fetch를 쓰는 것”이 아니라, 운영에서 예측 가능한 흐름을 만드는 것입니다.
공통 래퍼, 에러 표준화, 명확한 캐시 전략을 두면 기능이 늘어도 데이터 흐름이 흔들리지 않습니다. 결국 이렇게 구성해야 장애 대응과 확장이 동시에 쉬워집니다.
