iBetter Books
수정

프로젝트 폴더 구조 잡기

책상이 지저분하면 필요한 것을 찾는 데 시간이 걸립니다. 책은 책꽂이에, 메모는 메모판에, 자주 쓰는 것은 손 닿는 곳에. 정리된 책상에서는 필요한 것이 어디 있는지 바로 압니다. 코드도 마찬가지입니다. 어디에 무엇이 있는지 예측할 수 있는 구조가 좋은 구조입니다.

이 챕터에서는 TypeScript 프로젝트의 폴더 구조를 어떻게 잡을지 살펴봅니다. "유일하게 올바른 구조"는 없습니다. 하지만 많은 팀이 비슷한 고민 끝에 비슷한 결론에 도달했습니다. 그 공통된 패턴을 배웁니다.

가장 단순한 구조

연습 중인 소규모 프로젝트나 학습용 코드라면 이 정도면 충분합니다.

my-project/
├── src/
│   └── index.ts
├── tsconfig.json
└── package.json

src/ 폴더 하나에 모든 TypeScript 파일을 넣습니다. 컴파일하면 dist/ 폴더에 JavaScript 파일이 생깁니다. 파일이 10개 미만이라면 이 구조로도 불편함이 없습니다.

기본 구조 — 역할별로 나누기

파일이 20~30개를 넘어가기 시작하면 역할별로 폴더를 나눌 때가 됩니다.

my-project/
├── src/
│   ├── index.ts          ← 진입점
│   ├── types/            ← 타입 정의
│   │   └── index.ts
│   ├── utils/            ← 유틸리티 함수
│   │   ├── array.ts
│   │   ├── string.ts
│   │   └── index.ts      ← 배럴 파일
│   └── services/         ← 비즈니스 로직
│       ├── user.ts
│       └── product.ts
├── tsconfig.json
└── package.json

각 폴더의 역할이 분명합니다. types/에는 타입만, utils/에는 순수 함수만, services/에는 비즈니스 로직만 들어갑니다.

이 구조를 코드로 만들어봅시다.

먼저 타입을 정의합니다.

// 새 파일: src/types/index.tsexport interface User {  id: number;  name: string;  email: string;  role: "admin" | "member" | "guest";}export interface Product {  id: number;  name: string;  price: number;  stock: number;}export interface ApiResponse<T> {  data: T;  message: string;  success: boolean;}

다음으로 유틸리티 함수를 각 파일에 나눠 담습니다.

// 새 파일: src/utils/array.tsexport function chunk<T>(arr: T[], size: number): T[][] {  const result: T[][] = [];  for (let i = 0; i < arr.length; i += size) {    result.push(arr.slice(i, i + size));  }  return result;}export function unique<T>(arr: T[]): T[] {  return [...new Set(arr)];}export function groupBy<T>(arr: T[], key: keyof T): Record<string, T[]> {  return arr.reduce((acc, item) => {    const groupKey = String(item[key]);    acc[groupKey] = acc[groupKey] ?? [];    acc[groupKey].push(item);    return acc;  }, {} as Record<string, T[]>);}
// 새 파일: src/utils/string.tsexport function capitalize(str: string): string {  return str.charAt(0).toUpperCase() + str.slice(1);}export function truncate(str: string, maxLength: number): string {  if (str.length <= maxLength) return str;  return str.slice(0, maxLength) + "...";}export function slugify(str: string): string {  return str    .toLowerCase()    .replace(/\s+/g, "-")    .replace(/[^a-z0-9-]/g, "");}

배럴 파일로 묶습니다.

// 새 파일: src/utils/index.tsexport * from "./array";export * from "./string";

서비스 레이어에서 타입과 유틸리티를 가져다 씁니다.

