TimewareTimeware
블로그 목록으로
블로그

TypeScript 엔터프라이즈 패턴: 대형 코드베이스를 유지보수 가능하게 만드는 설계 원칙

100명 이상이 개발하는 엔터프라이즈 TypeScript 프로젝트에서 반복되는 설계 실수와 해결 패턴. Branded Types, Result 타입, Discriminated Union, 레이어드 아키텍처 TypeScript 구현.

2026년 3월 5일Timeware Engineeringtypescriptarchitectureenterprisebackendpatterns
TypeScript 엔터프라이즈 패턴: 대형 코드베이스를 유지보수 가능하게 만드는 설계 원칙

요약

100명 이상이 개발하는 엔터프라이즈 TypeScript 프로젝트에서 반복되는 설계 실수와 해결 패턴. Branded Types, Result 타입, Discriminated Union, 레이어드 아키텍처 TypeScript 구현.

TypeScript 엔터프라이즈 패턴: 대형 코드베이스를 유지보수 가능하게 만드는 설계 원칙

Executive Summary - Topic: 엔터프라이즈 TypeScript 프로젝트 설계 패턴과 타입 시스템 활용 - Target: 시니어 TypeScript 개발자, 테크리드, 아키텍트 - TL;DR 1: any는 TypeScript를 JavaScript로 만드는 것 — Branded Types와 Result 타입으로 타입 안전성 극대화 - TL;DR 2: Discriminated Union은 if-else 지옥을 컴파일 타임 안전성으로 바꾼다 - TL;DR 3: 도메인 레이어는 프레임워크·DB에 의존하면 안 된다 — 의존성 역전 원칙

5년 전 스타트업에서 시작한 TypeScript 프로젝트가 엔터프라이즈 규모로 성장하면 어떤 일이 일어날까요? 대부분의 경우: any가 곳곳에 생기고, 타입이 있어도 런타임 에러가 나고, 새 팀원이 합류하면 코드를 이해하는 데 몇 주가 걸립니다.

Timeware에서 레거시 JS 프로젝트를 TypeScript로 마이그레이션하면서, 그리고 여러 엔터프라이즈 클라이언트의 TS 코드베이스를 리팩토링하면서 반복적으로 발견한 패턴들을 정리합니다.

패턴 1: Branded Types — 원시 타입의 의미론적 구분

문제:

typescript
1function sendEmail(userId: string, email: string): Promise<void> {}
2
3// 이 호출이 맞는지 틀린지 컴파일러가 모름
4await sendEmail(user.email, user.id); // 순서 바뀜! 런타임에서야 발견

Branded Types로 해결:

typescript
1// Brand 타입 유틸리티
2type Brand<T, B extends string> = T & { readonly __brand: B };
3
4type UserId = Brand<string, 'UserId'>;
5type Email = Brand<string, 'Email'>;
6type OrderId = Brand<string, 'OrderId'>;
7
8// 생성 함수 (검증 포함)
9function createUserId(id: string): UserId {
10 if (!id.match(/^user_[a-z0-9]+$/)) {
11 throw new Error(`Invalid UserId format: ${id}`);
12 }
13 return id as UserId;
14}
15
16function createEmail(email: string): Email {
17 if (!email.includes('@')) {
18 throw new Error(`Invalid email: ${email}`);
19 }
20 return email as Email;
21}
22
23// 함수 시그니처가 명확해짐
24function sendEmail(userId: UserId, email: Email): Promise<void> {}
25
26// 이제 순서가 바뀌면 컴파일 에러!
27await sendEmail(user.email, user.id); // ❌ Type Error!
28await sendEmail(user.id, user.email); // ✅ OK

패턴 2: Result 타입 — throw 없는 에러 처리

문제:

typescript
1// 이 함수가 언제 throw를 할지 타입만 보고는 모름
2async function getUser(id: string): Promise<User> {
3 // UserNotFoundError? DatabaseError? NetworkError?
4 // 호출하는 쪽은 어떤 에러를 catch해야 할지 모름
5}

Result 타입으로 해결:

