[Next.js] API 초기 세팅하기 (serverAPI와 clientAPI분리)

.DS_Store·2025년 9월 1일
1

개발기

목록 보기
2/5
post-thumbnail

React와 달리 Next.js에서는 데이터를 가져오는 방법이 다양하다. 서버 전용 API 모듈클라이언트 전용 API 모듈을 분리하고, 프록시 Route Handler로 브라우저 요청에 안전하게 토큰 주입하는 구조를 만들어본 과정을 기록해본다!

별도 페칭 라이브러리(react-query, SWR 등)는 쓰지 않고 순수 Next.js로 구현

Server API와 Client API의 차이

Server API (서버 컴포넌트/Server Action/Route Handler)

  • SSR/SEO 유리함: 초기 렌더 데이터를 서버에서 바로 가져와서 HTML에 넣어줌
  • HttpOnly 쿠키 토큰을 JS에 노출 없이 읽어서 Authorization 헤더로 주입 가능
  • 서버 → 백엔드 호출이라 CORS 걱정 없음
  • revalidate/tags로 Next 캐시 관리 쉬움
  • 브라우저 종속적인 로컬스토리지에는 접근 불가 (로컬스토리지에 엑세스토큰 저장시 사용 불가함)

Client API (브라우저에서 직접 호출)

  • 페이지가 그려진 후 데이터를 불러옴
  • 필터, 페이지네이션, 사용자 인터랙션 위주 CSR 갱신할 때 주로 사용
  • 단, 브라우저는 HttpOnly 쿠키를 JS로 읽지 못하기 때문에 /api/proxy 라우트 거쳐서 서버가 토큰 주입해줘야 함

프로젝트 구조 (API)

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         # 프록시: 브라우저 요청 → 토큰 주입 → 백엔드 전달

구현 상세

공통 모듈: http.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);
}

서버 전용: server.ts

// 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);
}

클라이언트 전용: client.ts

// 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);
}

프록시 Route Handler (클라이언트 사용)

브라우저 요청 받아서 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>
  );
}

What I learned

쿠키 설정

  • HttpOnly: JS 접근 차단
  • Secure: HTTPS에서만 전송
  • SameSite=Lax (cross-site 필요하면 None + HTTPS)

프록시 사용의 이유

  • HttpOnly 쿠키 접근: 클라이언트는 HttpOnly 쿠키 못 읽으니까 서버가 읽어서 Bearer 토큰으로 바꿔줌
  • CORS 해결: 모든 호출을 같은 출처 /api/proxy/**로 보내서 CORS 문제 없앰
  • 토큰 관리 중앙화: 토큰 주입, 갱신, 로깅을 한 곳에서 처리

서버 컴포넌트, 클라이언트 컴포넌트 환경변

# 서버 전용
API_BASE_URL=https://api.backend.com

# 클라이언트 노출 (선택 - 프록시만 쓰면 없어도 됨)
NEXT_PUBLIC_BASE_URL=https://api.backend.com

Next.js 15 주의점

동적 Route Handler에서 params가 Promise일 수 있음:

const { path: segments = [] } = await ctx.params; // await 필수

0개의 댓글