// 새 파일: src/services/user.tsimport type { User, ApiResponse } from "../types";import { groupBy } from "../utils";let users: User[] = [];let nextId = 1;export function createUser(name: string, email: string): ApiResponse<User> {  const user: User = {    id: nextId++,    name,    email,    role: "member",  };  users.push(user);  return { data: user, message: "사용자가 생성되었습니다.", success: true };}export function findUser(id: number): ApiResponse<User | null> {  const user = users.find((u) => u.id === id) ?? null;  if (!user) {    return { data: null, message: "사용자를 찾을 수 없습니다.", success: false };  }  return { data: user, message: "사용자를 찾았습니다.", success: true };}export function getUsersByRole(): Record<string, User[]> {  return groupBy(users, "role");}export function getAllUsers(): User[] {  return [...users];}

마지막으로 진입점에서 조합합니다.

// 새 파일: src/index.tsimport { createUser, findUser, getUsersByRole } from "./services/user";// 사용자 생성const result1 = createUser("김철수", "[email protected]");const result2 = createUser("이영희", "[email protected]");const result3 = createUser("박민준", "[email protected]");console.log(result1.message); // 사용자가 생성되었습니다.console.log(result2.data.name); // 이영희// 사용자 조회const found = findUser(1);if (found.success && found.data) {  console.log(`찾은 사용자: ${found.data.name}`);}// 역할별 그룹핑const byRole = getUsersByRole();console.log("역할별 사용자:", byRole);

타입 파일 분리 전략

types/ 폴더를 어떻게 구성할지는 프로젝트 성격에 따라 달라집니다.

전략 1: 하나의 index.ts에 모두 모으기

프로젝트가 작을 때 적합합니다.

src/types/
└── index.ts    ← 모든 타입이 여기에

간단하고 찾기 쉽습니다. 파일이 100줄을 넘어가면 나눌 시기가 됩니다.

전략 2: 도메인별로 나누기

중규모 이상에서 적합합니다.

src/types/
├── index.ts        ← 배럴 파일 (re-export)
├── user.ts         ← User, UserRole, UserProfile
├── product.ts      ← Product, ProductCategory
├── order.ts        ← Order, OrderItem, OrderStatus
└── common.ts       ← ApiResponse, Pagination, Error 등 공통 타입
// 새 파일: src/types/common.tsexport interface ApiResponse<T> {  data: T;  message: string;  success: boolean;}export interface Pagination {  page: number;  pageSize: number;  total: number;}export interface PaginatedResponse<T> extends ApiResponse<T[]> {  pagination: Pagination;}export type Nullable<T> = T | null;export type Optional<T> = T | undefined;
// 새 파일: src/types/user.tsexport type UserRole = "admin" | "member" | "guest";export interface User {  id: number;  name: string;  email: string;  role: UserRole;  createdAt: Date;}export interface UserProfile extends User {  bio: string;  avatarUrl: string;}export type CreateUserInput = Omit<User, "id" | "createdAt">;export type UpdateUserInput = Partial<CreateUserInput>;
// 새 파일: src/types/index.tsexport * from "./common";export * from "./user";export * from "./product";export * from "./order";

사용하는 쪽에서는 여전히 from "../types"만 씁니다. 내부 구조가 바뀌어도 import를 수정할 필요가 없습니다.

모듈별 폴더 구조

서비스가 여러 도메인을 다룰 때는 도메인별로 폴더를 만듭니다.

src/
├── user/
│   ├── user.types.ts      ← User 관련 타입
│   ├── user.service.ts    ← User 비즈니스 로직
│   ├── user.utils.ts      ← User 관련 유틸리티
│   └── index.ts           ← 배럴 파일
├── product/
│   ├── product.types.ts
│   ├── product.service.ts
│   └── index.ts
├── shared/
│   ├── types.ts           ← 공통 타입
│   └── utils.ts           ← 공통 유틸리티
└── index.ts

파일명에 .types.ts, .service.ts 같은 접미사를 붙이면 역할이 바로 보입니다. 같은 도메인 파일들이 같은 폴더에 모이므로 찾기 편합니다.

규모별 추천 구조

프로젝트 규모에 따른 추천 구조를 정리합니다.

소규모 (파일 수 10개 미만)

연습 프로젝트, 간단한 CLI 도구, 학습용.

