개발 컨벤션
TypeScript
타입 선언
type 사용 원칙
typescript
// ✅ 기본적으로 type 사용
type User = {
id: string;
name: string;
email: string;
};
// ❌ interface는 extends 필요 시에만
interface ButtonProps extends HTMLButtonElement {
variant: 'primary' | 'secondary';
}이유:
- declaration merging 방지
- IDE에서 타입 호버 시 속성 정보 직접 표시
- 일관된 코드 스타일
Props 타입
Readonly<{}> 필수
typescript
type ComponentProps = Readonly<{
title: string;
count: number;
onClose?: () => void;
}>;
export default function Component({ title, count, onClose }: ComponentProps) {
return <div>{title}: {count}</div>;
}이유:
- Props 불변성 명시적 보장
- 실수로 props 수정 방지
반복문
for...of 사용
typescript
// ✅ 권장
for (const item of items) {
console.log(item);
}
for (const [key, value] of Object.entries(obj)) {
console.log(key, value);
}
// ❌ 금지
items.forEach((item) => console.log(item));이유:
break,continue사용 가능async/await와 호환- 성능상 이점
문자열 보간
템플릿 리터럴(백틱) 필수
typescript
// ✅ 권장
const message = `안녕하세요, ${userName}님`;
const url = `/api/users/${userId}`;
const className = `button ${isActive ? 'active' : ''}`;
// ❌ 금지
const message = '안녕하세요, ' + userName + '님';
const url = '/api/users/' + userId;이유:
- 가독성이 뛰어남
- 여러 줄 문자열 지원
- 표현식 삽입이 직관적
전역 객체
기본은 globalThis, 브라우저 API는 window
typescript
// ✅ 일반 전역 변수
const myGlobal = globalThis.myCustomGlobal;
// ✅ 브라우저 API (명확성)
window.addEventListener('resize', handleResize);
window.location.href = '/login';
window.matchMedia('(prefers-color-scheme: dark)');Import 순서
typescript
// 1. React
import { useState, useEffect } from 'react';
// 2. 외부 라이브러리
import { useQuery } from '@tanstack/react-query';
// 3. 내부 모듈 (@alias)
import { useToast } from '@shared/hooks';
import { Button } from '@shared/ui';
// 4. 상대 경로
import * as styles from './Component.css';React
함수형 컴포넌트
함수 선언문 사용
typescript
// ✅ default export
export default function Component({ title }: ComponentProps) {
return <div>{title}</div>;
}
// ✅ named export
export function Component({ title }: ComponentProps) {
return <div>{title}</div>;
}
// ✅ hoisting으로 서브 컴포넌트를 아래 배치 가능
export default function Parent() {
return <Child />;
}
function Child() {
return <div>Child Component</div>;
}이유:
- 함수명이 명시적
- Stack trace가 명확 (디버깅 용이)
- Hoisting으로 유연한 코드 구성
Event Handler
handle prefix 사용
typescript
const handleClick = () => {};
const handleSubmit = () => {};
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {};이유:
- 이벤트 prop(
onClick,onSubmit)과 구분 - 일관된 네이밍 컨벤션
Boolean Props
is/has/can prefix
typescript
type Props = Readonly<{
isOpen: boolean;
hasError: boolean;
canEdit: boolean;
disabled?: boolean; // 예외: HTML 속성과 동일
}>;Hooks
typescript
// ✅ use prefix 필수
export function useCustomHook() {
const [state, setState] = useState();
return { state, setState };
}
// Hook 규칙
// - 컴포넌트 최상단에서 호출
// - 조건부 호출 금지
// - 반복문 안에서 호출 금지파일 네이밍
| 타입 | 케이스 | Prefix | 예시 |
|---|---|---|---|
| 컴포넌트 | PascalCase | - | UserProfile.tsx |
| 컴포넌트 폴더 | PascalCase | - | UserProfile/ |
| 훅 | camelCase | use | useForm.ts |
| 유틸 | kebab-case | - | error-handler.ts |
| 상수 | kebab-case | - | routes.ts |
| 타입 | kebab-case | - | user.ts |
| API | kebab-case | - | user.ts |
Dot Naming 규칙
폴더로 역할이 명확하면 dot 제거
typescript
// ❌ 중복
/api/ersu.api.ts /
utils /
string.utils.ts /
// ✅ 폴더명으로 충분
api /
user.ts /
utils /
string.ts;혼합된 경우 dot 사용
typescript
// ✅ 같은 폴더에 여러 역할
/entities/user/api/
├── user.api.ts
├── user.dto.ts
└── user.mapper.ts
// ✅ 항상 dot 사용
Component.css.ts
Component.test.tsx
Button.stories.tsx스타일링
Vanilla Extract
typescript
// Component.css.ts
import { style } from '@vanilla-extract/css';
import { token } from '@shared/styles';
export const container = style({
padding: token.layout.spacing.size20,
backgroundColor: token.background.base,
borderRadius: token.layout.radius.md,
});
export const title = style({
fontSize: '20px',
fontWeight: 'bold',
color: token.text.default.primary,
});typescript
// Component.tsx
import * as styles from './Component.css';
export default function Component() {
return (
<div className={styles.container}>
<h1 className={styles.title}>Title</h1>
</div>
);
}상태 관리
Query Key 중앙 관리
typescript
// shared/config/queryKeys.ts
export const queryKeys = {
user: {
all: ['user'] as const,
current: () => [...queryKeys.user.all, 'current'] as const,
list: (filters: UserFilters) => [...queryKeys.user.all, 'list', filters] as const,
detail: (id: string) => [...queryKeys.user.all, 'detail', id] as const,
},
processing: {
all: ['processing'] as const,
list: (filters: ProcessingFilters) => [...queryKeys.processing.all, 'list', filters] as const,
detail: (id: string) => [...queryKeys.processing.all, 'detail', id] as const,
},
};사용
typescript
// ✅ DO
useQuery({
queryKey: queryKeys.user.current(),
queryFn: getCurrentUser,
});
// ❌ DON'T
useQuery({
queryKey: ['currentUser'],
queryFn: getCurrentUser,
});API 호출
API 함수 패턴
typescript
// entities/user/api/user.api.ts
import { api } from '@shared/api/base';
import { UserDto, UpdateUserDto } from './user.dto';
export async function getCurrentUser(): Promise<UserDto> {
const { data } = await api.get<UserDto>('/api/users/me');
return data;
}
export async function updateUser(userId: string, userData: UpdateUserDto): Promise<UserDto> {
const { data } = await api.put<UserDto>(`/api/users/${userId}`, userData);
return data;
}
export async function getUserPage(request: PageRequest): Promise<PageResponse<UserDto>> {
const { data } = await api.get<PageResponse<UserDto>>('/api/users', {
params: request,
});
return data;
}DTO와 Mapper 패턴
백엔드 API 응답을 프론트엔드 도메인 모델로 변환할 때 Mapper를 사용합니다.
폴더 구조:
entities/menu/
├── api/
│ ├── menu.api.ts # API 함수
│ ├── menu.dto.ts # 백엔드 응답 타입 (DTO)
│ └── menu.mapper.ts # DTO → Model 변환
└── model/
└── menu.ts # 프론트엔드 도메인 모델DTO (menu.dto.ts):
typescript
export type RouteKeyDto = 'HOME' | 'STORAGE' | 'PROCESSING';
export type MenuDto = {
id: string;
title: string;
routeKey: RouteKeyDto;
};Model (menu.ts):
typescript
export enum RouteKey {
HOME = 'home',
STORAGE = 'storage',
PROCESSING = 'processing',
}
export type Menu = {
id: string;
title: string;
routeKey: RouteKey;
};Mapper (menu.mapper.ts):
typescript
import { MenuDto, RouteKeyDto } from './menu.dto';
import { Menu, RouteKey } from '../model/menu';
export const fromMenuDto = (dto: MenuDto): Menu => {
return {
id: dto.id,
title: dto.title,
routeKey: fromRouteKeyDto(dto.routeKey),
};
};
const fromRouteKeyDto = (dto: RouteKeyDto): RouteKey => {
switch (dto) {
case 'HOME':
return RouteKey.HOME;
case 'STORAGE':
return RouteKey.STORAGE;
case 'PROCESSING':
return RouteKey.PROCESSING;
}
};API에서 사용:
typescript
import { api } from '@shared/api/base';
import { fromMenuDto } from './menu.mapper';
export const getMenus = async (): Promise<Menu[]> => {
const { data } = await api.get('/api/menus');
return data.menus.map(fromMenuDto);
};이유:
- DTO는 백엔드 API 스펙을 그대로 반영
- Model은 프론트엔드에 최적화된 타입
- Mapper로 변환 로직 분리 → 백엔드 스펙 변경 시 영향 최소화
에러 처리
ApiError 타입
모든 API 에러는 ApiError 타입으로 통일합니다.
typescript
// shared/api/error.ts
export type ApiError = Error & {
type: ErrorCode; // 에러 코드
message: string; // 사용자에게 표시할 메시지
status: number; // HTTP 상태 코드
detail: string; // 상세 정보
url: string; // 에러 발생 URL
timestamp: string; // 발생 시각
};에러 코드 정의
백엔드와 통일된 에러 코드 사용
백엔드 API에서 정의한 에러 코드를 프론트엔드에서도 동일하게 enum으로 정의하고, 사용자에게 표시할 한글 메시지를 매핑합니다.
네이밍 규칙: {DOMAIN}-{NUMBER} (백엔드와 동일)
typescript
// entities/user/api/user.error.ts
export enum UserErrorCode {
USER_NOT_FOUND = 'USER-1000', // 백엔드와 동일한 코드
DUPLICATED_EMAIL = 'USER-1001',
WRONG_PASSWORD = 'USER-1002',
}
export const userErrorMessages: Record<UserErrorCode, () => string> = {
[UserErrorCode.USER_NOT_FOUND]: () => '사용자 정보를 찾을 수 없습니다.',
[UserErrorCode.DUPLICATED_EMAIL]: () => '중복된 이메일입니다.',
[UserErrorCode.WRONG_PASSWORD]: () => '잘못된 비밀번호입니다.',
};도메인 prefix (백엔드와 통일):
COMM-: 공통 에러USER-: 사용자 관련AUTH-: 인증 관련FILE-: 파일 관련PROC-: 처리 작업 관련
중요: 백엔드에서 새로운 에러 코드가 추가되면 프론트엔드에도 동일하게 추가해야 합니다.
isApiError 타입 가드
에러가 ApiError인지 확인할 때 사용합니다.
typescript
import { isApiError } from '@shared/api/error-handler';
try {
await updateUser(userId, userData);
} catch (error) {
if (isApiError(error)) {
console.log(error.type); // ErrorCode
console.log(error.message); // 사용자용 메시지
}
}컴포넌트에서 에러 처리
TanStack Query의 onError 사용
typescript
const { mutate } = useMutation({
mutationFn: updateUser,
onError: (error) => {
if (isApiError(error)) {
toast.error(error.message);
// 특정 에러 코드별 처리
if (error.type === UserErrorCode.DUPLICATED_EMAIL) {
setEmailError('이미 사용 중인 이메일입니다.');
}
}
},
onSuccess: () => {
toast.success('사용자 정보가 업데이트되었습니다.');
},
});useQuery의 경우
typescript
const { data, error } = useQuery({
queryKey: queryKeys.user.current(),
queryFn: getCurrentUser,
});
useEffect(() => {
if (error && isApiError(error)) {
toast.error(error.message);
}
}, [error]);Toast 사용
typescript
import { useToast } from '@shared/hooks/useToast';
const toast = useToast();
// 성공
toast.success('저장되었습니다.');
// 에러
toast.error('저장에 실패했습니다.');
// 경고
toast.warning('이미 존재하는 항목입니다.');
// 정보
toast.info('처리 중입니다.');폼 관리
useForm Hook
typescript
const { values, errors, handleChange, handleBlur } = useForm({
initialValues: {
email: '',
password: '',
},
validation: {
email: (value) => {
if (!emailRegex.test(value)) return '올바른 이메일을 입력하세요';
return null;
},
password: (value, formState) => {
// 다른 필드 값 참조 가능
if (value !== formState.confirmPassword) {
return '비밀번호가 일치하지 않습니다';
}
return null;
},
},
});Validation 규칙:
- 에러 메시지는 한글
null반환 시 에러 없음- 두 번째 인자로 전체 form state 접근 가능
Git 컨벤션
Commit Message
feat: 새로운 기능 추가
fix: 버그 수정
refactor: 코드 리팩토링
chore: 빌드 설정, 패키지 업데이트
docs: 문서 수정
test: 테스트 코드
style: 코드 포맷팅예시:
feat: 프로필 이미지 업로드 기능 추가
fix: 로그인 시 토큰 갱신 오류 수정
refactor: forEach를 for...of로 변경체크리스트
새 컴포넌트/기능 작성 시:
- [ ] Props는
Readonly<{}>패턴 - [ ] 반복문은
for...of - [ ] 문자열 보간 시 템플릿 리터럴 사용 (문자열 연결 금지)
- [ ] Event handler는
handleprefix - [ ] Boolean props는
is/has/canprefix - [ ] Vanilla Extract로 스타일 작성
- [ ] CSS 값도 템플릿 리터럴로 작성
- [ ] Query Key는 중앙 관리
- [ ] 에러 처리 (
onError,isApiError, Toast) - [ ] 파일명은 규칙 준수
- [ ] TypeScript strict mode 준수
- [ ] ESLint 경고 없음