서론
타임딜 플랫폼의 핵심은 타임딜 오픈 직후, 폭주하는 트래픽을 제어하여 시스템을 보호하는 것이다. 이를 위해 설계된 Queue Service의 아키텍처와 도메인 구조를 DDD(Domain-Driven Design)와 헥사고날 아키텍처(Ports & Adapters Pattern)을 적용하여 코드를 작성하기로 하였다.
본론
우리 대기열 서비스는 외부의 기술적 변화(Redis, Kafka, DB 등)로부터 핵심 비즈니스 로직을 격리하기 위해 헥사고날 아키텍처를 채택하기로 하였다. 다시 말하면 도메인(비즈니스 로직)이 외부 기술에 의존하지 않도록 격리하는 것이다. 따라서 계층 구조를 아래와 같이 구성하였다.
패키지 구조
com.rushcrew.queue
├── application # [애플리케이션 계층] 유스케이스 흐름 제어
│ ├── command # 유스케이스 실행을 위한 명령 객체 (Request DTO와 분리)
│ │ ├── policy
│ │ └── queue
│ ├── dto # 서비스 간 데이터 전송 객체 (QueueRedisResponse 등)
│ ├── port # [Port] 인터페이스 정의
│ │ └── in
│ ├── service # [Service] 유스케이스 구현체 (QueueService, QueuePolicyService)
│ └── validator
├── common # 공통 예외 및 에러 코드
├── domain # [도메인 계층] 핵심 비즈니스 로직 및 모델
│ ├── dto
│ ├── entity # 핵심 엔티티 (QueueToken, QueuePolicy)
│ ├── enums # 상태값 (QueueStatus, QueuePolicyStatus)
│ ├── repository # [Outbound Port] 영속성 인터페이스 (QueueRepository 등)
│ └── vo # 값 객체 (TokenId, TimePeriod, TrafficSetting)
├── infrastructure # [인프라 계층] 외부 기술 구현체 (Adapter)
│ ├── config # 설정
│ ├── filter # Spring Security 보안 필터 (AuthorizationFilter)
│ ├── kafka # 메시징 (Consumer 구현)
│ ├── repository # [Outbound Adapter] (RedisQueueRepository, JPA 구현체)
│ ├── scheduler # 동적 스케줄링 로직 (QueueScheduler)
│ └── security
└── presentation # [표현 계층] 외부 진입점
├── controller
├── dto
└── mapper # DTO <-> Domain 변환 (QueuePresentationMapper)
비즈니스 요구사항
대기열 정책 관리: 이벤트별로 다른 대기열 정책 적용
토큰 기반 진입 제어: 순서 보장 및 공정한 입장 처리
동적 트래픽 제어: 시스템 부하에 따른 유입량 조절
실시간 대기 정보: 사용자의 대기 순번 제공
핵심 설계 원칙
의존성 방향 규칙은 다음과 같다.
Presentation → Application → Domain ← Infrastructure
- Domain Layer: 의존성이 없는 순수한 비즈니스 로직
- Application Layer: Domain을 조율하는 Use Case 구현
- Infrastructure Layer: Domain의 인터페이스를 구현 (의존성 역전)
- Presentation Layer: 외부 세계와의 접점
도메인 모델링: Redis 기반의 Root Aggregate
대기열 서비스의 중심에는 QueueToken이 있다. 일반적으로 Root Aggregate는 RDB 테이블과 매핑되지만, 해당 대기열 서비스는 성능을 위해 Redis 자료구조와 매핑되는 순수 객체가 Root Aggregate 역할을 수행한다.
즉, 실시간 트래픽 처리를 위한 Redis 기반 도메인과 대기열 정책 관리를 위한 RDB 도메인이 공존한다.
핵심 도메인 객체
- QueueToken (Root Aggregate): 대기열의 상태와 진입 권한을 책임집니다.
- TokenId (VO): UUID를 래핑한 값 객체로, 원시 타입 집착을 방지하고 타입 안정성을 확보한다.
- TimePeriod (VO): 타임딜 시작/종료 시간을 관리하며 "현재 유효한 정책인가"를 판단하는 도메인 로직
- TrafficSetting (VO): 대기열에서 유량 제어를 관리하는 값 객체 (최대 활성 인원, 활성 배치 크기, 활성 체크 주기 등)
- QueuePolicy (Entity): 상품별 타임딜 서비스 정책(최대 인원, 유입 주기 등)을 관리하는 RDB 기반 엔티티
QueueToken.java
package com.rushcrew.queue.domain.entity;
import com.rushcrew.queue.domain.enums.QueueStatus;
import com.rushcrew.queue.domain.vo.TokenId;
import java.util.UUID;
import lombok.Builder;
import lombok.Getter;
/**
* 실질적 Aggregate Root가 됨
* (엔티티 어노테이션이 없는 순수 자바 객체이나 생명주기가 있고, DDD상의 개념은 엔티티로 들어감)
*/
@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 계산용)
// 대기 토큰 생성
public static QueueToken create(UUID productId, Long userId) {
return QueueToken.builder()
.id(TokenId.generate())
.productId(productId)
.userId(userId)
.status(QueueStatus.WAITING) // 처음 생성 시 기본값 WAITING
.requestTime(System.currentTimeMillis())
.build();
}
// 토큰 활성화 (WAIT -> ACTIVE 상태 변경)
public void activate() {
if (this.status != QueueStatus.WAITING) {
throw new IllegalStateException("대기 상태의 토큰만 활성화할 수 있습니다.");
}
this.status = QueueStatus.ACTIVE;
this.activeTime = System.currentTimeMillis();
}
// 토큰 만료
public void expire() {
this.status = QueueStatus.EXPIRED;
}
// 활성화 여부 확인
public boolean isActive() {
return this.status == QueueStatus.ACTIVE;
}
}
헥사고날 아키텍처 (Ports & Adapters)
Port & Adapter 구조의 핵심
Inbound Port (application.port.in)
외부(Web)에서 시스템으로 들어오는 인터페이스이다. QueuePort 인터페이스가 이에 해당하며, 컨트롤러는 이 포트를 통해 비즈니스 로직을 수행한다.
Outbound Port (domain.repository)
도메인이 데이터를 저장하거나 조회할 때 사용하는 인터페이스이다. DDD 원칙에 따라 도메인 계층에 위치하며, 비즈니스 로직은 실제 데이터가 Redis에 있는지 DB에 있는지 알 필요가 없다.
Infrastructure Adapters (infrastructure.repository)
도메인의 Outbound Port를 실제로 구현한다. RedisQueueRepositoryImpl은 Redis의 ZSet 명령어를 통해 대기열을 처리하고, QueuePolicyRepositoryImpl은 JPA를 통해 정책 정보를 영속화한다.
Port & Adapter 패턴 적용
UseCase Interface (Port In)
- 애플리케이션의 진입점을 명확히 정의
- 외부(Controller)에서 내부(Domain)로의 의존성 방향 제어
- 각 Use Case는 단일 책임을 가짐
public interface QueuePort {
/**
* 대기열 진입 (토큰 발급)
*/
QueueRedisResponse enterQueue(EnterQueueCommand command);
/**
* 대기 상태 조회 (Polling)
*/
QueueRedisResponse getQueueRank(UUID productId, String token, Long userId);
/**
* 토큰 형식 검증
*/
TokenId extractValidQueueTokenId(String token);
/**
* 토큰 형식 검증 + 유저 토큰 활성화 여부
*/
boolean validateActivatedQueueToken(UUID productId, String token);
/**
* 토큰 활성화
* 스케줄러가 호출: 대기 -> 활성 전환
*/
boolean activateTokens(UUID productId, TrafficSetting trafficSetting);
/**
* [명시적 대기열 퇴장/취소]
* 토큰 만료 (삭제) 처리
* 대기 중 취소하거나, 주문 완료 후 호출
* UserId를 넘겨서 USER_INDEX_KEY까지 확실하게 지움 -> 이후에 즉시 재진입 가능하도록
*/
void exitQueue(UUID productId, String token, Long userId);
}
Repository Interface (Port Out)
도메인 계층에 인터페이스를 정의하고 Infrastructure 레이어에서 구현함으로써 의존성 역전 원칙(DIP)을 준수한다. 도메인이 외부 저장소에 의존하지 않도록 추상화하였기에 외부 저장소 교체 구현이 용이하다.
그리고 이 QueueRepository 인터페이스의 구현체인 RedisQueueRepository(Impl)에서는 본격적으로 Redis를 사용하여 대기열을 관리하는 역할을 할 수 있다.
// QueueRepository.java - 대기열 토큰 저장소
public interface QueueRepository {
QueueToken save(QueueToken token);
Optional<QueueToken> findByTokenId(TokenId tokenId);
List<QueueToken> findWaitingTokensByPolicy(Long policyId);
Long countActiveTokens(Long policyId);
}
결론
타임딜 프로젝트에서는 DDD를 통해 비즈니스 규칙을 도메인을 중심으로 구성하였고, 헥사고날 아키텍처를 통해 외부 기술을 인프라 계층으로 따로 격리하여 관리하도록 하였다. 이는 아래와 같은 장점을 가지게 된다.
- Redis, JPA, Kafka 등 외부 기술의 변화가 도메인에 전파되지 않음
- 패키지별로 레이어의 책임이 명확히 구분되어 유지보수 측면에서 좋다
- 기술적 구현보다 "대기열 정책(어떤 기준으로 유입을 막을 것인가)"과 "토큰 관리(토큰 상태를 어떻게 관리할 것인가)"라는 핵심 도메인에 집중할 수 있는 구조를 완성할 수 있었다
'Project > RUSH DEAL' 카테고리의 다른 글
| [RUSH_DEAL] 6. 대기열 순번 조회 로직 (feat.UUID 함정) (0) | 2025.12.04 |
|---|---|
| [RUSH_DEAL] 5. 대기열 진입 로직 (0) | 2025.12.04 |
| [RUSH_DEAL] 3. 대기열 Redis 데이터 구조 설계 (0) | 2025.12.03 |
| [RUSH_DEAL] 2. DDD에서의 VO(Value Object) (0) | 2025.12.02 |
| [RUSH_DEAL] 1. 타임딜 서비스 개요 (feat. DDD Bounded Context) (0) | 2025.11.26 |