src/
├── index.ts
├── types.ts      ← 타입 정의 한 파일
└── utils.ts      ← 유틸리티 한 파일

복잡하게 나눌 필요가 없습니다. 단순하게 유지합니다.

중규모 (파일 수 30~100개)

일반적인 업무 도구, API 서버, 중소 규모 앱.

src/
├── index.ts
├── types/
│   ├── index.ts
│   ├── user.ts
│   └── common.ts
├── utils/
│   ├── index.ts
│   ├── array.ts
│   └── string.ts
├── services/
│   ├── user.service.ts
│   └── product.service.ts
└── config/
    └── index.ts

역할별로 폴더를 나눕니다. 각 폴더에 배럴 파일(index.ts)을 둡니다.

프로젝트 전체 구성

실제 프로젝트에서 자주 보는 최상위 구조입니다.

my-project/
├── src/
│   └── (위의 구조)
├── dist/              ← 컴파일 결과 (gitignore)
├── tests/             ← 테스트 파일
│   └── user.test.ts
├── tsconfig.json
├── package.json
└── .gitignore

dist/는 컴파일된 파일이 들어가는 폴더입니다. git으로 관리할 필요가 없으므로 .gitignore에 추가합니다.

# .gitignore
node_modules/
dist/
*.js.map

배럴 파일의 주의점

배럴 파일이 편리하지만 주의할 점이 있습니다.

순환 참조(circular dependency) 입니다. A가 B를 가져오고, B가 A를 가져오면 순환 참조가 생깁니다.

# 나쁜 예
user.ts → types/index.ts → user.ts (순환!)

배럴 파일이 잘못 구성되면 이런 상황이 생기기 쉽습니다. 순환 참조를 피하려면 "타입은 서비스에 의존하지 않는다", "서비스는 타입에 의존한다"처럼 의존 방향을 단방향으로 유지합니다.

types/ ← utils/ ← services/ ← index.ts

화살표 방향(왼쪽이 의존 대상)을 한 방향으로 유지합니다. types/는 다른 것에 의존하지 않습니다. utils/는 타입에만 의존합니다. services/는 타입과 유틸리티에 의존합니다.

타입 파일을 따로 분리해야 하는 이유

타입만 모아두는 types/ 폴더를 따로 두는 이유가 있습니다.

첫째, 타입은 런타임에 사라집니다. TypeScript의 타입은 컴파일 후 JavaScript에 남지 않습니다. 타입 정의 파일을 별도로 두면 "이 파일은 런타임 로직 없이 타입만 있다"는 것이 구조적으로 보입니다.

둘째, 재사용성이 높아집니다. 여러 서비스 파일이 같은 User 타입을 씁니다. 타입을 서비스 안에 정의하면 다른 서비스가 그 서비스를 import해야 합니다. 타입을 types/에 두면 서비스끼리 직접 의존하지 않아도 됩니다.

셋째, 찾기 편합니다. "User의 구조가 뭔지 확인하고 싶다"면 src/types/user.ts를 열면 됩니다. 어떤 서비스 파일 안에 파묻혀 있지 않습니다.


좋은 폴더 구조는 처음부터 완벽하게 잡을 필요가 없습니다. 작게 시작하고, 불편해질 때 나눕니다. "파일이 너무 길어졌다", "같은 폴더에 관계없는 파일들이 쌓였다", "어디 있는지 모르겠다"는 느낌이 들 때 재구성합니다.

중요한 것은 구조를 따르는 일관성입니다. 팀 전체가 같은 패턴을 쓸 때 "이 타입은 아마 types/에 있겠지", "이 유틸리티는 utils/에 있겠지"를 예측할 수 있습니다. 예측 가능한 코드베이스가 유지보수하기 좋은 코드베이스입니다.

이것으로 PART 07이 끝납니다. import/export로 파일을 연결하고, tsconfig.json으로 컴파일러를 설정하고, 폴더 구조로 코드를 정리하는 방법을 배웠습니다. 다음 PART에서는 지금까지 배운 모든 것을 활용해 미니 프로젝트를 만들어봅니다.