typescript
1type Success<T> = { ok: true; value: T };
2type Failure<E> = { ok: false; error: E };
3type Result<T, E = Error> = Success<T> | Failure<E>;
4
5// 에러 타입도 명시적으로
6type UserError =
7 | { type: 'NOT_FOUND'; userId: string }
8 | { type: 'UNAUTHORIZED'; requiredRole: string }
9 | { type: 'DATABASE_ERROR'; cause: Error };
10
11async function getUser(id: UserId): Promise<Result<User, UserError>> {
12 try {
13 const user = await db.users.findById(id);
14 if (!user) {
15 return { ok: false, error: { type: 'NOT_FOUND', userId: id } };
16 }
17 return { ok: true, value: user };
18 } catch (cause) {
19 return { ok: false, error: { type: 'DATABASE_ERROR', cause: cause as Error } };
20 }
21}
22
23// 호출하는 쪽에서 에러 처리 강제
24const result = await getUser(userId);
25if (!result.ok) {
26 switch (result.error.type) {
27 case 'NOT_FOUND':
28 return res.status(404).json({ message: `User ${result.error.userId} not found` });
29 case 'UNAUTHORIZED':
30 return res.status(403).json({ message: 'Forbidden' });
31 case 'DATABASE_ERROR':
32 logger.error('DB error', result.error.cause);
33 return res.status(500).json({ message: 'Internal error' });
34 }
35 // 컴파일러가 모든 케이스 처리 확인
36}
37
38const user = result.value; // 여기서 User 타입 보장

패턴 3: Discriminated Union — 상태 기계 모델링

문제:

typescript
1// 이 타입은 일관성을 보장하지 못함
2interface Order {
3 id: string;
4 status: 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
5 confirmedAt?: Date; // pending일 때는 없어야 하는데...
6 shippedAt?: Date; // confirmed일 때는 없어야 하는데...
7 trackingNumber?: string;
8 cancelReason?: string;
9}
10
11// 이런 버그 발생 가능
12const order = { status: 'pending', confirmedAt: new Date() }; // 논리적으로 이상

Discriminated Union으로 해결:

typescript
1type Order =
2 | {
3 id: OrderId;
4 status: 'pending';
5 createdAt: Date;
6 }
7 | {
8 id: OrderId;
9 status: 'confirmed';
10 createdAt: Date;
11 confirmedAt: Date; // confirmed 상태에서만 존재
12 }
13 | {
14 id: OrderId;
15 status: 'shipped';
16 createdAt: Date;
17 confirmedAt: Date;
18 shippedAt: Date; // shipped 이후에만 존재
19 trackingNumber: string; // 필수!
20 }
21 | {
22 id: OrderId;
23 status: 'delivered';
24 createdAt: Date;
25 confirmedAt: Date;
26 shippedAt: Date;
27 deliveredAt: Date; // delivered 이후에만 존재
28 trackingNumber: string;
29 }
30 | {
31 id: OrderId;
32 status: 'cancelled';
33 createdAt: Date;
34 cancelledAt: Date; // cancelled 이후에만 존재
35 cancelReason: string; // 필수!
36 };
37
38// 상태 전환 함수도 타입 안전하게
39function confirmOrder(
40 order: Extract<Order, { status: 'pending' }>
41): Extract<Order, { status: 'confirmed' }> {
42 return {
43 ...order,
44 status: 'confirmed',
45 confirmedAt: new Date(),
46 };
47}
48
49// pending이 아닌 주문을 confirm하려 하면 컴파일 에러
50confirmOrder(shippedOrder); // ❌ Type Error!

패턴 4: 의존성 역전으로 테스트 가능한 구조

문제:

typescript
1// 구현에 직접 의존 — 테스트 불가
2class OrderService {
3 async createOrder(data: CreateOrderData): Promise<Order> {
4 const user = await prisma.user.findUnique({ where: { id: data.userId } });
5 // prisma 없이는 테스트 불가
6 }
7}

인터페이스 기반 의존성 역전:

