CUBE.ai

바이브코딩

규칙 파일 한 장으로 AI 코드 스타일 통일하기 — AGENTS.md 입문

2026.08.23

목차

  1. 규칙 파일은 대체 뭘까? — 신입에게 주는 온보딩 문서
  2. 규칙 없이 세 번 시키면 정말 갈릴까? — 실측 ①
  3. 첫 AGENTS.md, 무엇부터 채워야 할까?
  4. 규칙을 준 뒤에는 통일될까? — 실측 ②
  5. 규칙 파일이 안 먹을 때, 무엇을 의심해야 할까?
  6. 이 문서, 결국 누구를 위한 걸까?

  • 부록 - 1. Agents.md 실습예시
  • 부록 - 2. 실험 원자료 — 같은 프롬프트 6회 실행 결과

A_wide_horizontal_whiteboard-style_educational_inf-1787406923664.webp

지도는 왼쪽에서 오른쪽으로 흐릅니다. 왼쪽 끝에 "AI는 어제를 기억하지 못한다"는 문제, 가운데에 해결책인 규칙 파일 한 장, 오른쪽에는 그 한 장을 넣기 전과 후를 실제로 재본 저울 두 개가 놓여 있습니다. 아래쪽 가지는 "그래도 안 먹을 때"로 빠지고요.


들어가며 — "어제 그 코드랑 왜 다르지?"

월요일에 AI에게 컴포넌트 하나를 맡겼습니다. src/components/에 PascalCase로 얌전히 만들어줬죠. 목요일, 같은 프로젝트에 같은 요구사항으로 하나 더 시켰습니다. 이번엔 src/features/ 아래에 케밥케이스로 나왔고, 에러는 alert()로 처리돼 있습니다. 리뷰어가 묻습니다. "이거 같은 사람이 쓴 코드 맞아요?"

원인은 하나입니다. AI는 대화가 끝나면 우리 팀 규칙을 잊습니다. 매일 아침 기억을 잃은 신입이 새로 출근하는 셈이죠. 답도 하나고요. 기억에 맡기지 말고 문서로 책상에 붙여두면 됩니다.


1. 규칙 파일은 대체 뭘까? — 신입에게 주는 온보딩 문서

A_wide_horizontal_whiteboard-style_educational_inf-1787407263394.webp

지도 왼쪽 첫 블록, 문제 바로 옆 칸입니다.

규칙 파일은 거창한 게 아닙니다. 사람에게 주면 온보딩 문서, AI에게 주면 규칙 파일입니다. 저장소 루트에 마크다운 한 장 놓아두면 끝이고, 정해진 문법도 없습니다. 이미 아실 수도 있지만 이름만 세 가지가 돌아다니니, 한 표로 끊고 갑시다.

파일누가 읽나지금 상태
AGENTS.mdCodex, Cursor, Copilot, Jules, Devin 등 20여 종공통 표준. 2025년 12월 OpenAI가 리눅스 재단 산하 Agentic AI Foundation에 MCP·goose와 함께 기부. 공식 사이트 집계 기준 오픈소스 6만여 개가 사용 중
CLAUDE.mdClaude Code클로드 전용. Claude Code는 AGENTS.md를 직접 읽지 않습니다
.cursorrulesCursor(구버전)사실상 은퇴. .cursor/rules/로 이동

AGENTS.md가 바탕이고, 툴별 파일은 그 위에 얇게 덧칠하는 층입니다. Claude Code를 함께 쓴다면 내용을 복사하지 말고 불러오세요. 공식 문서가 안내하는 방식입니다.

<!-- CLAUDE.md -->
@AGENTS.md
 
## Claude Code
- `src/billing/` 아래를 고칠 때는 plan 모드를 먼저 쓴다.

여기서 실제로 할 일: 지금 프로젝트 루트에 규칙 파일이 있는지 ls로만 확인해 보세요. 작성은 3번 섹션까지 읽고 하셔도 됩니다.

참고 자료


2. 규칙 없이 세 번 시키면 정말 갈릴까? — 실측 ①

자, 지도 오른쪽의 첫 번째 저울입니다. 말로만 "갈린다"고 하면 믿기 어려우니 실제로 재봤습니다.

A_wide_horizontal_whiteboard-style_educational_inf-1787407267112.webp

