목차
- 규칙 파일은 대체 뭘까? — 신입에게 주는 온보딩 문서
- 규칙 없이 세 번 시키면 정말 갈릴까? — 실측 ①
- 첫 AGENTS.md, 무엇부터 채워야 할까?
- 규칙을 준 뒤에는 통일될까? — 실측 ②
- 규칙 파일이 안 먹을 때, 무엇을 의심해야 할까?
- 이 문서, 결국 누구를 위한 걸까?
- 부록 - 1. Agents.md 실습예시
- 부록 - 2. 실험 원자료 — 같은 프롬프트 6회 실행 결과

지도는 왼쪽에서 오른쪽으로 흐릅니다. 왼쪽 끝에 "AI는 어제를 기억하지 못한다"는 문제, 가운데에 해결책인 규칙 파일 한 장, 오른쪽에는 그 한 장을 넣기 전과 후를 실제로 재본 저울 두 개가 놓여 있습니다. 아래쪽 가지는 "그래도 안 먹을 때"로 빠지고요.
들어가며 — "어제 그 코드랑 왜 다르지?"
월요일에 AI에게 컴포넌트 하나를 맡겼습니다. src/components/에 PascalCase로 얌전히 만들어줬죠. 목요일, 같은 프로젝트에 같은 요구사항으로 하나 더 시켰습니다. 이번엔 src/features/ 아래에 케밥케이스로 나왔고, 에러는 alert()로 처리돼 있습니다. 리뷰어가 묻습니다. "이거 같은 사람이 쓴 코드 맞아요?"
원인은 하나입니다. AI는 대화가 끝나면 우리 팀 규칙을 잊습니다. 매일 아침 기억을 잃은 신입이 새로 출근하는 셈이죠. 답도 하나고요. 기억에 맡기지 말고 문서로 책상에 붙여두면 됩니다.
1. 규칙 파일은 대체 뭘까? — 신입에게 주는 온보딩 문서

지도 왼쪽 첫 블록, 문제 바로 옆 칸입니다.
규칙 파일은 거창한 게 아닙니다. 사람에게 주면 온보딩 문서, AI에게 주면 규칙 파일입니다. 저장소 루트에 마크다운 한 장 놓아두면 끝이고, 정해진 문법도 없습니다. 이미 아실 수도 있지만 이름만 세 가지가 돌아다니니, 한 표로 끊고 갑시다.
| 파일 | 누가 읽나 | 지금 상태 |
|---|---|---|
AGENTS.md | Codex, Cursor, Copilot, Jules, Devin 등 20여 종 | 공통 표준. 2025년 12월 OpenAI가 리눅스 재단 산하 Agentic AI Foundation에 MCP·goose와 함께 기부. 공식 사이트 집계 기준 오픈소스 6만여 개가 사용 중 |
CLAUDE.md | Claude Code | 클로드 전용. Claude Code는 AGENTS.md를 직접 읽지 않습니다 |
.cursorrules | Cursor(구버전) | 사실상 은퇴. .cursor/rules/로 이동 |
AGENTS.md가 바탕이고, 툴별 파일은 그 위에 얇게 덧칠하는 층입니다. Claude Code를 함께 쓴다면 내용을 복사하지 말고 불러오세요. 공식 문서가 안내하는 방식입니다.
<!-- CLAUDE.md -->
@AGENTS.md
## Claude Code
- `src/billing/` 아래를 고칠 때는 plan 모드를 먼저 쓴다.
여기서 실제로 할 일: 지금 프로젝트 루트에 규칙 파일이 있는지
ls로만 확인해 보세요. 작성은 3번 섹션까지 읽고 하셔도 됩니다.
참고 자료
- AGENTS.md 공식 사이트 — https://agents.md/
- 리눅스 재단 Agentic AI Foundation 발족 — https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation
- Claude Code 메모리 문서(AGENTS.md 항목) — https://code.claude.com/docs/en/memory
2. 규칙 없이 세 번 시키면 정말 갈릴까? — 실측 ①
자, 지도 오른쪽의 첫 번째 저울입니다. 말로만 "갈린다"고 하면 믿기 어려우니 실제로 재봤습니다.

