Skip to content

개발 컨벤션

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/
camelCaseuseuseForm.ts
유틸kebab-case-error-handler.ts
상수kebab-case-routes.ts
타입kebab-case-user.ts
APIkebab-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는 handle prefix
  • [ ] Boolean props는 is/has/can prefix
  • [ ] Vanilla Extract로 스타일 작성
  • [ ] CSS 값도 템플릿 리터럴로 작성
  • [ ] Query Key는 중앙 관리
  • [ ] 에러 처리 (onError, isApiError, Toast)
  • [ ] 파일명은 규칙 준수
  • [ ] TypeScript strict mode 준수
  • [ ] ESLint 경고 없음