17.3 레이어드 아키텍처 — Repository/Service/Controller 타입 경계
레이어드 아키텍처 개요
HTTP 요청 → Controller (입력 유효성 검사)
→ Service (비즈니스 로직)
→ Repository (데이터 접근)
→ Database
각 레이어는 명확한 타입 경계를 가져야 합니다. 아래 레이어의 타입이 위 레이어로 새어 나오지 않도록 합니다.
도메인 타입 정의
// domain/types.ts
// 도메인 엔티티 — DB와 독립적인 비즈니스 객체
export interface User {
readonly id: string
readonly email: string
readonly name: string
readonly role: UserRole
readonly createdAt: Date
readonly updatedAt: Date
}
export type UserRole = 'admin' | 'user' | 'guest'
// 값 객체 (불변)
export type Email = string & { readonly _brand: 'Email' }
export function createEmail(value: string): Email {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new Error(`유효하지 않은 이메일: ${value}`)
}
return value as Email
}
// DTO (Data Transfer Object)
export interface CreateUserDto {
email: string
name: string
password: string
role?: UserRole
}
export interface UpdateUserDto {
name?: string
role?: UserRole
}
// 응답 타입 — 민감한 필드 제외
export type UserResponse = Omit<User, never> // 모든 필드 노출
export type PublicUserResponse = Pick<User, 'id' | 'name'> // 공개 정보만
Repository 레이어
// repositories/user.repository.interface.ts
// 인터페이스 — 비즈니스 로직에서 DB 구현을 숨김
export interface UserRepository {
findById(id: string): Promise<User | null>
findByEmail(email: string): Promise<User | null>
findAll(options?: FindAllOptions): Promise<PaginatedResult<User>>
save(user: Partial<User> & Pick<User, 'id'>): Promise<User>
create(data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User>
delete(id: string): Promise<void>
}
export interface FindAllOptions {
page?: number
limit?: number
search?: string
role?: UserRole
}
export interface PaginatedResult<T> {
data: T[]
total: number
page: number
limit: number
}
// repositories/prisma-user.repository.ts
// Prisma 구현체 — 인터페이스를 준수
@Injectable()
export class PrismaUserRepository implements UserRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<User | null> {
const record = await this.prisma.user.findUnique({ where: { id } })
return record ? this.toDomain(record) : null
}
// DB 레코드 → 도메인 객체 변환 (타입 경계!)
private toDomain(record: PrismaUser): User {
return {
id: record.id,
email: record.email,
name: record.name,
role: record.role as UserRole,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
}
}
}
Service 레이어
// services/user.service.ts
@Injectable()
export class UserService {
constructor(
private readonly userRepository: UserRepository, // 인터페이스 의존
private readonly passwordService: PasswordService,
private readonly eventBus: EventBus,
) {}
async createUser(dto: CreateUserDto): Promise<UserResponse> {
// 1. 비즈니스 규칙 검증
const existing = await this.userRepository.findByEmail(dto.email)
if (existing) {
throw new ConflictError('이미 사용 중인 이메일입니다.')
}
// 2. 값 객체 생성 (검증 포함)
const email = createEmail(dto.email)
// 3. 비즈니스 로직 실행
const hashedPassword = await this.passwordService.hash(dto.password)
const user = await this.userRepository.create({
email,
name: dto.name,
password: hashedPassword,
role: dto.role ?? 'user',
})
// 4. 도메인 이벤트 발행
await this.eventBus.publish(new UserCreatedEvent(user))
// 5. 응답 DTO 변환 (password 제외)
return this.toResponse(user)
}
private toResponse(user: User): UserResponse {
const { ...response } = user
return response
}
}
Controller 레이어
// controllers/user.controller.ts
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
@HttpCode(201)
async create(@Body() dto: CreateUserDto): Promise<UserResponse> {
// Controller는 오직 HTTP 관심사만 처리
return this.userService.createUser(dto)
}
@Get()
async findAll(@Query() query: FindAllOptions): Promise<PaginatedResult<UserResponse>> {
return this.userService.findAll(query)
}
@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<UserResponse> {
const user = await this.userService.findById(id)
if (!user) throw new NotFoundException()
return user
}
}
의존성 역전 원칙 (DIP)
// 잘못된 방법 — 고수준 모듈이 저수준 구현에 직접 의존
class UserService {
private repo = new PrismaUserRepository() // 구체 클래스 직접 사용 ❌
}
// 올바른 방법 — 인터페이스에 의존
class UserService {
constructor(
private readonly userRepo: UserRepository // 인터페이스에 의존 ✅
) {}
}
// IoC 컨테이너에서 구현체 교체 가능
// 테스트: MockUserRepository
// 개발: PrismaUserRepository
// 레거시: TypeOrmUserRepository
고수 팁
레이어 간 타입 변환 자동화
// 매핑 라이브러리 (class-transformer) 활용
import { plainToInstance, Expose, Exclude } from 'class-transformer'
@Exclude() // 기본적으로 모든 필드 숨김
export class UserResponseDto {
@Expose() id: string
@Expose() email: string
@Expose() name: string
@Expose() role: string
@Expose() createdAt: Date
// password는 @Expose() 없으므로 자동 제외
}
function toUserResponse(user: User): UserResponseDto {
return plainToInstance(UserResponseDto, user, {
excludeExtraneousValues: true,
})
}