무대는 Next.js 15 + TypeScript 프로젝트에 Header.tsx 딱 하나만 있는 상태. 의존성에는 axios와 @tanstack/react-query, zustand가 전부 들어 있어 어느 길로 가도 말이 되는 상황입니다. 여기에 서로를 볼 수 없는 에이전트 3개(같은 모델)에게 똑같은 한 문장을 던졌습니다. "회원 목록 API 붙이는 컴포넌트 만들어줘." 같은 공고로 신입 3명을 뽑아 첫날 같은 일을 시킨 셈이죠.
[1회차] [2회차] [3회차]
src/types/member.ts (타입을 api 파일에 같이) src/types/member.ts
src/lib/api/client.ts src/lib/api/client.ts src/lib/api-client.ts
src/lib/api/members.ts src/lib/api/members.ts src/lib/members-api.ts
src/hooks/useMembers.ts (훅 없음) src/hooks/useMembers.ts
src/components/providers/ src/components/providers/ src/providers/
QueryProvider.tsx QueryProvider.tsx QueryProvider.tsx
src/components/MemberList.tsx src/components/MemberList.tsx src/components/MemberList.tsx
| 비교 항목 | 1회차 | 2회차 | 3회차 |
|---|---|---|---|
| 타입 파일 위치 | src/types/ | API 파일 안에 같이 | src/types/ |
| API 함수 이름 | fetchMembers | getMembers | fetchMembers |
| 데이터 가져오는 층 | useMembers 훅으로 분리 | 컴포넌트에서 useQuery 직접 | 훅으로 분리 |
| HTTP 설정 | axios, baseURL "/api" | axios, baseURL "" | axios, baseURL "/api" + 타임아웃 10초 |
컴포넌트 이름은 세 번 다 MemberList.tsx로 같았습니다. 그런데 열어보면 서로 70줄 넘게 달랐습니다. 게다가 응답이 배열인지 { members: [...] }인지 몰라서, 2·3회차는 양쪽을 다 받아내는 방어 코드를 스스로 붙였습니다. 몰라서 쓴 코드가 그대로 저장소에 쌓이는 겁니다.
여기서 실제로 할 일: 여러분 저장소에서도 같은 프롬프트를 3번만 돌려보고, 위 4개 항목만 표에 적어보세요. 30분이면 충분합니다.
참고 자료
- 실험 환경: Next.js 15 / React 19 / TypeScript 5.7, 격리된 동일 모델 에이전트 3회 반복
- AGENTS.md 필드 가이드 2026 — https://www.iuriio.com/blog/posts/2026/05/agents-md-field-guide-2026
3. 첫 AGENTS.md, 무엇부터 채워야 할까?
문제를 눈으로 봤으니, 지도 한가운데 있는 그 한 장을 직접 만들 차례입니다. 프로젝트 루트에 AGENTS.md를 만들고 아래 여섯 칸을 순서대로 채우면 됩니다. 온보딩 문서 목차와 거의 같습니다.

# AGENTS.md
## 1. 프로젝트 개요 ← 무엇을 만드는 서비스인가 (2~3줄)
## 2. 기술 스택과 버전 ← 추측 방지용. "axios 말고 fetch"처럼 선택까지
## 3. 폴더 구조 ← 새 파일을 어디에 놓아야 하는가
## 4. 코딩 규칙 ← 네이밍, 작성 패턴
## 5. 실행·테스트 명령어 ← npm run dev / npm test
## 6. 하지 말아야 할 것 ← 금지 목록
승부는 문장이 얼마나 구체적이냐에서 납니다. 같은 항목이라도 이렇게 갈립니다.
❌ 나쁜 예 — 사람도 못 지키는 문장
- 코드를 깔끔하게 작성한다
- 폴더 구조를 잘 지킨다
- 에러 처리를 꼼꼼히 한다
⭕ 좋은 예 — 지켰는지 눈으로 채점 가능한 문장
- 함수는 40줄을 넘기지 않는다
- 새 컴포넌트는 반드시 `src/components/`에 만든다 (`src/app` 아래 금지)
- 로딩·에러·빈 목록 세 상태를 각각 화면으로 렌더링한다 (`alert()` 금지)
- "회원"은 전부 `Member`로 쓴다. `User`는 금지
- 회원 목록 API 경로는 `GET /api/members`
왼쪽은 표어고 오른쪽은 규칙입니다. AI든 사람이든 채점할 수 없는 문장은 지킬 수도 없습니다.
여기서 실제로 할 일: 여섯 칸 중 2·3·5번(스택·폴더·명령어)만 먼저 채우세요. 이 셋이 전체 효과의 절반을 냅니다.
참고 자료
- AGENTS.md 권장 섹션과 스펙 — https://www.morphllm.com/agents-md-guide
- AGENTS.md 팀 도입 가이드 2026 — https://blog.buildbetter.ai/agents-md-complete-guide-for-engineering-teams-in-2026/
4. 규칙을 준 뒤에는 통일될까? — 실측 ②
두 번째 저울입니다. 위에서 만든 규칙 파일만 루트에 넣고, 2번 섹션과 완전히 똑같은 조건으로 3회를 다시 돌렸습니다.

