[나]: 우리가 계속 shared/domain에 타입을 모아두는 게 단순히 코드 중복을 줄이려는 게 아니에요.
[독자]: 그럼 뭔가요?
[나]: 이게 사실 도메인 지식을 중앙 집중화하는 첫 번째 단계거든요.
[독자]: 아! 그래서 누락 없이 모든 변경점을 찾아낼 수 있는 거네요.
[나]: 맞아요! 이게 바로 "별거 없는" 변경 요청을 정말로 "별거 없게" 만들어주는 핵심 메커니즘이에요.
[독자]: 이제 왜 타입 중앙화가 도메인 모델의 전제조건인지 이해가 되네요!
이런 대화를 통해 핵심을 이해했다면, 이제 구체적으로 왜 이 접근법이 중요한지 체계적으로 살펴보겠습니다.
타입 중복과 불일치 문제의 심각성
실제 프로젝트에서 가장 흔히 발생하는 문제는 동일한 데이터에 대해 여러 개발자가 서로 다른 타입을 정의하면서 생기는 혼란입니다.
한 컴포넌트에서는 User 타입으로, API 레이어에서는 UserData 타입으로, 서비스에서는 또 다른 이름으로 동일한 사용자 데이터를 다르게 정의하면서 런타임 오류와
개발 생산성 저하를 초래하게 됩니다.
더 심각한 것은 API가 변경될 때 모든 타입 정의를 찾아서 일일이 수정해야 한다는 점입니다.
shared/domain을 통한 도메인 모델 중앙 집중화
이런 문제를 해결하기 위해 shared/domain 디렉토리에 핵심 엔티티들을 중앙 집중화하여 정의합니다.
이를 통해 모든 레이어에서 동일한 타입을 사용하게 되어 일관성을 보장할 수 있습니다.
Service Layer 글에서 보았듯이, Service
레이어에서 비즈니스 로직을 처리할 때도 중앙 집중화된 타입을 활용하여 로직의 재사용성과 테스트 용이성을 확보할 수 있습니다.
여러 레이어에서의 타입 활용 패턴
HTTP 레이어에서는 제네릭을 활용한 타입 안전한 API 클라이언트를 구성하여 컴파일 타임에 타입 오류를 방지하고, Service 레이어에서는 비즈니스 로직과 데이터 변환 과정에서
중앙 집중화된 타입을 활용하여 안전한 데이터 조작을 보장합니다.
그리고 이번 글에서 다루는 도메인에서는 이런 타입들을 기반으로 점진적으로 도메인 모델로 발전시키면서 복잡한 비즈니스 규칙을 캡슐화할 수 있습니다.
Type-Driven Development의 실현
중앙 집중화된 타입 시스템은 Type-Driven Development를 가능하게 합니다.
백엔드 개발자가 "User 스키마에서 name 필드가 제거될 예정"이라고 알려주면,
중앙의 User 타입만 수정하면 TypeScript 컴파일러가 관련된 모든 코드에서 타입 오류를 표시해주어 누락 없이 모든 변경점을 찾아낼 수 있습니다.
이는 "별거 없는" 변경 요청을 정말로 "별거 없게" 만들어주는 핵심 메커니즘입니다.
BFF 패턴에서의 타입 조합
복잡한 프론트엔드 요구사항을 해결하기 위해 여러 API를 조합하는 BFF 패턴에서도 중앙 집중화된 타입이 중요한 역할을 합니다.
Service Layer에서 여러 도메인의 타입을 조합하여 프론트엔드 최적화된 데이터 구조를 만들 때, 각 도메인이 명확한 타입을 가지고 있어야 안전한 조합이 가능합니다.
결론적으로, shared/domain을 통한 타입 중앙 집중화는 단순히 코드 중복을 줄이는 것을 넘어서 전체 애플리케이션의 타입 안정성, 유지보수성, 확장성을 보장하는 핵심
아키텍처 전략입니다.
이를 통해 개발자는 "원래 있던 기능이니 금방 하시죠?" 라는 요청에 대해 정말로 빠르고 안전하게 대응할 수 있는 코드 구조를 구축할 수 있습니다.
해결책: 점진적 도메인 모델 도입
1단계: 타입에서 시작 (현재 상태)
typescript
// 📁 shared/domain/user.ts// ✅ 1단계: 순수 타입으로 시작 (지금까지 우리가 한 방식)exporttypeUserStatus='premium-active'|'active'|'new'|'inactive';exporttypeUser={ id:string; name:string; email:string; isPremium:boolean; subscriptionStatus:'active'|'inactive'; lastLoginDate:Date; createdAt:Date; hasReceivedWelcomeEmail:boolean;};// 📁 services/userService.tsimporttype{User,UserStatus}from'@/shared/domain/user';exportconst getUserStatus =(user:User):UserStatus=>{// 기존 로직...};
2단계: 책임 분리하기
💡 핵심 요약
도메인 모델로 옮길 로직: 엔티티 자체의 상태 판단, 기본 권한 검증
Service에 남길 로직: 외부 의존성 필요한 로직, 여러 도메인 협력 로직
핵심 원칙: 데이터와 그 데이터를 다루는 로직을 함께 배치
어떤 로직을 도메인 모델로 옮겨야 할까요?
typescript
// 📁 domains/user/User.ts// ✅ 2단계: 사용자 자체의 상태와 능력을 클래스로 캡슐화exportclassUser{constructor(privatereadonly id:string,privatereadonly name:string,privatereadonly email:string,privatereadonly isPremium:boolean,privatereadonly subscriptionStatus:'active'|'inactive',privatereadonly lastLoginDate:Date,privatereadonly createdAt:Date,privatereadonly hasReceivedWelcomeEmail:boolean,){}// 🎯 사용자 자체의 상태 판단 (도메인 모델)getStatus():UserStatus{const now =Date.now();const sevenDaysAgo = now -7*24*60*60*1000;const thirtyDaysAgo = now -30*24*60*60*1000;if(this.isPremium&&this.lastLoginDate.getTime()> sevenDaysAgo){return'premium-active';}if(this.subscriptionStatus==='active'){return'active';}if(this.createdAt.getTime()> thirtyDaysAgo){return'new';}return'inactive';}// 🎯 사용자의 기본 권한 판단 (도메인 모델)canWritePost():boolean{// 기본 조건: 활성 사용자여야 함const status =this.getStatus();return status !=='inactive';}canComment():boolean{// 댓글은 신규 사용자도 가능returntrue;}canUploadFile():boolean{// 파일 업로드는 프리미엄 또는 활성 사용자만const status =this.getStatus();return status ==='premium-active'|| status ==='active';}}// 📁 services/userService.ts// ✅ 2단계: Service는 외부 의존성이 필요한 복잡한 로직만 처리exportclassUserService{constructor(privatereadonly userRepository:UserRepository,privatereadonly notificationService:NotificationService,){}// 🎯 여러 도메인이 협력하는 복잡한 로직 (Service)asynccanUserCreatePremiumContent(user:User):Promise<boolean>{// 1. 기본 권한 확인 (도메인 모델 사용)if(!user.canWritePost()){returnfalse;}// 2. 외부 시스템 확인이 필요한 로직 (Service)const hasValidSubscription =awaitthis.checkSubscriptionValidity(user.id);const isNotBanned =awaitthis.checkUserBanStatus(user.id);return hasValidSubscription && isNotBanned;}asyncsendWelcomeEmailIfNeeded(user:User):Promise<void>{// 1. 도메인 모델의 상태 확인const status = user.getStatus();// 2. 외부 서비스와의 협력if(status ==='new'&&!user.hasReceivedWelcomeEmail){awaitthis.notificationService.sendWelcomeEmail(user.email);}}}
💡 DTO 변환은 어떻게 하나요?
API 응답과 도메인 모델 구조가 다를 때는 Mapper 패턴을 고려해보세요.
Mapper의 장점:
명확한 관심사 분리 (도메인 ↔ DTO 변환 전담)
의존성 방향 개선 (도메인이 외부 계층에 의존하지 않음)
Mapper의 단점:
코드량 증가 (매퍼 클래스 추가 작성 필요)
유지보수 부담 (모델 변경 시 매퍼도 수정)
비즈니스 로직 누출 위험 (단순 매핑을 넘어선 로직 추가 시)
typescript
// 📁 mappers/userMapper.tsexportclassUserMapper{statictoDto(user:User):UserRes{return{ id: user.id, name: user.name/* ... */};}statictoDomain(dto:UserRes):User{// DTO -> 도메인 모델 변환}}
3단계: 상황에 맞는 접근 방식 선택
💡 핵심 요약
함수형: 단순한 계산/검증 로직, 팀이 함수형에 익숙한 경우
객체지향: 복잡한 상태 관리, 도메인 로직이 많은 경우, 확장성이 중요한 경우
하이브리드: 실제 대부분의 프로젝트에서 권장
어떤 방식을 선택할지 판단하는 기준을 알아보겠습니다.
🔧 함수형 접근이 적합한 경우
장점: 학습 비용 낮음, 테스트 용이, 불변성 보장, 함수 조합 용이
적합한 상황:
단순한 계산/변환 로직
상태가 없는 검증 로직
팀이 함수형에 익숙한 경우
typescript
// 📁 domains/user/userDomain.ts - 함수형 방식exportconst getUserStatus =(user:User):UserStatus=>{// 상태 계산 로직...};exportconst canUserWritePost =(user:User):boolean=>{const status =getUserStatus(user);return status !=='inactive';};// Service에서 함수들을 조합exportconstcreateUserService= deps =>({asynccanUserCreatePremiumContent(user:User):Promise<boolean>{if(!canUserWritePost(user))returnfalse;returnawait deps.userRepository.checkSubscription(user.id);},});
🏗️ 객체지향 접근이 적합한 경우
장점: 관련 로직 응집, 캡슐화, 확장성, 직관적 모델링
적합한 상황:
복잡한 상태를 가진 엔티티
도메인 로직이 많고 확장 가능성이 높은 경우
다형성이 필요한 경우
typescript
// 📁 domains/user/User.ts - 객체지향 방식exportclassUser{constructor(/* 속성들 */){}getStatus():UserStatus{// 상태 계산 로직을 내부에 캡슐화}canWritePost():boolean{returnthis.getStatus()!=='inactive';}// 다른 도메인 로직들...}// Service는 도메인 모델을 활용exportclassUserService{asynccanUserCreatePremiumContent(user:User):Promise<boolean>{if(!user.canWritePost())returnfalse;returnawaitthis.userRepository.checkSubscription(user.id);}}