Ch 05. 팀 프로젝트 타입 규칙 세우기
혼자 쓰는 코드라면 개인 취향대로 해도 됩니다. 하지만 팀 프로젝트에서 타입 규칙이 없으면 같은 개념을 각자 다른 방식으로 표현하게 됩니다. 어떤 파일은 interface, 어떤 파일은 type, 어떤 곳은 IUser, 어떤 곳은 UserType. 코드 리뷰마다 같은 이야기를 반복하게 되죠.
이 챕터에서는 팀이 합의해야 할 타입 규칙들을 정리하고, ESLint로 규칙을 자동 강제하는 방법까지 다룹니다.
interface vs type — 언제 무엇을 쓸까
가장 많이 논쟁이 되는 주제입니다. 정답은 없지만, 팀 안에서 일관성이 있어야 합니다.
// 파일: src/team/interface-vs-type.ts// interface — 객체 구조를 정의할 때 권장// 장점: 선언 병합(declaration merging) 가능, 에러 메시지가 더 명확interface User { id: number; name: string; email: string;}// 확장이 자유로움interface AdminUser extends User { role: "admin"; permissions: string[];}// type — 유니온, 인터섹션, 유틸리티 타입에 필요// interface로는 표현할 수 없는 타입에 사용type UserId = number;type UserRole = "admin" | "editor" | "viewer";type UserOrAdmin = User | AdminUser;type UserKeys = keyof User;type ReadonlyUser = Readonly<User>;
권장 팀 규칙 예시. 객체 형태를 정의할 때는 interface를 쓴다. 유니온, 인터섹션, 유틸리티 타입, 원시 타입 별칭에는 type을 쓴다.
네이밍 컨벤션
// 파일: src/team/naming-convention.ts// 타입과 인터페이스 — PascalCaseinterface OrderItem { /* ... */ }type PaymentMethod = "card" | "transfer";// 제네릭 타입 매개변수 — 단일 대문자 또는 설명적 PascalCasefunction identity<T>(value: T): T { return value; }function mapArray<TItem, TResult>(arr: TItem[], fn: (item: TItem) => TResult): TResult[] { return arr.map(fn);}// 열거형 — PascalCase (enum 사용 시)// 실무에서는 as const 객체를 더 많이 씀 (런타임 비용 없음)const Direction = { Up: "UP", Down: "DOWN", Left: "LEFT", Right: "RIGHT",} as const;type Direction = typeof Direction[keyof typeof Direction];// 브랜드 타입 — 기본 타입 이름 + Id/Brand suffixtype UserId = number & { readonly __brand: "UserId" };type ProductId = string & { readonly __brand: "ProductId" };// 피해야 할 네이밍// interface IUser {} — I 접두어 불필요 (Java 스타일, TS에선 비관용적)// type UserType = ... — Type 접미어 불필요// type TUser = ... — T 접두어 불필요 (제네릭 매개변수와 혼동)
공용 타입 파일 구조
src/
types/
index.ts ← 전체 re-export
api.ts ← API 요청/응답 타입
domain/
user.ts ← 도메인 타입 (User, UserRole 등)
order.ts ← 도메인 타입 (Order, OrderItem 등)
product.ts
store/
user-store.ts ← 상태 관리 타입
forms/
signup-form.ts ← 폼 타입 (Zod 스키마 infer)
// 파일: src/types/domain/user.tsexport interface User { id: number; name: string; email: string; role: UserRole; createdAt: Date;}export type UserRole = "admin" | "editor" | "viewer";export type CreateUserInput = Omit<User, "id" | "createdAt">;export type UpdateUserInput = Partial<Omit<User, "id" | "createdAt">>;export type PublicUser = Omit<User, "createdAt">;
// 파일: src/types/index.tsexport type { User, UserRole, CreateUserInput, UpdateUserInput, PublicUser } from "./domain/user";export type { Order, OrderItem, OrderStatus } from "./domain/order";export type { ApiResponse, PaginatedResponse, ApiError } from "./api";
API 타입 표준화
팀 전체가 동일한 API 응답 타입을 쓰도록 공통 래퍼를 정의합니다.
// 파일: src/types/api.ts// 성공/실패를 모두 담는 공통 응답 타입export type ApiResponse<T> = | { success: true; data: T; message?: string } | { success: false; error: string; code: number };// 페이지네이션 응답export interface PaginatedResponse<T> { items: T[]; total: number; page: number; pageSize: number; hasNext: boolean;}// 에러 타입export interface ApiError { code: number; message: string; details?: Record<string, string[]>;}// 사용 예async function fetchUsers(page: number): Promise<ApiResponse<PaginatedResponse<User>>> { const response = await fetch(`/api/users?page=${page}`); return response.json();}
tsconfig.json 팀 설정
{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "noImplicitAny": true, "strictNullChecks": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "baseUrl": ".", "paths": { "@types/*": ["src/types/*"], "@utils/*": ["src/utils/*"], "@services/*": ["src/services/*"] }, "skipLibCheck": true }}
noUncheckedIndexedAccess와 exactOptionalPropertyTypes는 기본 strict에 포함되지 않지만, 팀 코드 품질을 높이는 데 효과적입니다.
ESLint로 타입 규칙 강제하기
규칙을 문서로만 두면 지켜지지 않습니다. ESLint로 자동 검사합니다.
npm install --save-dev eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
// 파일: eslint.config.js (ESLint 9.x flat config)import js from "@eslint/js";import tsPlugin from "@typescript-eslint/eslint-plugin";import tsParser from "@typescript-eslint/parser";export default [ js.configs.recommended, { files: ["**/*.ts", "**/*.tsx"], languageOptions: { parser: tsParser, parserOptions: { project: "./tsconfig.json", }, }, plugins: { "@typescript-eslint": tsPlugin, }, rules: { // any 사용 금지 (경고로 시작, 팀이 익숙해지면 error로 격상) "@typescript-eslint/no-explicit-any": "warn", // 암묵적 any 금지 "@typescript-eslint/no-unsafe-assignment": "error", "@typescript-eslint/no-unsafe-member-access": "error", "@typescript-eslint/no-unsafe-call": "error", // 타입 단언 제한 — as any 금지, as unknown as T는 허용 "@typescript-eslint/no-unsafe-type-assertion": "off", // 사용하지 않는 변수 금지 (언더스코어 제외) "@typescript-eslint/no-unused-vars": [ "error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, ], // interface 선호 (객체 타입에) "@typescript-eslint/consistent-type-definitions": ["error", "interface"], // import type 강제 (타입 전용 import) "@typescript-eslint/consistent-type-imports": [ "error", { prefer: "type-imports", fixStyle: "inline-type-imports" }, ], // 빈 interface 금지 "@typescript-eslint/no-empty-interface": "error", // 불필요한 타입 단언 금지 "@typescript-eslint/no-unnecessary-type-assertion": "error", // Promise 반환 함수에 await 누락 경고 "@typescript-eslint/require-await": "warn", // I 접두어 금지 (IUser → User) "@typescript-eslint/naming-convention": [ "error", { selector: "interface", format: ["PascalCase"], custom: { regex: "^I[A-Z]", match: false }, }, { selector: "typeAlias", format: ["PascalCase"], }, ], }, },];
코드 리뷰 체크리스트
ESLint로 자동화할 수 없는 부분은 코드 리뷰 체크리스트로 관리합니다.
## 타입 관련 코드 리뷰 체크리스트
### 필수 확인
- [ ] any를 사용했다면 주석으로 이유를 설명했는가.
- [ ] 새로운 도메인 타입은 src/types/domain/에 정의했는가.
- [ ] API 요청/응답 타입은 ApiResponse 래퍼를 사용했는가.
- [ ] 외부 데이터(API, 폼)는 Zod로 런타임 검증을 했는가.
### 타입 설계
- [ ] 불가능한 상태를 표현하는 타입 조합이 없는가.
- [ ] 객체 타입에 interface를, 유니온/유틸리티에 type을 사용했는가.
- [ ] 변경되지 않아야 하는 데이터에 readonly를 붙였는가.
- [ ] 공용 타입은 index.ts를 통해 re-export했는가.
점진적 도입 전략
기존 프로젝트에 이 모든 규칙을 한 번에 적용하면 수백 개의 에러가 쏟아집니다. 단계적으로 도입하는 것이 현실적입니다.
1단계로 noImplicitAny: true만 추가합니다. 암묵적 any를 명시적으로 바꾸거나 타입을 붙이는 작업입니다.
2단계로 strictNullChecks: true를 추가합니다. null/undefined 처리가 명확해집니다.
3단계로 @typescript-eslint/no-explicit-any를 경고로 설정합니다. 기존 any에 주석을 달고 새로운 코드에서는 금지합니다.
4단계로 나머지 strict 옵션과 ESLint 규칙을 추가합니다. 새 파일부터 적용하고 기존 파일은 점진적으로 수정합니다.
규칙은 팀이 함께 만들고 함께 지켜야 합니다. ESLint로 강제할 수 있는 것은 강제하고, 나머지는 코드 리뷰로 보완하는 방식이 현실적입니다. 중요한 것은 규칙의 엄격함이 아니라 일관성입니다.