[1회차] [2회차] [3회차]
src/lib/types.ts src/lib/types.ts src/lib/types.ts
src/lib/api.ts src/lib/api.ts src/lib/api.ts
src/hooks/useMembers.ts src/hooks/useMembers.ts src/hooks/useMembers.ts
src/components/MemberList.tsx src/components/MemberList.tsx src/components/MemberList.tsx
# 2·3회차의 src/lib/api.ts는 주석까지 사실상 같은 파일이 나왔습니다
export async function fetchMembers(): Promise<Member[]> {
const res = await fetch("/api/members");
if (!res.ok) {
throw new Error(`회원 목록을 불러오지 못했습니다. (status: ${res.status})`);
}
return res.json();
}
| 비교 항목 | 규칙 없음 (3회) | 규칙 있음 (3회) |
|---|---|---|
| 파일 위치 | 3인 3색 | 3회 모두 동일 ⭕ |
| 네이밍 | fetchMembers / getMembers 혼재 | 3회 모두 fetchMembers, Member ⭕ |
| 데이터 가져오는 층 | 훅 분리 2, 직접 호출 1 | 3회 모두 useMembers 훅 ⭕ |
| HTTP 도구 | axios 3회 (지시 안 함) | fetch 3회 ⭕ |
| 타입 선언 | interface 3회 | type 3회 ⭕ |
| 에러 처리 | 형태·문구 제각각 | 3회 모두 로딩·에러·빈 목록 3상태 ⭕ |
그런데 정직하게 남겨야 할 게 있습니다. 여전히 갈린 항목이 셋 있었습니다.
- react-query 프로바이더 위치 —
app/providers.tsx/components/QueryProvider.tsx/ 아예 안 만듦 - 응답 형태 — 1회차만
{ members: [...] }로 감싼 형태를 가정 - 어디까지 만들 것인가 — 화면(
page.tsx)까지 연결한 회차는 1회뿐 셋의 공통점이 보이시나요? 제가 규칙 파일에 안 적은 것들입니다. 규칙 파일은 마법이 아니라 거울입니다. 적은 만큼 지켜지고, 안 적은 칸은 그대로 비어 있습니다.
여기서 실제로 할 일: before/after를 돌려보고 여전히 갈린 항목만 규칙 파일에 한 줄씩 추가하세요. 규칙 파일은 그렇게 자랍니다.
참고 자료
- 실측 조건: 2번 섹션과 동일한 프로젝트·프롬프트·모델,
AGENTS.md만 추가 - 참고로 실제 도구는 루트 규칙 파일을 자동으로 읽습니다. 이 실험에서는 그 동작을 프롬프트로 대신했습니다.
5. 규칙 파일이 안 먹을 때, 무엇을 의심해야 할까?
지도 아래쪽으로 빠지는 가지입니다. "썼는데 안 지켜져요"라는 말을 들으면 저는 세 가지부터 확인합니다.

