AI가 팀 컨벤션을 100% 따르게 만드는 법
CLAUDE.md에서 가장 자주 업데이트하게 되는 섹션이 코딩 규약입니다.
"Claude Code에 이렇게 작성해 달라"는 규칙을 하나씩 추가해 나갈수록 출력 품질이 조금씩, 그러나 확실하게 높아집니다.
이 장에서는 언어·프레임워크별로 바로 복붙해서 쓸 수 있는 코딩 규약 예시와, 규약을 잘 작성하는 원칙을 소개합니다.
"깔끔한 코드를 작성한다"는 너무 모호합니다. AI는 이 문장을 보고 나름의 기준으로 해석하는데, 그 결과가 항상 여러분의 의도와 일치하지 않습니다. 구체적인 수치나 조건으로 풀어서 작성하세요.
# ❌ 모호한 작성 방식
- 깔끔한 코드를 작성한다
- 적절한 에러 핸들링을 한다
# ✅ 구체적인 작성 방식
- 함수는 30행 이내로 작성한다. 초과하는 경우에는 책임에 따라 분할한다
- 외부 API 호출은 try-catch로 감싸고, 에러는 AppError 클래스로 래핑한 뒤 상위로 전파한다
"A 또는 B 중 어느 쪽이든 괜찮다"는 식의 모호함을 남기면 AI의 출력이 매 번 달라집니다. 하나를 선택해 강제하세요.
# ❌ 모호한 방식
- interface 또는 type으로 타입을 정의한다
# ✅ 명확한 방식
- 타입 정의에는 type을 사용한다. interface는 사용하지 않는다
팀 내에서 아직 합의가 안 된 사항이라도, 일단 하나를 골라 CLAUDE.md에 명시해 두는 편이 낫습니다. AI의 출력이 일관되어야 코드 리뷰 부담이 줄어듭니다.
모든 규칙에는 예외가 있습니다. 예외를 명시하지 않으면 AI가 규칙을 기계적으로 적용해 오히려 역효과가 나는 경우가 생깁니다.
- 변수명은 영어로 작성한다. 단, 국제화(i18n) 키에는 한국어를 포함해도 된다
- any 타입은 사용하지 않는다. 단, 외부 라이브러리의 타입 정의가 불완전한 경우는
`// eslint-disable-next-line @typescript-eslint/no-explicit-any` 주석을 붙여 허용
- console.log는 사용하지 않는다. 단, 개발 서버 진단용 코드에는 `// TODO: remove` 주석 필수
## TypeScript 규약
### 타입
- `strict: true`를 전제로 한다
- 타입 정의는 `type`을 사용한다. `interface`는 외부 라이브러리와의 호환성이 필요한 경우에만 사용
- `any`는 금지. `unknown`을 사용하고, 타입 가드로 좁힌다
- 유니온 타입은 3개까지. 그 이상은 Discriminated Union 패턴을 사용한다
- 제네릭 타입 파라미터명: 단일 문자(`T`)보다 의미 있는 이름 사용 (`TData`, `TError`)
### 명명
- 변수·함수: camelCase
- 타입·클래스·컴포넌트: PascalCase
- 상수: UPPER_SNAKE_CASE (파일 스코프 상수에만)
- boolean 변수: `is`, `has`, `can`, `should` 접두사
- 이벤트 핸들러: `handle` 접두사 (예: `handleSubmit`, `handleChange`)
### 함수
- 순수 함수를 우선한다. 부작용이 있는 함수는 이름에 명시한다 (`saveUser`, `deleteFile`)
- 인수가 3개 이상이면 객체 인수로 만든다
- 반환값 타입은 생략하지 않고 명시한다
- 비동기 함수는 `async/await`를 사용한다. `.then()` 체이닝은 사용하지 않는다
### 임포트
- 상대 경로는 `@/` 별칭을 사용한다 (`../../../components` 금지)
- 미사용 import는 허용하지 않는다
- 타입만의 임포트는 `import type`을 사용한다
- 임포트 순서: 외부 라이브러리 → 내부 모듈 → 타입 (빈 줄로 구분)
## React 규약
### 컴포넌트
- 함수 컴포넌트만 사용한다. 클래스 컴포넌트는 사용하지 않는다
- `export default`는 사용하지 않는다. named export만 사용한다
- props 타입은 같은 파일 안에서 `type Props = { ... }`로 정의한다
- children prop이 필요한 경우는 `React.PropsWithChildren<Props>`를 사용한다
- 컴포넌트 파일 1개당 컴포넌트 1개. 여러 컴포넌트를 한 파일에 담지 않는다
### 상태 관리
- 로컬 상태: `useState` / `useReducer`
- 서버 상태: TanStack Query (SWR은 사용하지 않는다)
- 전역 상태: Zustand (꼭 필요한 경우에만, 최소한으로)
- URL 파라미터로 표현할 수 있는 상태는 URL로 관리한다 (`nuqs` 라이브러리 사용)
- 파생 상태는 `useMemo` 없이 렌더 시점에 계산한다
### 성능
- `useMemo`와 `useCallback`은 프로파일링으로 필요하다고 확인된 경우에만 사용한다
- 리스트 렌더링은 `key`에 안정적인 고유값을 사용한다 (`index` 사용 금지)
- 이미지는 `next/image`를 사용하고, `width`와 `height`를 반드시 명시한다
- 동적 임포트(`next/dynamic`)는 초기 번들 크기가 50KB를 넘는 컴포넌트에 적용한다
### 테스트
- 컴포넌트 테스트는 Testing Library를 사용한다
- `getByTestId`는 최후의 수단. `getByRole`, `getByLabelText`, `getByText`를 우선한다
- 유저 이벤트는 `@testing-library/user-event`를 사용한다 (`fireEvent` 금지)
- 스냅샷 테스트는 사용하지 않는다 (변경에 취약하고 의미가 없음)
## API 설계 규약
### 엔드포인트
- RESTful하게 설계한다
- 리소스명은 복수형 (`/users`, `/orders`)
- 중첩은 2계층까지 (`/users/:id/orders`는 OK, `/users/:id/orders/:orderId/items`는 NG)
- 액션은 동사가 아닌 HTTP 메서드로 표현한다 (`POST /orders`이지, `/createOrder`가 아님)
- 버전 접두사 필수: `/api/v1/...`
### 응답 형식
성공 시:
\`\`\`json
{
"data": { ... },
"meta": { "total": 100, "page": 1, "perPage": 20 }
}
\`\`\`
에러 시:
\`\`\`json
{
"error": {
"code": "VALIDATION_ERROR",
"message": "이메일은 필수입니다",
"details": [
{ "field": "email", "message": "이메일 형식이 올바르지 않습니다" }
]
}
}
\`\`\`
### 유효성 검사
- 요청 바디는 Zod 스키마로 검증한다
- 유효성 검사 에러는 400을 반환한다
- 스키마는 `schemas/` 디렉토리에 리소스 단위로 배치한다
- 스키마는 OpenAPI 문서 자동 생성에도 활용한다
### HTTP 상태 코드
- 200: 조회·수정 성공
- 201: 생성 성공
- 204: 삭제 성공 (응답 바디 없음)
- 400: 유효성 검사 에러
- 401: 미인증 (토큰 없음 또는 만료)
- 403: 권한 부족 (인증은 됐지만 권한 없음)
- 404: 리소스 없음
- 409: 충돌 (중복 생성 등)
- 500: 서버 에러 (사용자에게 내부 정보를 노출하지 않는다)
## 데이터베이스 규약
### 테이블 설계
- 테이블명: snake_case, 복수형 (`users`, `order_items`)
- 컬럼명: snake_case
- 주키: `id` (UUIDv7)
- 타임스탬프: `created_at`, `updated_at`을 전 테이블에 부여
- 논리 삭제: `deleted_at` (NULL = 유효, 날짜시간 = 삭제됨)
- boolean 컬럼: `is_` 접두사 (`is_active`, `is_verified`)
- 개인정보 컬럼: 암호화 여부를 주석으로 명시 (`-- 암호화 저장`)
### 마이그레이션
- 마이그레이션 파일은 자동 생성된 것을 사용한다 (수동 편집 금지)
- 파괴적 변경 (컬럼 삭제, 타입 변경)은 새 마이그레이션으로 단계적으로 수행한다
- 프로덕션 적용 전 스테이징 환경에서 반드시 검증한다
- 시드 데이터는 `seed.ts`에 작성한다
### 쿼리 규칙
- N+1 쿼리 금지. 연관 데이터는 JOIN 또는 일괄 조회로 처리한다
- `SELECT *` 사용 금지. 필요한 컬럼만 명시한다
- 페이지네이션이 없는 전체 조회는 10,000건을 초과하지 않도록 경고 로그를 남긴다
## 국내 서비스 특화 규약
### 전화번호 처리
- 전화번호는 하이픈 없는 11자리로 정규화하여 저장한다 (예: `01012345678`)
- 화면 표시 시에는 `lib/utils/formatPhone.ts`의 `formatPhone()` 함수를 사용한다
- 전화번호를 로그에 출력하지 않는다
### 금액 처리
- 금액은 항상 정수(원 단위)로 저장한다. 소수점 금액 금지
- 화면 표시 시에는 `lib/utils/formatPrice.ts`의 `formatPrice()` 함수를 사용한다
(예: `15000` → `"15,000원"`)
- 결제 금액 계산은 반드시 서버에서 수행한다. 클라이언트 계산 금지
### 날짜·시간
- DB 저장: UTC
- 화면 표시: KST (Asia/Seoul)로 변환
- `new Date()` 직접 사용 금지. `lib/utils/date.ts`의 헬퍼 함수를 사용한다
- 날짜 표시 형식: `yyyy.MM.dd` (예: `2025.03.20`)
- 시간 표시 형식: `HH:mm` (예: `14:30`)
### 에러 메시지
- 사용자에게 보이는 에러 메시지는 한국어로 작성한다
- 로그용 에러 메시지(서버)는 영어로 작성한다- 에러 메시지 문자열은 `constants/errorMessages.ts`에 상수로 정의한다
CLAUDE.md의 규약은 처음부터 완벽하게 작성할 필요가 없습니다. 다음 사이클로 천천히 키워 나가세요.
## 코딩 규약
- TypeScript strict 모드 사용
- 테스트는 반드시 작성한다
- any 타입 금지
이 3줄만 있어도 Claude Code의 출력에 눈에 띄는 변화가 생깁니다.
Claude Code의 출력을 보고 "여기는 이렇게 해줬으면 했는데"라고 느낀 순간이 곧 규칙을 추가할 타이밍입니다. 나중에 몰아서 정리하려 하면 잊어버리기 쉽습니다.
예를 들어 Claude Code가 interface를 사용한 코드를 생성했는데 type을 원했다면, 즉시 추가합니다:
- 타입 정의에는 type을 사용한다. interface는 사용하지 않는다
규칙이 쌓이다 보면 어느 순간 모순이 발생합니다. 규칙이 20~30개를 넘으면 전체를 검토하는 시간을 갖습니다.
카테고리별 그룹화: 타입, 명명, 함수, 임포트 등으로 분류
중복 제거: 같은 내용을 다른 표현으로 쓴 항목 정리
모순 해소: "A를 사용한다"와 "B를 사용한다"가 동시에 있는 경우 하나로 통일
불필요한 항목 삭제: 이미 린터·포매터가 자동으로 처리하는 항목 제거
©2024-2026 MDRules dev., Hand-crafted & made with Jaewoo Kim.
이메일문의: jaewoo@mdrules.dev
AI강의/개발/기술자문, AI 업무 자동화 컨설팅 문의: https://talk.naver.com/ct/w5umt5
AI 업무 자동화/에이전트/워크플로우설계 컨설팅/AI교육: https://mdrules.dev
이 작가의 멤버십 구독자 전용 콘텐츠입니다.
작가의 명시적 동의 없이 저작물을 공유, 게재 시 법적 제재를 받을 수 있습니다.
오직 멤버십 구독자만 볼 수 있는,
이 작가의 특별 연재 콘텐츠