Next.js Image의 외부 이미지 remotePatterns 오류는 어떻게 해결하나요?
next/image로 외부 이미지를 표시하려면 실제 URL에 맞는 remotePatterns가 필요합니다. protocol·hostname·pathname을 대조하고 필요한 범위만 허용합니다.
적용 조건·범위: 자가 확인: 쿼리를 새로 붙이면 search: "" 조건과 충돌하는 이유를 설명하고 허용 범위를 무작정 전체 호스트로 넓히지 않습니다.
본문 근거: Image · next.config images
독립 실습: 한 이미지의 정확한 원격 패턴 허용하기
React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.
빈 연습 프로젝트 설정에 적용하고 dev 서버를 재시작한 뒤 /practice를 엽니다. 외부 이미지 네트워크가 필요합니다.
next.config.ts 전체 코드
import type { NextConfig } from 'next';
const config: NextConfig = { images: { remotePatterns: [{
protocol: 'https', hostname: 'images.unsplash.com',
pathname: '/photo-1499750310107-5fef28a66643', search: '',
}] } };
export default config;
app/practice/page.tsx 전체 코드
import Image from 'next/image';
export default function Page() {
return <Image
src="https://images.unsplash.com/photo-1499750310107-5fef28a66643"
alt="책상 위 학습 도구" width={800} height={533}
sizes="(max-width: 800px) 100vw, 800px"
/>;
}
기대 결과: 해당 한 URL만 허용됩니다. 설정을 빠뜨렸을 때의 허용 오류와 원격 서버 접속 실패를 Network와 오류문으로 구분합니다.
자가 확인: 쿼리를 새로 붙이면 search: “” 조건과 충돌하는 이유를 설명하고 허용 범위를 무작정 전체 호스트로 넓히지 않습니다.
오류 상황
import Image from 'next/image';
<Image
src="https://images.example.com/tasks/1.png"
alt="작업 이미지"
width={640}
height={360}
/>
설정에 없는 외부 호스트라면 오류가 발생합니다.
next.config.ts
import type {
NextConfig,
} from 'next';
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname:
'images.example.com',
pathname: '/tasks/**',
},
],
},
};
export default nextConfig;
허용 범위를 넓히지 않습니다
가능하면 실제 사용하는 host와 path를 좁게 지정합니다.
fill 사용 시
<div
style={{
position: 'relative',
width: 320,
height: 180,
}}
>
<Image
src={imageUrl}
alt=""
fill
sizes="320px"
/>
</div>
부모 요소의 크기와 position을 함께 확인합니다.
자주 발생하는 오류
- hostname 오타
- http와 https 불일치
- pathname 누락
- 설정 변경 후 dev 서버 재시작 안 함
- fill 부모 레이아웃 조건 누락
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Next.js로 페이지와 서버 기능을 확장합니다.
next/image와 구체적인 remotePatterns를 적용합니다. 외부 이미지는 공개 홈의 학습 예시로 표시하며 외부 네트워크가 필요합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow Next.js 프로젝트 적용
시작 체크포인트: 이전: Next.js metadata 적용 오류 해결의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: app/(public)/page.tsx, app/ui/image-example.tsx, next.config.ts, package.json. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
독자 질문·직접 확인
질문: 외부 이미지는 어느 화면에 있고 remotePatterns가 무엇을 허용하나요?
경로·데이터 결과: 공개 홈 /에서 app/ui/image-example.tsx의 외부 이미지 예시를 찾습니다. next.config.ts의 images.remotePatterns와 이미지 URL의 protocol·hostname·pathname을 비교하고 width·height·sizes를 확인합니다. 외부 네트워크가 연결되어야 실제 이미지 로딩을 볼 수 있습니다.
오류 점검: 허용되지 않은 URL 오류는 호스트·프로토콜·경로 오타와 설정 후 서버 재시작을 확인합니다. 네트워크 실패와 설정 오류를 구분하세요. 이 회차의 예시는 작업 상세 썸네일이 아닙니다.
다음 학습: 29회차에서 옛 /todo URL을 /tasks로 이동시키는 규칙을 살핍니다.
빌드·서버·브라우저 실행 검증은 수행하지 않아 미확인입니다.
완료 코드 ZIP 다운로드 — next-step-28-image
이전 체크포인트: next-step-27-metadata-fix
이번 회차의 변경
next/image와 구체적인 remotePatterns를 적용합니다. 외부 이미지는 공개 홈의 학습 예시로 표시하며 외부 네트워크가 필요합니다. width·height·sizes를 함께 지정합니다.
변경·추가 파일:
next.config.tsapp/(public)/page.tsxapp/ui/image-example.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입니다.
저장소는 단일 Node 프로세스 메모리입니다. 서버 재시작·프로세스 분리·서버리스에서는 데이터 유지가 보장되지 않습니다. 운영 서비스는 DB 및 트랜잭션 저장소로 교체해야 합니다.
인증: npm run setup-auth로 로컬 학습 계정을 설정한 뒤 서버를 재시작합니다. 비밀번호 입력은 터미널에 보이므로 학습용 값을 사용하세요. 실제 운영 계정 시스템이 아니며 가입·복구·시도 제한 기능은 포함하지 않습니다. .env.local은 공유하지 마세요. Auth.js 5 beta 버전을 고정했습니다.
개인 작업·설정은 로그인 필수입니다. Proxy뿐 아니라 데이터 조회와 Server Action/API가 세션을 검사하고 소유자별로 접근합니다.
캐시 실습: 별도 터미널 npm run cache-api 실행 → /cache-lab 접속 → 공개 API에 POST하여 버전 증가 → 재검증 버튼과 다음 요청에서 갱신 확인. 개인 작업은 공유 Data Cache에 넣지 않습니다. Cache Components는 끈 구성입니다.
전체 코드와 ZIP은 같은 완료 체크포인트입니다. 자동 생성 worker는 ZIP에 포함하며 라이브러리 파일은 코드 뷰어에서 생략합니다.
완료 체크포인트: next-step-28-image
실습 프로젝트 전체 코드
.gitignore
node_modules
.next
.env*
!.env.example
*.tsbuildinfo
.env.example
AUTH_SECRET=
DEMO_EMAIL=
DEMO_PASSWORD_SALT=
DEMO_PASSWORD_HASH=
SITE_URL=
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 TaskActions from "@/app/ui/task-actions";
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>
<TaskActions id={task.id} completed={task.completed} />
</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";
import { requireUser } from "@/app/lib/session";
export const metadata = { robots: { index: false, follow: false } };
export default async function AppLayout({
children,
modal,
}: {
children: ReactNode;
modal: ReactNode;
}) {
await requireUser();
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";
import TaskActions from "@/app/ui/task-actions";
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const task = await getTask(id);
return {
title: task?.title || "작업 없음",
robots: { index: false, follow: false },
};
}
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>
<TaskActions id={task.id} completed={task.completed} />
<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/layout.tsx
import { cookies } from "next/headers";
import type { ReactNode } from "react";
export default async function TasksLayout({
children,
}: {
children: ReactNode;
}) {
const cookieStore = await cookies();
const density =
cookieStore.get("taskflow-density")?.value === "compact"
? "compact"
: "normal";
return <section data-density={density}>{children}</section>;
}
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
export const dynamic = "force-dynamic";
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";
import TaskForm from "@/app/ui/task-form";
import {
QueryClient,
dehydrate,
HydrationBoundary,
} from "@tanstack/react-query";
import TaskListClient from "@/app/ui/task-list-client";
import { requireUser } from "@/app/lib/session";
export default async function TasksPage({
searchParams,
}: {
searchParams: Promise<{ q?: string | string[]; status?: string | string[] }>;
}) {
const tasks = await getTasks();
const params = await searchParams;
const q = typeof params.q === "string" ? params.q : "";
const status = typeof params.status === "string" ? params.status : "all";
const user = await requireUser();
const client = new QueryClient();
await client.prefetchQuery({
queryKey: ["tasks", user.id],
queryFn: () => Promise.resolve(tasks),
staleTime: 60000,
});
return (
<main>
<h1>작업 목록</h1>
<Suspense fallback={<p>검색 준비 중...</p>}>
<TaskSearch />
</Suspense>
<TaskForm />
<HydrationBoundary state={dehydrate(client)}>
<TaskListClient ownerId={user.id!} q={q} status={status} />
</HydrationBoundary>
<Suspense fallback={<p>통계 로딩 중...</p>}>
<TaskStats />
</Suspense>
<MswProvider>
<ApiDemo />
<ApiStatus />
</MswProvider>
<p>
<Link href="/cache-lab">공개 데이터 캐시 실험</Link>
</p>
</main>
);
}
app/(app)/template.tsx
import type { ReactNode } from "react";
export default function Template({ children }: { children: ReactNode }) {
return <>{children}</>;
}
app/(public)/cache-lab/page.tsx
import { revalidateTag } from "next/cache";
export const dynamic = "force-dynamic";
async function refresh() {
"use server";
revalidateTag("public-stats", "max");
}
export default async function CacheLab() {
let data: { revision: number; label: string } | null = null;
try {
const response = await fetch("http://127.0.0.1:4100/public-stats", {
next: { revalidate: 60, tags: ["public-stats"] },
});
if (response.ok) data = await response.json();
} catch {}
return (
<main>
<h1>공개 데이터 캐시 실험</h1>
<p>개인 작업은 캐시하지 않고, 별도 공개 통계만 60초 캐시합니다.</p>
{data ? (
<p>공개 통계 버전: {data.revision}</p>
) : (
<p>별도 터미널에서 npm run cache-api를 실행하세요.</p>
)}
<form action={refresh}>
<button>공개 통계 재검증 요청</button>
</form>
<p>
max 프로필은 stale-while-revalidate이므로 다음 요청에서 이전 값이 잠깐
보일 수 있습니다.
</p>
</main>
);
}
app/(public)/page.tsx
import ImageExample from "@/app/ui/image-example";
import Link from "next/link";
export default function HomePage() {
return (
<main>
<h1>TaskFlow Next.js</h1>
<p>App Router 기반 작업 관리 서비스입니다.</p>
<Link href="/tasks">작업 목록 보기</Link>
<ImageExample />
</main>
);
}
app/_components/SiteHeader.tsx
import Link from "next/link";
import LogoutButton from "@/app/ui/logout-button";
export default function SiteHeader() {
return (
<header>
<nav aria-label="주 메뉴">
<Link href="/">홈</Link>
<Link href="/tasks">작업</Link>
<Link href="/settings">설정</Link>
<Link href="/login">로그인</Link>
<LogoutButton />
</nav>
</header>
);
}
app/api/auth/[…nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
app/api/tasks/route.ts
import { revalidatePath } from "next/cache";
import { listTasks, addTask } from "@/app/lib/store";
import { auth } from "@/auth";
export async function GET() {
const session = await auth();
if (!session?.user?.id)
return Response.json({ message: "로그인이 필요합니다." }, { status: 401 });
const owner = session.user.id;
return Response.json(listTasks(owner), {
headers: { "Cache-Control": "private, no-store" },
});
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id)
return Response.json({ message: "로그인이 필요합니다." }, { status: 401 });
const owner = session.user.id;
const origin = request.headers.get("origin");
if (origin && origin !== new URL(request.url).origin)
return Response.json(
{ message: "허용되지 않은 출처입니다." },
{ status: 403 },
);
try {
const body: unknown = await request.json();
const title =
typeof body === "object" && body !== null && "title" in body
? body.title
: undefined;
const task = addTask(owner, title);
revalidatePath("/tasks");
return Response.json(task, { status: 201 });
} catch {
return Response.json(
{ message: "올바른 JSON과 2~100자 제목이 필요합니다." },
{ status: 400 },
);
}
}
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";
import Providers from "./providers";
export const metadata: Metadata = {
title: { default: "TaskFlow Next.js", template: "%s | TaskFlow" },
description: "서버와 브라우저 역할을 배우는 TaskFlow 학습 프로젝트",
metadataBase: new URL(process.env.SITE_URL || "http://localhost:3000"),
openGraph: {
title: "TaskFlow",
description: "Next.js 학습 프로젝트",
images: ["/opengraph-image"],
},
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
app/lib/actions.ts
"use server";
import { requireUser } from "./session";
import { addTask, changeTask } from "./store";
import { revalidatePath } from "next/cache";
export async function createTask(formData: FormData) {
const user = await requireUser();
try {
addTask(user.id!, formData.get("title"));
} catch {
return { ok: false, message: "제목은 2~100자로 입력해 주세요." };
}
revalidatePath("/tasks");
return { ok: true, message: "작업을 추가했습니다." };
}
export async function updateTask(id: string, operation: "toggle" | "delete") {
const user = await requireUser();
if (operation !== "toggle" && operation !== "delete")
throw new Error("잘못된 작업입니다.");
changeTask(user.id!, id, operation);
revalidatePath("/tasks");
}
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 "server-only";
import { requireUser } from "./session";
import { listTasks } from "./store";
export async function getTasks() {
const user = await requireUser();
return listTasks(user.id!);
}
export async function getTask(id: string) {
return (await getTasks()).find((t) => t.id === id);
}
export async function getTaskStats() {
const tasks = await getTasks();
return {
total: tasks.length,
completed: tasks.filter((t) => t.completed).length,
};
}
app/lib/session.ts
import "server-only";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export async function requireUser() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
return session.user;
}
app/lib/store.ts
import "server-only";
import type { Task } from "./types";
const globalStore = globalThis as typeof globalThis & {
taskflowStore?: Map<string, Task[]>;
};
const store: Map<string, Task[]> = (globalStore.taskflowStore ??= new Map<
string,
Task[]
>());
export function listTasks(ownerId: string) {
if (!store.has(ownerId))
store.set(ownerId, [
{
id: "task-1",
title: "App Router 공부",
description: "서버와 브라우저의 역할을 구분합니다.",
completed: false,
ownerId,
},
{
id: "task-2",
title: "TaskFlow 만들기",
description: "누적 실습을 완성합니다.",
completed: true,
ownerId,
},
]);
return store.get(ownerId)!.map((task) => ({ ...task }));
}
export function validateTitle(value: unknown) {
if (
typeof value !== "string" ||
value.trim().length < 2 ||
value.trim().length > 100
)
throw new Error("제목은 2~100자로 입력해 주세요.");
return value.trim();
}
export function addTask(ownerId: string, value: unknown) {
const title = validateTitle(value);
const task: Task = {
id: crypto.randomUUID(),
title,
description: "학습 중 추가한 작업",
completed: false,
ownerId,
};
store.set(ownerId, [task, ...listTasks(ownerId)]);
return task;
}
export function changeTask(
ownerId: string,
id: string,
operation: "toggle" | "delete",
) {
const tasks = listTasks(ownerId);
const task = tasks.find((t) => t.id === id && t.ownerId === ownerId);
if (!task) throw new Error("작업이 없거나 권한이 없습니다.");
store.set(
ownerId,
operation === "delete"
? tasks.filter((t) => t.id !== id)
: tasks.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)),
);
}
app/lib/types.ts
export type Task = {
id: string;
title: string;
description: string;
completed: boolean;
ownerId: string;
};
app/login/page.tsx
import { signIn } from "@/auth";
import { AuthError } from "next-auth";
import { redirect } from "next/navigation";
export const metadata = {
title: "로그인",
robots: { index: false, follow: false },
};
export default async function Login({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
async function login(data: FormData) {
"use server";
try {
await signIn("credentials", {
email: data.get("email"),
password: data.get("password"),
redirectTo: "/tasks",
});
} catch (error) {
if (error instanceof AuthError) redirect("/login?error=credentials");
throw error;
}
}
return (
<main>
<h1>로그인</h1>
<p>README의 로컬 계정 설정을 먼저 완료하세요.</p>
{error ? (
<p role="alert">로그인 정보 또는 로컬 인증 설정을 확인해 주세요.</p>
) : null}
<form action={login}>
<label htmlFor="email">이메일</label>
<input
id="email"
name="email"
type="email"
autoComplete="username"
required
/>
<label htmlFor="password">비밀번호</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
/>
<button>로그인</button>
</form>
</main>
);
}
app/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function Image() {
return new ImageResponse(
<div
style={{
display: "flex",
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
background: "#181818",
color: "#fff",
fontSize: 72,
}}
>
TaskFlow Next.js
</div>,
size,
);
}
app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
export default function Providers({ children }: { children: ReactNode }) {
const [client] = useState(() => new QueryClient());
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
app/ui/api-demo.tsx
"use client";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
export default function ApiDemo() {
const router = useRouter();
const query = useQueryClient();
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();
if (response.ok && method === "POST") {
await query.invalidateQueries({ queryKey: ["tasks"] });
router.refresh();
}
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/image-example.tsx
import Image from "next/image";
export default function ImageExample() {
return (
<figure>
<Image
src="https://images.unsplash.com/photo-1499750310107-5fef28a66643"
alt="책상 위 학습 도구"
width={800}
height={533}
sizes="(max-width: 880px) 100vw, 880px"
/>
<figcaption>외부 이미지 요청은 네트워크가 필요합니다.</figcaption>
</figure>
);
}
app/ui/logout-button.tsx
import { signOut } from "@/auth";
export default function LogoutButton() {
return (
<form
action={async () => {
"use server";
await signOut({
redirectTo: "/",
});
}}
>
<button>로그아웃</button>
</form>
);
}
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-actions.tsx
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateTask } from "@/app/lib/actions";
export default function TaskActions({
id,
completed,
}: {
id: string;
completed: boolean;
}) {
const router = useRouter();
const query = useQueryClient();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function run(op: "toggle" | "delete") {
setBusy(true);
try {
await updateTask(id, op);
await query.invalidateQueries({ queryKey: ["tasks"] });
if (op === "delete") router.replace("/tasks");
router.refresh();
} catch {
setError("변경에 실패했습니다. 권한과 연결을 확인하세요.");
} finally {
setBusy(false);
}
}
return (
<section>
<div className="controls">
<button disabled={busy} onClick={() => run("toggle")}>
{completed ? "진행 중으로 변경" : "완료로 변경"}
</button>
<button disabled={busy} onClick={() => run("delete")}>
삭제
</button>
</div>
<p role="alert">{error}</p>
</section>
);
}
app/ui/task-form.tsx
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { createTask } from "@/app/lib/actions";
export default function TaskForm() {
const router = useRouter();
const query = useQueryClient();
const [message, setMessage] = useState("");
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<{ title: string }>();
return (
<form
onSubmit={handleSubmit(async ({ title }) => {
const data = new FormData();
data.set("title", title);
try {
const result = await createTask(data);
setMessage(result.message);
if (result.ok) {
reset();
await query.invalidateQueries({ queryKey: ["tasks"] });
router.refresh();
}
} catch {
setMessage("저장에 실패했습니다. 다시 시도해 주세요.");
}
})}
>
<label htmlFor="task-title">새 작업</label>
<input
id="task-title"
aria-invalid={!!errors.title}
aria-describedby="task-error"
{...register("title", {
setValueAs: (v) => v.trim(),
required: "제목이 필요합니다.",
minLength: { value: 2, message: "2자 이상 입력해 주세요." },
maxLength: { value: 100, message: "100자 이하로 입력해 주세요." },
})}
/>
<p id="task-error" role="alert">
{errors.title?.message}
</p>
<button disabled={isSubmitting}>작업 추가</button>
<p role="status">{message}</p>
</form>
);
}
app/ui/task-list-client.tsx
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import type { Task } from "@/app/lib/types";
export default function TaskListClient({
ownerId,
q,
status,
}: {
ownerId: string;
q: string;
status: string;
}) {
const {
data = [],
isError,
isFetching,
refetch,
} = useQuery<Task[]>({
queryKey: ["tasks", ownerId],
queryFn: async () => {
const response = await fetch("/api/tasks", { cache: "no-store" });
if (!response.ok) throw new Error("조회 실패");
return response.json();
},
staleTime: 60000,
});
const tasks = data.filter(
(t) =>
t.title.toLowerCase().includes(q.toLowerCase()) &&
(status === "done"
? t.completed
: status === "active"
? !t.completed
: true),
);
return (
<section>
{isError ? <p role="alert">목록 조회에 실패했습니다.</p> : null}
<button disabled={isFetching} onClick={() => void refetch()}>
목록 새로 조회
</button>
<ul>
{tasks.map((task) => (
<li key={task.id}>
<Link href={"/tasks/" + task.id}>{task.title}</Link> ·{" "}
{task.completed ? "완료" : "진행 중"}
</li>
))}
</ul>
{tasks.length === 0 ? <p>조건에 맞는 작업이 없습니다.</p> : null}
</section>
);
}
app/ui/task-search.tsx
"use client";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
export default function TaskSearch() {
const search = useSearchParams();
const router = useRouter();
const path = usePathname();
return (
<form
key={search.toString()}
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const params = new URLSearchParams(search);
for (const key of ["q", "status"]) {
const value = String(data.get(key) || "");
if (value) params.set(key, value);
else params.delete(key);
}
router.replace(path + "?" + params.toString());
}}
>
<label htmlFor="q">작업 검색</label>
<input id="q" name="q" defaultValue={search.get("q") || ""} />
<label htmlFor="status">상태</label>
<select
id="status"
name="status"
defaultValue={search.get("status") || "all"}
>
<option value="all">전체</option>
<option value="active">진행 중</option>
<option value="done">완료</option>
</select>
<button>검색 적용</button>
</form>
);
}
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>
);
}
auth.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { scryptSync, timingSafeEqual } from "node:crypto";
export const { handlers, auth, signIn, signOut } = NextAuth({
pages: { signIn: "/login" },
session: { strategy: "jwt" },
providers: [
Credentials({
credentials: { email: { type: "email" }, password: { type: "password" } },
async authorize(input) {
const email = process.env.DEMO_EMAIL;
const salt = process.env.DEMO_PASSWORD_SALT;
const hash = process.env.DEMO_PASSWORD_HASH;
if (
!email ||
!salt ||
!hash ||
typeof input.email !== "string" ||
typeof input.password !== "string" ||
input.password.length > 256 ||
input.email !== email
)
return null;
const expected = Buffer.from(hash, "hex");
const actual = scryptSync(input.password, salt, 64);
if (
expected.length !== actual.length ||
!timingSafeEqual(expected, actual)
)
return null;
return { id: "demo", name: "TaskFlow 학습자", email };
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) token.sub = user.id;
return token;
},
session({ session, token }) {
if (session.user && token.sub) session.user.id = token.sub;
return session;
},
},
});
next-env.d.ts
/// <reference types="next" />
/// <reference types="next/image-types/global" />
next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
cacheComponents: false,
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com", pathname: "/**" },
],
},
};
export default config;
package.json
{
"name": "taskflow-next",
"version": "1.0.28",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build --webpack",
"start": "next start",
"typecheck": "tsc --noEmit",
"setup-auth": "node scripts/setup-auth.mjs",
"cache-api": "node scripts/cache-api.mjs"
},
"dependencies": {
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0",
"axios": "1.20.0",
"server-only": "0.0.1",
"react-hook-form": "7.88.0",
"next-auth": "5.0.0-beta.32",
"@tanstack/react-query": "5.103.1"
},
"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"
}
}
proxy.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export const proxy = auth((request) => {
if (!request.auth?.user)
return NextResponse.redirect(new URL("/login", request.url));
return NextResponse.next();
});
export const config = { matcher: ["/tasks/:path*", "/settings/:path*"] };
scripts/cache-api.mjs
import { createServer } from "node:http";
let revision = 1;
createServer((req, res) => {
if (req.url !== "/public-stats") {
res.writeHead(404).end();
return;
}
if (req.method === "POST") revision++;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ revision, label: "공개 통계 실습" }));
}).listen(4100, "127.0.0.1", () => console.log("공개 통계 실습 API 준비 완료"));
scripts/setup-auth.mjs
import { randomBytes, scryptSync } from "node:crypto";
import { writeFile, access } from "node:fs/promises";
import { createInterface } from "node:readline/promises";
try {
await access(".env.local");
throw new Error(".env.local이 이미 있습니다. 기존 설정을 보존합니다.");
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
const input = createInterface({ input: process.stdin, output: process.stdout });
const email = (await input.question("학습용 이메일: ")).trim();
const password = await input.question(
"학습용 비밀번호(입력 내용이 화면에 보입니다): ",
);
input.close();
if (!email.includes("@") || password.length < 12)
throw new Error("이메일과 12자 이상 학습용 비밀번호가 필요합니다.");
const salt = randomBytes(16).toString("hex");
const values = {
AUTH_SECRET: randomBytes(32).toString("hex"),
DEMO_EMAIL: email,
DEMO_PASSWORD_SALT: salt,
DEMO_PASSWORD_HASH: scryptSync(password, salt, 64).toString("hex"),
};
await writeFile(
".env.local",
Object.entries(values)
.map(([key, value]) => key + "=" + JSON.stringify(value))
.join("\n") + "\n",
{ mode: 0o600 },
);
console.log("로컬 인증 설정을 저장했습니다. 서버를 재시작하세요.");
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"],
"exclude": ["node_modules"]
}
글 소개·학습 준비·전체 목차

Next.js · 28/38 · TaskFlow 누적 실습
Next.js Image remotePatterns 오류 해결
Next.js · 28 / 38 · 선택 참고 · Image remotePatterns
next/image로 외부 이미지를 표시하려면 허용할 원격 URL 패턴을 설정해야 합니다. 무작정 모든 호스트를 허용하기보다 실제 필요한 protocol, hostname, pathname 범위를 지정합니다.
이번 글에서 배우는 내용
- next/image
- remotePatterns
- protocol
- hostname
- pathname
- width/height와 fill
- 외부 이미지 허용 범위
목차
다음 학습
다음 글에서는 next.config redirects와 redirect()를 구분해 URL 변경과 서버 이동을 처리합니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 39개 · 필수 38개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.