무대는 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 함수 이름fetchMembersgetMembersfetchMembers
데이터 가져오는 층useMembers 훅으로 분리컴포넌트에서 useQuery 직접훅으로 분리
HTTP 설정axios, baseURL "/api"axios, baseURL ""axios, baseURL "/api" + 타임아웃 10초

컴포넌트 이름은 세 번 다 MemberList.tsx로 같았습니다. 그런데 열어보면 서로 70줄 넘게 달랐습니다. 게다가 응답이 배열인지 { members: [...] }인지 몰라서, 2·3회차는 양쪽을 다 받아내는 방어 코드를 스스로 붙였습니다. 몰라서 쓴 코드가 그대로 저장소에 쌓이는 겁니다.

여기서 실제로 할 일: 여러분 저장소에서도 같은 프롬프트를 3번만 돌려보고, 위 4개 항목만 표에 적어보세요. 30분이면 충분합니다.

참고 자료


3. 첫 AGENTS.md, 무엇부터 채워야 할까?

문제를 눈으로 봤으니, 지도 한가운데 있는 그 한 장을 직접 만들 차례입니다. 프로젝트 루트에 AGENTS.md를 만들고 아래 여섯 칸을 순서대로 채우면 됩니다. 온보딩 문서 목차와 거의 같습니다.

A_wide_horizontal_whiteboard-style_educational_inf-1787407270320.webp

# 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번(스택·폴더·명령어)만 먼저 채우세요. 이 셋이 전체 효과의 절반을 냅니다.

참고 자료


4. 규칙을 준 뒤에는 통일될까? — 실측 ②

두 번째 저울입니다. 위에서 만든 규칙 파일만 루트에 넣고, 2번 섹션과 완전히 똑같은 조건으로 3회를 다시 돌렸습니다.

A_wide_horizontal_whiteboard-style_educational_inf-1787407273766.webp

[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, 직접 호출 13회 모두 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. 규칙 파일이 안 먹을 때, 무엇을 의심해야 할까?

지도 아래쪽으로 빠지는 가지입니다. "썼는데 안 지켜져요"라는 말을 들으면 저는 세 가지부터 확인합니다.

A_wide_horizontal_whiteboard-style_educational_inf-1787407276873.webp

첫째, 너무 깁니다. 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줄을 넘었다면, 새 규칙을 추가하기 전에 낡은 줄부터 지우세요.

참고 자료


6. 이 문서, 결국 누구를 위한 걸까?

지도의 마지막 칸이자, 오늘의 반전입니다.

A_wide_horizontal_whiteboard-style_educational_inf-1787407280580.webp

방금 쓴 AGENTS.md를 다시 읽어보세요. 프로젝트 개요, 기술 스택, 폴더 구조, 코딩 규칙, 실행 명령어, 금지 목록. 다음 주에 입사할 신입에게 그대로 건네도 되는 문서입니다. AI를 위해 썼는데 사람 온보딩 문서가 한 장 생긴 거죠. 어쩌면 순서가 반대일지도 모릅니다. 팀에 이 문서가 없었던 건, 그동안 아무도 "우리 규칙이 뭐냐"를 한 장으로 적어본 적이 없어서니까요. AI는 그 빈칸을 눈에 보이게 만들어준 것뿐입니다.

그런데 규칙 파일이 있어도, AI가 만든 코드는 결국 사람이 승인해야 합니다. 다음 글은 그 이야기입니다. "그래도 통과된 AI 코드, 어떻게 리뷰할까" — 규칙 파일이 걸러주지 못하는 것들에 대해서요.

여기서 실제로 할 일: 완성한 AGENTS.md를 팀 채널에 공유하고 "빠진 규칙 있나요?"라고 한 번만 물어보세요. 이 파일은 그때부터 팀 문서가 됩니다.

참고 자료


부록


1. Agents.md 실습예시

1. 프로젝트 개요

사내 스터디 모임을 관리하는 웹 서비스입니다. 회원 목록 조회, 모임 개설, 참석 체크 세 가지가 핵심 기능입니다. 관리자용 화면은 없습니다. 모든 화면은 로그인한 일반 회원이 봅니다.

2. 기술 스택과 버전

  • Next.js 15 (App Router). Pages Router는 쓰지 않습니다.
  • React 19 / TypeScript 5.7 (strict 모드)
  • 서버 데이터 조회는 @tanstack/react-query v5로 통일합니다.
  • 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.tsxexport 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;
};

댓글

불러오는 중입니다…