DDD의 VO라는 개념에 대해 간단히 정리해보고, 대기열 서비스(msa)에서는 어떻게 적용되는지 정리해보았다.
VO(Value Object)란 무엇이며, 대기열에 어떻게 적용할까?
DDD에서 객체는 크게 엔티티(Entity)와 값 객체(VO)로 나뉜다.
- Entity: 식별자(ID)가 있고, 생명주기가 있어 내용이 변할 수 있는 객체. (예: QueuePolicy, QueueToken)
- VO (Value Object): 식별자가 없고, 값 그 자체로 의미를 가지며, 불변(Immutable)인 객체
그러면 아래에 있는 QueueToken은 VO일까 아니면 Entity일까? 보시다시피 @Entity 어노테이션은 따로 붙여져 있지 않은 상태이다.
@Getter
@Builder
public class QueueToken {
private final TokenId id; // Redis 토큰 발급
private Long userId;
private UUID productId;
private QueueStatus status;
private Long requestTime; // 대기열 진입 요청 시간 (Score용)
private Long activeTime; // 활성화 시간 (TTL 계산용)
...
}
QueueToken은 실질적으로는 Entity(Aggregate Root)라고 볼 수 있다.
엔티티 어노테이션이 없는 순수 자바 객체이나 고유 식별자(TokenId)가 있고,
QueueStatus의 상태가 서비스 흐름에 따라 바뀌게 되므로 생명주기가 있다.
따라서 DDD상의 개념은 엔티티로 들어간다.
VO (Value Object)를 사용하는 이유
VO(Value Object)는 DDD에서 도메인의 속성을 의미있는 단위로 묶고, 그 값이 항상 유효함을 보장하는 데 사용되는 객체이다. VO를 사용하면 서비스 계층의 코드가 비즈니스 로직이 아닌 단순한 유효성 검사로 복잡해지는 것을 방지하고, 코드의 응집도와 안전성을 높일 수 있다.
유효성 검증의 책임 이동
VO 도입의 가장 큰 이유는 유효성 검증의 책임을 서비스 계층에서 도메인 계층으로 옮기는 것이다.
예를 들어, 저 QueueToken의 고유식별자인 TokenId도 아래와 같이 VO 형식으로 정의해두었다. 아래 코드에서 TokenId의 생성자를 보면, VO 객체 자체에서 유효성 검증을 하는 것이 훨씬 깔끔하다는 것을 알 수 있다.
- Before (기존 방식): Service 계층이나 같은 Domain 계층인 QueueToken 생성 시에 유효성 검증을 수행했었다. 개발자가 이 검사를 실수로 누락하면 무효한 데이터가 생성될 수도 있다.
- After (VO 도입): TokenId와 같은 VO의 생성자에 불변식 검사를 위치시킨다. 유효하지 않은 값으로는 객체 자체가 생성될 수 없으므로, "유효한 객체만 존재할 수 있다"는 원칙이 보장된다.
@Getter
@EqualsAndHashCode
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TokenId {
private UUID value;
public TokenId(UUID value) {
if (value == null) {
throw new IllegalArgumentException("[Queue:Error] 토큰 ID는 비어있을 수 없습니다.");
}
this.value = value;
}
public static TokenId generate() {
return new TokenId(UUID.randomUUID());
}
public static TokenId of(UUID value) {
return new TokenId(value);
}
}
높은 응집도
VO는 여러 개의 기본 타입(Primitive Type) 속성을 하나의 비즈니스 개념으로 묶어준다. 아래 Domain 계층 엔티티 코드를 보면 startTime과 endTime의 경우, 각각 시작 시간과 종료 시간을 의미한다는 것을 알 수 있다.
그런데 이 둘을 "TimePeriod"라는 논리적 그룹으로 묶는다면? 따로 분리되어 있는 컬럼들을 합침으로써 코드 의도가 명확해진다.
package com.sparta.queue.domain.policy;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "p_queue_policy", schema = "queue_schema")
public class QueuePolicy { // BaseEntity 상속 생략 (필요 시 추가)
@Id
@Column(name = "policy_id")
private UUID id; // PK는 비즈니스 ID가 아닌 경우 그냥 'id'로 짓기도 함 (취향)
...
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private QueueStatus status;
// --- TimePeriod (VO 개념 논리적 그룹) ---
@Column(name = "start_time", nullable = false)
private LocalDateTime startTime;
@Column(name = "end_time", nullable = false)
private LocalDateTime endTime;
// --- TrafficSetting (VO 개념 논리적 그룹) ---
@Column(name = "limit_size", nullable = false)
private Integer limitSize; // 활성 허용 인원 (N명)
@Column(name = "queue_gap", nullable = false)
private Integer queueGap; // 진입 주기 (초)
@Column(name = "ttl", nullable = false)
private Integer ttl; // 활성 토큰 만료 시간 (초)
@Builder
public QueuePolicy(UUID id, UUID productId, String name, QueueStatus status,
LocalDateTime startTime, LocalDateTime endTime,
Integer limitSize, Integer queueGap, Integer ttl) {
// 생성 시점의 검증 로직 (도메인 규칙)
if (endTime.isBefore(startTime)) {
throw new IllegalArgumentException("종료 시간은 시작 시간보다 빠를 수 없습니다.");
}
if (limitSize <= 0) {
throw new IllegalArgumentException("입장 허용 인원은 1명 이상이어야 합니다.");
}
this.id = id != null ? id : UUID.randomUUID();
this.productId = productId;
this.name = name;
this.status = status;
this.startTime = startTime;
this.endTime = endTime;
this.limitSize = limitSize;
this.queueGap = queueGap;
this.ttl = ttl;
}
}
대기열 도메인에서 VO는 뭘로 만들어야 할까?
위의 QueuePolicy 코드를 보면 하나의 논리적 그룹으로 합칠 수 있는 여러 속성들이 보인다. 그 그룹에 의미를 부여할 때 VO를 쓴다.
TimePeriod(VO)
- startTime, endTime을 묶는다.
- 검증 로직 : "종료 시간은 시작 시간보다 이를 수 없다."
TrafficSetting(VO)
- limitSize, queueGap, ttl을 묶어서 "트래픽 제어 설정"이라는 VO로 만든다.
- 검증 로직 : "limitSize는 0보다 작을 수 없다", "ttl은 10초 이상어야 한다" 등의 비즈니스 규칙이 있다.
VO와 Entity의 명확한 구분 (QueueToken 분석)
DDD에서 VO와 Entity를 구분하는 것은 객체의 책임과 생명주기를 이해하는 핵심 요소이다.
VO의 정의 (값 그 자체)
| 특징 | 예시 (TimePeriod) |
| 식별자 없음 | TimePeriod 자체를 식별할 ID가 없습니다. |
| 불변 (Immutable) | 생성 후에는 startTime과 endTime을 변경할 수 없다 |
| 동등성 판단 | 두 VO의 모든 속성 값이 같으면 같은 객체로 간주합니다. (필요 시 equals()/hashCode() 오버라이딩) |
Entity의 정의 (식별자 및 생명주기)
| 특징 | 예시 (QueueToken / QueuePolicy) |
| 식별자 (ID) 존재 | QueuePolicy는 policyId, QueueToken은 tokenId라는 고유 식별자가 있다 |
| 가변 (Mutable) | QueueToken의 status가 WAIT -> ACTIVE로 변경되는 등 서비스 흐름에 따라상태가 변한다 |
| 생명주기 | 생성되고, 상태가 변하고, 삭제되는 생명주기가 존재한다 |
VO 분리 및 Service 계층 코드 적용 예시
@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TimePeriod {
@Column(name = "start_time", nullable = false)
private LocalDateTime startTime;
@Column(name = "end_time", nullable = false)
private LocalDateTime endTime;
public TimePeriod(LocalDateTime startTime, LocalDateTime endTime) {
if (startTime == null || endTime == null) {
throw new IllegalArgumentException("시작 시간과 종료 시간은 필수입니다.");
}
if (endTime.isBefore(startTime)) {
throw new IllegalArgumentException("종료 시간은 시작 시간보다 빠를 수 없습니다.");
}
this.startTime = startTime;
this.endTime = endTime;
}
}
TrafficSetting (VO)
@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TrafficSetting {
// 활성 허용 인원
@Column(name = "limit_size", nullable = false)
private Integer limitSize;
// 활성 체크 주기 (몇 초 마다 확인해서 들여보낼 지 주기)
@Column(name = "queue_gap", nullable = false)
private Integer queueGap;
// 토큰 유효 시 (Active 토큰의 만료 시간 (예: 300초))
@Column(name = "ttl", nullable = false)
private Integer ttl;
public TrafficSetting(Integer limitSize, Integer queueGap, Integer ttl) {
if (limitSize == null || limitSize <= 0) {
throw new IllegalArgumentException("진입 허용 인원은 1명 이상이어야 합니다.");
}
if (queueGap == null || queueGap <= 0) {
throw new IllegalArgumentException("활성 체크 주기는 1초 이상이어야 합니다.");
}
if (ttl == null || ttl <= 60) {
throw new IllegalArgumentException("TTL은 최소 60초 이상이어야 합니다.");
}
this.limitSize = limitSize;
this.queueGap = queueGap;
this.ttl = ttl;
}
}
Entity에서 VO 사용 (QueuePolicy)
Entity는 @Embedded 어노테이션을 사용하여 VO를 속성으로 포함한다
@Embedded : 이 필드에 @Embeddable 클래스가 포함됨을 알림 (DB 상에서는 컬럼 레벨에서 매핑됨)
@Entity
public class QueuePolicy extends BaseEntity {
// ...
// VO 적용
@Embedded
private TimePeriod timePeriod;
@Embedded
private TrafficSetting trafficSetting;
// ...
}
VO를 적용한 서비스 계층의 변화
VO 도입 후, QueuePolicyService는 데이터의 유효성을 걱정할 필요 없이 오직 비즈니스 로직의 흐름에만 집중할 수 있다.
@Transactional
public UUID registerPolicy(PolicyCreateRequest request) {
// VO 생성 (유효성 검증의 핵심)
// -> 여기서 유효성 검증 실패 시 즉시 예외 발생
TimePeriod timePeriod = new TimePeriod(request.startTime(), request.endTime());
TrafficSetting trafficSetting = new TrafficSetting(request.limitSize(), request.queueGap(), request.ttl());
// Entity 생성 (유효한 VO들로 생성)
QueuePolicy policy = QueuePolicy.create(
request.productId(),
request.timeDealName(),
timePeriod, // 안전한 VO 전달
trafficSetting // 안전한 VO 전달
);
// 저장
queuePolicyRepository.save(policy);
return policy.getPolicyId();
}
'Project' 카테고리의 다른 글
| [RUSH_CREW] 3. 대기열 Redis 데이터 구조 설계 (0) | 2025.12.03 |
|---|---|
| [RUSH_CREW] 1. 타임딜 서비스 개요 (feat. DDD Bounded Context) (0) | 2025.11.26 |
| [DELIVERY_SIGNAL] Circuit Breaker 구현(Resilience4j) (0) | 2025.11.21 |
| [DELIVERY_SIGNAL] 배송 담당자 순번 할당 설계/구현 (0) | 2025.11.07 |
| [DELIVERY_SIGNAL] DDD 계층 구조 적용 (0) | 2025.11.06 |