Rich Domain Model
백엔드 코드를 작성하다 보면 엔티티 클래스가 순수한 데이터 컨테이너로만 쓰이는 경우가 많다. 프로퍼티만 잔뜩 선언해놓고, 실제 비즈니스 로직은 전부 서비스 계층에 몰아넣는 방식이다. 이걸 Anemic Domain Model(빈혈 도메인 모델)이라고 부른다.
// Anemic Domain Model - 엔티티는 데이터만 담는 그릇
@Entity()
class Session {
@Property()
downloadToken?: string;
@Property()
downloadTokenExpiresAt?: Date;
@Enum()
status: SessionStatus;
}
// 모든 로직이 서비스에 있음
class SessionService {
generateToken(session: Session) {
session.downloadToken = randomUUID().replace(/-/g, '');
session.downloadTokenExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
}
isTokenValid(session: Session): boolean {
if (!session.downloadToken || !session.downloadTokenExpiresAt) return false;
return new Date() < session.downloadTokenExpiresAt;
}
canCancel(session: Session): boolean {
return session.status === SessionStatus.CREATED
|| session.status === SessionStatus.PAYMENT_PENDING;
}
}
이게 왜 문제일까? 겉보기에는 깔끔해 보이지만 실제로 프로젝트가 커지면 심각한 문제가 생긴다.
Anemic Model의 문제점
1. 비즈니스 규칙이 흩어진다
토큰 유효성 검증이 SessionService에도 있고, DownloadService에도 있고, 심지어 컨트롤러에서 직접 if (session.downloadTokenExpiresAt > new Date()) 같은 코드를 쓰는 경우도 생긴다. 같은 규칙이 여러 곳에 중복되면 하나만 고치고 나머지는 놓치는 버그가 발생한다.
// SessionService에서는 이렇게 검증하고
if (!session.downloadToken || !session.downloadTokenExpiresAt) return false;
return new Date() < session.downloadTokenExpiresAt;
// DownloadController에서는 이렇게 검증함
if (session.downloadTokenExpiresAt && session.downloadTokenExpiresAt > new Date()) {
// 미묘하게 다른 로직 — downloadToken null 체크가 빠져있음
}
2. 엔티티의 불변식(invariant)을 보장할 수 없다
엔티티의 필드가 전부 public이고 아무 데서나 직접 수정 가능하면, "이 필드는 반드시 이 조건을 만족해야 한다"는 규칙을 강제할 방법이 없다. 예를 들어 downloadToken과 downloadTokenExpiresAt는 항상 함께 설정되어야 하는데, 누군가가 토큰만 설정하고 만료일을 빼먹을 수 있다.
3. 서비스가 비대해진다
엔티티에 로직이 없으니 모든 게 서비스로 간다. 서비스 하나가 수백 줄이 되고, 메서드 간의 의존 관계도 복잡해진다. 테스트할 때도 서비스의 의존성을 전부 모킹해야 해서 테스트 코드가 본 코드보다 길어지는 상황이 벌어진다.
Rich Domain Model이란
Rich Domain Model은 엔티티 스스로가 자신의 비즈니스 로직을 가지는 패턴이다. "이 데이터에 대한 규칙은 이 데이터가 가장 잘 안다"는 원칙을 따른다. 객체지향의 기본 원리인 캡슐화를 제대로 적용하는 것이다.
@Entity()
class Session {
@Property()
downloadToken?: string;
@Property()
downloadTokenExpiresAt?: Date;
@Enum()
status: SessionStatus;
/**
* 다운로드 토큰 생성 (24시간 유효)
*/
generateDownloadToken(): string {
this.downloadToken =
randomUUID().replace(/-/g, '') +
randomUUID().replace(/-/g, '').slice(0, 32);
this.downloadTokenExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
return this.downloadToken;
}
/**
* 다운로드 토큰 유효성 검사
*/
isDownloadTokenValid(): boolean {
if (!this.downloadToken || !this.downloadTokenExpiresAt) {
return false;
}
return new Date() < this.downloadTokenExpiresAt;
}
}
generateDownloadToken()은 토큰과 만료일을 항상 함께 설정한다. 누가 이 메서드를 호출하든 불변식이 깨지지 않는다. isDownloadTokenValid()도 엔티티 안에 있으니 토큰 유효성 검증 로직이 한 곳에만 존재한다.
어떤 로직을 엔티티에 넣어야 하는가
모든 로직을 엔티티에 넣으라는 게 아니다. 핵심은 "이 로직이 이 엔티티의 상태에만 의존하는가?"이다.
엔티티에 넣기 좋은 로직
상태 변경 메서드 — 엔티티의 필드를 변경하는데 특정 규칙이 있는 경우:
@Entity()
class StoreService {
@Property()
isEnabled: boolean = true;
@Property({ type: 'json' })
config: Record<string, any> = {};
enable(): void {
this.isEnabled = true;
this.updatedAt = new Date();
}
disable(): void {
this.isEnabled = false;
this.updatedAt = new Date();
}
updateConfig(config: Record<string, any>): void {
this.config = { ...this.config, ...config };
this.updatedAt = new Date();
}
}
직접 storeService.isEnabled = false 대신 storeService.disable()을 쓰면 updatedAt도 자동으로 갱신된다. 단순해 보이지만 이런 부수 효과를 놓치는 게 실제 버그의 주요 원인이다.
유효성 검증 — 엔티티 자신의 상태를 기반으로 판단하는 경우:
@Entity()
class Session {
isDownloadTokenValid(): boolean {
if (!this.downloadToken || !this.downloadTokenExpiresAt) {
return false;
}
return new Date() < this.downloadTokenExpiresAt;
}
canTransitionTo(newStatus: SessionStatus): boolean {
const transitions: Record<SessionStatus, SessionStatus[]> = {
[SessionStatus.CREATED]: [SessionStatus.PAYMENT_PENDING, SessionStatus.CANCELLED],
[SessionStatus.PAYMENT_PENDING]: [SessionStatus.SHOOTING, SessionStatus.CANCELLED],
[SessionStatus.SHOOTING]: [SessionStatus.PROCESSING, SessionStatus.CANCELLED],
[SessionStatus.PROCESSING]: [SessionStatus.COMPLETED],
[SessionStatus.COMPLETED]: [SessionStatus.EXPIRED],
[SessionStatus.CANCELLED]: [],
[SessionStatus.EXPIRED]: [],
};
return transitions[this.status]?.includes(newStatus) ?? false;
}
}
파생 값 계산 — 기존 필드에서 계산할 수 있는 값:
@Entity()
class Session {
@Property()
quantity: number = 1;
@Property()
unitPrice?: number;
get totalAmount(): number {
return (this.unitPrice ?? 0) * this.quantity;
}
get isExpired(): boolean {
return this.expiresAt ? new Date() > this.expiresAt : false;
}
}
서비스에 남겨야 할 로직
- 외부 시스템 호출: S3 업로드, 이메일 발송, 결제 API 호출
- 여러 엔티티 간 조율: 세션 생성 시 디바이스 검증 + 재고 확인 + 결제 처리
- 데이터베이스 쿼리: 조건부 조회, 집계, 통계
- 트랜잭션 관리: 여러 엔티티를 원자적으로 업데이트
// 서비스에 남겨야 할 것들
class SessionService {
// 외부 시스템 호출
async uploadComposedImage(session: Session, buffer: Buffer): Promise<string> {
const key = `sessions/${session.sessionId}/composed.jpg`;
await this.s3Service.upload(key, buffer);
session.composedImageS3Key = key;
return key;
}
// 여러 엔티티 조율
async createSession(deviceId: string, conceptId: string): Promise<Session> {
const device = await this.deviceRepo.findOneOrFail(deviceId);
const concept = await this.conceptRepo.findOneOrFail(conceptId);
// 비즈니스 규칙 검증
if (!concept.isActive) throw new BadRequestException('비활성 컨셉');
const session = new Session(device);
session.concept = concept;
await this.em.persistAndFlush(session);
return session;
}
}
구현 패턴
팩토리 메서드
constructor에서 복잡한 초기화 로직을 처리하기 어려울 때 static 팩토리 메서드를 쓴다:
@Entity()
class Payment {
static createForSession(
session: Session,
amount: number,
gateway: PaymentGateway,
currency: string = 'KRW',
): Payment {
const payment = new Payment();
payment.session = session;
payment.sessionId = session.sessionId;
payment.amount = amount;
payment.gateway = gateway;
payment.currency = currency;
payment.status = PaymentStatus.PENDING;
return payment;
}
complete(externalId: string, data?: PaymentData): void {
if (this.status !== PaymentStatus.PENDING) {
throw new Error('결제 완료는 PENDING 상태에서만 가능');
}
this.status = PaymentStatus.COMPLETED;
this.externalPaymentId = externalId;
this.data = data;
this.completedAt = new Date();
}
fail(reason: string): void {
if (this.status !== PaymentStatus.PENDING) {
throw new Error('결제 실패 처리는 PENDING 상태에서만 가능');
}
this.status = PaymentStatus.FAILED;
this.data = { message: reason };
}
}
complete()와 fail()에서 현재 상태를 검증한다. 이미 완료된 결제를 다시 완료 처리하는 건 도메인 규칙 위반이고, 이걸 엔티티가 스스로 방어한다.
도메인 이벤트 스타일
상태 변경 시 어떤 일이 일어나야 하는지를 엔티티가 "알려주는" 방식:
@Entity()
class Session {
private pendingEvents: DomainEvent[] = [];
cancel(reason: string): void {
if (!this.canTransitionTo(SessionStatus.CANCELLED)) {
throw new Error(`${this.status} 상태에서 취소 불가`);
}
this.status = SessionStatus.CANCELLED;
this.pendingEvents.push(new SessionCancelledEvent(this.sessionId, reason));
}
pullEvents(): DomainEvent[] {
const events = [...this.pendingEvents];
this.pendingEvents = [];
return events;
}
}
// 서비스에서 이벤트 처리
class SessionService {
async cancelSession(sessionId: string, reason: string) {
const session = await this.sessionRepo.findOneOrFail(sessionId);
session.cancel(reason); // 엔티티가 상태 변경 + 이벤트 발행
const events = session.pullEvents();
for (const event of events) {
await this.eventBus.publish(event); // 환불 처리, 알림 등
}
await this.em.flush();
}
}
엔티티는 "내 상태가 바뀌었다"는 사실만 기록하고, 실제 부수 효과(환불, 알림 등)는 서비스나 이벤트 핸들러가 처리한다. 이렇게 하면 엔티티가 외부 의존성 없이 순수하게 유지된다.
ORM과의 관계
MikroORM이나 TypeORM 같은 ORM을 쓸 때 Rich Domain Model이 잘 어울린다. ORM 엔티티가 곧 도메인 객체이기 때문이다.
@Entity()
class StoreService {
// ORM 데코레이터 + 도메인 메서드가 공존
@Property()
isEnabled: boolean = true;
@Property({ type: 'json' })
config: Record<string, any> = {};
// 도메인 메서드
enable(): void {
this.isEnabled = true;
this.updatedAt = new Date();
}
disable(): void {
this.isEnabled = false;
this.updatedAt = new Date();
}
updateConfig(newConfig: Record<string, any>): void {
this.config = { ...this.config, ...newConfig };
this.updatedAt = new Date();
}
}
주의할 점: ORM의 변경 감지(dirty checking)는 프로퍼티 값 변경을 추적한다. 도메인 메서드 안에서 프로퍼티를 변경하면 ORM이 자동으로 감지해서 flush() 시 DB에 반영한다. 별도의 save() 호출이 필요 없다.
다만 getter/computed property는 @Property로 매핑하지 않는 게 좋다. DB에 저장할 필요 없는 파생 값이기 때문이다:
// 이건 DB 컬럼이 아닌 런타임 계산 값
get isExpired(): boolean {
return this.expiresAt ? new Date() > this.expiresAt : false;
}
Anemic vs Rich — 판단 기준
둘 중 하나만 쓰라는 게 아니다. 프로젝트 상황에 따라 적절한 균형을 찾아야 한다.
| 상황 | 권장 |
|---|---|
| 단순 CRUD, 비즈니스 규칙 거의 없음 | Anemic 충분 |
| 상태 전이 규칙이 복잡함 | Rich Model |
| 같은 검증 로직이 여러 서비스에 중복 | Rich Model |
| 엔티티 자체 데이터로만 판단 가능 | Rich Model |
| 외부 시스템 호출이 필요한 로직 | 서비스에 유지 |
| 여러 엔티티를 조율하는 로직 | 서비스에 유지 |
실제 프로젝트에서는 대부분 혼합해서 쓴다. 핵심 도메인(결제, 세션 상태 관리 등)은 Rich Model로, 주변 도메인(로깅, 설정 등)은 Anemic으로 가는 게 현실적이다.
테스트 관점에서의 장점
Rich Domain Model의 가장 큰 실용적 장점은 테스트가 쉬워진다는 것이다. 도메인 로직이 엔티티 안에 있으면 DB나 외부 서비스 없이 순수하게 테스트할 수 있다:
describe('Session', () => {
it('다운로드 토큰을 생성하면 24시간 유효하다', () => {
const session = new Session(mockDevice);
const token = session.generateDownloadToken();
expect(token).toBeDefined();
expect(session.downloadToken).toBe(token);
expect(session.downloadTokenExpiresAt).toBeDefined();
expect(session.isDownloadTokenValid()).toBe(true);
});
it('만료된 토큰은 유효하지 않다', () => {
const session = new Session(mockDevice);
session.generateDownloadToken();
// 시간을 미래로 이동
session.downloadTokenExpiresAt = new Date(Date.now() - 1000);
expect(session.isDownloadTokenValid()).toBe(false);
});
it('COMPLETED 상태에서는 취소할 수 없다', () => {
const session = new Session(mockDevice);
session.status = SessionStatus.COMPLETED;
expect(session.canTransitionTo(SessionStatus.CANCELLED)).toBe(false);
});
});
서비스 테스트와 비교하면 차이가 명확하다. 서비스를 테스트하려면 리포지토리 모킹, EntityManager 모킹, 외부 서비스 모킹이 필요하지만, 엔티티 메서드는 new Session()만으로 테스트할 수 있다. 테스트가 빠르고, 깨지기 어렵고, 의도가 명확하다.
흔한 실수
엔티티에 너무 많은 걸 넣는 것
Rich Domain Model이 좋다고 해서 모든 걸 엔티티에 넣으면 안 된다. 엔티티가 수백 줄이 되면 그건 Rich가 아니라 Fat이다.
// ❌ 엔티티에 넣으면 안 되는 것들
@Entity()
class Session {
async sendNotification() { /* 외부 호출 */ }
async refund() { /* 결제 게이트웨이 호출 */ }
async uploadToS3() { /* AWS SDK 호출 */ }
}
엔티티는 순수해야 한다. 외부 의존성(HTTP 클라이언트, SDK, 다른 리포지토리)을 주입받으면 안 된다.
setter만 만드는 것
// ❌ 이건 Anemic과 다를 바 없음
setStatus(status: SessionStatus): void {
this.status = status;
}
// ✅ 의미 있는 도메인 메서드
cancel(reason: string): void {
if (!this.canTransitionTo(SessionStatus.CANCELLED)) {
throw new Error(`${this.status} 상태에서 취소 불가`);
}
this.status = SessionStatus.CANCELLED;
}
단순한 setter는 session.status = newStatus와 다를 바 없다. 도메인 메서드는 비즈니스 규칙을 강제하는 역할을 해야 한다.
정리
- 엔티티 자신의 상태에만 의존하는 로직(상태 전이, 유효성 검증, 파생 값 계산)은 엔티티 안에 두면 중복이 사라지고 불변식이 보장된다
- 외부 시스템 호출, 여러 엔티티 조율, 트랜잭션 관리는 서비스에 남기고 엔티티는 순수하게 유지한다
- 핵심 도메인은 Rich Model로, 단순 CRUD 주변 도메인은 Anemic으로 가는 혼합 전략이 현실적이다
관련 문서
- Strategy 패턴 - 인터페이스 추상화와 런타임 디스패치
- State Machine 패턴 - 상태 전이 규칙 검증
- DTO Transformer 패턴 - 엔티티와 DTO 간 변환 로직 분리