React와 달리 Next.js에서는 데이터를 가져오는 방법이 다양하다. 서버 전용 API 모듈과 클라이언트 전용 API 모듈을 분리하고, 프록시 Route Handler로 브라우저 요청에 안전하게 토큰 주입하는 구조를 만들어본 과정을 기록해본다!
별도 페칭 라이브러리(react-query, SWR 등)는 쓰지 않고 순수 Next.js로 구현
revalidate
/tags
로 Next 캐시 관리 쉬움/api/proxy
라우트 거쳐서 서버가 토큰 주입해줘야 함src/
lib/
api/
http.ts # 공통: 응답/에러 파싱 (서버/클라이언트 공용)
server.ts # 서버 전용 fetch 래퍼 (cookies() 접근)
client.ts # 클라이언트 전용 fetch 래퍼 (/api/proxy 경유)
endpoints/
shops.client.ts # 클라이언트에서 사용하는 엔드포인트
shops.server.ts # 서버에서 사용하는 엔드포인트
app/
api/
proxy/
[...path]/
route.ts # 프록시: 브라우저 요청 → 토큰 주입 → 백엔드 전달
서버/클라이언트에서 같은 에러 정책을 사용하기 위해 응답 파싱 로직 공통화
// src/lib/api/http.ts
export type ApiErrorBody = {
code: number;
message: string;
description?: string;
error_code?: string;
};
function isApiErrorBody(v: unknown): v is ApiErrorBody {
return (
typeof v === "object" &&
v !== null &&
typeof (v as { code?: unknown }).code === "number" &&
typeof (v as { message?: unknown }).message === "string"
);
}
function getMessageFromUnknown(u: unknown): string | undefined {
if (typeof u === "string") return u;
if (typeof u === "object" && u !== null) {
const m = (u as any).message;
if (typeof m === "string") return m;
if (Array.isArray(m) && m.every((x) => typeof x === "string")) {
return m.join(", ");
}
}
}
export class HttpError extends Error {
constructor(
public status: number,
public body?: ApiErrorBody | unknown
) {
super(
(isApiErrorBody(body) ? body.message : getMessageFromUnknown(body)) ??
`HTTP ${status}`
);
this.name = "HttpError";
}
}
export async function json<T>(res: Response): Promise<T> {
if (res.ok) return res.json() as Promise<T>;
let parsed: unknown;
try {
parsed = await res.clone().json();
if (isApiErrorBody(parsed)) {
throw new HttpError(res.status, parsed);
}
const msg = getMessageFromUnknown(parsed);
if (msg) {
throw new HttpError(res.status, { code: res.status, message: msg });
}
} catch {}
throw new HttpError(res.status, parsed);
}
// src/lib/api/server.ts
import "server-only";
import { cookies } from "next/headers";
import { json } from "./http";
const BASE = process.env.API_BASE_URL;
const ACCESS_TOKEN_COOKIE = "access_token";
export type ServerApiOptions = {
auth?: "required" | "none";
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
body?: unknown;
headers?: Record<string, string>;
revalidate?: number;
tags?: string[];
signal?: AbortSignal;
};
/** 필요하면 raw Response가 필요한 곳에서 사용 */
export async function serverFetch(
path: string,
opts: ServerApiOptions = {}
) {
if (!BASE) {
throw new Error("API_BASE_URL이 설정되지 않았습니다.");
}
const store = await cookies();
const token = opts.auth === "required"
? store.get(ACCESS_TOKEN_COOKIE)?.value
: undefined;
const headers: Record<string, string> = {
Accept: "application/json",
...(opts.body ? { "Content-Type": "application/json" } : {}),
...(opts.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
return fetch(`${BASE}${path}`, {
method: opts.method ?? "GET",
headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
credentials: "include",
signal: opts.signal,
next: opts.revalidate != null || opts.tags
? { revalidate: opts.revalidate, tags: opts.tags }
: undefined,
});
}
/** 실사용 */
export async function serverApi<T>(
path: string,
opts?: ServerApiOptions
) {
const res = await serverFetch(path, opts);
return json<T>(res);
}
// src/lib/api/client.ts
"use client";
import { json } from "./http";
const EXTERNAL_BASE = process.env.NEXT_PUBLIC_BASE_URL;
export type ClientApiOptions = {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
body?: unknown; // JSON | FormData
headers?: Record<string, string>;
token?: string; // 정말 필요할 때만 (대부분 프록시 경유)
signal?: AbortSignal;
};
/** 저수준 fetch (Response 그대로): 필요하면 raw Response가 필요한 곳에서 사용 */
async function clientFetch(path: string, opts: ClientApiOptions = {}) {
const isAbsolute = /^https?:\/\//i.test(path);
const isInternal = path.startsWith("/api/"); // 프론트 내부 라우트(프록시)
if (!isInternal && !isAbsolute && !EXTERNAL_BASE) {
throw new Error("NEXT_PUBLIC_BASE_URL이 설정되지 않았습니다.");
}
const url = isAbsolute
? path
: isInternal
? path
: `${EXTERNAL_BASE}${path}`;
const headers: Record<string, string> = {
Accept: "application/json",
...(opts.headers ?? {}),
};
const body = opts.body instanceof FormData
? opts.body
: opts.body != null
? JSON.stringify(opts.body)
: undefined;
if (body && !(opts.body instanceof FormData)) {
headers["Content-Type"] = "application/json";
}
if (opts.token) {
headers["Authorization"] = `Bearer ${opts.token}`;
}
return fetch(url, {
method: opts.method ?? "GET",
headers,
body,
credentials: isInternal ? "same-origin" : "include",
signal: opts.signal,
});
}
/** 실사용 */
export async function clientApi<T>(
path: string,
opts?: ClientApiOptions
) {
const res = await clientFetch(path, opts);
return json<T>(res);
}
브라우저 요청 받아서 HttpOnly 쿠키에서 토큰 읽고 백엔드로 넘겨주는 부분
// src/app/api/proxy/[...path]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
const BASE = process.env.API_BASE_URL!;
const ACCESS_COOKIE = "access_token";
// 보안을 위한 허용 경로 화이트리스트
const ALLOWED_PATHS = new Set([
"/shops",
"/shops/stats"
]);
export async function handler(
req: NextRequest,
ctx: { params: Promise<{ path?: string[] }> }
) {
const { path: segments = [] } = await ctx.params;
const pathname = "/" + segments.join("/");
const search = req.nextUrl.search || "";
// 화이트리스트 검사
if (![...ALLOWED_PATHS].some((allowedPath) =>
pathname.startsWith(allowedPath)
)) {
return NextResponse.json(
{ message: "Not allowed" },
{ status: 404 }
);
}
// HttpOnly 쿠키에서 토큰 읽기
const token = (await cookies()).get(ACCESS_COOKIE)?.value;
// 헤더 설정
const headers = new Headers(req.headers);
headers.set("content-type", "application/json");
if (token) {
headers.set("authorization", `Bearer ${token}`);
}
// 요청 바디 처리
let body: string | undefined;
if (req.method !== "GET" &&
req.headers.get("content-type")?.includes("application/json")) {
body = JSON.stringify(await req.json());
}
// 백엔드로 프록시
const upstream = await fetch(`${BASE}${pathname}${search}`, {
method: req.method,
headers,
body,
credentials: "include",
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
}
// 모든 HTTP 메서드 지원
export {
handler as GET,
handler as POST,
handler as PUT,
handler as PATCH,
handler as DELETE
};
serverApi나 clientApi를 사용해 실제 api호출 내용 정리
서버 엔드포인트:
// src/lib/api/endpoints/shops.server.ts
import { serverApi } from "@/lib/api/server";
import type { ShopsResponseType } from "@/types/shop";
export async function getShopsServer(): Promise<ShopsResponseType> {
return serverApi<ShopsResponseType>("/shops", {
auth: "required",
method: "GET",
revalidate: 300, // 5분 캐시
tags: ["shops"],
});
}
클라이언트 엔드포인트:
// src/lib/api/endpoints/shops.client.ts
import { clientApi } from "@/lib/api/client";
import type { ShopsResponseType } from "@/types/shop";
export async function getShopsClient(): Promise<ShopsResponseType> {
return clientApi<ShopsResponseType>("/api/proxy/shops", {
method: "GET"
});
}
export async function createShopClient(
shopData: CreateShopRequest
): Promise<ShopResponse> {
return clientApi<ShopResponse>("/api/proxy/shops", {
method: "POST",
body: shopData,
});
}
서버 컴포넌트에서:
// src/app/(console)/shops/page.tsx
import { getShopsServer } from "@/lib/api/endpoints/shops.server";
export default async function ShopsPage() {
const shops = await getShopsServer();
return (
<div>
<h1>상점 목록</h1>
{shops.data.map((shop) => (
<div key={shop.id}>{shop.name}</div>
))}
</div>
);
}
클라이언트 컴포넌트에서:
// src/components/ShopsFilter.tsx
"use client";
import { useEffect, useState } from "react";
import { getShopsClient } from "@/lib/api/endpoints/shops.client";
import type { ShopsResponseType } from "@/types/shop";
export default function ShopsFilter() {
const [data, setData] = useState<ShopsResponseType | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getShopsClient()
.then(setData)
.catch((error) => {
console.error("상점 데이터 로딩 실패:", error);
})
.finally(() => setLoading(false));
}, []);
if (loading) return <div>로딩 중...</div>;
if (!data) return <div>데이터를 불러올 수 없습니다.</div>;
return (
<div>
{data.data.map((shop) => (
<div key={shop.id}>{shop.name}</div>
))}
</div>
);
}
HttpOnly
: JS 접근 차단Secure
: HTTPS에서만 전송 SameSite=Lax
(cross-site 필요하면 None + HTTPS
)/api/proxy/**
로 보내서 CORS 문제 없앰 # 서버 전용
API_BASE_URL=https://api.backend.com
# 클라이언트 노출 (선택 - 프록시만 쓰면 없어도 됨)
NEXT_PUBLIC_BASE_URL=https://api.backend.com
동적 Route Handler에서 params
가 Promise일 수 있음:
const { path: segments = [] } = await ctx.params; // await 필수