첫째, 너무 깁니다. 300줄짜리 규칙 파일은 신입 첫날에 사규집 300쪽을 던져주는 격입니다. Anthropic 공식 문서는 파일당 200줄 이하를 권하고, OpenAI Codex는 규칙 파일 합산 크기가 기본 32KiB를 넘으면 그 뒤를 읽지 않습니다. 뒷부분이 안 지켜지는 게 아니라 애초에 읽히지 않은 겁니다.
둘째, 추상적입니다. 3번 섹션의 왼쪽 표어들이 여기 다시 등장합니다. "깔끔하게" ❌ → "함수 40줄 이내" ⭕.
셋째, 자동 생성해놓고 방치합니다. /init 한 번 돌려놓고 스택이 바뀌어도 그대로 두면, AI는 없는 규칙을 성실히 따릅니다.
그런데 여기서 솔직하게 말씀드릴 게 있습니다. 연구 결과가 엇갈립니다. 저장소 10곳·PR 124건에서 OpenAI Codex를 측정한 연구는 실행 시간 −28.6%, 출력 토큰 −16.6%를 보고했습니다. 반면 SWE-bench 기반의 ETH 취리히 연구는 규칙 파일이 버그 수정 성공률을 유의미하게 올리지는 못했고, 추론 비용은 20% 넘게 늘었다고 보고합니다. 다만 같은 연구가 이것도 확인했습니다. 규칙에 적힌 도구를 에이전트가 실제로 훨씬 더 많이 썼다는 것.
그러니 기대치는 이렇게 잡으시면 됩니다. 규칙 파일은 AI를 더 똑똑하게 만들지 않습니다. 우리 팀 방식대로 쓰게 만들 뿐입니다. 오늘 실험에서 보신 것도 정확히 그거였고요.
여기서 실제로 할 일: 규칙 파일이 200줄을 넘었다면, 새 규칙을 추가하기 전에 낡은 줄부터 지우세요.
참고 자료
- Claude Code 메모리 문서(200줄 권고) — https://code.claude.com/docs/en/memory
- OpenAI Codex AGENTS.md 설정(
project_doc_max_bytes) — https://learn.chatgpt.com/docs/agent-configuration/agents-md - On the Impact of AGENTS.md Files on the Efficiency of AI Coding Agents (arXiv 2601.20404) — https://arxiv.org/abs/2601.20404
- Evaluating AGENTS.md: Are Repository-Level Context Files Helpful for Coding Agents? (arXiv 2602.11988) — https://arxiv.org/abs/2602.11988
6. 이 문서, 결국 누구를 위한 걸까?
지도의 마지막 칸이자, 오늘의 반전입니다.