typescript
1// 도메인 레이어 — 프레임워크 무관
2interface UserRepository {
3 findById(id: UserId): Promise<Result<User, UserError>>;
4 save(user: User): Promise<Result<User, UserError>>;
5}
6
7interface OrderRepository {
8 save(order: Order): Promise<Result<Order, OrderError>>;
9 findByUserId(userId: UserId): Promise<Order[]>;
10}
11
12// 비즈니스 로직 — 순수 TypeScript
13class OrderService {
14 constructor(
15 private readonly userRepo: UserRepository,
16 private readonly orderRepo: OrderRepository,
17 private readonly eventBus: EventBus,
18 ) {}
19
20 async createOrder(
21 command: CreateOrderCommand
22 ): Promise<Result<Order, CreateOrderError>> {
23 const userResult = await this.userRepo.findById(command.userId);
24 if (!userResult.ok) {
25 return { ok: false, error: { type: 'USER_NOT_FOUND' } };
26 }
27
28 // 비즈니스 규칙 검증
29 if (!this.canCreateOrder(userResult.value)) {
30 return { ok: false, error: { type: 'ORDER_LIMIT_EXCEEDED' } };
31 }
32
33 const order: Order = { id: createOrderId(), status: 'pending', ... };
34 const saveResult = await this.orderRepo.save(order);
35
36 if (saveResult.ok) {
37 await this.eventBus.publish({ type: 'ORDER_CREATED', payload: order });
38 }
39
40 return saveResult;
41 }
42}
43
44// 인프라 레이어 — Prisma 구현
45class PrismaUserRepository implements UserRepository {
46 constructor(private readonly prisma: PrismaClient) {}
47
48 async findById(id: UserId): Promise<Result<User, UserError>> {
49 try {
50 const user = await this.prisma.user.findUnique({ where: { id } });
51 if (!user) return { ok: false, error: { type: 'NOT_FOUND', userId: id } };
52 return { ok: true, value: toDomain(user) };
53 } catch (cause) {
54 return { ok: false, error: { type: 'DATABASE_ERROR', cause: cause as Error } };
55 }
56 }
57}
58
59// 테스트 — Mock 구현
60class InMemoryUserRepository implements UserRepository {
61 private users = new Map<string, User>();
62
63 async findById(id: UserId): Promise<Result<User, UserError>> {
64 const user = this.users.get(id);
65 return user
66 ? { ok: true, value: user }
67 : { ok: false, error: { type: 'NOT_FOUND', userId: id } };
68 }
69}
70
71// 테스트에서 Prisma 없이 비즈니스 로직 테스트 가능!
72describe('OrderService', () => {
73 it('should reject order when user at limit', async () => {
74 const userRepo = new InMemoryUserRepository();
75 const orderRepo = new InMemoryOrderRepository();
76 const eventBus = new InMemoryEventBus();
77 const service = new OrderService(userRepo, orderRepo, eventBus);
78
79 // 순수한 단위 테스트
80 const result = await service.createOrder(command);
81 expect(result.ok).toBe(false);
82 });
83});

패턴 5: Zod로 런타임 타입 검증

typescript
1import { z } from 'zod';
2
3// 한 번 정의 → TypeScript 타입 + 런타임 검증 동시 제공
4const CreateOrderSchema = z.object({
5 userId: z.string().regex(/^user_/).brand<'UserId'>(),
6 items: z.array(z.object({
7 productId: z.string().min(1),
8 quantity: z.number().int().positive().max(100),
9 })).min(1).max(50),
10 deliveryAddress: z.object({
11 postcode: z.string().regex(/^\d{5}$/),
12 address: z.string().min(1).max(200),
13 }),
14 paymentMethod: z.enum(['card', 'bank_transfer', 'kakao_pay']),
15});
16
17// 타입 추론
18type CreateOrderCommand = z.infer<typeof CreateOrderSchema>;
19
20// API 엔드포인트에서 사용
21app.post('/api/orders', async (req, res) => {
22 const parseResult = CreateOrderSchema.safeParse(req.body);
23 if (!parseResult.success) {
24 return res.status(400).json({
25 error: 'Validation failed',
26 details: parseResult.error.flatten(),
27 });
28 }
29
30 const command: CreateOrderCommand = parseResult.data;
31 // 이후 command는 완전히 타입 안전
32});

실무 적용 가이드

점진적 도입 순서:

  1. Week 1-2: tsconfig.json strict 모드 활성화
json
1{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true } }
  1. Week 3-4: API 경계에 Zod 스키마 도입
  1. Month 2: 핵심 도메인 Branded Types 적용
  1. Month 3: Result 타입으로 에러 처리 체계화
  1. Month 4+: 의존성 역전 리팩토링

FAQ

Q. `any` 타입을 완전히 제거하는 것이 현실적인가요? A. 제로 any는 현실적인 목표입니다. 레거시 마이그레이션 중에는 @ts-expect-error를 사용해 의도적 억제를 명시적으로 표시하고, ESLint @typescript-eslint/no-explicit-any 규칙으로 새로운 any 추가를 막으세요. 외부 라이브러리 타입이 없을 때만 unknown으로 받고 좁혀나가는 방식을 사용합니다.

Q. Branded Types와 Zod를 같이 써야 하나요? A. 권장합니다. Branded Types는 컴파일 타임 안전성, Zod는 런타임 검증입니다. Zod .brand<'UserId'>()로 두 가지를 동시에 구현할 수 있습니다. 외부 입력은 Zod로 검증 후 Branded Type을 생성하고, 이후 내부 로직에서는 Branded Types만 사용합니다.

Q. Result 타입 vs throw/catch, 어떤 것이 더 낫나요? A. 비즈니스 로직 레이어에서는 Result 타입, 예상치 못한 에러(버그, 네트워크 단절)는 throw가 적합합니다. 규칙: 예상 가능한 실패(유저 없음, 권한 없음)는 Result, 프로그래밍 에러나 인프라 문제는 throw로 구분하면 코드 가독성이 좋아집니다.