Next.js에서 Axios와 서버 fetch는 어떤 요청 역할로 구분하나요?
Axios는 브라우저 API 호출에 공통 요청 설정이 필요할 때 선택할 수 있습니다. Next.js 서버 조회의 fetch와 역할을 구분하고 설치만으로 모든 요청이 바뀌지는 않는다는 점을 확인합니다.
적용 조건·범위: 서버에서 Next.js 캐시·재검증과 함께 데이터를 읽는 경우 fetch가 자연스럽습니다.
본문 근거: Axios
독립 실습: 실제 로컬 endpoint를 Axios로 호출하기
React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.
npm install axios 후 npm run dev, /practice에서 조회를 누릅니다. MSW나 인증은 필요 없습니다.
app/api/practice/route.ts 전체 코드
export async function GET() { return Response.json({ message: '로컬 API 성공' }); }
app/practice/page.tsx 전체 코드
"use client";
import axios from 'axios';
import { useState } from 'react';
export default function Page() {
const [message, setMessage] = useState('대기');
async function load() {
try {
const response = await axios.get<{ message: string }>('/api/practice');
setMessage(response.data.message);
} catch (error) {
setMessage(axios.isAxiosError(error) ? `요청 실패: ${error.response?.status ?? '네트워크'}` : '알 수 없는 실패');
}
}
return <main><button onClick={load}>조회</button><p>{message}</p></main>;
}
기대 결과: 로컬 API 성공이 보이고 Network에 GET /api/practice 200이 남습니다.
자가 확인: 요청 주소를 /api/missing으로 잠시 바꿔 404 실패 문구와 네트워크 오류를 구분하고 복구합니다.
설치
npm install axios
기본 요청
'use client';
import axios from 'axios';
export async function loadStatus() {
const response = await axios.get(
'/api/status',
);
return response.data;
}
공통 instance
import axios from 'axios';
export const apiClient = axios.create({
baseURL:
process.env.NEXT_PUBLIC_API_BASE_URL,
timeout: 5000,
});
NEXT_PUBLIC_이 붙은 값은 브라우저 번들에 노출될 수 있으므로 비밀값을 넣지 않습니다.
fetch와 Axios 선택 기준
서버에서 Next.js 캐시·재검증과 함께 데이터를 읽는 경우 fetch가 자연스럽습니다.
브라우저에서 공통 interceptor, JSON 처리, 외부 API client가 필요한 경우 Axios가 편할 수 있습니다.
도구를 하나로 통일하는 것보다 실행 위치와 요구사항을 먼저 봅니다.
자주 발생하는 오류
- 서버 fetch까지 무조건 Axios로 변경
- NEXT_PUBLIC_에 비밀 토큰 저장
- baseURL 환경 분리 누락
- Axios 오류를 any로 처리
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Next.js로 페이지와 서버 기능을 확장합니다.
MSW 준비 경계 안에 Axios API 상태 버튼을 추가합니다. 서버 데이터 조회를 Axios로 일괄 교체하지 않습니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow Next.js 프로젝트 적용
시작 체크포인트: 이전: Next.js MSW 2 Mock API 만들기의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: app/(app)/tasks/page.tsx, app/lib/axios-client.ts, app/ui/api-status.tsx, package.json. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
독자가 직접 확인할 질문과 결과
질문: Axios를 추가하면 TaskFlow의 모든 데이터 조회가 바뀌나요?
npm install후 15회차처럼 worker와NEXT_PUBLIC_API_MOCKING=enabled를 준비하고 개발 서버를 다시 시작합니다./tasks의 ‘API 연결 확인’ 버튼은app/ui/api-status.tsx에서 같은 출처GET /api/tasks를 호출합니다.- MSW가 처리하면 ‘API 연결 성공’, 요청 실패면 ‘API 연결 실패’가 표시됩니다. 네트워크 탭에서 경로·응답을 확인하세요. 이 버튼은 응답 본문이나 HTTP 상태를 화면에 표시하지 않습니다.
- 기본 서버 작업 목록은 기존
getTasks를 유지합니다. 이 예제는 설정 페이지나 외부 도메인 API 호출이 아닙니다. MSW가 꺼지면 17회차 전까지 실제 endpoint가 없을 수 있습니다.
오류 점검: 같은 출처 경로, worker, 환경변수, 개발 서버 재시작을 확인하고 실제 HTTP 응답은 네트워크 탭에서 봅니다. 클라이언트 코드에 비밀 토큰을 넣지 마세요. 빌드·서버·브라우저 실행 검증은 미확인입니다.
다음 학습: 17회차에서 실제 app/api/tasks/route.ts와 허용 메서드·405 응답을 확인합니다.
완료 코드 ZIP 다운로드 — next-step-16-client-api
이전 체크포인트: next-step-15-msw
이번 회차의 변경
MSW 준비 경계 안에 Axios API 상태 버튼을 추가합니다. 서버 데이터 조회를 Axios로 일괄 교체하지 않습니다. 기본 baseURL은 같은 출처입니다.
변경·추가 파일:
app/(app)/tasks/page.tsxapp/lib/axios-client.tsapp/ui/api-status.tsx
전체 파일·코드 보기
실행과 검증
Node.js 22 LTS 이상 권장(Next.js 최소 20.9). 루트 app/ 구조, CSS 사용.
npm install
npm run dev
npm run build
npm run start
MSW: 개발 시에만 NEXT_PUBLIC_API_MOCKING을 enabled로 설정합니다. npx msw init public/ --save로 worker를 만들 수 있습니다. Mock 데이터와 서버 데이터는 별개이며 17편부터 기본 흐름은 실제 Route Handler입니다.
전체 코드와 ZIP은 같은 완료 체크포인트입니다. 자동 생성 worker는 ZIP에 포함하며 라이브러리 파일은 코드 뷰어에서 생략합니다.
완료 체크포인트: next-step-16-client-api
실습 프로젝트 전체 코드
.gitignore
node_modules
.next
.env*
!.env.example
*.tsbuildinfo
.env.example
NEXT_PUBLIC_API_MOCKING=
app/(app)/@modal/(.)tasks/[id]/page.tsx
import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
import Modal from "@/app/ui/modal";
export default async function TaskModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const task = await getTask(id);
if (!task) notFound();
return (
<Modal>
<h1>{task.title}</h1>
<p>{task.description}</p>
<p>상태: {task.completed ? "완료" : "진행 중"}</p>
</Modal>
);
}
app/(app)/@modal/[…catchAll]/page.tsx
export default function ResetModal() {
return null;
}
app/(app)/@modal/default.tsx
export default function Default() {
return null;
}
app/(app)/layout.tsx
import type { ReactNode } from "react";
import SiteHeader from "@/app/_components/SiteHeader";
export default async function AppLayout({
children,
modal,
}: {
children: ReactNode;
modal: ReactNode;
}) {
return (
<>
<SiteHeader />
{children}
{modal}
</>
);
}
app/(app)/settings/page.tsx
import ThemeSetting from "@/app/ui/theme-setting";
export default function Settings() {
return (
<main>
<h1>설정</h1>
<ThemeSetting />
</main>
);
}
app/(app)/tasks/[id]/page.tsx
import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
export default async function TaskDetail({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const task = await getTask(id);
if (!task) notFound();
return (
<main>
<h1>{task.title}</h1>
<p>{task.description}</p>
<p>상태: {task.completed ? "완료" : "진행 중"}</p>
<Link href="/tasks">목록</Link>
</main>
);
}
app/(app)/tasks/error.tsx
"use client";
export default function ErrorPage({
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<main>
<h1>작업을 불러오지 못했습니다.</h1>
<button onClick={reset}>다시 시도</button>
</main>
);
}
app/(app)/tasks/loading.tsx
export default function Loading() {
return (
<main>
<p>작업을 불러오는 중...</p>
</main>
);
}
app/(app)/tasks/not-found.tsx
export default function NotFound() {
return (
<main>
<h1>작업을 찾을 수 없습니다.</h1>
</main>
);
}
app/(app)/tasks/page.tsx
import Link from "next/link";
import { getTasks } from "@/app/lib/data";
import TaskSearch from "@/app/ui/task-search";
import { Suspense } from "react";
import TaskStats from "@/app/ui/task-stats";
import ApiDemo from "@/app/ui/api-demo";
import MswProvider from "@/app/ui/msw-provider";
import ApiStatus from "@/app/ui/api-status";
export default async function TasksPage() {
const tasks = await getTasks();
return (
<main>
<h1>작업 목록</h1>
<TaskSearch />
<ul>
{tasks.map((task) => (
<li key={task.id}>
<Link href={"/tasks/" + task.id}>{task.title}</Link>
<span> · {task.completed ? "완료" : "진행 중"}</span>
</li>
))}
</ul>
<Suspense fallback={<p>통계 로딩 중...</p>}>
<TaskStats />
</Suspense>
<MswProvider>
<ApiDemo />
<ApiStatus />
</MswProvider>
</main>
);
}
app/(app)/template.tsx
import type { ReactNode } from "react";
export default function Template({ children }: { children: ReactNode }) {
return <>{children}</>;
}
app/(public)/page.tsx
import Link from "next/link";
export default function HomePage() {
return (
<main>
<h1>TaskFlow Next.js</h1>
<p>App Router 기반 작업 관리 서비스입니다.</p>
<Link href="/tasks">작업 목록 보기</Link>
</main>
);
}
app/_components/SiteHeader.tsx
import Link from "next/link";
export default function SiteHeader() {
return (
<header>
<nav aria-label="주 메뉴">
<Link href="/">홈</Link>
<Link href="/tasks">작업</Link>
<Link href="/settings">설정</Link>
</nav>
</header>
);
}
app/globals.css
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #171717;
background: #fafafa;
font-family: Arial, sans-serif;
line-height: 1.6;
}
header,
main {
width: min(880px, calc(100% - 32px));
margin: 24px auto;
}
nav {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
a {
color: inherit;
text-underline-offset: 3px;
}
button,
input,
select {
min-height: 44px;
padding: 8px 12px;
font: inherit;
border: 1px solid #aaa;
background: #fff;
color: #171717;
max-width: 100%;
}
button {
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: wait;
}
input {
display: block;
width: 100%;
}
label {
display: block;
}
form,
section,
aside {
margin: 20px 0;
}
ul {
padding: 0;
list-style: none;
}
li {
padding: 12px;
margin: 12px 0;
border: 1px solid #ccc;
overflow-wrap: anywhere;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.controls form {
margin: 0;
}
.modal {
width: min(640px, calc(100% - 32px));
border: 1px solid #777;
padding: 24px;
background: #fff;
color: #171717;
}
.modal::backdrop {
background: rgb(0 0 0 / 55%);
}
:focus-visible {
outline: 3px solid #555;
outline-offset: 3px;
}
[data-theme="dark"] {
background: #202020;
color: #fafafa;
padding: 16px;
}
[data-density="compact"] li {
padding: 8px;
margin: 8px 0;
}
img {
max-width: 100%;
height: auto;
}
app/layout.tsx
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
export const metadata: Metadata = {
title: { default: "TaskFlow Next.js", template: "%s | TaskFlow" },
description: "서버와 브라우저 역할을 배우는 TaskFlow 학습 프로젝트",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}
app/lib/axios-client.ts
import axios from "axios";
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL ?? "",
timeout: 5000,
});
app/lib/data.ts
import type { Task } from "./types";
const tasks: Task[] = [
{
id: "task-1",
title: "App Router 공부",
description: "서버와 브라우저의 역할을 구분합니다.",
completed: false,
ownerId: "demo",
},
{
id: "task-2",
title: "TaskFlow 만들기",
description: "누적 실습을 완성합니다.",
completed: true,
ownerId: "demo",
},
];
export async function getTasks() {
return tasks;
}
export async function getTask(id: string) {
return tasks.find((t) => t.id === id);
}
export async function getTaskStats() {
const ts = await getTasks();
return { total: ts.length, completed: ts.filter((t) => t.completed).length };
}
app/lib/types.ts
export type Task = {
id: string;
title: string;
description: string;
completed: boolean;
ownerId: string;
};
app/ui/api-demo.tsx
"use client";
import { useState } from "react";
export default function ApiDemo() {
const [message, setMessage] = useState("API 응답 확인 전");
const [busy, setBusy] = useState(false);
async function request(method: "GET" | "POST") {
setBusy(true);
try {
const response = await fetch("/api/tasks", {
method,
headers: { "Content-Type": "application/json" },
...(method === "POST"
? { body: JSON.stringify({ title: "API 실습 작업" }) }
: {}),
});
const data = await response.json();
setMessage(
response.ok
? `${response.status}: ${Array.isArray(data) ? data.length + "개 작업" : data.title}`
: `${response.status}: ${data.message}`,
);
} catch {
setMessage("API 요청 실패");
} finally {
setBusy(false);
}
}
return (
<section>
<h2>API 실습</h2>
<p>
Mock 저장소와 서버 저장소는 별개입니다. 17편부터 실제 Route Handler를
사용합니다.
</p>
<div className="controls">
<button disabled={busy} onClick={() => request("GET")}>
목록 API 조회
</button>
<button disabled={busy} onClick={() => request("POST")}>
API 작업 추가
</button>
</div>
<p role="status">{message}</p>
</section>
);
}
app/ui/api-status.tsx
"use client";
import { useState } from "react";
import { apiClient } from "../lib/axios-client";
export default function ApiStatus() {
const [message, setMessage] = useState("확인 전");
const handleCheck = async () => {
try {
await apiClient.get("/api/tasks");
setMessage("API 연결 성공");
} catch {
setMessage("API 연결 실패");
}
};
return (
<section>
<button onClick={handleCheck}>API 상태 확인</button>
<p>{message}</p>
</section>
);
}
app/ui/modal.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { useRouter } from "next/navigation";
export default function Modal({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDialogElement>(null);
const router = useRouter();
useEffect(() => {
const dialog = ref.current;
if (dialog && !dialog.open) dialog.showModal();
return () => {
dialog?.close();
};
}, []);
return (
<dialog
className="modal"
ref={ref}
aria-label="작업 상세"
onCancel={(event) => {
event.preventDefault();
router.back();
}}
>
<button type="button" onClick={() => router.back()}>
닫기
</button>
{children}
</dialog>
);
}
app/ui/msw-provider.tsx
"use client";
import { useState, useEffect, type ReactNode } from "react";
export default function MswProvider({ children }: { children: ReactNode }) {
const enabled =
process.env.NODE_ENV === "development" &&
process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
const [ready, setReady] = useState(!enabled);
const [error, setError] = useState("");
useEffect(() => {
if (!enabled) return;
let active = true;
import("@/src/mocks/browser")
.then(({ startMockWorker }) => startMockWorker())
.then(() => {
if (active) setReady(true);
})
.catch(() => {
if (active)
setError("Mock API 초기화에 실패했습니다. worker 파일을 확인하세요.");
});
return () => {
active = false;
};
}, [enabled]);
return error ? (
<p role="alert">{error}</p>
) : ready ? (
<>{children}</>
) : (
<p>Mock API 준비 중...</p>
);
}
app/ui/task-search.tsx
"use client";
import { useState } from "react";
export default function TaskSearch() {
const [keyword, setKeyword] = useState("");
return (
<label>
작업 검색
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="검색어 입력"
/>
</label>
);
}
app/ui/task-stats.tsx
import { getTaskStats } from "@/app/lib/data";
export default async function TaskStats() {
await new Promise((resolve) => setTimeout(resolve, 600));
const s = await getTaskStats();
return (
<aside aria-label="작업 통계">
<p>
전체: {s.total} · 완료: {s.completed}
</p>
</aside>
);
}
app/ui/theme-setting.tsx
"use client";
import { useState, useEffect } from "react";
export default function ThemeSetting() {
const [theme, setTheme] = useState<"light" | "dark">("light");
useEffect(() => {
try {
const saved = localStorage.getItem("taskflow-theme");
if (saved === "light" || saved === "dark") setTheme(saved);
} catch {}
}, []);
function change(value: "light" | "dark") {
setTheme(value);
try {
localStorage.setItem("taskflow-theme", value);
} catch {}
}
return (
<section data-theme={theme}>
<p>현재 테마: {theme}</p>
<div className="controls">
<button onClick={() => change("light")}>밝게</button>
<button onClick={() => change("dark")}>어둡게</button>
</div>
</section>
);
}
next-env.d.ts
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = { cacheComponents: false };
export default config;
package.json
{
"name": "taskflow-next",
"version": "1.0.16",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build --webpack",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0",
"axios": "1.20.0"
},
"devDependencies": {
"typescript": "5.9.3",
"@types/node": "24.13.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"msw": "2.15.0"
}
}
src/mocks/browser.ts
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
let startPromise: ReturnType<typeof worker.start> | undefined;
export function startMockWorker() {
if (!startPromise) {
startPromise = worker.start({ onUnhandledRequest: "bypass" }).catch((error) => {
startPromise = undefined;
throw error;
});
}
return startPromise;
}
src/mocks/handlers.ts
import { http, HttpResponse } from "msw";
let tasks = [
{
id: "task-1",
title: "Mock API 연결",
completed: false,
},
];
export const handlers = [
http.get("/api/tasks", () => {
return HttpResponse.json(tasks);
}),
http.post("/api/tasks", async ({ request }) => {
const body = (await request.json()) as {
title?: string;
};
if (typeof body.title !== "string" || body.title.trim().length < 2) {
return HttpResponse.json(
{
message: "제목을 입력해 주세요.",
},
{
status: 400,
},
);
}
const newTask = {
id: crypto.randomUUID(),
title: body.title.trim(),
completed: false,
};
tasks = [newTask, ...tasks];
return HttpResponse.json(newTask, {
status: 201,
});
}),
];
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
글 소개·학습 준비·전체 목차
Next.js · 16/38 · TaskFlow 누적 실습
Axios 설치와 Next.js API 요청 구조
Next.js · 16 / 38 · 필수 학습 · Axios
Next.js에서는 서버 데이터 조회에 fetch를 그대로 사용할 수 있습니다. 그렇다고 Axios가 필요 없는 것은 아닙니다. 브라우저에서 외부 API를 호출하거나 공통 요청 설정이 필요할 때 Axios를 선택할 수 있습니다.
이번 글에서 배우는 내용
- Axios 설치
- fetch와 Axios 역할 차이
- 브라우저 요청 위치
- 공통 instance
- baseURL
- 오류 응답 처리
목차
다음 학습
다음 글에서는 Next.js 자체 API endpoint인 Route Handler를 만들고 405 Method Not Allowed 오류를 해결합니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 39개 · 필수 38개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.