방금 쓴 AGENTS.md를 다시 읽어보세요. 프로젝트 개요, 기술 스택, 폴더 구조, 코딩 규칙, 실행 명령어, 금지 목록. 다음 주에 입사할 신입에게 그대로 건네도 되는 문서입니다. AI를 위해 썼는데 사람 온보딩 문서가 한 장 생긴 거죠. 어쩌면 순서가 반대일지도 모릅니다. 팀에 이 문서가 없었던 건, 그동안 아무도 "우리 규칙이 뭐냐"를 한 장으로 적어본 적이 없어서니까요. AI는 그 빈칸을 눈에 보이게 만들어준 것뿐입니다.
그런데 규칙 파일이 있어도, AI가 만든 코드는 결국 사람이 승인해야 합니다. 다음 글은 그 이야기입니다. "그래도 통과된 AI 코드, 어떻게 리뷰할까" — 규칙 파일이 걸러주지 못하는 것들에 대해서요.
여기서 실제로 할 일: 완성한
AGENTS.md를 팀 채널에 공유하고 "빠진 규칙 있나요?"라고 한 번만 물어보세요. 이 파일은 그때부터 팀 문서가 됩니다.
참고 자료
- AGENTS.md 공식 사이트 — https://agents.md/
부록
1. Agents.md 실습예시
1. 프로젝트 개요
사내 스터디 모임을 관리하는 웹 서비스입니다. 회원 목록 조회, 모임 개설, 참석 체크 세 가지가 핵심 기능입니다. 관리자용 화면은 없습니다. 모든 화면은 로그인한 일반 회원이 봅니다.
2. 기술 스택과 버전
- Next.js 15 (App Router). Pages Router는 쓰지 않습니다.
- React 19 / TypeScript 5.7 (strict 모드)
- 서버 데이터 조회는
@tanstack/react-queryv5로 통일합니다. - HTTP 요청은
axios가 아니라 표준fetch를 씁니다. (axios는 제거 예정) - 전역 UI 상태(모달 열림 등)만
zustand를 씁니다. 서버 데이터는 zustand에 넣지 않습니다.
3. 폴더 구조
src/
app/ 라우트(page.tsx, layout.tsx)만 둡니다
components/ 재사용 UI 컴포넌트. 한 파일에 컴포넌트 하나
hooks/ react-query 훅 (use*.ts)
lib/ API 호출 함수, 타입 정의
새 화면용 컴포넌트는 src/app 안에 두지 말고 반드시 src/components/에 만듭니다.
API 호출 함수는 src/lib/api.ts에, 타입은 src/lib/types.ts에 모읍니다.
4. 코딩 규칙
- 컴포넌트 파일명·컴포넌트명은 PascalCase (
MemberList.tsx→export default function MemberList) - 훅 파일명은 camelCase (
useMembers.ts) - "회원"을 가리키는 이름은 전부
Member로 씁니다.User,Users는 금지합니다. - API 응답 타입은
interface가 아니라type으로 선언합니다. - 회원 목록 API 경로는
GET /api/members입니다. - 함수는 40줄을 넘기지 않습니다.
5. 실행·테스트 명령어
npm run dev # 개발 서버
npm run lint # 린트
npm test # vitest 단위 테스트
6. 하지 말아야 할 것
- 임의로 라이브러리를 추가하지 마세요. 위 스택 안에서 해결합니다.
- 에러를
alert()나console.log로 처리하지 마세요. 로딩·에러·빈 목록 세 가지 상태를 컴포넌트 안에서 각각 화면으로 렌더링합니다. any타입을 쓰지 마세요.- 요청하지 않은 파일(README, 설정 파일)을 함께 수정하지 마세요.
2. 실험 원자료 — 같은 프롬프트 6회 실행 결과
프롬프트:
회원 목록 API 붙이는 컴포넌트 만들어줘환경: Next.js 15 / React 19 / TypeScript 5.7 (strict). 시작 상태는src/components/Header.tsx한 개. 의존성에 axios · @tanstack/react-query · zustand가 모두 포함되어 있어 선택지가 열려 있는 상태. A그룹(a1a3)은 규칙 파일 없음, B그룹(b1b3)은 루트에 AGENTS.md 배치. 세션은 서로 완전히 격리.
규칙 없음 · 1회차 (a1)
src/app/layout.tsx
src/app/members/page.tsx
src/components/MemberList.tsx
src/components/providers/QueryProvider.tsx
src/hooks/useMembers.ts
src/lib/api/client.ts
src/lib/api/members.ts
src/types/member.ts
src/app/layout.tsx
import Header from "@/components/Header";
import QueryProvider from "@/components/providers/QueryProvider";
export const metadata = { title: "member-club" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<QueryProvider>
<Header />
<main className="px-6 py-8">{children}</main>
</QueryProvider>
</body>
</html>
);
}
src/app/members/page.tsx
import MemberList from "@/components/MemberList";
export const metadata = { title: "회원 목록 | member-club" };
export default function MembersPage() {
return (
<section className="flex flex-col gap-4">
<h2 className="text-xl font-bold">회원 목록</h2>
<MemberList />
</section>
);
}
src/components/MemberList.tsx
"use client";
import { useMembers } from "@/hooks/useMembers";
export default function MemberList() {
const { data: members, isLoading, isError, error, refetch } = useMembers();
if (isLoading) {
return <p role="status">회원 목록을 불러오는 중입니다...</p>;
}
if (isError) {
return (
<div role="alert" className="flex flex-col gap-2">
<p>
회원 목록을 불러오지 못했습니다
{error instanceof Error ? `: ${error.message}` : "."}
</p>
<button
type="button"
onClick={() => refetch()}
className="self-start rounded border px-3 py-1 text-sm"
>
다시 시도
</button>
</div>
);
}
if (!members || members.length === 0) {
return <p>등록된 회원이 없습니다.</p>;
}
return (
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-4">이름</th>
<th className="py-2 pr-4">이메일</th>
<th className="py-2 pr-4">역할</th>
<th className="py-2 pr-4">가입일</th>
</tr>
</thead>
<tbody>
{members.map((member) => (
<tr key={member.id} className="border-b">
<td className="py-2 pr-4">{member.name}</td>
<td className="py-2 pr-4">{member.email}</td>
<td className="py-2 pr-4">{member.role ?? "-"}</td>
<td className="py-2 pr-4">
{new Date(member.joinedAt).toLocaleDateString("ko-KR")}
</td>
</tr>
))}
</tbody>
</table>
);
}
src/components/providers/QueryProvider.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export default function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
src/hooks/useMembers.ts
import { useQuery } from "@tanstack/react-query";
import { fetchMembers } from "@/lib/api/members";
export function useMembers() {
return useQuery({
queryKey: ["members"],
queryFn: fetchMembers,
});
}
src/lib/api/client.ts
import axios from "axios";
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api",
headers: {
"Content-Type": "application/json",
},
});
src/lib/api/members.ts
import { apiClient } from "@/lib/api/client";
import type { Member } from "@/types/member";
export async function fetchMembers(): Promise<Member[]> {
const { data } = await apiClient.get<Member[]>("/members");
return data;
}
src/types/member.ts
export interface Member {
id: string;
name: string;
email: string;
role?: string;
joinedAt: string;
}
규칙 없음 · 2회차 (a2)
src/app/layout.tsx
src/app/members/page.tsx
src/components/MemberList.tsx
src/components/providers/QueryProvider.tsx
src/lib/api/client.ts
src/lib/api/members.ts
src/app/layout.tsx
import Header from "@/components/Header";
import QueryProvider from "@/components/providers/QueryProvider";
export const metadata = { title: "member-club" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<QueryProvider>
<Header />
<main className="px-6 py-8">{children}</main>
</QueryProvider>
</body>
</html>
);
}
src/app/members/page.tsx
import MemberList from "@/components/MemberList";
export const metadata = { title: "회원 목록 | member-club" };
export default function MembersPage() {
return (
<div className="flex flex-col gap-4">
<h1 className="text-xl font-bold">회원 목록</h1>
<MemberList />
</div>
);
}
src/components/MemberList.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
import { getMembers, membersQueryKey } from "@/lib/api/members";
export default function MemberList() {
const {
data: members,
isLoading,
isError,
error,
refetch,
isFetching,
} = useQuery({
queryKey: membersQueryKey,
queryFn: getMembers,
});
if (isLoading) {
return <p role="status">회원 목록을 불러오는 중입니다...</p>;
}
if (isError) {
return (
<div role="alert" className="flex flex-col gap-2">
<p>회원 목록을 불러오지 못했습니다. {error instanceof Error ? error.message : ""}</p>
<button
type="button"
onClick={() => refetch()}
className="w-fit rounded border px-3 py-1 text-sm"
>
다시 시도
</button>
</div>
);
}
if (!members || members.length === 0) {
return <p>등록된 회원이 없습니다.</p>;
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">회원 목록 ({members.length}명)</h2>
<button
type="button"
onClick={() => refetch()}
disabled={isFetching}
className="rounded border px-3 py-1 text-sm disabled:opacity-50"
>
{isFetching ? "새로고침 중..." : "새로고침"}
</button>
</div>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-4">이름</th>
<th className="py-2 pr-4">이메일</th>
<th className="py-2 pr-4">역할</th>
<th className="py-2 pr-4">가입일</th>
</tr>
</thead>
<tbody>
{members.map((member) => (
<tr key={member.id} className="border-b last:border-0">
<td className="py-2 pr-4">{member.name}</td>
<td className="py-2 pr-4">{member.email}</td>
<td className="py-2 pr-4">{member.role}</td>
<td className="py-2 pr-4">{member.joinedAt}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
src/components/providers/QueryProvider.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export default function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
})
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
src/lib/api/client.ts
import axios from "axios";
/**
* 백엔드 API 공용 axios 인스턴스.
* NEXT_PUBLIC_API_BASE_URL 이 설정되어 있으면 해당 주소를,
* 없으면 같은 오리진의 상대 경로("/api/...")를 baseURL로 사용합니다.
*/
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL ?? "",
headers: {
"Content-Type": "application/json",
},
});
src/lib/api/members.ts
import { apiClient } from "@/lib/api/client";
export interface Member {
id: string;
name: string;
email: string;
role: string;
joinedAt: string;
}
export interface GetMembersResponse {
members: Member[];
}
/**
* 회원 목록을 조회합니다.
* GET /members
*/
export async function getMembers(): Promise<Member[]> {
const { data } = await apiClient.get<GetMembersResponse | Member[]>("/members");
return Array.isArray(data) ? data : data.members;
}
export const membersQueryKey = ["members"] as const;
규칙 없음 · 3회차 (a3)
src/app/layout.tsx
src/app/members/page.tsx
src/components/MemberList.tsx
src/hooks/useMembers.ts
src/lib/api-client.ts
src/lib/members-api.ts
src/providers/QueryProvider.tsx
src/types/member.ts
src/app/layout.tsx
import Header from "@/components/Header";
import QueryProvider from "@/providers/QueryProvider";
export const metadata = { title: "member-club" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<QueryProvider>
<Header />
<main className="px-6 py-8">{children}</main>
</QueryProvider>
</body>
</html>
);
}
src/app/members/page.tsx
import MemberList from "@/components/MemberList";
export const metadata = { title: "회원 목록 | member-club" };
export default function MembersPage() {
return (
<div className="flex flex-col gap-6">
<h1 className="text-xl font-bold">회원 목록</h1>
<MemberList />
</div>
);
}
src/components/MemberList.tsx
"use client";
import { useMembers } from "@/hooks/useMembers";
export default function MemberList() {
const { data: members, isLoading, isError, error, refetch, isFetching } = useMembers();
if (isLoading) {
return <p className="text-sm text-gray-500">회원 목록을 불러오는 중입니다...</p>;
}
if (isError) {
return (
<div className="flex items-center gap-3 text-sm text-red-600">
<span>
회원 목록을 불러오지 못했습니다{error instanceof Error ? `: ${error.message}` : "."}
</span>
<button
type="button"
onClick={() => refetch()}
className="rounded border px-2 py-1 text-xs hover:bg-gray-50"
>
다시 시도
</button>
</div>
);
}
if (!members || members.length === 0) {
return <p className="text-sm text-gray-500">등록된 회원이 없습니다.</p>;
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">회원 목록 ({members.length}명)</h2>
<button
type="button"
onClick={() => refetch()}
disabled={isFetching}
className="rounded border px-2 py-1 text-xs hover:bg-gray-50 disabled:opacity-50"
>
{isFetching ? "새로고침 중..." : "새로고침"}
</button>
</div>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-2 pr-4">이름</th>
<th className="py-2 pr-4">이메일</th>
<th className="py-2 pr-4">역할</th>
<th className="py-2 pr-4">가입일</th>
</tr>
</thead>
<tbody>
{members.map((member) => (
<tr key={member.id} className="border-b last:border-b-0">
<td className="py-2 pr-4">{member.name}</td>
<td className="py-2 pr-4">{member.email}</td>
<td className="py-2 pr-4">{member.role}</td>
<td className="py-2 pr-4">{member.joinedAt}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
src/hooks/useMembers.ts
import { useQuery } from "@tanstack/react-query";
import { fetchMembers } from "@/lib/members-api";
export const membersQueryKey = ["members"] as const;
export function useMembers() {
return useQuery({
queryKey: membersQueryKey,
queryFn: fetchMembers,
staleTime: 30_000,
});
}
src/lib/api-client.ts
import axios from "axios";
/**
* 공용 axios 인스턴스.
* 실제 API 서버 주소는 NEXT_PUBLIC_API_BASE_URL 환경변수로 주입하고,
* 별도 설정이 없으면 Next.js가 같은 오리진에서 서빙하는 /api 경로를 기본값으로 사용합니다.
*/
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api",
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
src/lib/members-api.ts
import { apiClient } from "@/lib/api-client";
import type { Member, MemberListResponse } from "@/types/member";
/**
* 회원 목록을 조회합니다.
* 백엔드가 배열을 그대로 내려주는 경우와 { members, total } 형태로 감싸서 내려주는
* 경우를 모두 지원하도록 응답 형태를 정규화합니다.
*/
export async function fetchMembers(): Promise<Member[]> {
const { data } = await apiClient.get<Member[] | MemberListResponse>("/members");
if (Array.isArray(data)) {
return data;
}
return data.members;
}
src/providers/QueryProvider.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export default function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
src/types/member.ts
export interface Member {
id: string;
name: string;
email: string;
role: string;
joinedAt: string;
}
export interface MemberListResponse {
members: Member[];
total: number;
}
규칙 있음 · 1회차 (b1)
src/app/layout.tsx
src/app/members/page.tsx
src/app/providers.tsx
src/components/MemberList.tsx
src/hooks/useMembers.ts
src/lib/api.ts
src/lib/types.ts
src/app/layout.tsx
import Header from "@/components/Header";
import Providers from "./providers";
export const metadata = { title: "member-club" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<Providers>
<Header />
<main className="px-6 py-8">{children}</main>
</Providers>
</body>
</html>
);
}
src/app/members/page.tsx
import MemberList from "@/components/MemberList";
export default function MembersPage() {
return (
<div>
<h2 className="text-xl font-bold mb-4">회원 목록</h2>
<MemberList />
</div>
);
}
src/app/providers.tsx
"use client";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
src/components/MemberList.tsx
"use client";
import { useMembers } from "@/hooks/useMembers";
export default function MemberList() {
const { data, isLoading, isError, error } = useMembers();
if (isLoading) {
return <p>회원 목록을 불러오는 중입니다...</p>;
}
if (isError) {
return (
<p role="alert">
{error instanceof Error
? error.message
: "회원 목록을 불러오는 중 오류가 발생했습니다."}
</p>
);
}
const members = data?.members ?? [];
if (members.length === 0) {
return <p>등록된 회원이 없습니다.</p>;
}
return (
<ul className="flex flex-col gap-2">
{members.map((member) => (
<li key={member.id} className="flex justify-between border-b py-2">
<span>{member.name}</span>
<span className="text-sm text-gray-500">{member.email}</span>
</li>
))}
</ul>
);
}
src/hooks/useMembers.ts
import { useQuery } from "@tanstack/react-query";
import { fetchMembers } from "@/lib/api";
export function useMembers() {
return useQuery({
queryKey: ["members"],
queryFn: fetchMembers,
});
}
src/lib/api.ts
import type { MemberListResponse } from "./types";
export async function fetchMembers(): Promise<MemberListResponse> {
const res = await fetch("/api/members");
if (!res.ok) {
throw new Error(`회원 목록을 불러오지 못했습니다. (status: ${res.status})`);
}
return res.json() as Promise<MemberListResponse>;
}
src/lib/types.ts
export type Member = {
id: string;
name: string;
email: string;
joinedAt: string;
};
export type MemberListResponse = {
members: Member[];
};
규칙 있음 · 2회차 (b2)
src/app/layout.tsx
src/components/MemberList.tsx
src/components/QueryProvider.tsx
src/hooks/useMembers.ts
src/lib/api.ts
src/lib/types.ts
src/app/layout.tsx
import Header from "@/components/Header";
import QueryProvider from "@/components/QueryProvider";
export const metadata = { title: "member-club" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<QueryProvider>
<Header />
<main className="px-6 py-8">{children}</main>
</QueryProvider>
</body>
</html>
);
}
src/components/MemberList.tsx
"use client";
import { useMembers } from "@/hooks/useMembers";
export default function MemberList() {
const { data: members, isPending, isError, error } = useMembers();
if (isPending) {
return <p role="status">회원 목록을 불러오는 중입니다...</p>;
}
if (isError) {
return (
<p role="alert">
회원 목록을 불러오지 못했습니다: {error instanceof Error ? error.message : "알 수 없는 오류"}
</p>
);
}
if (members.length === 0) {
return <p>등록된 회원이 없습니다.</p>;
}
return (
<ul className="flex flex-col gap-2">
{members.map((member) => (
<li key={member.id} className="flex items-center justify-between border-b py-2">
<span className="font-medium">{member.name}</span>
<span className="text-sm text-gray-500">{member.email}</span>
</li>
))}
</ul>
);
}
src/components/QueryProvider.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export default function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
src/hooks/useMembers.ts
import { useQuery } from "@tanstack/react-query";
import { fetchMembers } from "@/lib/api";
export function useMembers() {
return useQuery({
queryKey: ["members"],
queryFn: fetchMembers,
});
}
src/lib/api.ts
import type { Member } from "@/lib/types";
export async function fetchMembers(): Promise<Member[]> {
const res = await fetch("/api/members");
if (!res.ok) {
throw new Error(`회원 목록을 불러오지 못했습니다. (status: ${res.status})`);
}
return res.json();
}
src/lib/types.ts
export type Member = {
id: string;
name: string;
email: string;
joinedAt: string;
};
규칙 있음 · 3회차 (b3)
src/app/layout.tsx
src/components/MemberList.tsx
src/hooks/useMembers.ts
src/lib/api.ts
src/lib/types.ts
src/app/layout.tsx
import Header from "@/components/Header";
export const metadata = { title: "member-club" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<Header />
<main className="px-6 py-8">{children}</main>
</body>
</html>
);
}
src/components/MemberList.tsx
"use client";
import { useMembers } from "@/hooks/useMembers";
export default function MemberList() {
const { data: members, isLoading, isError, error } = useMembers();
if (isLoading) {
return <p role="status">회원 목록을 불러오는 중입니다...</p>;
}
if (isError) {
return (
<p role="alert">
회원 목록을 불러오지 못했습니다.
{error instanceof Error ? ` ${error.message}` : ""}
</p>
);
}
if (!members || members.length === 0) {
return <p>등록된 회원이 없습니다.</p>;
}
return (
<ul className="flex flex-col gap-2">
{members.map((member) => (
<li
key={member.id}
className="flex items-center justify-between border-b py-2"
>
<span className="font-medium">{member.name}</span>
<span className="text-sm text-gray-500">{member.email}</span>
</li>
))}
</ul>
);
}
src/hooks/useMembers.ts
import { useQuery } from "@tanstack/react-query";
import { fetchMembers } from "@/lib/api";
export function useMembers() {
return useQuery({
queryKey: ["members"],
queryFn: fetchMembers,
});
}
src/lib/api.ts
import type { Member } from "@/lib/types";
export async function fetchMembers(): Promise<Member[]> {
const res = await fetch("/api/members");
if (!res.ok) {
throw new Error(`회원 목록을 불러오지 못했습니다. (status: ${res.status})`);
}
return res.json() as Promise<Member[]>;
}
src/lib/types.ts
export type Member = {
id: string;
name: string;
email: string;
joinedAt: string;
};
댓글
불러오는 중입니다…