From ddd878ebfa69c5736160e413e48cf8610df2079f Mon Sep 17 00:00:00 2001 From: rlaxxwls13 Date: Thu, 14 May 2026 14:31:29 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20&=20=EC=B9=B4=ED=94=84=EC=B9=B4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to #27 --- build.gradle | 57 ++++++++++++++++--- src/main/resources/application.yml | 10 +--- .../db/migration/V1__create_inbox_table.sql | 7 +++ 3 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 src/main/resources/db/migration/V1__create_inbox_table.sql diff --git a/build.gradle b/build.gradle index 3ec442d..d68b443 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,20 @@ repositories { password = githubToken } } + maven { + url = 'https://maven.pkg.github.com/first-ticket/common-jpa' + credentials { + username = githubUser + password = githubToken + } + } + maven { + url = 'https://maven.pkg.github.com/first-ticket/common-messaging' + credentials { + username = githubUser + password = githubToken + } + } } ext { @@ -55,30 +69,57 @@ ext { } dependencies { + // 공통 모듈 + implementation 'com.first-ticket:common:0.0.4-SNAPSHOT' + implementation 'com.first-ticket:common-jpa:0.0.1-SNAPSHOT' + implementation 'com.first-ticket:common-messaging:0.0.1-SNAPSHOT' + + // Spring Boot implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-aop' + + // Spring Cloud implementation 'org.springframework.cloud:spring-cloud-starter-config' implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' - implementation 'org.springframework.retry:spring-retry' - implementation 'org.springframework.boot:spring-boot-starter-aop' - implementation 'com.first-ticket:common:0.0.4-SNAPSHOT' + + // kafka + implementation 'org.springframework.kafka:spring-kafka' + + // flyway + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-database-postgresql' + + // 모니터링 + implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'io.micrometer:micrometer-tracing-bridge-brave' + runtimeOnly 'io.zipkin.reporter2:zipkin-reporter-brave' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + + // PostgreSQL + runtimeOnly 'org.postgresql:postgresql' + + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-testcontainers' testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.springframework.kafka:spring-kafka-test' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' - - // JWT - implementation 'io.jsonwebtoken:jjwt-api:0.12.6' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' } dependencyManagement { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5603b2f..a6d3468 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,9 @@ spring: application: name: queue-service + profiles: + include: kafka + config: import: - "optional:file:.env[.properties]" @@ -9,7 +12,6 @@ spring: cloud: # ECS Fargate에서 Eureka에 자기 IP를 ECS 메타데이터 IP(169.254.172.2)가 아닌 VPC 내부 IP(172.31.x.x)로 등록되게 하기 위한 설정. - # InetUtils가 IP 선택 시점이 Config Server 받기 전이라 inetutils: preferred-networks: - 10\. @@ -22,12 +24,6 @@ spring: discovery: enabled: true service-id: config-server - fail-fast: true - retry: - max-attempts: 20 - initial-interval: 2000 - max-interval: 5000 - multiplier: 1.2 eureka: instance: diff --git a/src/main/resources/db/migration/V1__create_inbox_table.sql b/src/main/resources/db/migration/V1__create_inbox_table.sql new file mode 100644 index 0000000..2d37ab1 --- /dev/null +++ b/src/main/resources/db/migration/V1__create_inbox_table.sql @@ -0,0 +1,7 @@ +CREATE TABLE p_inbox +( + message_id UUID NOT NULL, + processed_at TIMESTAMP NOT NULL, + + CONSTRAINT pk_inbox PRIMARY KEY (message_id) +); From e260b6641272afb2d9cad8809b7b049d35a8127d Mon Sep 17 00:00:00 2001 From: rlaxxwls13 Date: Fri, 15 May 2026 01:02:18 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20=EC=95=A0=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EA=B1=B0=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20-=20queuetoken?= =?UTF-8?q?=EA=B3=BC=20programmeta=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to #27 --- .../application/QueueTokenService.java | 90 ++++ .../dto/CancelQueueTokenCommand.java | 18 + .../application/dto/GetQueueTokenQuery.java | 18 + .../dto/IssueQueueTokenCommand.java | 18 + .../application/dto/QueueTokenResult.java | 24 + .../queuetoken/config/JwtProperties.java | 25 + .../queuetoken/config/QueueProperties.java | 32 ++ .../queuetoken/domain/QueueToken.java | 104 ++++ .../domain/QueueTokenRepository.java | 62 +++ .../queuetoken/domain/TokenStatus.java | 34 ++ .../exception/DuplicateTokenException.java | 16 + .../exception/InvalidTokenStateException.java | 15 + .../domain/exception/QueueErrorCode.java | 17 + .../exception/TokenNotFoundException.java | 22 + .../queuetoken/domain/vo/IssuedAt.java | 41 ++ .../queuetoken/domain/vo/ProgramId.java | 26 + .../queuetoken/domain/vo/QueueTokenId.java | 26 + .../queuetoken/domain/vo/UserId.java | 26 + .../infrastructure/jwt/EntryTokenIssuer.java | 56 +++ .../redis/RedisQueueTokenRepository.java | 406 +++++++++++++++ .../scheduler/AdmissionScheduler.java | 108 ++++ .../presentation/QueueSuccessCode.java | 17 + .../presentation/QueueTokenController.java | 79 +++ .../presentation/dto/QueueTokenResponse.java | 25 + .../queuetoken/domain/QueueTokenTest.java | 227 +++++++++ .../redis/RedisQueueTokenRepositoryTest.java | 472 ++++++++++++++++++ .../QueueTokenControllerTest.java | 405 +++++++++++++++ 27 files changed, 2409 insertions(+) create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/application/QueueTokenService.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/application/dto/CancelQueueTokenCommand.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/application/dto/GetQueueTokenQuery.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/application/dto/IssueQueueTokenCommand.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/application/dto/QueueTokenResult.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/config/JwtProperties.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/config/QueueProperties.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/domain/QueueToken.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/domain/QueueTokenRepository.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/domain/TokenStatus.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/DuplicateTokenException.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/InvalidTokenStateException.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/QueueErrorCode.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/TokenNotFoundException.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/IssuedAt.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/ProgramId.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/QueueTokenId.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/UserId.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/jwt/EntryTokenIssuer.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/scheduler/AdmissionScheduler.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/presentation/QueueSuccessCode.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenController.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/presentation/dto/QueueTokenResponse.java create mode 100644 src/test/java/com/firstticket/queueservice/queuetoken/domain/QueueTokenTest.java create mode 100644 src/test/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepositoryTest.java create mode 100644 src/test/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenControllerTest.java diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/application/QueueTokenService.java b/src/main/java/com/firstticket/queueservice/queuetoken/application/QueueTokenService.java new file mode 100644 index 0000000..a759550 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/application/QueueTokenService.java @@ -0,0 +1,90 @@ +package com.firstticket.queueservice.queuetoken.application; + +import com.firstticket.queueservice.queuetoken.application.dto.CancelQueueTokenCommand; +import com.firstticket.queueservice.queuetoken.application.dto.GetQueueTokenQuery; +import com.firstticket.queueservice.queuetoken.application.dto.IssueQueueTokenCommand; +import com.firstticket.queueservice.queuetoken.application.dto.QueueTokenResult; +import com.firstticket.queueservice.queuetoken.domain.QueueToken; +import com.firstticket.queueservice.queuetoken.domain.QueueTokenRepository; +import com.firstticket.queueservice.queuetoken.domain.exception.DuplicateTokenException; +import com.firstticket.queueservice.queuetoken.domain.exception.InvalidTokenStateException; +import com.firstticket.queueservice.queuetoken.domain.exception.TokenNotFoundException; +import com.firstticket.queueservice.queuetoken.domain.vo.ProgramId; +import com.firstticket.queueservice.queuetoken.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * 대기열 진입 / 조회 / 취소를 처리하는 서비스. + */ +@Service +@RequiredArgsConstructor +public class QueueTokenService { + + private final QueueTokenRepository queueTokenRepository; + + /** + * 대기열에 진입한다. + * + *

대기 토큰을 발급한다. + * 같은 사용자 + 프로그램으로 토큰이 이미 있으면 기존 토큰을 폐기한 뒤 새로 발급한다. + * + * @return 발급된 토큰과 현재 순번 + * @throws DuplicateTokenException 동시 요청으로 race 발생 시 (드물게) + */ + public QueueTokenResult issueToken(IssueQueueTokenCommand command) { + UserId userId = command.userId(); + ProgramId programId = command.programId(); + + // 같은 user+program 토큰이 있으면 폐기 후 새로 발급 + queueTokenRepository.findByUserIdAndProgramId(userId, programId) + .ifPresent(queueTokenRepository::delete); + + QueueToken token = QueueToken.issue(userId, programId); + // race condition: 동시 요청 시 DuplicateTokenException 가능. v0.2.0 Lua 통합으로 해결. + queueTokenRepository.enqueue(token); + + Long position = queueTokenRepository.findPosition(userId, programId).orElse(null); + return QueueTokenResult.of(token, position); + } + + /** + * 사용자의 대기 정보를 조회한다. + * + *

주로 폴링용으로 호출된다. 토큰 정보와 현재 순번을 반환한다. + * + * @return 토큰 정보 + 현재 순번 (ADMITTED 등 큐에서 빠진 상태면 position = null) + * @throws TokenNotFoundException 해당 사용자의 토큰이 없을 때 + */ + public QueueTokenResult getToken(GetQueueTokenQuery query) { + UserId userId = query.userId(); + ProgramId programId = query.programId(); + + QueueToken token = queueTokenRepository.findByUserIdAndProgramId(userId, programId) + .orElseThrow(TokenNotFoundException::new); + + Long position = queueTokenRepository.findPosition(userId, programId).orElse(null); + return QueueTokenResult.of(token, position); + } + + /** + * 사용자가 대기를 취소한다. + * + *

대기 토큰을 CANCELLED 상태로 바꾼 뒤 저장소에서 제거한다. + * + * @throws TokenNotFoundException 해당 사용자의 토큰이 없을 때 + * @throws InvalidTokenStateException WAITING 이 아닌 상태에서 취소 시도 시 (예: ADMITTED) + */ + public void cancelToken(CancelQueueTokenCommand command) { + UserId userId = command.userId(); + ProgramId programId = command.programId(); + + QueueToken token = queueTokenRepository.findByUserIdAndProgramId(userId, programId) + .orElseThrow(TokenNotFoundException::new); + + // 도메인 상태 검증 + 전이 (WAITING → CANCELLED) + token.cancel(); + + queueTokenRepository.delete(token); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/application/dto/CancelQueueTokenCommand.java b/src/main/java/com/firstticket/queueservice/queuetoken/application/dto/CancelQueueTokenCommand.java new file mode 100644 index 0000000..ec6037d --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/application/dto/CancelQueueTokenCommand.java @@ -0,0 +1,18 @@ +package com.firstticket.queueservice.queuetoken.application.dto; + +import com.firstticket.queueservice.queuetoken.domain.vo.ProgramId; +import com.firstticket.queueservice.queuetoken.domain.vo.UserId; + +import java.util.UUID; + +public record CancelQueueTokenCommand( + UserId userId, + ProgramId programId +) { + public static CancelQueueTokenCommand of(UUID userId, UUID programId) { + return new CancelQueueTokenCommand( + UserId.of(userId), + ProgramId.of(programId) + ); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/application/dto/GetQueueTokenQuery.java b/src/main/java/com/firstticket/queueservice/queuetoken/application/dto/GetQueueTokenQuery.java new file mode 100644 index 0000000..65bbe58 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/application/dto/GetQueueTokenQuery.java @@ -0,0 +1,18 @@ +package com.firstticket.queueservice.queuetoken.application.dto; + +import com.firstticket.queueservice.queuetoken.domain.vo.ProgramId; +import com.firstticket.queueservice.queuetoken.domain.vo.UserId; + +import java.util.UUID; + +public record GetQueueTokenQuery( + UserId userId, + ProgramId programId +) { + public static GetQueueTokenQuery of(UUID userId, UUID programId) { + return new GetQueueTokenQuery( + UserId.of(userId), + ProgramId.of(programId) + ); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/application/dto/IssueQueueTokenCommand.java b/src/main/java/com/firstticket/queueservice/queuetoken/application/dto/IssueQueueTokenCommand.java new file mode 100644 index 0000000..b528132 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/application/dto/IssueQueueTokenCommand.java @@ -0,0 +1,18 @@ +package com.firstticket.queueservice.queuetoken.application.dto; + +import com.firstticket.queueservice.queuetoken.domain.vo.ProgramId; +import com.firstticket.queueservice.queuetoken.domain.vo.UserId; + +import java.util.UUID; + +public record IssueQueueTokenCommand( + UserId userId, + ProgramId programId +) { + public static IssueQueueTokenCommand of(UUID userId, UUID programId) { + return new IssueQueueTokenCommand( + UserId.of(userId), + ProgramId.of(programId) + ); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/application/dto/QueueTokenResult.java b/src/main/java/com/firstticket/queueservice/queuetoken/application/dto/QueueTokenResult.java new file mode 100644 index 0000000..3ad164c --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/application/dto/QueueTokenResult.java @@ -0,0 +1,24 @@ +package com.firstticket.queueservice.queuetoken.application.dto; + +import com.firstticket.queueservice.queuetoken.domain.QueueToken; +import com.firstticket.queueservice.queuetoken.domain.TokenStatus; +import com.firstticket.queueservice.queuetoken.domain.vo.IssuedAt; +import com.firstticket.queueservice.queuetoken.domain.vo.QueueTokenId; + +public record QueueTokenResult( + QueueTokenId tokenId, + TokenStatus status, + IssuedAt issuedAt, + Long position, + String entryToken +) { + public static QueueTokenResult of(QueueToken token, Long position) { + return new QueueTokenResult( + token.getId(), + token.getStatus(), + token.getIssuedAt(), + position, + token.getEntryToken() + ); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/config/JwtProperties.java b/src/main/java/com/firstticket/queueservice/queuetoken/config/JwtProperties.java new file mode 100644 index 0000000..4f65ae0 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/config/JwtProperties.java @@ -0,0 +1,25 @@ +package com.firstticket.queueservice.queuetoken.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +/** + * JWT 입장 토큰 설정. + * + *

도메인 정책 (TTL) 과 인프라 정책 (비밀키) 모두 포함. + */ +@ConfigurationProperties(prefix = "queue.jwt") +public record JwtProperties( + String secret, + Duration entryTokenTtl +) { + public JwtProperties { + if (secret == null || secret.isBlank()) { + throw new IllegalArgumentException("queue.jwt.secret must not be blank"); + } + if (entryTokenTtl == null || entryTokenTtl.isZero() || entryTokenTtl.isNegative()) { + throw new IllegalArgumentException("queue.jwt.entry-token-ttl must be positive"); + } + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/config/QueueProperties.java b/src/main/java/com/firstticket/queueservice/queuetoken/config/QueueProperties.java new file mode 100644 index 0000000..5fc4300 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/config/QueueProperties.java @@ -0,0 +1,32 @@ +package com.firstticket.queueservice.queuetoken.config; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import java.time.Duration; + +/** + * queue-service의 운영 정책 설정. + * config-repo의 queue-service.yml 에서 외부 주입된다. + */ +@Validated +@ConfigurationProperties(prefix = "queue.token") +public record QueueProperties( + + /** + * 대기 토큰의 TTL. + * 이 시간 내 입장 승인 못 받으면 자동 만료 + */ + @NotNull + Duration waitingTtl, + + /** + * 한 번의 배치 스케줄러 실행 시 입장 승인할 인원 수. + */ + @Min(1) + int admissionBatchSize + +) { +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/domain/QueueToken.java b/src/main/java/com/firstticket/queueservice/queuetoken/domain/QueueToken.java new file mode 100644 index 0000000..633515b --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/domain/QueueToken.java @@ -0,0 +1,104 @@ +package com.firstticket.queueservice.queuetoken.domain; + +import com.firstticket.queueservice.queuetoken.domain.exception.InvalidTokenStateException; +import com.firstticket.queueservice.queuetoken.domain.vo.IssuedAt; +import com.firstticket.queueservice.queuetoken.domain.vo.ProgramId; +import com.firstticket.queueservice.queuetoken.domain.vo.QueueTokenId; +import com.firstticket.queueservice.queuetoken.domain.vo.UserId; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Objects; + +/** + * 대기 토큰 애그리거트 루트. + * + *

한 사용자의 한 프로그램에 대한 대기 상태를 표현한다. + * 발급(WAITING) 후 입장 승인(ADMITTED), 취소(CANCELLED), 만료(EXPIRED) 중 하나로 전이된다. + * + *

상태 전이 규칙: + *

+ */ +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class QueueToken { + private final QueueTokenId id; + private final UserId userId; + private final ProgramId programId; + private final IssuedAt issuedAt; + private TokenStatus status; + private String entryToken; + + /** + * 새로운 대기 토큰을 발급한다. + */ + public static QueueToken issue(UserId userId, ProgramId programId) { + Objects.requireNonNull(userId, "UserId는 필수입니다"); + Objects.requireNonNull(programId, "ProgramId는 필수입니다"); + return new QueueToken( + QueueTokenId.of(), + userId, + programId, + IssuedAt.now(), + TokenStatus.WAITING, + null // entryToken: 발급 시점엔 없음, admit 시 부여 + ); + } + + /** + * Redis 등 외부 저장소에서 토큰을 복원한다. + */ + public static QueueToken restore( + QueueTokenId id, + UserId userId, + ProgramId programId, + IssuedAt issuedAt, + TokenStatus status, + String entryToken + ) { + Objects.requireNonNull(id, "QueueTokenId는 필수입니다"); + Objects.requireNonNull(userId, "UserId는 필수입니다"); + Objects.requireNonNull(programId, "ProgramId는 필수입니다"); + Objects.requireNonNull(issuedAt, "IssuedAt은 필수입니다"); + Objects.requireNonNull(status, "TokenStatus는 필수입니다"); + return new QueueToken(id, userId, programId, issuedAt, status, entryToken); + } + + /** + * 입장을 승인한다 (WAITING -> ADMITTED) + */ + public void admit(String entryToken) { + ensureWaiting(); + this.status = TokenStatus.ADMITTED; + this.entryToken = entryToken; + } + + /** + * 사용자가 대기를 취소한다 (WAITING -> CANCELLED) + */ + public void cancel() { + ensureWaiting(); + this.status = TokenStatus.CANCELLED; + } + + /** + * 시간 만료로 토큰을 폐기한다 (WAITING -> EXPIRED) + */ + public void expire() { + ensureWaiting(); + this.status = TokenStatus.EXPIRED; + } + + /** + * 현재 상태가 WAITING이 아니면 예외를 던진다. + */ + private void ensureWaiting() { + if (status.isTerminal()) { + throw new InvalidTokenStateException(); + } + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/domain/QueueTokenRepository.java b/src/main/java/com/firstticket/queueservice/queuetoken/domain/QueueTokenRepository.java new file mode 100644 index 0000000..0283b20 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/domain/QueueTokenRepository.java @@ -0,0 +1,62 @@ +package com.firstticket.queueservice.queuetoken.domain; + +import com.firstticket.queueservice.queuetoken.domain.exception.DuplicateTokenException; +import com.firstticket.queueservice.queuetoken.domain.vo.ProgramId; +import com.firstticket.queueservice.queuetoken.domain.vo.QueueTokenId; +import com.firstticket.queueservice.queuetoken.domain.vo.UserId; + +import java.util.List; +import java.util.Optional; + +/** + * 대기 토큰의 영속성 저장소. + * + *

도메인 규칙: 한 사용자는 한 프로그램에 활성 토큰을 하나만 가질 수 있다. + * 중복 진입 시도는 인프라 구현체가 거부한다. + */ +public interface QueueTokenRepository { + + /** + * 신규 대기 토큰을 등록한다. + * + * @throws DuplicateTokenException 같은 사용자가 이미 같은 프로그램에 토큰 보유 시 + */ + void enqueue(QueueToken token); + + /** + * 토큰 ID로 대기 토큰을 조회한다. + */ + Optional findById(QueueTokenId id); + + /** + * 사용자가 특정 프로그램에 보유한 대기 토큰을 조회한다. + * 동일 사용자는 한 프로그램에 하나의 활성 토큰만 가질 수 있다. + */ + Optional findByUserIdAndProgramId(UserId userId, ProgramId programId); + + /** + * 대기 토큰을 삭제한다. + */ + void delete(QueueToken token); + + /** + * 사용자가 특정 프로그램의 대기 순번을 조회한다. + * 토큰이 없거나 WAITING 상태가 아니면 Optional.empty() + */ + Optional findPosition(UserId userId, ProgramId programId); + + /** + * 특정 프로그램의 다음 입장 승인 대상자들을 조회한다 (앞에서 batchSize 명). + */ + List findAdmissionCandidates(ProgramId programId, int batchSize); + + /** + * 입장 승인된 토큰의 상태를 업데이트한다. + */ + void admit(QueueToken token); + + /** + * 현재 큐가 존재하는 모든 프로그램 ID 를 조회한다. + */ + List findActiveProgramIds(); +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/domain/TokenStatus.java b/src/main/java/com/firstticket/queueservice/queuetoken/domain/TokenStatus.java new file mode 100644 index 0000000..7dd4548 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/domain/TokenStatus.java @@ -0,0 +1,34 @@ +package com.firstticket.queueservice.queuetoken.domain; + +/** + * 대기 토큰의 상태. + * + *

상태 전이 규칙: + *

+ *   WAITING → ADMITTED  (입장 승인)
+ *   WAITING → CANCELLED (사용자 취소)
+ *   WAITING → EXPIRED   (시간 만료)
+ *   ADMITTED, CANCELLED, EXPIRED → (전이 불가, 최종 상태)
+ * 
+ */ +public enum TokenStatus { + + /** 대기 중 */ + WAITING, + + /** 입장 승인됨 */ + ADMITTED, + + /** 사용자 또는 시스템에 의해 취소됨 */ + CANCELLED, + + /** 시간 만료 */ + EXPIRED; + + /** + * 최종 상태인지 (더 이상 전이 불가) 여부를 반환한다. + */ + public boolean isTerminal() { + return this != WAITING; + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/DuplicateTokenException.java b/src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/DuplicateTokenException.java new file mode 100644 index 0000000..eec26f9 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/DuplicateTokenException.java @@ -0,0 +1,16 @@ +package com.firstticket.queueservice.queuetoken.domain.exception; + +import com.firstticket.common.exception.BusinessException; + +/** + * 같은 사용자 + 같은 프로그램 조합으로 토큰을 중복 발급하려 할 때 발생하는 예외. + * + *

정상 흐름에 발생하지 않으며, 동시 요청으로 인한 race condition 시 Repository 의 setIfAbsent 에 의해 노출된다. + * + *

해결: 클라이언트 재시도. 근본 해결은 v0.2.0 의 Lua Script 통합 예정. + */ +public class DuplicateTokenException extends BusinessException { + public DuplicateTokenException() { + super(QueueErrorCode.DUPLICATE_TOKEN); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/InvalidTokenStateException.java b/src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/InvalidTokenStateException.java new file mode 100644 index 0000000..725d5f7 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/InvalidTokenStateException.java @@ -0,0 +1,15 @@ +package com.firstticket.queueservice.queuetoken.domain.exception; + +import com.firstticket.common.exception.BusinessException; + +/** + * 대기 토큰의 상태 전이 규칙을 위반했을 때 발생하는 예외 + * + *

예: ADMITTED 상태인 토큰을 cancel() 시도 + */ +public class InvalidTokenStateException extends BusinessException { + + public InvalidTokenStateException() { + super(QueueErrorCode.INVALID_TOKEN_STATE); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/QueueErrorCode.java b/src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/QueueErrorCode.java new file mode 100644 index 0000000..bf70cb5 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/QueueErrorCode.java @@ -0,0 +1,17 @@ +package com.firstticket.queueservice.queuetoken.domain.exception; + +import com.firstticket.common.response.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum QueueErrorCode implements ErrorCode { + INVALID_TOKEN_STATE(HttpStatus.BAD_REQUEST, "대기 토큰 상태 전이 규칙을 위반했습니다"), + DUPLICATE_TOKEN(HttpStatus.CONFLICT, "이미 대기 중인 토큰이 있습니다"), + TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "토큰을 찾을 수 없습니다"); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/TokenNotFoundException.java b/src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/TokenNotFoundException.java new file mode 100644 index 0000000..0e6e488 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/domain/exception/TokenNotFoundException.java @@ -0,0 +1,22 @@ +package com.firstticket.queueservice.queuetoken.domain.exception; + +import com.firstticket.common.exception.BusinessException; + +/** + * 요청한 토큰을 찾을 수 없을 때 발생하는 예외. + * + *

다음 두 케이스에서 동일하게 던진다: + *

+ * + *

두 케이스를 구분하지 않는 이유는 "리소스 존재 여부" 정보 누출 방지 (보안). + * 공격자가 tokenId 추측 공격으로 토큰 존재 여부를 알아낼 수 없게 한다. + */ +public class TokenNotFoundException extends BusinessException { + + public TokenNotFoundException() { + super(QueueErrorCode.TOKEN_NOT_FOUND); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/IssuedAt.java b/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/IssuedAt.java new file mode 100644 index 0000000..2b785d5 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/IssuedAt.java @@ -0,0 +1,41 @@ +package com.firstticket.queueservice.queuetoken.domain.vo; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Objects; + +/** + * 대기 토큰의 발급 시각을 표현하는 VO. + * + *

모든 시간은 UTC 기준으로 다룬다. + */ +public record IssuedAt(LocalDateTime value) { + public IssuedAt { + Objects.requireNonNull(value, "IssuedAt은 null일 수 없습니다"); + } + + /** + * 현재 시각으로 IssuedAt을 생성한다 (UTC 기준). + */ + public static IssuedAt now() { + return new IssuedAt(LocalDateTime.now(ZoneOffset.UTC)); + } + + /** + * epoch milli로부터 IssuedAt을 복원한다 (UTC 기준). + */ + public static IssuedAt fromEpochMilli(long epochMilli) { + return new IssuedAt(LocalDateTime.ofInstant( + Instant.ofEpochMilli(epochMilli), + ZoneOffset.UTC + )); + } + + /** + * Redis Sorted Set score 에 사용할 epoch milli 변환. + */ + public long toEpochMilli() { + return value.toInstant(ZoneOffset.UTC).toEpochMilli(); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/ProgramId.java b/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/ProgramId.java new file mode 100644 index 0000000..cef028e --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/ProgramId.java @@ -0,0 +1,26 @@ +package com.firstticket.queueservice.queuetoken.domain.vo; + +import java.util.Objects; +import java.util.UUID; + +/** + * 프로그램(공연) ID를 표현하는 VO. + */ +public record ProgramId(UUID id) { + + public ProgramId { + Objects.requireNonNull(id, "ProgramId는 null일 수 없습니다"); + } + + public static ProgramId of(UUID id) { + return new ProgramId(id); + } + + public static ProgramId fromString(String id) { + return new ProgramId(UUID.fromString(id)); + } + + public String asString() { + return id.toString(); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/QueueTokenId.java b/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/QueueTokenId.java new file mode 100644 index 0000000..e2d3ab6 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/QueueTokenId.java @@ -0,0 +1,26 @@ +package com.firstticket.queueservice.queuetoken.domain.vo; + +import java.util.Objects; +import java.util.UUID; + +/** + * 대기 토큰 ID를 표현하는 VO. + */ +public record QueueTokenId(UUID id) { + + public QueueTokenId { + Objects.requireNonNull(id, "QueueTokenId는 null일 수 없습니다"); + } + + public static QueueTokenId of() { + return new QueueTokenId(UUID.randomUUID()); + } + + public static QueueTokenId fromString(String id) { + return new QueueTokenId(UUID.fromString(id)); + } + + public String asString() { + return id.toString(); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/UserId.java b/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/UserId.java new file mode 100644 index 0000000..84a5d03 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/UserId.java @@ -0,0 +1,26 @@ +package com.firstticket.queueservice.queuetoken.domain.vo; + +import java.util.Objects; +import java.util.UUID; + +/** + * 사용자 ID를 표현하는 VO. + */ +public record UserId(UUID id) { + + public UserId { + Objects.requireNonNull(id, "UserId는 null일 수 없습니다"); + } + + public static UserId of(UUID id) { + return new UserId(id); + } + + public static UserId fromString(String id) { + return new UserId(UUID.fromString(id)); + } + + public String asString() { + return id.toString(); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/jwt/EntryTokenIssuer.java b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/jwt/EntryTokenIssuer.java new file mode 100644 index 0000000..107c45f --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/jwt/EntryTokenIssuer.java @@ -0,0 +1,56 @@ +package com.firstticket.queueservice.queuetoken.infrastructure.jwt; + +import com.firstticket.queueservice.queuetoken.config.JwtProperties; +import com.firstticket.queueservice.queuetoken.domain.QueueToken; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +/** + * 입장 토큰 (JWT) 발급기. + * + *

대기열에서 admit 된 사용자에게 발급되는 JWT. + * 예매 서비스가 stateless 하게 검증한다 (queue-service 별도 호출 X). + */ +@Component +public class EntryTokenIssuer { + + private static final String ISSUER = "queue-service"; + private static final String CLAIM_PROGRAM_ID = "programId"; + + private final SecretKey secretKey; + private final Duration ttl; + + public EntryTokenIssuer(JwtProperties properties) { + this.secretKey = Keys.hmacShaKeyFor(properties.secret().getBytes(StandardCharsets.UTF_8)); + this.ttl = properties.entryTokenTtl(); + } + + /** + * 입장 토큰을 발급한다. + * + * @param token admit 된 대기 토큰 + * @return JWT 형식의 입장 토큰 + */ + public String issue(QueueToken token) { + Instant now = Instant.now(); + Instant expiration = now.plus(ttl); + + return Jwts.builder() + .id(UUID.randomUUID().toString()) + .issuer(ISSUER) + .subject(token.getUserId().asString()) + .claim(CLAIM_PROGRAM_ID, token.getProgramId().asString()) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiration)) + .signWith(secretKey) + .compact(); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java new file mode 100644 index 0000000..3494459 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java @@ -0,0 +1,406 @@ +package com.firstticket.queueservice.queuetoken.infrastructure.redis; + +import com.firstticket.queueservice.queuetoken.config.QueueProperties; +import com.firstticket.queueservice.queuetoken.domain.QueueToken; +import com.firstticket.queueservice.queuetoken.domain.QueueTokenRepository; +import com.firstticket.queueservice.queuetoken.domain.TokenStatus; +import com.firstticket.queueservice.queuetoken.domain.exception.DuplicateTokenException; +import com.firstticket.queueservice.queuetoken.domain.vo.IssuedAt; +import com.firstticket.queueservice.queuetoken.domain.vo.ProgramId; +import com.firstticket.queueservice.queuetoken.domain.vo.QueueTokenId; +import com.firstticket.queueservice.queuetoken.domain.vo.UserId; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.*; +import org.springframework.stereotype.Repository; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.*; + +/** + * 대기 토큰의 Redis 기반 영속성 구현체. + * + *

3가지 자료구조를 조합하여 사용한다: + *

+ * + *

저장 시 역인덱스(setIfAbsent)와 트랜잭션(MULTI/EXEC)으로 일관성을 보장한다. + * 자세한 흐름은 {@link #enqueue(QueueToken)} 참고. + */ +@Slf4j +@Repository +@RequiredArgsConstructor +public class RedisQueueTokenRepository implements QueueTokenRepository { + + // ===== 키 prefix 상수 ===== + private static final String QUEUE_KEY_PREFIX = "queue:"; + private static final String PROGRAM_KEY_PREFIX = "program:"; + private static final String TOKEN_KEY_PREFIX = "token:"; + private static final String USER_KEY_PREFIX = "user:"; + + // ===== Hash 필드 이름 상수 ===== + private static final String FIELD_USER_ID = "userId"; + private static final String FIELD_PROGRAM_ID = "programId"; + private static final String FIELD_ISSUED_AT = "issuedAt"; + private static final String FIELD_STATUS = "status"; + private static final String FIELD_ENTRY_TOKEN = "entryToken"; + + private final StringRedisTemplate redisTemplate; + private final QueueProperties properties; + + /** + * Redis 기반 enqueue 구현. + * + *

2단계 처리: + *

    + *
  1. 역인덱스(setIfAbsent)를 락처럼 사용하여 중복 진입 방지
  2. + *
  3. Sorted Set + Hash 를 MULTI/EXEC 트랜잭션으로 저장
  4. + *
+ * + *

한계: 1단계 성공 후 2단계 실패 시 orphan 역인덱스가 남을 수 있다. + * TTL 로 자동 정리되지만, 진짜 원자성을 위해 향후 Lua Script 도입 예정. + * + *

Sorted Set 의 멤버는 키 단위 TTL 불가하므로, + * {@link #delete(QueueToken)} 호출 시 명시적 ZREM 으로 정리한다. + */ + @Override + public void enqueue(QueueToken token) { + + // 키 생성 + String programKey = programKey(token.getProgramId()); + String tokenKey = tokenKey(token.getId()); + String userProgramKey = userProgramKey(token.getUserId(), token.getProgramId()); + + String tokenIdStr = token.getId().asString(); + + // Sorted Set의 score로 사용할 진입 시각 (epoch milli) + long issuedAtEpochMilli = token.getIssuedAt().toEpochMilli(); + + Duration ttl = properties.waitingTtl(); + + // 1단계: 역인덱스를 락처럼 사용하여 중복 진입 방지 + Boolean acquired = redisTemplate.opsForValue() + .setIfAbsent(userProgramKey, tokenIdStr, ttl); + + if (!Boolean.TRUE.equals(acquired)) { + throw new DuplicateTokenException(); + } + + // 2단계: 나머지 키를 트랜잭션으로 저장 + Map tokenFields = Map.of( + FIELD_USER_ID, token.getUserId().asString(), + FIELD_PROGRAM_ID, token.getProgramId().asString(), + FIELD_ISSUED_AT, String.valueOf(issuedAtEpochMilli), + FIELD_STATUS, token.getStatus().name() + ); + + // 트랜잭션으로 Sorted Set + Hash 저장 + Hash TTL 설정 + redisTemplate.execute(new SessionCallback>() { + @SuppressWarnings({"unchecked"}) + @Override + public List execute(RedisOperations operations) { + operations.multi(); + + // 1. Sorted Set: 대기열에 추가 (score = 진입 시각) + operations.opsForZSet().add(programKey, tokenIdStr, issuedAtEpochMilli); + + // 2. Hash: 토큰 메타 데이터 저장 + operations.opsForHash().putAll(tokenKey, tokenFields); + + // 3. Hash 키에 TTL 설정 (Sorted Set은 컬렉션이라 별도 정리 필요) + operations.expire(tokenKey, ttl); + + return operations.exec(); + } + }); + } + + /** + * Redis 기반 findById 구현. + * + *

Hash 전체 조회 후 도메인 객체로 복원한다. + * 토큰이 없으면 빈 Map이 반환되며 (null 아님), Optional.empty()로 처리한다. + */ + @Override + public Optional findById(QueueTokenId id) { + String tokenKey = tokenKey(id); + + // 타입 명시한 Map 으로 받기 위해 hashOps 변수 사용 + HashOperations hashOps = redisTemplate.opsForHash(); + // Redis 에서 Hash 전체 조회 + Map entries = hashOps.entries(tokenKey); + + // 토큰 없으면 빈 Map 이 옴 (null 아님) + if (entries.isEmpty()) { + return Optional.empty(); + } + + // 깨진 레코드 자동 정리 향후 도입 + try { + // Hash 데이터로 QueueToken 객체 만들어서 반환 + return Optional.of(toQueueToken(id, entries)); + } catch (Exception e) { + log.warn("깨진 Hash 레코드 발견. tokenId={}", id.asString(), e); + return Optional.empty(); + } + } + + /** + * Redis Hash 데이터로부터 QueueToken 도메인 객체를 복원한다. + */ + private QueueToken toQueueToken(QueueTokenId id, Map entries) { + UserId userId = UserId.fromString(entries.get(FIELD_USER_ID)); + ProgramId programId = ProgramId.fromString(entries.get(FIELD_PROGRAM_ID)); + IssuedAt issuedAt = IssuedAt.fromEpochMilli(Long.parseLong(entries.get(FIELD_ISSUED_AT))); + TokenStatus status = TokenStatus.valueOf(entries.get(FIELD_STATUS)); + String entryToken = entries.get(FIELD_ENTRY_TOKEN); // null 가능 (WAITING 상태) + + return QueueToken.restore(id, userId, programId, issuedAt, status, entryToken); + } + + /** + * Redis 기반 findByUserIdAndProgramId 구현. + * + *

2단계 조회: + *

    + *
  1. 역인덱스로 tokenId 조회
  2. + *
  3. tokenId로 토큰 전체 조회 ({@link #findById} 재사용)
  4. + *
+ */ + @Override + public Optional findByUserIdAndProgramId(UserId userId, ProgramId programId) { + String userProgramKey = userProgramKey(userId, programId); + + // 1단계: 역인덱스로 tokenId 조회 + String tokenIdStr = redisTemplate.opsForValue().get(userProgramKey); + + if (tokenIdStr == null) { + return Optional.empty(); + } + + // 2단계: tokenId로 토큰 전체 조회 (findById 재사용) + return findById(QueueTokenId.fromString(tokenIdStr)); + } + + /** + * Redis 기반 delete 구현. + * + *

3개 키를 트랜잭션으로 묶어 삭제한다: + *

    + *
  1. Sorted Set 에서 토큰 멤버 제거 (ZREM)
  2. + *
  3. Hash 키 삭제
  4. + *
  5. 역인덱스 키 삭제
  6. + *
+ * + *

이미 만료/삭제된 토큰에 대해서도 안전하게 호출 가능 (멱등). + */ + @Override + public void delete(QueueToken token) { + String programKey = programKey(token.getProgramId()); + String tokenKey = tokenKey(token.getId()); + String userProgramKey = userProgramKey(token.getUserId(), token.getProgramId()); + String tokenIdStr = token.getId().asString(); + + // 역인덱스 값과 토큰 ID가 일치할 때만 삭제 + // (다른 토큰이 차지한 경우는 그대로 둠 — race condition 방어) + String current = redisTemplate.opsForValue().get(userProgramKey); + if (tokenIdStr.equals(current)) { + redisTemplate.delete(userProgramKey); + } + + redisTemplate.execute(new SessionCallback>() { + @SuppressWarnings({"unchecked"}) + @Override + public List execute(RedisOperations operations) { + operations.multi(); + + // 1. Sorted Set 에서 멤버 제거 + operations.opsForZSet().remove(programKey, tokenIdStr); + + // 2. Hash 키 삭제 + operations.delete(tokenKey); + + return operations.exec(); + } + }); + } + + /** + * Redis 기반 findPosition 구현. + * + *

2단계 조회: + *

    + *
  1. 역인덱스로 tokenId 조회
  2. + *
  3. Sorted Set의 ZRANK로 순번 조회
  4. + *
+ * + *

Redis ZRANK는 0-based이므로 사용자에게 보여줄 1-based로 변환한다. + */ + @Override + public Optional findPosition(UserId userId, ProgramId programId) { + String userProgramKey = userProgramKey(userId, programId); + + // 1단계: 역인덱스로 tokenId 조회 + String tokenIdStr = redisTemplate.opsForValue().get(userProgramKey); + + if (tokenIdStr == null) { + return Optional.empty(); + } + + // 2단계: Sorted Set에서 순번 조회 (0-based) + String programKey = programKey(programId); + Long rank = redisTemplate.opsForZSet().rank(programKey, tokenIdStr); + + if (rank == null) { + return Optional.empty(); + } + + // 3단계: 1-based로 변환하여 반환 + return Optional.of(rank + 1); + } + + /** + * Redis 기반 findAdmissionCandidates 구현. + * + *

2단계 조회: + *

    + *
  1. Sorted Set ZRANGE로 앞에서 batchSize개의 tokenId 추출
  2. + *
  3. 각 tokenId로 토큰 전체 조회 (N번 Hash 조회)
  4. + *
+ * + *

Hash가 만료되어 토큰을 복원할 수 없는 orphan 케이스는 자연 필터링된다. + * 향후 N번 조회를 단일 Lua Script로 통합 검토. + */ + @Override + public List findAdmissionCandidates(ProgramId programId, int batchSize) { + if (batchSize <= 0) { + throw new IllegalArgumentException("batchSize는 1 이상이어야 합니다: " + batchSize); + } + + String programKey = programKey(programId); + + // 1단계: Sorted Set에서 앞에서부터 batchSize명의 tokenId 조회 + Set tokenIds = redisTemplate.opsForZSet().range(programKey, 0, batchSize - 1); + + if (tokenIds == null || tokenIds.isEmpty()) { + return List.of(); + } + + // 2단계: 각 tokenId로 토큰 전체 조회 + return tokenIds.stream() + // String → Optional + .map(tokenIdStr -> findById(QueueTokenId.fromString(tokenIdStr))) + // 토큰 있는 것만 + .filter(Optional::isPresent) + // Optional → QueueToken + .map(Optional::get) + .toList(); + } + + /** + * Redis 기반 admit 구현. + * + *

2가지 작업을 트랜잭션으로 처리한다: + *

    + *
  1. Sorted Set 에서 토큰 멤버 제거 (큐에서 빠짐 → position 조회 X)
  2. + *
  3. Hash 의 status / entryToken 업데이트
  4. + *
+ * + *

역인덱스는 유지 — 사용자가 GET 으로 자기 토큰 조회 가능 (status: ADMITTED 응답). + */ + public void admit(QueueToken token) { + String programKey = programKey(token.getProgramId()); + String tokenKey = tokenKey(token.getId()); + String tokenIdStr = token.getId().asString(); + + Map updates = Map.of( + FIELD_STATUS, token.getStatus().name(), + FIELD_ENTRY_TOKEN, token.getEntryToken() + ); + + redisTemplate.execute(new SessionCallback>() { + @SuppressWarnings({"unchecked"}) + @Override + public List execute(RedisOperations operations) { + operations.multi(); + + // 1. Sorted Set 에서 멤버 제거 (큐에서 빠짐) + operations.opsForZSet().remove(programKey, tokenIdStr); + + // 2. Hash 의 status / entryToken 업데이트 + operations.opsForHash().putAll(tokenKey, updates); + + return operations.exec(); + } + }); + } + + /** + * Redis 기반 findActiveProgramIds 구현. + * + *

{@code queue:program:*} 패턴으로 SCAN 하여 활성 프로그램 ID 목록을 반환한다. + * + *

SCAN 사용 이유: KEYS 명령은 production 에서 블로킹 발생 위험. SCAN 은 점진적. + * + *

Future Work

+ * 본 메서드는 MVP 의 가정 (큐 존재 = 활성 프로그램) 을 따른다. + *

program-service 와 Kafka 이벤트 통합 후엔: + *

    + *
  • {@code program.opened} 이벤트 → Redis Set 의 활성 프로그램 추가
  • + *
  • {@code program.closed} 이벤트 → Set 에서 제거
  • + *
  • 본 메서드는 Redis Set 직접 조회 (SCAN 불필요)
  • + *
+ */ + public List findActiveProgramIds() { + String pattern = QUEUE_KEY_PREFIX + PROGRAM_KEY_PREFIX + "*"; + + // 1. SCAN 으로 모든 큐 키 수집 + Set keys = redisTemplate.execute((RedisCallback>) connection -> { + Set result = new HashSet<>(); + ScanOptions options = ScanOptions.scanOptions() + .match(pattern) // queue:program:* 매칭 + .count(100) // 한 번에 100 개씩 점진적 조회 + .build(); + + // try-with-resources 로 cursor 자동 정리 + try (Cursor cursor = connection.scan(options)) { + while (cursor.hasNext()) { + // Redis 는 byte[] 반환 → UTF-8 문자열로 변환 + result.add(new String(cursor.next(), StandardCharsets.UTF_8)); + } + } + return result; + }); + + if (keys == null || keys.isEmpty()) { + return List.of(); + } + + // 2. 키에서 UUID 추출하여 ProgramId 로 변환 + // 예: "queue:program:abc-123" → "abc-123" → ProgramId.of("abc-123") + String prefix = QUEUE_KEY_PREFIX + PROGRAM_KEY_PREFIX; + return keys.stream() + .map(key -> key.substring(prefix.length())) + .map(ProgramId::fromString) + .toList(); + } + + // ===== 키 생성 헬퍼 ===== + + private String programKey(ProgramId programId) { + return QUEUE_KEY_PREFIX + PROGRAM_KEY_PREFIX + programId.asString(); + } + + private String tokenKey(QueueTokenId tokenId) { + return QUEUE_KEY_PREFIX + TOKEN_KEY_PREFIX + tokenId.asString(); + } + + private String userProgramKey(UserId userId, ProgramId programId) { + return QUEUE_KEY_PREFIX + USER_KEY_PREFIX + userId.asString() + + ":" + PROGRAM_KEY_PREFIX + programId.asString(); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/scheduler/AdmissionScheduler.java b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/scheduler/AdmissionScheduler.java new file mode 100644 index 0000000..2632e5e --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/scheduler/AdmissionScheduler.java @@ -0,0 +1,108 @@ +package com.firstticket.queueservice.queuetoken.infrastructure.scheduler; + +import com.firstticket.queueservice.queuetoken.config.QueueProperties; +import com.firstticket.queueservice.queuetoken.domain.QueueToken; +import com.firstticket.queueservice.queuetoken.domain.QueueTokenRepository; +import com.firstticket.queueservice.queuetoken.domain.vo.ProgramId; +import com.firstticket.queueservice.queuetoken.infrastructure.jwt.EntryTokenIssuer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 대기열 입장 승인 스케줄러. + * + *

주기적으로 활성 프로그램의 큐 앞에서 batchSize 명을 admit 한다. + * admit 시점에 JWT 입장 토큰을 발급하고, QueueToken 의 상태를 ADMITTED 로 전이시킨다. + * + *

흐름: + *

    + *
  1. Redis SCAN 으로 활성 프로그램 (큐가 존재하는 프로그램) 발견
  2. + *
  3. 각 프로그램의 큐 앞에서 batchSize 명 조회 (Sorted Set ZRANGE)
  4. + *
  5. 각 토큰에 대해 JWT 입장 토큰 발급 + 도메인 상태 전이 + Redis 영속성
  6. + *
+ * + *

MVP 단계 한계: + *

    + *
  • 단일 인스턴스 가정 — 여러 인스턴스에서 동시 실행 시 race 가능 (v0.2.0 별도 이슈)
  • + *
  • 활성 프로그램 발견을 Redis SCAN 에 의존 — program-service 통합 후 변경
  • + *
+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AdmissionScheduler { + + private final QueueTokenRepository queueTokenRepository; + private final EntryTokenIssuer entryTokenIssuer; + private final QueueProperties queueProperties; + + /** + * 매 5 초마다 활성 프로그램의 큐 앞 batchSize 명을 admit. + * + *

fixedRate 5000ms = 이전 실행이 끝나든 안 끝나든 5 초마다 시작. + */ + @Scheduled(fixedRate = 5000) + public void admit() { + List activePrograms = queueTokenRepository.findActiveProgramIds(); + + if (activePrograms.isEmpty()) { + return; + } + + log.debug("[AdmissionScheduler] 활성 프로그램 {} 개 발견", activePrograms.size()); + + for (ProgramId programId : activePrograms) { + try { + admitProgram(programId); + } catch (Exception e) { + // 한 프로그램의 실패가 다른 프로그램 처리에 영향을 주지 않도록 격리 + log.error("[AdmissionScheduler] program 단위 처리 실패 - programId={}", + programId.asString(), e); + } + } + } + + /** + * 특정 프로그램의 큐 앞 batchSize 명을 admit. + * + *

한 토큰의 admit 실패가 다른 토큰 처리에 영향을 주지 않도록 개별 try-catch 처리. + */ + private void admitProgram(ProgramId programId) { + int batchSize = queueProperties.admissionBatchSize(); + List candidates = queueTokenRepository.findAdmissionCandidates(programId, batchSize); + + if (candidates.isEmpty()) { + return; + } + + log.info("[AdmissionScheduler] programId={} 의 {} 명을 admit 합니다", + programId.asString(), candidates.size()); + + int successCount = 0; + for (QueueToken queueToken : candidates) { + try { + // 1. JWT 입장 토큰 발급 + String entryToken = entryTokenIssuer.issue(queueToken); + // 2. 도메인 상태 전이 (WAITING -> ADMITTED) + entryToken 부여 + queueToken.admit(entryToken); + // 3. Redis 영속성 (Sorted Set 제거 + Hash status/entryToken 갱신) + queueTokenRepository.admit(queueToken); + successCount++; + } catch (Exception e) { + // 한 토큰의 실패가 같은 batch 의 다른 토큰을 막지 않도록 격리 + // 실패한 토큰은 Sorted Set 에 남아 다음 cycle 에서 재시도 + log.error("[AdmissionScheduler] admit 실패 - tokenId={}, programId={}", + queueToken.getId().asString(), programId.asString(), e); + } + } + + if (successCount < candidates.size()) { + log.warn("[AdmissionScheduler] 부분 실패 - programId={}, success={}/{}", + programId.asString(), successCount, candidates.size()); + } + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/presentation/QueueSuccessCode.java b/src/main/java/com/firstticket/queueservice/queuetoken/presentation/QueueSuccessCode.java new file mode 100644 index 0000000..c6b5e5f --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/presentation/QueueSuccessCode.java @@ -0,0 +1,17 @@ +package com.firstticket.queueservice.queuetoken.presentation; + +import com.firstticket.common.response.SuccessCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum QueueSuccessCode implements SuccessCode { + QUEUE_TOKEN_ISSUED(HttpStatus.CREATED, "대기 토큰이 발급되었습니다"), + QUEUE_TOKEN_FOUND(HttpStatus.OK, "대기 토큰을 조회했습니다"), + QUEUE_TOKEN_CANCELLED(HttpStatus.OK, "대기를 취소했습니다"); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenController.java b/src/main/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenController.java new file mode 100644 index 0000000..5854d48 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenController.java @@ -0,0 +1,79 @@ +package com.firstticket.queueservice.queuetoken.presentation; + +import com.firstticket.common.response.ApiResponse; +import com.firstticket.common.web.AuthContext; +import com.firstticket.queueservice.queuetoken.application.QueueTokenService; +import com.firstticket.queueservice.queuetoken.application.dto.CancelQueueTokenCommand; +import com.firstticket.queueservice.queuetoken.application.dto.GetQueueTokenQuery; +import com.firstticket.queueservice.queuetoken.application.dto.IssueQueueTokenCommand; +import com.firstticket.queueservice.queuetoken.application.dto.QueueTokenResult; +import com.firstticket.queueservice.queuetoken.presentation.dto.QueueTokenResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +/** + * 대기열 진입 / 조회 / 취소를 처리하는 REST 컨트롤러. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/queues/programs/{programId}") +public class QueueTokenController { + + private final QueueTokenService queueTokenService; + + /** + * 대기열 진입. + * + * @return 201 Created + */ + @PostMapping + public ResponseEntity> issueToken( + @PathVariable UUID programId + ) { + IssueQueueTokenCommand command = IssueQueueTokenCommand.of( + AuthContext.getUserId(), + programId + ); + QueueTokenResult result = queueTokenService.issueToken(command); + return ApiResponse.success( + QueueSuccessCode.QUEUE_TOKEN_ISSUED, + QueueTokenResponse.from(result) + ); + } + + /** + * 대기 정보 조회 (폴링용). + * + * @return 200 OK + */ + @GetMapping + public ResponseEntity> getToken( + @PathVariable UUID programId + ) { + GetQueueTokenQuery query = GetQueueTokenQuery.of(AuthContext.getUserId(), programId); + QueueTokenResult result = queueTokenService.getToken(query); + return ApiResponse.success( + QueueSuccessCode.QUEUE_TOKEN_FOUND, + QueueTokenResponse.from(result)); + } + + /** + * 대기 취소. + * + * @return 200 OK + */ + @DeleteMapping + public ResponseEntity> cancelToken( + @PathVariable UUID programId + ) { + CancelQueueTokenCommand command = CancelQueueTokenCommand.of( + AuthContext.getUserId(), + programId + ); + queueTokenService.cancelToken(command); + return ApiResponse.success(QueueSuccessCode.QUEUE_TOKEN_CANCELLED); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/presentation/dto/QueueTokenResponse.java b/src/main/java/com/firstticket/queueservice/queuetoken/presentation/dto/QueueTokenResponse.java new file mode 100644 index 0000000..1f33b7d --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/presentation/dto/QueueTokenResponse.java @@ -0,0 +1,25 @@ +package com.firstticket.queueservice.queuetoken.presentation.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.firstticket.queueservice.queuetoken.application.dto.QueueTokenResult; + +import java.time.LocalDateTime; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record QueueTokenResponse( + String tokenId, + String status, + LocalDateTime issuedAt, + Long position, + String entryToken +) { + public static QueueTokenResponse from(QueueTokenResult result) { + return new QueueTokenResponse( + result.tokenId().asString(), + result.status().name(), + result.issuedAt().value(), + result.position(), + result.entryToken() + ); + } +} diff --git a/src/test/java/com/firstticket/queueservice/queuetoken/domain/QueueTokenTest.java b/src/test/java/com/firstticket/queueservice/queuetoken/domain/QueueTokenTest.java new file mode 100644 index 0000000..4c82c2d --- /dev/null +++ b/src/test/java/com/firstticket/queueservice/queuetoken/domain/QueueTokenTest.java @@ -0,0 +1,227 @@ +package com.firstticket.queueservice.queuetoken.domain; + +import com.firstticket.queueservice.queuetoken.domain.exception.InvalidTokenStateException; +import com.firstticket.queueservice.queuetoken.domain.exception.QueueErrorCode; +import com.firstticket.queueservice.queuetoken.domain.vo.ProgramId; +import com.firstticket.queueservice.queuetoken.domain.vo.UserId; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class QueueTokenTest { + + private static final String DUMMY_ENTRY_TOKEN = "dummy-entry-token"; + + private final UserId userId = UserId.of(UUID.randomUUID()); + private final ProgramId programId = ProgramId.of(UUID.randomUUID()); + + @Nested + @DisplayName("발급(issue)") + class Issue { + + @Test + @DisplayName("발급된 토큰의 초기 상태는 WAITING이다") + void 발급_시_WAITING_상태() { + QueueToken token = QueueToken.issue(userId, programId); + + assertThat(token.getStatus()).isEqualTo(TokenStatus.WAITING); + } + + @Test + @DisplayName("발급 시 entryToken은 null 이다") + void 발급_시_entryToken_null() { + QueueToken token = QueueToken.issue(userId, programId); + + assertThat(token.getEntryToken()).isNull(); + } + + @Test + @DisplayName("발급 시 ID가 자동 생성된다") + void 발급_시_ID_자동_생성() { + QueueToken token1 = QueueToken.issue(userId, programId); + QueueToken token2 = QueueToken.issue(userId, programId); + + assertThat(token1.getId()).isNotEqualTo(token2.getId()); + } + + @Test + @DisplayName("발급 시 IssuedAt이 현재 시각으로 설정된다") + void 발급_시_IssuedAt_설정() { + QueueToken token = QueueToken.issue(userId, programId); + + assertThat(token.getIssuedAt()).isNotNull(); + } + + @Test + @DisplayName("UserId가 null이면 예외가 발생한다") + void userId_null_예외() { + assertThatThrownBy(() -> QueueToken.issue(null, programId)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("ProgramId가 null이면 예외가 발생한다") + void programId_null_예외() { + assertThatThrownBy(() -> QueueToken.issue(userId, null)) + .isInstanceOf(NullPointerException.class); + } + } + + @Nested + @DisplayName("입장 승인(admit)") + class Admit { + + @Test + @DisplayName("WAITING 상태에서 admit 호출 시 ADMITTED로 전이된다") + void waiting에서_admit_성공() { + QueueToken token = QueueToken.issue(userId, programId); + + token.admit(DUMMY_ENTRY_TOKEN); + + assertThat(token.getStatus()).isEqualTo(TokenStatus.ADMITTED); + } + + @Test + @DisplayName("admit 시 entryToken이 저장된다") + void admit_시_entryToken_저장() { + QueueToken token = QueueToken.issue(userId, programId); + + token.admit(DUMMY_ENTRY_TOKEN); + + assertThat(token.getEntryToken()).isEqualTo(DUMMY_ENTRY_TOKEN); + } + + @Test + @DisplayName("이미 ADMITTED 상태에서 admit 시 예외가 발생한다") + void admitted에서_admit_시_예외() { + QueueToken token = QueueToken.issue(userId, programId); + token.admit(DUMMY_ENTRY_TOKEN); + + assertThatThrownBy(() -> token.admit(DUMMY_ENTRY_TOKEN)) + .isInstanceOf(InvalidTokenStateException.class) + .extracting("errorCode") + .isEqualTo(QueueErrorCode.INVALID_TOKEN_STATE); + } + + @Test + @DisplayName("CANCELLED 상태에서 admit 시 예외가 발생한다") + void cancelled에서_admit_시_예외() { + QueueToken token = QueueToken.issue(userId, programId); + token.cancel(); + + assertThatThrownBy(() -> token.admit(DUMMY_ENTRY_TOKEN)) + .isInstanceOf(InvalidTokenStateException.class); + } + + @Test + @DisplayName("EXPIRED 상태에서 admit 시 예외가 발생한다") + void expired에서_admit_시_예외() { + QueueToken token = QueueToken.issue(userId, programId); + token.expire(); + + assertThatThrownBy(() -> token.admit(DUMMY_ENTRY_TOKEN)) + .isInstanceOf(InvalidTokenStateException.class); + } + } + + @Nested + @DisplayName("취소(cancel)") + class Cancel { + + @Test + @DisplayName("WAITING 상태에서 cancel 호출 시 CANCELLED로 전이된다") + void waiting에서_cancel_성공() { + QueueToken token = QueueToken.issue(userId, programId); + + token.cancel(); + + assertThat(token.getStatus()).isEqualTo(TokenStatus.CANCELLED); + } + + @Test + @DisplayName("ADMITTED 상태에서 cancel 시 예외가 발생한다") + void admitted에서_cancel_시_예외() { + QueueToken token = QueueToken.issue(userId, programId); + token.admit(DUMMY_ENTRY_TOKEN); + + assertThatThrownBy(token::cancel) + .isInstanceOf(InvalidTokenStateException.class); + } + } + + @Nested + @DisplayName("만료(expire)") + class Expire { + + @Test + @DisplayName("WAITING 상태에서 expire 호출 시 EXPIRED로 전이된다") + void waiting에서_expire_성공() { + QueueToken token = QueueToken.issue(userId, programId); + + token.expire(); + + assertThat(token.getStatus()).isEqualTo(TokenStatus.EXPIRED); + } + + @Test + @DisplayName("CANCELLED 상태에서 expire 시 예외가 발생한다") + void cancelled에서_expire_시_예외() { + QueueToken token = QueueToken.issue(userId, programId); + token.cancel(); + + assertThatThrownBy(token::expire) + .isInstanceOf(InvalidTokenStateException.class); + } + } + + @Nested + @DisplayName("복원(restore)") + class Restore { + + @Test + @DisplayName("ADMITTED 토큰을 entryToken과 함께 복원한다") + void ADMITTED_토큰_복원() { + QueueToken issued = QueueToken.issue(userId, programId); + issued.admit(DUMMY_ENTRY_TOKEN); + + QueueToken restored = QueueToken.restore( + issued.getId(), + issued.getUserId(), + issued.getProgramId(), + issued.getIssuedAt(), + issued.getStatus(), + issued.getEntryToken() + ); + + assertThat(restored.getId()).isEqualTo(issued.getId()); + assertThat(restored.getUserId()).isEqualTo(issued.getUserId()); + assertThat(restored.getProgramId()).isEqualTo(issued.getProgramId()); + assertThat(restored.getIssuedAt()).isEqualTo(issued.getIssuedAt()); + assertThat(restored.getStatus()).isEqualTo(TokenStatus.ADMITTED); + assertThat(restored.getEntryToken()).isEqualTo(DUMMY_ENTRY_TOKEN); + } + + @Test + @DisplayName("WAITING 토큰은 entryToken null 로 복원된다") + void WAITING_토큰_복원() { + QueueToken issued = QueueToken.issue(userId, programId); + + QueueToken restored = QueueToken.restore( + issued.getId(), + issued.getUserId(), + issued.getProgramId(), + issued.getIssuedAt(), + issued.getStatus(), + null + ); + + assertThat(restored.getStatus()).isEqualTo(TokenStatus.WAITING); + assertThat(restored.getEntryToken()).isNull(); + } + } +} diff --git a/src/test/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepositoryTest.java b/src/test/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepositoryTest.java new file mode 100644 index 0000000..5741c7f --- /dev/null +++ b/src/test/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepositoryTest.java @@ -0,0 +1,472 @@ +package com.firstticket.queueservice.queuetoken.infrastructure.redis; + +import com.firstticket.queueservice.queuetoken.domain.QueueToken; +import com.firstticket.queueservice.queuetoken.domain.TokenStatus; +import com.firstticket.queueservice.queuetoken.domain.exception.DuplicateTokenException; +import com.firstticket.queueservice.queuetoken.domain.vo.ProgramId; +import com.firstticket.queueservice.queuetoken.domain.vo.QueueTokenId; +import com.firstticket.queueservice.queuetoken.domain.vo.UserId; +import com.firstticket.queueservice.queuetoken.infrastructure.redis.RedisQueueTokenRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * RedisQueueTokenRepository 통합 테스트. + * + *

Testcontainers로 실제 Redis를 띄워 테스트한다. + * 매 테스트 후 flushAll로 데이터를 초기화하여 격리를 보장한다. + */ +@SpringBootTest +@ActiveProfiles("test") +@Testcontainers +class RedisQueueTokenRepositoryTest { + + @Container + @ServiceConnection(name = "redis") + @SuppressWarnings("resource") + static GenericContainer redis = new GenericContainer<>("redis:7-alpine") + .withExposedPorts(6379); + + @Autowired + private RedisQueueTokenRepository repository; + + @Autowired + private StringRedisTemplate redisTemplate; + + @AfterEach + void cleanUp() { + redisTemplate.execute((RedisCallback) connection -> { + connection.serverCommands().flushAll(); + return null; + }); + } + + @Nested + @DisplayName("enqueue") + class Enqueue { + + @Test + @DisplayName("새 토큰을 등록하면 조회할 수 있다") + void 새_토큰_등록() { + QueueToken token = newToken(); + + repository.enqueue(token); + + Optional found = repository.findById(token.getId()); + assertThat(found).isPresent(); + assertThat(found.get().getStatus()).isEqualTo(TokenStatus.WAITING); + assertThat(found.get().getUserId()).isEqualTo(token.getUserId()); + assertThat(found.get().getProgramId()).isEqualTo(token.getProgramId()); + } + + @Test + @DisplayName("같은 사용자가 같은 프로그램에 중복 진입 시 DuplicateTokenException 발생") + void 중복_진입_예외() { + UserId userId = UserId.of(UUID.randomUUID()); + ProgramId programId = ProgramId.of(UUID.randomUUID()); + + QueueToken first = QueueToken.issue(userId, programId); + repository.enqueue(first); + + QueueToken second = QueueToken.issue(userId, programId); + assertThatThrownBy(() -> repository.enqueue(second)) + .isInstanceOf(DuplicateTokenException.class); + } + + @Test + @DisplayName("같은 사용자라도 다른 프로그램이면 진입 가능") + void 다른_프로그램_진입() { + UserId userId = UserId.of(UUID.randomUUID()); + + QueueToken first = QueueToken.issue(userId, ProgramId.of(UUID.randomUUID())); + QueueToken second = QueueToken.issue(userId, ProgramId.of(UUID.randomUUID())); + + repository.enqueue(first); + repository.enqueue(second); + + assertThat(repository.findById(first.getId())).isPresent(); + assertThat(repository.findById(second.getId())).isPresent(); + } + } + + @Nested + @DisplayName("findById") + class FindById { + + @Test + @DisplayName("저장된 토큰의 모든 필드가 정확히 복원된다") + void 토큰_필드_복원() { + QueueToken token = newToken(); + repository.enqueue(token); + + QueueToken found = repository.findById(token.getId()).orElseThrow(); + + assertThat(found.getId()).isEqualTo(token.getId()); + assertThat(found.getUserId()).isEqualTo(token.getUserId()); + assertThat(found.getProgramId()).isEqualTo(token.getProgramId()); + assertThat(found.getStatus()).isEqualTo(token.getStatus()); + } + + @Test + @DisplayName("저장 안 된 ID는 빈 Optional 반환") + void 미저장_ID() { + Optional found = repository.findById( + QueueTokenId.of() + ); + + assertThat(found).isEmpty(); + } + } + + @Nested + @DisplayName("findByUserIdAndProgramId") + class FindByUserIdAndProgramId { + + @Test + @DisplayName("저장된 사용자 토큰을 조회할 수 있다") + void 저장된_토큰_조회() { + UserId userId = UserId.of(UUID.randomUUID()); + ProgramId programId = ProgramId.of(UUID.randomUUID()); + QueueToken token = QueueToken.issue(userId, programId); + repository.enqueue(token); + + Optional found = repository.findByUserIdAndProgramId(userId, programId); + + assertThat(found).isPresent(); + assertThat(found.get().getId()).isEqualTo(token.getId()); + } + + @Test + @DisplayName("저장 안 된 사용자는 빈 Optional 반환") + void 미저장_사용자() { + Optional found = repository.findByUserIdAndProgramId( + UserId.of(UUID.randomUUID()), + ProgramId.of(UUID.randomUUID()) + ); + + assertThat(found).isEmpty(); + } + } + + @Nested + @DisplayName("findRank") + class FindRank { + + @Test + @DisplayName("첫 번째 진입자의 순번은 1이다") + void 첫_번째_진입자() { + ProgramId programId = ProgramId.of(UUID.randomUUID()); + UserId userId = UserId.of(UUID.randomUUID()); + + QueueToken token = QueueToken.issue(userId, programId); + repository.enqueue(token); + + Optional rank = repository.findPosition(userId, programId); + + assertThat(rank).isPresent(); + assertThat(rank.get()).isEqualTo(1L); + } + + @Test + @DisplayName("두 번째 진입자의 순번은 2이다") + void 두_번째_진입자() throws InterruptedException { + ProgramId programId = ProgramId.of(UUID.randomUUID()); + + QueueToken first = QueueToken.issue(UserId.of(UUID.randomUUID()), programId); + repository.enqueue(first); + + // epoch milli 차이 보장 (Sorted Set score 충돌 방지) + Thread.sleep(2); + + UserId secondUser = UserId.of(UUID.randomUUID()); + QueueToken second = QueueToken.issue(secondUser, programId); + repository.enqueue(second); + + Optional rank = repository.findPosition(secondUser, programId); + + assertThat(rank).isPresent(); + assertThat(rank.get()).isEqualTo(2L); + } + + @Test + @DisplayName("미진입 사용자는 빈 Optional 반환") + void 미진입_사용자() { + Optional rank = repository.findPosition( + UserId.of(UUID.randomUUID()), + ProgramId.of(UUID.randomUUID()) + ); + + assertThat(rank).isEmpty(); + } + } + + @Nested + @DisplayName("delete") + class Delete { + + @Test + @DisplayName("삭제하면 모든 키에서 조회되지 않는다") + void 삭제_후_조회_불가() { + QueueToken token = newToken(); + repository.enqueue(token); + + repository.delete(token); + + assertThat(repository.findById(token.getId())).isEmpty(); + assertThat(repository.findByUserIdAndProgramId( + token.getUserId(), token.getProgramId())).isEmpty(); + assertThat(repository.findPosition( + token.getUserId(), token.getProgramId())).isEmpty(); + } + + @Test + @DisplayName("삭제 후 같은 사용자가 재진입 가능 (DuplicateTokenException X)") + void 삭제_후_재진입() { + UserId userId = UserId.of(UUID.randomUUID()); + ProgramId programId = ProgramId.of(UUID.randomUUID()); + + QueueToken first = QueueToken.issue(userId, programId); + repository.enqueue(first); + repository.delete(first); + + QueueToken second = QueueToken.issue(userId, programId); + repository.enqueue(second); // 예외 발생 안 해야 함 + + assertThat(repository.findByUserIdAndProgramId(userId, programId)) + .isPresent(); + } + + @Test + @DisplayName("이미 없는 토큰을 삭제해도 예외 없음 (멱등)") + void 멱등성() { + QueueToken token = newToken(); + + // enqueue 없이 바로 delete + repository.delete(token); // 예외 없어야 함 + } + + @Test + @DisplayName("역인덱스가 다른 토큰을 가리키면 역인덱스는 유지된다 (race 방어)") + void 역인덱스_compare_and_delete() { + UserId userId = UserId.of(UUID.randomUUID()); + ProgramId programId = ProgramId.of(UUID.randomUUID()); + + // 1. tokenA 발급 후 강제 삭제 (Sorted Set, Hash, 역인덱스 모두 사라짐) + QueueToken tokenA = QueueToken.issue(userId, programId); + repository.enqueue(tokenA); + + // 2. 같은 사용자가 tokenB 로 재진입 (역인덱스 = tokenB) + repository.delete(tokenA); + QueueToken tokenB = QueueToken.issue(userId, programId); + repository.enqueue(tokenB); + + // 3. 늦게 도착한 tokenA 의 delete (race 시뮬) + repository.delete(tokenA); + + // 4. tokenB 의 역인덱스는 그대로 — findByUserIdAndProgramId 로 tokenB 조회 가능 + Optional found = repository.findByUserIdAndProgramId(userId, programId); + assertThat(found).isPresent(); + assertThat(found.get().getId()).isEqualTo(tokenB.getId()); + } + } + + @Nested + @DisplayName("findAdmissionCandidates") + class FindAdmissionCandidates { + + @Test + @DisplayName("앞에서 batchSize명을 진입 시각 순으로 반환") + void 앞_N명_조회() throws InterruptedException { + ProgramId programId = ProgramId.of(UUID.randomUUID()); + + QueueToken first = QueueToken.issue(UserId.of(UUID.randomUUID()), programId); + repository.enqueue(first); + Thread.sleep(2); + + QueueToken second = QueueToken.issue(UserId.of(UUID.randomUUID()), programId); + repository.enqueue(second); + Thread.sleep(2); + + QueueToken third = QueueToken.issue(UserId.of(UUID.randomUUID()), programId); + repository.enqueue(third); + + List candidates = repository.findAdmissionCandidates(programId, 2); + + assertThat(candidates).hasSize(2); + assertThat(candidates.get(0).getId()).isEqualTo(first.getId()); + assertThat(candidates.get(1).getId()).isEqualTo(second.getId()); + } + + @Test + @DisplayName("토큰이 없으면 빈 리스트 반환") + void 빈_프로그램() { + List candidates = repository.findAdmissionCandidates( + ProgramId.of(UUID.randomUUID()), 10); + + assertThat(candidates).isEmpty(); + } + + @Test + @DisplayName("batchSize가 실제 인원보다 크면 있는 만큼 반환") + void 적은_인원() { + ProgramId programId = ProgramId.of(UUID.randomUUID()); + + QueueToken token = QueueToken.issue(UserId.of(UUID.randomUUID()), programId); + repository.enqueue(token); + + List candidates = repository.findAdmissionCandidates(programId, 100); + + assertThat(candidates).hasSize(1); + } + } + + @Nested + @DisplayName("admit") + class Admit { + + @Test + @DisplayName("admit 후 Sorted Set 에서 제거되어 position 조회되지 않는다") + void admit_후_position_없음() { + // given + UserId userId = UserId.of(UUID.randomUUID()); + ProgramId programId = ProgramId.of(UUID.randomUUID()); + QueueToken token = QueueToken.issue(userId, programId); + repository.enqueue(token); + + // when + token.admit("dummy-jwt-token"); + repository.admit(token); + + // then + Optional position = repository.findPosition(userId, programId); + assertThat(position).isEmpty(); + } + + @Test + @DisplayName("admit 후 status 와 entryToken 이 영속된다") + void admit_후_status_entryToken_저장() { + // given + UserId userId = UserId.of(UUID.randomUUID()); + ProgramId programId = ProgramId.of(UUID.randomUUID()); + QueueToken token = QueueToken.issue(userId, programId); + repository.enqueue(token); + + // when + String entryToken = "dummy-jwt-token"; + token.admit(entryToken); + repository.admit(token); + + // then + Optional found = repository.findById(token.getId()); + assertThat(found).isPresent(); + assertThat(found.get().getStatus()).isEqualTo(TokenStatus.ADMITTED); + assertThat(found.get().getEntryToken()).isEqualTo(entryToken); + } + + @Test + @DisplayName("admit 후에도 사용자가 자기 토큰 조회 가능 (역인덱스 유지)") + void admit_후_사용자_조회_가능() { + // given + UserId userId = UserId.of(UUID.randomUUID()); + ProgramId programId = ProgramId.of(UUID.randomUUID()); + QueueToken token = QueueToken.issue(userId, programId); + repository.enqueue(token); + + // when + token.admit("dummy-jwt-token"); + repository.admit(token); + + // then + Optional found = repository.findByUserIdAndProgramId(userId, programId); + assertThat(found).isPresent(); + assertThat(found.get().getStatus()).isEqualTo(TokenStatus.ADMITTED); + } + } + + @Nested + @DisplayName("findActiveProgramIds") + class FindActiveProgramIds { + + @Test + @DisplayName("큐가 없으면 빈 리스트 반환") + void 큐_없음_빈_리스트() { + List result = repository.findActiveProgramIds(); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("한 프로그램에 토큰이 있으면 그 프로그램 ID 1 개 반환") + void 한_프로그램_1_개() { + // given + UserId userId = UserId.of(UUID.randomUUID()); + ProgramId programId = ProgramId.of(UUID.randomUUID()); + repository.enqueue(QueueToken.issue(userId, programId)); + + // when + List result = repository.findActiveProgramIds(); + + // then + assertThat(result).containsExactly(programId); + } + + @Test + @DisplayName("여러 프로그램에 토큰이 있으면 모든 프로그램 ID 반환") + void 여러_프로그램_모두() { + // given + ProgramId program1 = ProgramId.of(UUID.randomUUID()); + ProgramId program2 = ProgramId.of(UUID.randomUUID()); + ProgramId program3 = ProgramId.of(UUID.randomUUID()); + repository.enqueue(QueueToken.issue(UserId.of(UUID.randomUUID()), program1)); + repository.enqueue(QueueToken.issue(UserId.of(UUID.randomUUID()), program2)); + repository.enqueue(QueueToken.issue(UserId.of(UUID.randomUUID()), program3)); + + // when + List result = repository.findActiveProgramIds(); + + // then + assertThat(result).containsExactlyInAnyOrder(program1, program2, program3); + } + + @Test + @DisplayName("같은 프로그램에 여러 토큰이 있어도 프로그램 ID 1 개만 반환") + void 같은_프로그램_중복_X() { + // given + ProgramId programId = ProgramId.of(UUID.randomUUID()); + repository.enqueue(QueueToken.issue(UserId.of(UUID.randomUUID()), programId)); + repository.enqueue(QueueToken.issue(UserId.of(UUID.randomUUID()), programId)); + repository.enqueue(QueueToken.issue(UserId.of(UUID.randomUUID()), programId)); + + // when + List result = repository.findActiveProgramIds(); + + // then + assertThat(result).containsExactly(programId); + } + } + + private QueueToken newToken() { + return QueueToken.issue( + UserId.of(UUID.randomUUID()), + ProgramId.of(UUID.randomUUID()) + ); + } +} diff --git a/src/test/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenControllerTest.java b/src/test/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenControllerTest.java new file mode 100644 index 0000000..f6565fe --- /dev/null +++ b/src/test/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenControllerTest.java @@ -0,0 +1,405 @@ +package com.firstticket.queueservice.queuetoken.presentation; + +import com.firstticket.common.exception.BusinessException; +import com.firstticket.common.exception.GlobalExceptionHandler; +import com.firstticket.common.response.CommonErrorCode; +import com.firstticket.common.web.AuthContext; +import com.firstticket.queueservice.queuetoken.application.QueueTokenService; +import com.firstticket.queueservice.queuetoken.application.dto.QueueTokenResult; +import com.firstticket.queueservice.queuetoken.domain.QueueToken; +import com.firstticket.queueservice.queuetoken.domain.exception.DuplicateTokenException; +import com.firstticket.queueservice.queuetoken.domain.exception.InvalidTokenStateException; +import com.firstticket.queueservice.queuetoken.domain.exception.TokenNotFoundException; +import com.firstticket.queueservice.queuetoken.domain.vo.ProgramId; +import com.firstticket.queueservice.queuetoken.domain.vo.UserId; +import com.firstticket.queueservice.queuetoken.presentation.QueueTokenController; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyHeaders; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 대기열 API 통합 테스트. + * + *

WebMvcTest 슬라이스로 Controller 만 로드하고 Service 는 mock 한다. + * 테스트 통과 시 REST Docs snippet 이 자동 생성되며, + * AsciiDoc 빌드를 거쳐 build/docs/asciidoc/index.html 로 문서화된다. + * + *

인증 처리: + *

    + *
  • 실제 운영 환경에선 Gateway 가 Authorization Bearer 토큰 검증 후 + * 사용자 ID 를 Filter 통해 ThreadLocal (AuthContext) 에 주입한다.
  • + *
  • 테스트에선 AuthContext.getUserId() 를 mockStatic 으로 직접 mock 하므로 + * 실제 헤더는 의미 없다. 단, REST Docs 문서화를 위해 외부 클라이언트 + * 시각의 Authorization 헤더를 dummy 값으로 보낸다.
  • + *
+ * + *

주요 검증: + *

    + *
  • HTTP 메서드별 정상 동작 (POST 201, GET 200, DELETE 200)
  • + *
  • 인증 실패 시 401
  • + *
  • 도메인 예외 → HTTP status 매핑 (404, 400, 409)
  • + *
+ */ +@WebMvcTest(QueueTokenController.class) +@AutoConfigureRestDocs +@ActiveProfiles("test") +@Import(GlobalExceptionHandler.class) +class QueueTokenControllerTest { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String DUMMY_BEARER_TOKEN = "Bearer dummy-access-token"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private QueueTokenService queueTokenService; + + // ===== 성공 케이스 ===== + + @Test + @DisplayName("대기열 진입 성공") + void 대기열_진입_성공() throws Exception { + // given + UUID userId = UUID.randomUUID(); + UUID programId = UUID.randomUUID(); + QueueToken token = QueueToken.issue(UserId.of(userId), ProgramId.of(programId)); + QueueTokenResult result = QueueTokenResult.of(token, 1L); + + when(queueTokenService.issueToken(any())).thenReturn(result); + + try (MockedStatic mocked = mockStatic(AuthContext.class)) { + mocked.when(AuthContext::getUserId).thenReturn(userId); + + // when & then + mockMvc.perform(post("/api/v1/queues/programs/{programId}", programId) + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isCreated()) + .andDo(document("queue-token-issue", + preprocessRequest( + prettyPrint(), + modifyHeaders().remove("Content-Type") + ), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("programId").description("프로그램 ID") + ), + requestHeaders( + headerWithName("Authorization") + .description("Bearer access token (Keycloak 발급)") + ), + responseFields( + fieldWithPath("success").description("요청 성공 여부"), + fieldWithPath("code").description("응답 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("timestamp").description("응답 시각"), + fieldWithPath("data.tokenId").description("발급된 토큰 ID"), + fieldWithPath("data.status").description("토큰 상태 (WAITING)"), + fieldWithPath("data.issuedAt").description("발급 시각"), + fieldWithPath("data.position").description("현재 순번") + ) + )); + } + } + + @Test + @DisplayName("대기 정보 조회 성공") + void 대기_정보_조회_성공() throws Exception { + // given + UUID userId = UUID.randomUUID(); + UUID programId = UUID.randomUUID(); + QueueToken token = QueueToken.issue(UserId.of(userId), ProgramId.of(programId)); + QueueTokenResult result = QueueTokenResult.of(token, 50L); + + when(queueTokenService.getToken(any())).thenReturn(result); + + try (MockedStatic mocked = mockStatic(AuthContext.class)) { + mocked.when(AuthContext::getUserId).thenReturn(userId); + + // when & then + mockMvc.perform(get("/api/v1/queues/programs/{programId}", programId) + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isOk()) + .andDo(document("queue-token-get", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("programId").description("프로그램 ID") + ), + requestHeaders( + headerWithName("Authorization") + .description("Bearer access token (Keycloak 발급)") + ), + responseFields( + fieldWithPath("success").description("요청 성공 여부"), + fieldWithPath("code").description("응답 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("timestamp").description("응답 시각"), + fieldWithPath("data.tokenId").description("토큰 ID"), + fieldWithPath("data.status").description("토큰 상태 (WAITING / ADMITTED / EXPIRED)"), + fieldWithPath("data.issuedAt").description("발급 시각"), + fieldWithPath("data.position").description("현재 순번. ADMITTED 등 큐에서 빠진 상태면 null").optional() + ) + )); + } + } + + @Test + @DisplayName("대기 취소 성공") + void 대기_취소_성공() throws Exception { + // given + UUID userId = UUID.randomUUID(); + UUID programId = UUID.randomUUID(); + + doNothing().when(queueTokenService).cancelToken(any()); + + try (MockedStatic mocked = mockStatic(AuthContext.class)) { + mocked.when(AuthContext::getUserId).thenReturn(userId); + + // when & then + mockMvc.perform(delete("/api/v1/queues/programs/{programId}", programId) + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isOk()) + .andDo(document("queue-token-cancel", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("programId").description("프로그램 ID") + ), + requestHeaders( + headerWithName("Authorization") + .description("Bearer access token (Keycloak 발급)") + ), + responseFields( + fieldWithPath("success").description("요청 성공 여부"), + fieldWithPath("code").description("응답 코드 (QUEUE_TOKEN_CANCELLED)"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("timestamp").description("응답 시각") + ) + )); + } + } + + // ===== 에러 케이스 ===== + + @Test + @DisplayName("인증 실패 시 401 Unauthorized") + void 인증_실패_401() throws Exception { + // given + UUID programId = UUID.randomUUID(); + + try (MockedStatic mocked = mockStatic(AuthContext.class)) { + mocked.when(AuthContext::getUserId) + .thenThrow(new BusinessException(CommonErrorCode.UNAUTHORIZED)); + + // when & then + mockMvc.perform(post("/api/v1/queues/programs/{programId}", programId)) + .andExpect(status().isUnauthorized()) + .andDo(document("queue-token-unauthorized", + preprocessRequest( + prettyPrint(), + modifyHeaders().remove("Content-Type") + ), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("success").description("요청 성공 여부 (false)"), + fieldWithPath("code").description("에러 코드 (UNAUTHORIZED)"), + fieldWithPath("message").description("에러 메시지"), + fieldWithPath("timestamp").description("응답 시각") + ) + )); + } + } + + @Test + @DisplayName("동시 진입 시 race — 409 Conflict") + void 중복_진입_409() throws Exception { + // given + UUID userId = UUID.randomUUID(); + UUID programId = UUID.randomUUID(); + + when(queueTokenService.issueToken(any())).thenThrow(new DuplicateTokenException()); + + try (MockedStatic mocked = mockStatic(AuthContext.class)) { + mocked.when(AuthContext::getUserId).thenReturn(userId); + + mockMvc.perform(post("/api/v1/queues/programs/{programId}", programId) + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isConflict()) + .andDo(document("queue-token-duplicate", + preprocessRequest( + prettyPrint(), + modifyHeaders().remove("Content-Type") + ), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("success").description("요청 성공 여부 (false)"), + fieldWithPath("code").description("에러 코드 (DUPLICATE_TOKEN)"), + fieldWithPath("message").description("에러 메시지"), + fieldWithPath("timestamp").description("응답 시각") + ) + )); + } + } + + @Test + @DisplayName("토큰 없음 — 조회 시 404") + void 토큰_없음_조회_404() throws Exception { + // given + UUID userId = UUID.randomUUID(); + UUID programId = UUID.randomUUID(); + + when(queueTokenService.getToken(any())).thenThrow(new TokenNotFoundException()); + + try (MockedStatic mocked = mockStatic(AuthContext.class)) { + mocked.when(AuthContext::getUserId).thenReturn(userId); + + // when & then + mockMvc.perform(get("/api/v1/queues/programs/{programId}", programId) + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isNotFound()) + .andDo(document("queue-token-get-not-found", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("success").description("요청 성공 여부 (false)"), + fieldWithPath("code").description("에러 코드 (TOKEN_NOT_FOUND)"), + fieldWithPath("message").description("에러 메시지"), + fieldWithPath("timestamp").description("응답 시각") + ) + )); + } + } + + @Test + @DisplayName("토큰 없음 — 취소 시 404") + void 토큰_없음_취소_404() throws Exception { + // given + UUID userId = UUID.randomUUID(); + UUID programId = UUID.randomUUID(); + + doThrow(new TokenNotFoundException()).when(queueTokenService).cancelToken(any()); + + try (MockedStatic mocked = mockStatic(AuthContext.class)) { + mocked.when(AuthContext::getUserId).thenReturn(userId); + + // when & then + mockMvc.perform(delete("/api/v1/queues/programs/{programId}", programId) + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isNotFound()) + .andDo(document("queue-token-cancel-not-found", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("success").description("요청 성공 여부 (false)"), + fieldWithPath("code").description("에러 코드 (TOKEN_NOT_FOUND)"), + fieldWithPath("message").description("에러 메시지"), + fieldWithPath("timestamp").description("응답 시각") + ) + )); + } + } + + @Test + @DisplayName("WAITING 이 아닌 상태 취소 시도 — 400") + void 취소_불가_상태_400() throws Exception { + // given + UUID userId = UUID.randomUUID(); + UUID programId = UUID.randomUUID(); + + doThrow(new InvalidTokenStateException()).when(queueTokenService).cancelToken(any()); + + try (MockedStatic mocked = mockStatic(AuthContext.class)) { + mocked.when(AuthContext::getUserId).thenReturn(userId); + + // when & then + mockMvc.perform(delete("/api/v1/queues/programs/{programId}", programId) + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isBadRequest()) + .andDo(document("queue-token-cancel-invalid-state", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("success").description("요청 성공 여부 (false)"), + fieldWithPath("code").description("에러 코드 (INVALID_TOKEN_STATE)"), + fieldWithPath("message").description("에러 메시지"), + fieldWithPath("timestamp").description("응답 시각") + ) + )); + } + } + + @Test + @DisplayName("ADMITTED 상태 토큰 조회 시 entryToken 응답에 포함") + void ADMITTED_조회_entryToken_포함() throws Exception { + // given + UUID userId = UUID.randomUUID(); + UUID programId = UUID.randomUUID(); + QueueToken token = QueueToken.issue(UserId.of(userId), ProgramId.of(programId)); + String entryToken = "eyJhbGc.dummy.jwt"; + token.admit(entryToken); + QueueTokenResult result = QueueTokenResult.of(token, null); // position null + + when(queueTokenService.getToken(any())).thenReturn(result); + + try (MockedStatic mocked = mockStatic(AuthContext.class)) { + mocked.when(AuthContext::getUserId).thenReturn(userId); + + // when & then + mockMvc.perform(get("/api/v1/queues/programs/{programId}", programId) + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isOk()) + .andDo(document("queue-token-get-admitted", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("programId").description("프로그램 ID") + ), + requestHeaders( + headerWithName("Authorization") + .description("Bearer access token (Keycloak 발급)") + ), + responseFields( + fieldWithPath("success").description("요청 성공 여부"), + fieldWithPath("code").description("응답 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("timestamp").description("응답 시각"), + fieldWithPath("data.tokenId").description("토큰 ID"), + fieldWithPath("data.status").description("토큰 상태 (ADMITTED)"), + fieldWithPath("data.issuedAt").description("발급 시각"), + fieldWithPath("data.entryToken").description("입장 토큰 (JWT) — ADMITTED 상태일 때만 포함") + ) + )); + } + } +} From 1070fbcde04af83d9b6e7975b0b926ae64a3beb2 Mon Sep 17 00:00:00 2001 From: rlaxxwls13 Date: Sat, 16 May 2026 03:58:44 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20program=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20Kafka=20Consumer=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20+=20ProgramMeta=20Aggregate=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - programmeta Aggregate 신규 추가 (ProgramMeta Read Model) - program.created / time.updated / cancelled 토픽 수신 - queuetoken 과 도메인 이벤트로 통신 (ProgramCancelledEvent) - common-messaging Outbox 스케줄러 비활성 설정 Related to #27 --- .../application/QueueTokenService.java | 90 ---- .../dto/CancelQueueTokenCommand.java | 18 - .../application/dto/GetQueueTokenQuery.java | 18 - .../dto/IssueQueueTokenCommand.java | 18 - .../application/dto/QueueTokenResult.java | 24 - .../queueservice/config/JwtProperties.java | 25 - .../queueservice/config/QueueProperties.java | 32 -- .../queueservice/domain/QueueToken.java | 105 ---- .../domain/QueueTokenRepository.java | 62 --- .../queueservice/domain/TokenStatus.java | 34 -- .../exception/DuplicateTokenException.java | 16 - .../exception/InvalidTokenStateException.java | 16 - .../domain/exception/QueueErrorCode.java | 17 - .../exception/TokenNotFoundException.java | 22 - .../queueservice/domain/vo/IssuedAt.java | 41 -- .../queueservice/domain/vo/QueueTokenId.java | 26 - .../queueservice/domain/vo/UserId.java | 26 - .../infrastructure/jwt/EntryTokenIssuer.java | 56 --- .../redis/RedisQueueTokenRepository.java | 406 --------------- .../scheduler/AdmissionScheduler.java | 108 ---- .../presentation/QueueSuccessCode.java | 17 - .../presentation/QueueTokenController.java | 79 --- .../presentation/dto/QueueTokenResponse.java | 25 - .../application/ProgramMetaService.java | 81 +++ .../application/dto/CancelProgramCommand.java | 21 + .../dto/CreateProgramMetaCommand.java | 34 ++ .../dto/UpdateProgramTimeCommand.java | 28 ++ .../programmeta/domain/ProgramMeta.java | 82 +++ .../domain/ProgramMetaRepository.java | 32 ++ .../programmeta/domain/ProgramStatus.java | 23 + .../domain/event/ProgramEvents.java | 17 + .../domain/vo/ProgramId.java | 6 +- .../event/ProgramEventsImpl.java | 25 + .../messaging/ProgramKafkaConsumer.java | 83 ++++ .../payload/ProgramCancelledPayload.java | 13 + .../payload/ProgramCreatedPayload.java | 16 + .../payload/ProgramTimeUpdatedPayload.java | 15 + .../redis/RedisProgramMetaRepository.java | 152 ++++++ .../domain/QueueTokenRepository.java | 6 + .../queuetoken/domain/vo/ProgramId.java | 3 +- .../event/ProgramCancelledEventListener.java | 31 ++ .../redis/RedisQueueTokenRepository.java | 78 +++ .../shared/event/ProgramCancelledEvent.java | 9 + .../queueservice/domain/QueueTokenTest.java | 227 --------- .../redis/RedisQueueTokenRepositoryTest.java | 470 ------------------ .../QueueTokenControllerTest.java | 404 --------------- 46 files changed, 751 insertions(+), 2386 deletions(-) delete mode 100644 src/main/java/com/firstticket/queueservice/application/QueueTokenService.java delete mode 100644 src/main/java/com/firstticket/queueservice/application/dto/CancelQueueTokenCommand.java delete mode 100644 src/main/java/com/firstticket/queueservice/application/dto/GetQueueTokenQuery.java delete mode 100644 src/main/java/com/firstticket/queueservice/application/dto/IssueQueueTokenCommand.java delete mode 100644 src/main/java/com/firstticket/queueservice/application/dto/QueueTokenResult.java delete mode 100644 src/main/java/com/firstticket/queueservice/config/JwtProperties.java delete mode 100644 src/main/java/com/firstticket/queueservice/config/QueueProperties.java delete mode 100644 src/main/java/com/firstticket/queueservice/domain/QueueToken.java delete mode 100644 src/main/java/com/firstticket/queueservice/domain/QueueTokenRepository.java delete mode 100644 src/main/java/com/firstticket/queueservice/domain/TokenStatus.java delete mode 100644 src/main/java/com/firstticket/queueservice/domain/exception/DuplicateTokenException.java delete mode 100644 src/main/java/com/firstticket/queueservice/domain/exception/InvalidTokenStateException.java delete mode 100644 src/main/java/com/firstticket/queueservice/domain/exception/QueueErrorCode.java delete mode 100644 src/main/java/com/firstticket/queueservice/domain/exception/TokenNotFoundException.java delete mode 100644 src/main/java/com/firstticket/queueservice/domain/vo/IssuedAt.java delete mode 100644 src/main/java/com/firstticket/queueservice/domain/vo/QueueTokenId.java delete mode 100644 src/main/java/com/firstticket/queueservice/domain/vo/UserId.java delete mode 100644 src/main/java/com/firstticket/queueservice/infrastructure/jwt/EntryTokenIssuer.java delete mode 100644 src/main/java/com/firstticket/queueservice/infrastructure/redis/RedisQueueTokenRepository.java delete mode 100644 src/main/java/com/firstticket/queueservice/infrastructure/scheduler/AdmissionScheduler.java delete mode 100644 src/main/java/com/firstticket/queueservice/presentation/QueueSuccessCode.java delete mode 100644 src/main/java/com/firstticket/queueservice/presentation/QueueTokenController.java delete mode 100644 src/main/java/com/firstticket/queueservice/presentation/dto/QueueTokenResponse.java create mode 100644 src/main/java/com/firstticket/queueservice/programmeta/application/ProgramMetaService.java create mode 100644 src/main/java/com/firstticket/queueservice/programmeta/application/dto/CancelProgramCommand.java create mode 100644 src/main/java/com/firstticket/queueservice/programmeta/application/dto/CreateProgramMetaCommand.java create mode 100644 src/main/java/com/firstticket/queueservice/programmeta/application/dto/UpdateProgramTimeCommand.java create mode 100644 src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMeta.java create mode 100644 src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMetaRepository.java create mode 100644 src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramStatus.java create mode 100644 src/main/java/com/firstticket/queueservice/programmeta/domain/event/ProgramEvents.java rename src/main/java/com/firstticket/queueservice/{ => programmeta}/domain/vo/ProgramId.java (69%) create mode 100644 src/main/java/com/firstticket/queueservice/programmeta/infrastructure/event/ProgramEventsImpl.java create mode 100644 src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/ProgramKafkaConsumer.java create mode 100644 src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/payload/ProgramCancelledPayload.java create mode 100644 src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/payload/ProgramCreatedPayload.java create mode 100644 src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/payload/ProgramTimeUpdatedPayload.java create mode 100644 src/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.java create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/event/ProgramCancelledEventListener.java create mode 100644 src/main/java/com/firstticket/queueservice/shared/event/ProgramCancelledEvent.java delete mode 100644 src/test/java/com/firstticket/queueservice/domain/QueueTokenTest.java delete mode 100644 src/test/java/com/firstticket/queueservice/infrastructure/redis/RedisQueueTokenRepositoryTest.java delete mode 100644 src/test/java/com/firstticket/queueservice/presentation/QueueTokenControllerTest.java diff --git a/src/main/java/com/firstticket/queueservice/application/QueueTokenService.java b/src/main/java/com/firstticket/queueservice/application/QueueTokenService.java deleted file mode 100644 index 5dec203..0000000 --- a/src/main/java/com/firstticket/queueservice/application/QueueTokenService.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.firstticket.queueservice.application; - -import com.firstticket.queueservice.application.dto.CancelQueueTokenCommand; -import com.firstticket.queueservice.application.dto.GetQueueTokenQuery; -import com.firstticket.queueservice.application.dto.IssueQueueTokenCommand; -import com.firstticket.queueservice.application.dto.QueueTokenResult; -import com.firstticket.queueservice.domain.QueueToken; -import com.firstticket.queueservice.domain.QueueTokenRepository; -import com.firstticket.queueservice.domain.exception.DuplicateTokenException; -import com.firstticket.queueservice.domain.exception.InvalidTokenStateException; -import com.firstticket.queueservice.domain.exception.TokenNotFoundException; -import com.firstticket.queueservice.domain.vo.ProgramId; -import com.firstticket.queueservice.domain.vo.UserId; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -/** - * 대기열 진입 / 조회 / 취소를 처리하는 서비스. - */ -@Service -@RequiredArgsConstructor -public class QueueTokenService { - - private final QueueTokenRepository queueTokenRepository; - - /** - * 대기열에 진입한다. - * - *

대기 토큰을 발급한다. - * 같은 사용자 + 프로그램으로 토큰이 이미 있으면 기존 토큰을 폐기한 뒤 새로 발급한다. - * - * @return 발급된 토큰과 현재 순번 - * @throws DuplicateTokenException 동시 요청으로 race 발생 시 (드물게) - */ - public QueueTokenResult issueToken(IssueQueueTokenCommand command) { - UserId userId = command.userId(); - ProgramId programId = command.programId(); - - // 같은 user+program 토큰이 있으면 폐기 후 새로 발급 - queueTokenRepository.findByUserIdAndProgramId(userId, programId) - .ifPresent(queueTokenRepository::delete); - - QueueToken token = QueueToken.issue(userId, programId); - // race condition: 동시 요청 시 DuplicateTokenException 가능. v0.2.0 Lua 통합으로 해결. - queueTokenRepository.enqueue(token); - - Long position = queueTokenRepository.findPosition(userId, programId).orElse(null); - return QueueTokenResult.of(token, position); - } - - /** - * 사용자의 대기 정보를 조회한다. - * - *

주로 폴링용으로 호출된다. 토큰 정보와 현재 순번을 반환한다. - * - * @return 토큰 정보 + 현재 순번 (ADMITTED 등 큐에서 빠진 상태면 position = null) - * @throws TokenNotFoundException 해당 사용자의 토큰이 없을 때 - */ - public QueueTokenResult getToken(GetQueueTokenQuery query) { - UserId userId = query.userId(); - ProgramId programId = query.programId(); - - QueueToken token = queueTokenRepository.findByUserIdAndProgramId(userId, programId) - .orElseThrow(TokenNotFoundException::new); - - Long position = queueTokenRepository.findPosition(userId, programId).orElse(null); - return QueueTokenResult.of(token, position); - } - - /** - * 사용자가 대기를 취소한다. - * - *

대기 토큰을 CANCELLED 상태로 바꾼 뒤 저장소에서 제거한다. - * - * @throws TokenNotFoundException 해당 사용자의 토큰이 없을 때 - * @throws InvalidTokenStateException WAITING 이 아닌 상태에서 취소 시도 시 (예: ADMITTED) - */ - public void cancelToken(CancelQueueTokenCommand command) { - UserId userId = command.userId(); - ProgramId programId = command.programId(); - - QueueToken token = queueTokenRepository.findByUserIdAndProgramId(userId, programId) - .orElseThrow(TokenNotFoundException::new); - - // 도메인 상태 검증 + 전이 (WAITING → CANCELLED) - token.cancel(); - - queueTokenRepository.delete(token); - } -} diff --git a/src/main/java/com/firstticket/queueservice/application/dto/CancelQueueTokenCommand.java b/src/main/java/com/firstticket/queueservice/application/dto/CancelQueueTokenCommand.java deleted file mode 100644 index ea9bd8a..0000000 --- a/src/main/java/com/firstticket/queueservice/application/dto/CancelQueueTokenCommand.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.firstticket.queueservice.application.dto; - -import com.firstticket.queueservice.domain.vo.ProgramId; -import com.firstticket.queueservice.domain.vo.UserId; - -import java.util.UUID; - -public record CancelQueueTokenCommand( - UserId userId, - ProgramId programId -) { - public static CancelQueueTokenCommand of(UUID userId, UUID programId) { - return new CancelQueueTokenCommand( - UserId.of(userId), - ProgramId.of(programId) - ); - } -} diff --git a/src/main/java/com/firstticket/queueservice/application/dto/GetQueueTokenQuery.java b/src/main/java/com/firstticket/queueservice/application/dto/GetQueueTokenQuery.java deleted file mode 100644 index 4f384a0..0000000 --- a/src/main/java/com/firstticket/queueservice/application/dto/GetQueueTokenQuery.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.firstticket.queueservice.application.dto; - -import com.firstticket.queueservice.domain.vo.ProgramId; -import com.firstticket.queueservice.domain.vo.UserId; - -import java.util.UUID; - -public record GetQueueTokenQuery( - UserId userId, - ProgramId programId -) { - public static GetQueueTokenQuery of(UUID userId, UUID programId) { - return new GetQueueTokenQuery( - UserId.of(userId), - ProgramId.of(programId) - ); - } -} diff --git a/src/main/java/com/firstticket/queueservice/application/dto/IssueQueueTokenCommand.java b/src/main/java/com/firstticket/queueservice/application/dto/IssueQueueTokenCommand.java deleted file mode 100644 index b21a5d8..0000000 --- a/src/main/java/com/firstticket/queueservice/application/dto/IssueQueueTokenCommand.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.firstticket.queueservice.application.dto; - -import com.firstticket.queueservice.domain.vo.ProgramId; -import com.firstticket.queueservice.domain.vo.UserId; - -import java.util.UUID; - -public record IssueQueueTokenCommand( - UserId userId, - ProgramId programId -) { - public static IssueQueueTokenCommand of(UUID userId, UUID programId) { - return new IssueQueueTokenCommand( - UserId.of(userId), - ProgramId.of(programId) - ); - } -} diff --git a/src/main/java/com/firstticket/queueservice/application/dto/QueueTokenResult.java b/src/main/java/com/firstticket/queueservice/application/dto/QueueTokenResult.java deleted file mode 100644 index 7f4f8c4..0000000 --- a/src/main/java/com/firstticket/queueservice/application/dto/QueueTokenResult.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.firstticket.queueservice.application.dto; - -import com.firstticket.queueservice.domain.QueueToken; -import com.firstticket.queueservice.domain.TokenStatus; -import com.firstticket.queueservice.domain.vo.IssuedAt; -import com.firstticket.queueservice.domain.vo.QueueTokenId; - -public record QueueTokenResult( - QueueTokenId tokenId, - TokenStatus status, - IssuedAt issuedAt, - Long position, - String entryToken -) { - public static QueueTokenResult of(QueueToken token, Long position) { - return new QueueTokenResult( - token.getId(), - token.getStatus(), - token.getIssuedAt(), - position, - token.getEntryToken() - ); - } -} diff --git a/src/main/java/com/firstticket/queueservice/config/JwtProperties.java b/src/main/java/com/firstticket/queueservice/config/JwtProperties.java deleted file mode 100644 index af6a160..0000000 --- a/src/main/java/com/firstticket/queueservice/config/JwtProperties.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.firstticket.queueservice.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -import java.time.Duration; - -/** - * JWT 입장 토큰 설정. - * - *

도메인 정책 (TTL) 과 인프라 정책 (비밀키) 모두 포함. - */ -@ConfigurationProperties(prefix = "queue.jwt") -public record JwtProperties( - String secret, - Duration entryTokenTtl -) { - public JwtProperties { - if (secret == null || secret.isBlank()) { - throw new IllegalArgumentException("queue.jwt.secret must not be blank"); - } - if (entryTokenTtl == null || entryTokenTtl.isZero() || entryTokenTtl.isNegative()) { - throw new IllegalArgumentException("queue.jwt.entry-token-ttl must be positive"); - } - } -} diff --git a/src/main/java/com/firstticket/queueservice/config/QueueProperties.java b/src/main/java/com/firstticket/queueservice/config/QueueProperties.java deleted file mode 100644 index 943d9dd..0000000 --- a/src/main/java/com/firstticket/queueservice/config/QueueProperties.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.firstticket.queueservice.config; - -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.validation.annotation.Validated; - -import java.time.Duration; - -/** - * queue-service의 운영 정책 설정. - * config-repo의 queue-service.yml 에서 외부 주입된다. - */ -@Validated -@ConfigurationProperties(prefix = "queue.token") -public record QueueProperties( - - /** - * 대기 토큰의 TTL. - * 이 시간 내 입장 승인 못 받으면 자동 만료 - */ - @NotNull - Duration waitingTtl, - - /** - * 한 번의 배치 스케줄러 실행 시 입장 승인할 인원 수. - */ - @Min(1) - int admissionBatchSize - -) { -} diff --git a/src/main/java/com/firstticket/queueservice/domain/QueueToken.java b/src/main/java/com/firstticket/queueservice/domain/QueueToken.java deleted file mode 100644 index d7e6032..0000000 --- a/src/main/java/com/firstticket/queueservice/domain/QueueToken.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.firstticket.queueservice.domain; - -import com.firstticket.queueservice.domain.exception.InvalidTokenStateException; -import com.firstticket.queueservice.domain.exception.TokenNotFoundException; -import com.firstticket.queueservice.domain.vo.IssuedAt; -import com.firstticket.queueservice.domain.vo.ProgramId; -import com.firstticket.queueservice.domain.vo.QueueTokenId; -import com.firstticket.queueservice.domain.vo.UserId; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; - -import java.util.Objects; - -/** - * 대기 토큰 애그리거트 루트. - * - *

한 사용자의 한 프로그램에 대한 대기 상태를 표현한다. - * 발급(WAITING) 후 입장 승인(ADMITTED), 취소(CANCELLED), 만료(EXPIRED) 중 하나로 전이된다. - * - *

상태 전이 규칙: - *

    - *
  • WAITING → ADMITTED, CANCELLED, EXPIRED
  • - *
  • 나머지 상태에서는 전이 불가 (최종 상태)
  • - *
- */ -@Getter -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class QueueToken { - private final QueueTokenId id; - private final UserId userId; - private final ProgramId programId; - private final IssuedAt issuedAt; - private TokenStatus status; - private String entryToken; - - /** - * 새로운 대기 토큰을 발급한다. - */ - public static QueueToken issue(UserId userId, ProgramId programId) { - Objects.requireNonNull(userId, "UserId는 필수입니다"); - Objects.requireNonNull(programId, "ProgramId는 필수입니다"); - return new QueueToken( - QueueTokenId.of(), - userId, - programId, - IssuedAt.now(), - TokenStatus.WAITING, - null // entryToken: 발급 시점엔 없음, admit 시 부여 - ); - } - - /** - * Redis 등 외부 저장소에서 토큰을 복원한다. - */ - public static QueueToken restore( - QueueTokenId id, - UserId userId, - ProgramId programId, - IssuedAt issuedAt, - TokenStatus status, - String entryToken - ) { - Objects.requireNonNull(id, "QueueTokenId는 필수입니다"); - Objects.requireNonNull(userId, "UserId는 필수입니다"); - Objects.requireNonNull(programId, "ProgramId는 필수입니다"); - Objects.requireNonNull(issuedAt, "IssuedAt은 필수입니다"); - Objects.requireNonNull(status, "TokenStatus는 필수입니다"); - return new QueueToken(id, userId, programId, issuedAt, status, entryToken); - } - - /** - * 입장을 승인한다 (WAITING -> ADMITTED) - */ - public void admit(String entryToken) { - ensureWaiting(); - this.status = TokenStatus.ADMITTED; - this.entryToken = entryToken; - } - - /** - * 사용자가 대기를 취소한다 (WAITING -> CANCELLED) - */ - public void cancel() { - ensureWaiting(); - this.status = TokenStatus.CANCELLED; - } - - /** - * 시간 만료로 토큰을 폐기한다 (WAITING -> EXPIRED) - */ - public void expire() { - ensureWaiting(); - this.status = TokenStatus.EXPIRED; - } - - /** - * 현재 상태가 WAITING이 아니면 예외를 던진다. - */ - private void ensureWaiting() { - if (status.isTerminal()) { - throw new InvalidTokenStateException(); - } - } -} diff --git a/src/main/java/com/firstticket/queueservice/domain/QueueTokenRepository.java b/src/main/java/com/firstticket/queueservice/domain/QueueTokenRepository.java deleted file mode 100644 index c7173f8..0000000 --- a/src/main/java/com/firstticket/queueservice/domain/QueueTokenRepository.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.firstticket.queueservice.domain; - -import com.firstticket.queueservice.domain.exception.DuplicateTokenException; -import com.firstticket.queueservice.domain.vo.ProgramId; -import com.firstticket.queueservice.domain.vo.QueueTokenId; -import com.firstticket.queueservice.domain.vo.UserId; - -import java.util.List; -import java.util.Optional; - -/** - * 대기 토큰의 영속성 저장소. - * - *

도메인 규칙: 한 사용자는 한 프로그램에 활성 토큰을 하나만 가질 수 있다. - * 중복 진입 시도는 인프라 구현체가 거부한다. - */ -public interface QueueTokenRepository { - - /** - * 신규 대기 토큰을 등록한다. - * - * @throws DuplicateTokenException 같은 사용자가 이미 같은 프로그램에 토큰 보유 시 - */ - void enqueue(QueueToken token); - - /** - * 토큰 ID로 대기 토큰을 조회한다. - */ - Optional findById(QueueTokenId id); - - /** - * 사용자가 특정 프로그램에 보유한 대기 토큰을 조회한다. - * 동일 사용자는 한 프로그램에 하나의 활성 토큰만 가질 수 있다. - */ - Optional findByUserIdAndProgramId(UserId userId, ProgramId programId); - - /** - * 대기 토큰을 삭제한다. - */ - void delete(QueueToken token); - - /** - * 사용자가 특정 프로그램의 대기 순번을 조회한다. - * 토큰이 없거나 WAITING 상태가 아니면 Optional.empty() - */ - Optional findPosition(UserId userId, ProgramId programId); - - /** - * 특정 프로그램의 다음 입장 승인 대상자들을 조회한다 (앞에서 batchSize 명). - */ - List findAdmissionCandidates(ProgramId programId, int batchSize); - - /** - * 입장 승인된 토큰의 상태를 업데이트한다. - */ - void admit(QueueToken token); - - /** - * 현재 큐가 존재하는 모든 프로그램 ID 를 조회한다. - */ - List findActiveProgramIds(); -} diff --git a/src/main/java/com/firstticket/queueservice/domain/TokenStatus.java b/src/main/java/com/firstticket/queueservice/domain/TokenStatus.java deleted file mode 100644 index 0be7dfe..0000000 --- a/src/main/java/com/firstticket/queueservice/domain/TokenStatus.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.firstticket.queueservice.domain; - -/** - * 대기 토큰의 상태. - * - *

상태 전이 규칙: - *

- *   WAITING → ADMITTED  (입장 승인)
- *   WAITING → CANCELLED (사용자 취소)
- *   WAITING → EXPIRED   (시간 만료)
- *   ADMITTED, CANCELLED, EXPIRED → (전이 불가, 최종 상태)
- * 
- */ -public enum TokenStatus { - - /** 대기 중 */ - WAITING, - - /** 입장 승인됨 */ - ADMITTED, - - /** 사용자 또는 시스템에 의해 취소됨 */ - CANCELLED, - - /** 시간 만료 */ - EXPIRED; - - /** - * 최종 상태인지 (더 이상 전이 불가) 여부를 반환한다. - */ - public boolean isTerminal() { - return this != WAITING; - } -} diff --git a/src/main/java/com/firstticket/queueservice/domain/exception/DuplicateTokenException.java b/src/main/java/com/firstticket/queueservice/domain/exception/DuplicateTokenException.java deleted file mode 100644 index 559a5da..0000000 --- a/src/main/java/com/firstticket/queueservice/domain/exception/DuplicateTokenException.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.firstticket.queueservice.domain.exception; - -import com.firstticket.common.exception.BusinessException; - -/** - * 같은 사용자 + 같은 프로그램 조합으로 토큰을 중복 발급하려 할 때 발생하는 예외. - * - *

정상 흐름에 발생하지 않으며, 동시 요청으로 인한 race condition 시 Repository 의 setIfAbsent 에 의해 노출된다. - * - *

해결: 클라이언트 재시도. 근본 해결은 v0.2.0 의 Lua Script 통합 예정. - */ -public class DuplicateTokenException extends BusinessException { - public DuplicateTokenException() { - super(QueueErrorCode.DUPLICATE_TOKEN); - } -} diff --git a/src/main/java/com/firstticket/queueservice/domain/exception/InvalidTokenStateException.java b/src/main/java/com/firstticket/queueservice/domain/exception/InvalidTokenStateException.java deleted file mode 100644 index bf05be3..0000000 --- a/src/main/java/com/firstticket/queueservice/domain/exception/InvalidTokenStateException.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.firstticket.queueservice.domain.exception; - -import com.firstticket.common.exception.BusinessException; -import com.firstticket.common.response.ErrorCode; - -/** - * 대기 토큰의 상태 전이 규칙을 위반했을 때 발생하는 예외 - * - *

예: ADMITTED 상태인 토큰을 cancel() 시도 - */ -public class InvalidTokenStateException extends BusinessException { - - public InvalidTokenStateException() { - super(QueueErrorCode.INVALID_TOKEN_STATE); - } -} diff --git a/src/main/java/com/firstticket/queueservice/domain/exception/QueueErrorCode.java b/src/main/java/com/firstticket/queueservice/domain/exception/QueueErrorCode.java deleted file mode 100644 index 1a246af..0000000 --- a/src/main/java/com/firstticket/queueservice/domain/exception/QueueErrorCode.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.firstticket.queueservice.domain.exception; - -import com.firstticket.common.response.ErrorCode; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@Getter -@RequiredArgsConstructor -public enum QueueErrorCode implements ErrorCode { - INVALID_TOKEN_STATE(HttpStatus.BAD_REQUEST, "대기 토큰 상태 전이 규칙을 위반했습니다"), - DUPLICATE_TOKEN(HttpStatus.CONFLICT, "이미 대기 중인 토큰이 있습니다"), - TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "토큰을 찾을 수 없습니다"); - - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/firstticket/queueservice/domain/exception/TokenNotFoundException.java b/src/main/java/com/firstticket/queueservice/domain/exception/TokenNotFoundException.java deleted file mode 100644 index f51e995..0000000 --- a/src/main/java/com/firstticket/queueservice/domain/exception/TokenNotFoundException.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.firstticket.queueservice.domain.exception; - -import com.firstticket.common.exception.BusinessException; - -/** - * 요청한 토큰을 찾을 수 없을 때 발생하는 예외. - * - *

다음 두 케이스에서 동일하게 던진다: - *

    - *
  • 토큰 자체가 존재하지 않음
  • - *
  • 토큰은 존재하나 본인 소유가 아님
  • - *
- * - *

두 케이스를 구분하지 않는 이유는 "리소스 존재 여부" 정보 누출 방지 (보안). - * 공격자가 tokenId 추측 공격으로 토큰 존재 여부를 알아낼 수 없게 한다. - */ -public class TokenNotFoundException extends BusinessException { - - public TokenNotFoundException() { - super(QueueErrorCode.TOKEN_NOT_FOUND); - } -} diff --git a/src/main/java/com/firstticket/queueservice/domain/vo/IssuedAt.java b/src/main/java/com/firstticket/queueservice/domain/vo/IssuedAt.java deleted file mode 100644 index 7b666c8..0000000 --- a/src/main/java/com/firstticket/queueservice/domain/vo/IssuedAt.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.firstticket.queueservice.domain.vo; - -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.util.Objects; - -/** - * 대기 토큰의 발급 시각을 표현하는 VO. - * - *

모든 시간은 UTC 기준으로 다룬다. - */ -public record IssuedAt(LocalDateTime value) { - public IssuedAt { - Objects.requireNonNull(value, "IssuedAt은 null일 수 없습니다"); - } - - /** - * 현재 시각으로 IssuedAt을 생성한다 (UTC 기준). - */ - public static IssuedAt now() { - return new IssuedAt(LocalDateTime.now(ZoneOffset.UTC)); - } - - /** - * epoch milli로부터 IssuedAt을 복원한다 (UTC 기준). - */ - public static IssuedAt fromEpochMilli(long epochMilli) { - return new IssuedAt(LocalDateTime.ofInstant( - Instant.ofEpochMilli(epochMilli), - ZoneOffset.UTC - )); - } - - /** - * Redis Sorted Set score 에 사용할 epoch milli 변환. - */ - public long toEpochMilli() { - return value.toInstant(ZoneOffset.UTC).toEpochMilli(); - } -} diff --git a/src/main/java/com/firstticket/queueservice/domain/vo/QueueTokenId.java b/src/main/java/com/firstticket/queueservice/domain/vo/QueueTokenId.java deleted file mode 100644 index e67a9d6..0000000 --- a/src/main/java/com/firstticket/queueservice/domain/vo/QueueTokenId.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.firstticket.queueservice.domain.vo; - -import java.util.Objects; -import java.util.UUID; - -/** - * 대기 토큰 ID를 표현하는 VO. - */ -public record QueueTokenId(UUID id) { - - public QueueTokenId { - Objects.requireNonNull(id, "QueueTokenId는 null일 수 없습니다"); - } - - public static QueueTokenId of() { - return new QueueTokenId(UUID.randomUUID()); - } - - public static QueueTokenId fromString(String id) { - return new QueueTokenId(UUID.fromString(id)); - } - - public String asString() { - return id.toString(); - } -} diff --git a/src/main/java/com/firstticket/queueservice/domain/vo/UserId.java b/src/main/java/com/firstticket/queueservice/domain/vo/UserId.java deleted file mode 100644 index 9a77123..0000000 --- a/src/main/java/com/firstticket/queueservice/domain/vo/UserId.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.firstticket.queueservice.domain.vo; - -import java.util.Objects; -import java.util.UUID; - -/** - * 사용자 ID를 표현하는 VO. - */ -public record UserId(UUID id) { - - public UserId { - Objects.requireNonNull(id, "UserId는 null일 수 없습니다"); - } - - public static UserId of(UUID id) { - return new UserId(id); - } - - public static UserId fromString(String id) { - return new UserId(UUID.fromString(id)); - } - - public String asString() { - return id.toString(); - } -} diff --git a/src/main/java/com/firstticket/queueservice/infrastructure/jwt/EntryTokenIssuer.java b/src/main/java/com/firstticket/queueservice/infrastructure/jwt/EntryTokenIssuer.java deleted file mode 100644 index 2af2965..0000000 --- a/src/main/java/com/firstticket/queueservice/infrastructure/jwt/EntryTokenIssuer.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.firstticket.queueservice.infrastructure.jwt; - -import com.firstticket.queueservice.config.JwtProperties; -import com.firstticket.queueservice.domain.QueueToken; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.time.Instant; -import java.util.Date; -import java.util.UUID; - -/** - * 입장 토큰 (JWT) 발급기. - * - *

대기열에서 admit 된 사용자에게 발급되는 JWT. - * 예매 서비스가 stateless 하게 검증한다 (queue-service 별도 호출 X). - */ -@Component -public class EntryTokenIssuer { - - private static final String ISSUER = "queue-service"; - private static final String CLAIM_PROGRAM_ID = "programId"; - - private final SecretKey secretKey; - private final Duration ttl; - - public EntryTokenIssuer(JwtProperties properties) { - this.secretKey = Keys.hmacShaKeyFor(properties.secret().getBytes(StandardCharsets.UTF_8)); - this.ttl = properties.entryTokenTtl(); - } - - /** - * 입장 토큰을 발급한다. - * - * @param token admit 된 대기 토큰 - * @return JWT 형식의 입장 토큰 - */ - public String issue(QueueToken token) { - Instant now = Instant.now(); - Instant expiration = now.plus(ttl); - - return Jwts.builder() - .id(UUID.randomUUID().toString()) - .issuer(ISSUER) - .subject(token.getUserId().asString()) - .claim(CLAIM_PROGRAM_ID, token.getProgramId().asString()) - .issuedAt(Date.from(now)) - .expiration(Date.from(expiration)) - .signWith(secretKey) - .compact(); - } -} diff --git a/src/main/java/com/firstticket/queueservice/infrastructure/redis/RedisQueueTokenRepository.java b/src/main/java/com/firstticket/queueservice/infrastructure/redis/RedisQueueTokenRepository.java deleted file mode 100644 index 985814d..0000000 --- a/src/main/java/com/firstticket/queueservice/infrastructure/redis/RedisQueueTokenRepository.java +++ /dev/null @@ -1,406 +0,0 @@ -package com.firstticket.queueservice.infrastructure.redis; - -import com.firstticket.queueservice.config.QueueProperties; -import com.firstticket.queueservice.domain.QueueToken; -import com.firstticket.queueservice.domain.QueueTokenRepository; -import com.firstticket.queueservice.domain.TokenStatus; -import com.firstticket.queueservice.domain.exception.DuplicateTokenException; -import com.firstticket.queueservice.domain.vo.IssuedAt; -import com.firstticket.queueservice.domain.vo.ProgramId; -import com.firstticket.queueservice.domain.vo.QueueTokenId; -import com.firstticket.queueservice.domain.vo.UserId; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.*; -import org.springframework.stereotype.Repository; - -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.*; - -/** - * 대기 토큰의 Redis 기반 영속성 구현체. - * - *

3가지 자료구조를 조합하여 사용한다: - *

    - *
  • Sorted Set ({@code queue:program:{programId}}) — 대기 순번 관리. score = 진입 시각(epoch milli)
  • - *
  • Hash ({@code queue:token:{tokenId}}) — 토큰 메타 데이터 저장
  • - *
  • String ({@code queue:user:{userId}:program:{programId}}) — 역인덱스 (도메인 키 → 토큰 ID)
  • - *
- * - *

저장 시 역인덱스(setIfAbsent)와 트랜잭션(MULTI/EXEC)으로 일관성을 보장한다. - * 자세한 흐름은 {@link #enqueue(QueueToken)} 참고. - */ -@Slf4j -@Repository -@RequiredArgsConstructor -public class RedisQueueTokenRepository implements QueueTokenRepository { - - // ===== 키 prefix 상수 ===== - private static final String QUEUE_KEY_PREFIX = "queue:"; - private static final String PROGRAM_KEY_PREFIX = "program:"; - private static final String TOKEN_KEY_PREFIX = "token:"; - private static final String USER_KEY_PREFIX = "user:"; - - // ===== Hash 필드 이름 상수 ===== - private static final String FIELD_USER_ID = "userId"; - private static final String FIELD_PROGRAM_ID = "programId"; - private static final String FIELD_ISSUED_AT = "issuedAt"; - private static final String FIELD_STATUS = "status"; - private static final String FIELD_ENTRY_TOKEN = "entryToken"; - - private final StringRedisTemplate redisTemplate; - private final QueueProperties properties; - - /** - * Redis 기반 enqueue 구현. - * - *

2단계 처리: - *

    - *
  1. 역인덱스(setIfAbsent)를 락처럼 사용하여 중복 진입 방지
  2. - *
  3. Sorted Set + Hash 를 MULTI/EXEC 트랜잭션으로 저장
  4. - *
- * - *

한계: 1단계 성공 후 2단계 실패 시 orphan 역인덱스가 남을 수 있다. - * TTL 로 자동 정리되지만, 진짜 원자성을 위해 향후 Lua Script 도입 예정. - * - *

Sorted Set 의 멤버는 키 단위 TTL 불가하므로, - * {@link #delete(QueueToken)} 호출 시 명시적 ZREM 으로 정리한다. - */ - @Override - public void enqueue(QueueToken token) { - - // 키 생성 - String programKey = programKey(token.getProgramId()); - String tokenKey = tokenKey(token.getId()); - String userProgramKey = userProgramKey(token.getUserId(), token.getProgramId()); - - String tokenIdStr = token.getId().asString(); - - // Sorted Set의 score로 사용할 진입 시각 (epoch milli) - long issuedAtEpochMilli = token.getIssuedAt().toEpochMilli(); - - Duration ttl = properties.waitingTtl(); - - // 1단계: 역인덱스를 락처럼 사용하여 중복 진입 방지 - Boolean acquired = redisTemplate.opsForValue() - .setIfAbsent(userProgramKey, tokenIdStr, ttl); - - if (!Boolean.TRUE.equals(acquired)) { - throw new DuplicateTokenException(); - } - - // 2단계: 나머지 키를 트랜잭션으로 저장 - Map tokenFields = Map.of( - FIELD_USER_ID, token.getUserId().asString(), - FIELD_PROGRAM_ID, token.getProgramId().asString(), - FIELD_ISSUED_AT, String.valueOf(issuedAtEpochMilli), - FIELD_STATUS, token.getStatus().name() - ); - - // 트랜잭션으로 Sorted Set + Hash 저장 + Hash TTL 설정 - redisTemplate.execute(new SessionCallback>() { - @SuppressWarnings({"unchecked"}) - @Override - public List execute(RedisOperations operations) { - operations.multi(); - - // 1. Sorted Set: 대기열에 추가 (score = 진입 시각) - operations.opsForZSet().add(programKey, tokenIdStr, issuedAtEpochMilli); - - // 2. Hash: 토큰 메타 데이터 저장 - operations.opsForHash().putAll(tokenKey, tokenFields); - - // 3. Hash 키에 TTL 설정 (Sorted Set은 컬렉션이라 별도 정리 필요) - operations.expire(tokenKey, ttl); - - return operations.exec(); - } - }); - } - - /** - * Redis 기반 findById 구현. - * - *

Hash 전체 조회 후 도메인 객체로 복원한다. - * 토큰이 없으면 빈 Map이 반환되며 (null 아님), Optional.empty()로 처리한다. - */ - @Override - public Optional findById(QueueTokenId id) { - String tokenKey = tokenKey(id); - - // 타입 명시한 Map 으로 받기 위해 hashOps 변수 사용 - HashOperations hashOps = redisTemplate.opsForHash(); - // Redis 에서 Hash 전체 조회 - Map entries = hashOps.entries(tokenKey); - - // 토큰 없으면 빈 Map 이 옴 (null 아님) - if (entries.isEmpty()) { - return Optional.empty(); - } - - // 깨진 레코드 자동 정리 향후 도입 - try { - // Hash 데이터로 QueueToken 객체 만들어서 반환 - return Optional.of(toQueueToken(id, entries)); - } catch (Exception e) { - log.warn("깨진 Hash 레코드 발견. tokenId={}", id.asString(), e); - return Optional.empty(); - } - } - - /** - * Redis Hash 데이터로부터 QueueToken 도메인 객체를 복원한다. - */ - private QueueToken toQueueToken(QueueTokenId id, Map entries) { - UserId userId = UserId.fromString(entries.get(FIELD_USER_ID)); - ProgramId programId = ProgramId.fromString(entries.get(FIELD_PROGRAM_ID)); - IssuedAt issuedAt = IssuedAt.fromEpochMilli(Long.parseLong(entries.get(FIELD_ISSUED_AT))); - TokenStatus status = TokenStatus.valueOf(entries.get(FIELD_STATUS)); - String entryToken = entries.get(FIELD_ENTRY_TOKEN); // null 가능 (WAITING 상태) - - return QueueToken.restore(id, userId, programId, issuedAt, status, entryToken); - } - - /** - * Redis 기반 findByUserIdAndProgramId 구현. - * - *

2단계 조회: - *

    - *
  1. 역인덱스로 tokenId 조회
  2. - *
  3. tokenId로 토큰 전체 조회 ({@link #findById} 재사용)
  4. - *
- */ - @Override - public Optional findByUserIdAndProgramId(UserId userId, ProgramId programId) { - String userProgramKey = userProgramKey(userId, programId); - - // 1단계: 역인덱스로 tokenId 조회 - String tokenIdStr = redisTemplate.opsForValue().get(userProgramKey); - - if (tokenIdStr == null) { - return Optional.empty(); - } - - // 2단계: tokenId로 토큰 전체 조회 (findById 재사용) - return findById(QueueTokenId.fromString(tokenIdStr)); - } - - /** - * Redis 기반 delete 구현. - * - *

3개 키를 트랜잭션으로 묶어 삭제한다: - *

    - *
  1. Sorted Set 에서 토큰 멤버 제거 (ZREM)
  2. - *
  3. Hash 키 삭제
  4. - *
  5. 역인덱스 키 삭제
  6. - *
- * - *

이미 만료/삭제된 토큰에 대해서도 안전하게 호출 가능 (멱등). - */ - @Override - public void delete(QueueToken token) { - String programKey = programKey(token.getProgramId()); - String tokenKey = tokenKey(token.getId()); - String userProgramKey = userProgramKey(token.getUserId(), token.getProgramId()); - String tokenIdStr = token.getId().asString(); - - // 역인덱스 값과 토큰 ID가 일치할 때만 삭제 - // (다른 토큰이 차지한 경우는 그대로 둠 — race condition 방어) - String current = redisTemplate.opsForValue().get(userProgramKey); - if (tokenIdStr.equals(current)) { - redisTemplate.delete(userProgramKey); - } - - redisTemplate.execute(new SessionCallback>() { - @SuppressWarnings({"unchecked"}) - @Override - public List execute(RedisOperations operations) { - operations.multi(); - - // 1. Sorted Set 에서 멤버 제거 - operations.opsForZSet().remove(programKey, tokenIdStr); - - // 2. Hash 키 삭제 - operations.delete(tokenKey); - - return operations.exec(); - } - }); - } - - /** - * Redis 기반 findPosition 구현. - * - *

2단계 조회: - *

    - *
  1. 역인덱스로 tokenId 조회
  2. - *
  3. Sorted Set의 ZRANK로 순번 조회
  4. - *
- * - *

Redis ZRANK는 0-based이므로 사용자에게 보여줄 1-based로 변환한다. - */ - @Override - public Optional findPosition(UserId userId, ProgramId programId) { - String userProgramKey = userProgramKey(userId, programId); - - // 1단계: 역인덱스로 tokenId 조회 - String tokenIdStr = redisTemplate.opsForValue().get(userProgramKey); - - if (tokenIdStr == null) { - return Optional.empty(); - } - - // 2단계: Sorted Set에서 순번 조회 (0-based) - String programKey = programKey(programId); - Long rank = redisTemplate.opsForZSet().rank(programKey, tokenIdStr); - - if (rank == null) { - return Optional.empty(); - } - - // 3단계: 1-based로 변환하여 반환 - return Optional.of(rank + 1); - } - - /** - * Redis 기반 findAdmissionCandidates 구현. - * - *

2단계 조회: - *

    - *
  1. Sorted Set ZRANGE로 앞에서 batchSize개의 tokenId 추출
  2. - *
  3. 각 tokenId로 토큰 전체 조회 (N번 Hash 조회)
  4. - *
- * - *

Hash가 만료되어 토큰을 복원할 수 없는 orphan 케이스는 자연 필터링된다. - * 향후 N번 조회를 단일 Lua Script로 통합 검토. - */ - @Override - public List findAdmissionCandidates(ProgramId programId, int batchSize) { - if (batchSize <= 0) { - throw new IllegalArgumentException("batchSize는 1 이상이어야 합니다: " + batchSize); - } - - String programKey = programKey(programId); - - // 1단계: Sorted Set에서 앞에서부터 batchSize명의 tokenId 조회 - Set tokenIds = redisTemplate.opsForZSet().range(programKey, 0, batchSize - 1); - - if (tokenIds == null || tokenIds.isEmpty()) { - return List.of(); - } - - // 2단계: 각 tokenId로 토큰 전체 조회 - return tokenIds.stream() - // String → Optional - .map(tokenIdStr -> findById(QueueTokenId.fromString(tokenIdStr))) - // 토큰 있는 것만 - .filter(Optional::isPresent) - // Optional → QueueToken - .map(Optional::get) - .toList(); - } - - /** - * Redis 기반 admit 구현. - * - *

2가지 작업을 트랜잭션으로 처리한다: - *

    - *
  1. Sorted Set 에서 토큰 멤버 제거 (큐에서 빠짐 → position 조회 X)
  2. - *
  3. Hash 의 status / entryToken 업데이트
  4. - *
- * - *

역인덱스는 유지 — 사용자가 GET 으로 자기 토큰 조회 가능 (status: ADMITTED 응답). - */ - public void admit(QueueToken token) { - String programKey = programKey(token.getProgramId()); - String tokenKey = tokenKey(token.getId()); - String tokenIdStr = token.getId().asString(); - - Map updates = Map.of( - FIELD_STATUS, token.getStatus().name(), - FIELD_ENTRY_TOKEN, token.getEntryToken() - ); - - redisTemplate.execute(new SessionCallback>() { - @SuppressWarnings({"unchecked"}) - @Override - public List execute(RedisOperations operations) { - operations.multi(); - - // 1. Sorted Set 에서 멤버 제거 (큐에서 빠짐) - operations.opsForZSet().remove(programKey, tokenIdStr); - - // 2. Hash 의 status / entryToken 업데이트 - operations.opsForHash().putAll(tokenKey, updates); - - return operations.exec(); - } - }); - } - - /** - * Redis 기반 findActiveProgramIds 구현. - * - *

{@code queue:program:*} 패턴으로 SCAN 하여 활성 프로그램 ID 목록을 반환한다. - * - *

SCAN 사용 이유: KEYS 명령은 production 에서 블로킹 발생 위험. SCAN 은 점진적. - * - *

Future Work

- * 본 메서드는 MVP 의 가정 (큐 존재 = 활성 프로그램) 을 따른다. - *

program-service 와 Kafka 이벤트 통합 후엔: - *

    - *
  • {@code program.opened} 이벤트 → Redis Set 의 활성 프로그램 추가
  • - *
  • {@code program.closed} 이벤트 → Set 에서 제거
  • - *
  • 본 메서드는 Redis Set 직접 조회 (SCAN 불필요)
  • - *
- */ - public List findActiveProgramIds() { - String pattern = QUEUE_KEY_PREFIX + PROGRAM_KEY_PREFIX + "*"; - - // 1. SCAN 으로 모든 큐 키 수집 - Set keys = redisTemplate.execute((RedisCallback>) connection -> { - Set result = new HashSet<>(); - ScanOptions options = ScanOptions.scanOptions() - .match(pattern) // queue:program:* 매칭 - .count(100) // 한 번에 100 개씩 점진적 조회 - .build(); - - // try-with-resources 로 cursor 자동 정리 - try (Cursor cursor = connection.scan(options)) { - while (cursor.hasNext()) { - // Redis 는 byte[] 반환 → UTF-8 문자열로 변환 - result.add(new String(cursor.next(), StandardCharsets.UTF_8)); - } - } - return result; - }); - - if (keys == null || keys.isEmpty()) { - return List.of(); - } - - // 2. 키에서 UUID 추출하여 ProgramId 로 변환 - // 예: "queue:program:abc-123" → "abc-123" → ProgramId.of("abc-123") - String prefix = QUEUE_KEY_PREFIX + PROGRAM_KEY_PREFIX; - return keys.stream() - .map(key -> key.substring(prefix.length())) - .map(ProgramId::fromString) - .toList(); - } - - // ===== 키 생성 헬퍼 ===== - - private String programKey(ProgramId programId) { - return QUEUE_KEY_PREFIX + PROGRAM_KEY_PREFIX + programId.asString(); - } - - private String tokenKey(QueueTokenId tokenId) { - return QUEUE_KEY_PREFIX + TOKEN_KEY_PREFIX + tokenId.asString(); - } - - private String userProgramKey(UserId userId, ProgramId programId) { - return QUEUE_KEY_PREFIX + USER_KEY_PREFIX + userId.asString() - + ":" + PROGRAM_KEY_PREFIX + programId.asString(); - } -} diff --git a/src/main/java/com/firstticket/queueservice/infrastructure/scheduler/AdmissionScheduler.java b/src/main/java/com/firstticket/queueservice/infrastructure/scheduler/AdmissionScheduler.java deleted file mode 100644 index 02f103b..0000000 --- a/src/main/java/com/firstticket/queueservice/infrastructure/scheduler/AdmissionScheduler.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.firstticket.queueservice.infrastructure.scheduler; - -import com.firstticket.queueservice.config.QueueProperties; -import com.firstticket.queueservice.domain.QueueToken; -import com.firstticket.queueservice.domain.QueueTokenRepository; -import com.firstticket.queueservice.domain.vo.ProgramId; -import com.firstticket.queueservice.infrastructure.jwt.EntryTokenIssuer; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import java.util.List; - -/** - * 대기열 입장 승인 스케줄러. - * - *

주기적으로 활성 프로그램의 큐 앞에서 batchSize 명을 admit 한다. - * admit 시점에 JWT 입장 토큰을 발급하고, QueueToken 의 상태를 ADMITTED 로 전이시킨다. - * - *

흐름: - *

    - *
  1. Redis SCAN 으로 활성 프로그램 (큐가 존재하는 프로그램) 발견
  2. - *
  3. 각 프로그램의 큐 앞에서 batchSize 명 조회 (Sorted Set ZRANGE)
  4. - *
  5. 각 토큰에 대해 JWT 입장 토큰 발급 + 도메인 상태 전이 + Redis 영속성
  6. - *
- * - *

MVP 단계 한계: - *

    - *
  • 단일 인스턴스 가정 — 여러 인스턴스에서 동시 실행 시 race 가능 (v0.2.0 별도 이슈)
  • - *
  • 활성 프로그램 발견을 Redis SCAN 에 의존 — program-service 통합 후 변경
  • - *
- */ -@Slf4j -@Component -@RequiredArgsConstructor -public class AdmissionScheduler { - - private final QueueTokenRepository queueTokenRepository; - private final EntryTokenIssuer entryTokenIssuer; - private final QueueProperties queueProperties; - - /** - * 매 5 초마다 활성 프로그램의 큐 앞 batchSize 명을 admit. - * - *

fixedRate 5000ms = 이전 실행이 끝나든 안 끝나든 5 초마다 시작. - */ - @Scheduled(fixedRate = 5000) - public void admit() { - List activePrograms = queueTokenRepository.findActiveProgramIds(); - - if (activePrograms.isEmpty()) { - return; - } - - log.debug("[AdmissionScheduler] 활성 프로그램 {} 개 발견", activePrograms.size()); - - for (ProgramId programId : activePrograms) { - try { - admitProgram(programId); - } catch (Exception e) { - // 한 프로그램의 실패가 다른 프로그램 처리에 영향을 주지 않도록 격리 - log.error("[AdmissionScheduler] program 단위 처리 실패 - programId={}", - programId.asString(), e); - } - } - } - - /** - * 특정 프로그램의 큐 앞 batchSize 명을 admit. - * - *

한 토큰의 admit 실패가 다른 토큰 처리에 영향을 주지 않도록 개별 try-catch 처리. - */ - private void admitProgram(ProgramId programId) { - int batchSize = queueProperties.admissionBatchSize(); - List candidates = queueTokenRepository.findAdmissionCandidates(programId, batchSize); - - if (candidates.isEmpty()) { - return; - } - - log.info("[AdmissionScheduler] programId={} 의 {} 명을 admit 합니다", - programId.asString(), candidates.size()); - - int successCount = 0; - for (QueueToken queueToken : candidates) { - try { - // 1. JWT 입장 토큰 발급 - String entryToken = entryTokenIssuer.issue(queueToken); - // 2. 도메인 상태 전이 (WAITING -> ADMITTED) + entryToken 부여 - queueToken.admit(entryToken); - // 3. Redis 영속성 (Sorted Set 제거 + Hash status/entryToken 갱신) - queueTokenRepository.admit(queueToken); - successCount++; - } catch (Exception e) { - // 한 토큰의 실패가 같은 batch 의 다른 토큰을 막지 않도록 격리 - // 실패한 토큰은 Sorted Set 에 남아 다음 cycle 에서 재시도 - log.error("[AdmissionScheduler] admit 실패 - tokenId={}, programId={}", - queueToken.getId().asString(), programId.asString(), e); - } - } - - if (successCount < candidates.size()) { - log.warn("[AdmissionScheduler] 부분 실패 - programId={}, success={}/{}", - programId.asString(), successCount, candidates.size()); - } - } -} diff --git a/src/main/java/com/firstticket/queueservice/presentation/QueueSuccessCode.java b/src/main/java/com/firstticket/queueservice/presentation/QueueSuccessCode.java deleted file mode 100644 index 221c54c..0000000 --- a/src/main/java/com/firstticket/queueservice/presentation/QueueSuccessCode.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.firstticket.queueservice.presentation; - -import com.firstticket.common.response.SuccessCode; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@Getter -@RequiredArgsConstructor -public enum QueueSuccessCode implements SuccessCode { - QUEUE_TOKEN_ISSUED(HttpStatus.CREATED, "대기 토큰이 발급되었습니다"), - QUEUE_TOKEN_FOUND(HttpStatus.OK, "대기 토큰을 조회했습니다"), - QUEUE_TOKEN_CANCELLED(HttpStatus.OK, "대기를 취소했습니다"); - - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/firstticket/queueservice/presentation/QueueTokenController.java b/src/main/java/com/firstticket/queueservice/presentation/QueueTokenController.java deleted file mode 100644 index a77fd3e..0000000 --- a/src/main/java/com/firstticket/queueservice/presentation/QueueTokenController.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.firstticket.queueservice.presentation; - -import com.firstticket.common.response.ApiResponse; -import com.firstticket.common.web.AuthContext; -import com.firstticket.queueservice.application.QueueTokenService; -import com.firstticket.queueservice.application.dto.CancelQueueTokenCommand; -import com.firstticket.queueservice.application.dto.GetQueueTokenQuery; -import com.firstticket.queueservice.application.dto.IssueQueueTokenCommand; -import com.firstticket.queueservice.application.dto.QueueTokenResult; -import com.firstticket.queueservice.presentation.dto.QueueTokenResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.UUID; - -/** - * 대기열 진입 / 조회 / 취소를 처리하는 REST 컨트롤러. - */ -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/queues/programs/{programId}") -public class QueueTokenController { - - private final QueueTokenService queueTokenService; - - /** - * 대기열 진입. - * - * @return 201 Created - */ - @PostMapping - public ResponseEntity> issueToken( - @PathVariable UUID programId - ) { - IssueQueueTokenCommand command = IssueQueueTokenCommand.of( - AuthContext.getUserId(), - programId - ); - QueueTokenResult result = queueTokenService.issueToken(command); - return ApiResponse.success( - QueueSuccessCode.QUEUE_TOKEN_ISSUED, - QueueTokenResponse.from(result) - ); - } - - /** - * 대기 정보 조회 (폴링용). - * - * @return 200 OK - */ - @GetMapping - public ResponseEntity> getToken( - @PathVariable UUID programId - ) { - GetQueueTokenQuery query = GetQueueTokenQuery.of(AuthContext.getUserId(), programId); - QueueTokenResult result = queueTokenService.getToken(query); - return ApiResponse.success( - QueueSuccessCode.QUEUE_TOKEN_FOUND, - QueueTokenResponse.from(result)); - } - - /** - * 대기 취소. - * - * @return 200 OK - */ - @DeleteMapping - public ResponseEntity> cancelToken( - @PathVariable UUID programId - ) { - CancelQueueTokenCommand command = CancelQueueTokenCommand.of( - AuthContext.getUserId(), - programId - ); - queueTokenService.cancelToken(command); - return ApiResponse.success(QueueSuccessCode.QUEUE_TOKEN_CANCELLED); - } -} diff --git a/src/main/java/com/firstticket/queueservice/presentation/dto/QueueTokenResponse.java b/src/main/java/com/firstticket/queueservice/presentation/dto/QueueTokenResponse.java deleted file mode 100644 index 901a4e5..0000000 --- a/src/main/java/com/firstticket/queueservice/presentation/dto/QueueTokenResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.firstticket.queueservice.presentation.dto; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.firstticket.queueservice.application.dto.QueueTokenResult; - -import java.time.LocalDateTime; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public record QueueTokenResponse( - String tokenId, - String status, - LocalDateTime issuedAt, - Long position, - String entryToken -) { - public static QueueTokenResponse from(QueueTokenResult result) { - return new QueueTokenResponse( - result.tokenId().asString(), - result.status().name(), - result.issuedAt().value(), - result.position(), - result.entryToken() - ); - } -} diff --git a/src/main/java/com/firstticket/queueservice/programmeta/application/ProgramMetaService.java b/src/main/java/com/firstticket/queueservice/programmeta/application/ProgramMetaService.java new file mode 100644 index 0000000..a6ae847 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/programmeta/application/ProgramMetaService.java @@ -0,0 +1,81 @@ +package com.firstticket.queueservice.programmeta.application; + +import com.firstticket.queueservice.programmeta.application.dto.CancelProgramCommand; +import com.firstticket.queueservice.programmeta.application.dto.CreateProgramMetaCommand; +import com.firstticket.queueservice.programmeta.application.dto.UpdateProgramTimeCommand; +import com.firstticket.queueservice.programmeta.domain.ProgramMeta; +import com.firstticket.queueservice.programmeta.domain.ProgramMetaRepository; +import com.firstticket.queueservice.programmeta.domain.event.ProgramEvents; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * ProgramMeta 도메인의 Command 처리 서비스. + * Kafka Consumer 가 전달한 Command 를 받아 Aggregate 의 상태를 변경하고, + * 필요 시 도메인 이벤트를 발행한다. + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class ProgramMetaService { + private final ProgramMetaRepository programMetaRepository; + private final ProgramEvents programEvents; + + /** + * program.created 처리. + * ProgramMeta 새로 생성하여 저장. + */ + public void handleCreated(CreateProgramMetaCommand command) { + log.info("Program created. programId={}, status={}", command.programId(), command.status()); + + ProgramMeta programMeta = ProgramMeta.of( + command.programId(), + command.openAt(), + command.closeAt(), + command.status() + ); + programMetaRepository.save(programMeta); + } + + /** + * program.time.updated 처리. + * 기존 ProgramMeta 의 openAt / closeAt 갱신. + * Meta 가 존재하지 않으면 경고 로그만 남긴다 (이벤트 순서가 어긋난 경우 대비). + */ + public void handleTimeUpdated(UpdateProgramTimeCommand command) { + log.info("Program time updated. programId={}, openAt={}, closeAt={}", + command.programId(), command.openAt(), command.closeAt()); + + programMetaRepository.findById(command.programId()) + .ifPresentOrElse( + programMeta -> { + programMeta.updateTime(command.openAt(), command.closeAt()); + programMetaRepository.save(programMeta); + }, + () -> log.warn("ProgramMeta 없음 (time updated). programId={}", command.programId()) + ); + } + + /** + * program.cancelled 처리. + * 1. ProgramMeta 의 status 를 CANCELLED 로 갱신. + * 2. ProgramCancelledEvent 발행 (queuetoken Aggregate 가 토큰 정리). + */ + public void handleCancelled(CancelProgramCommand command) { + log.info("Program cancelled. programId={}", command.programId()); + + programMetaRepository.findById(command.programId()) + .ifPresentOrElse( + programMeta -> { + programMeta.cancel(); + programMetaRepository.save(programMeta); + }, + () -> log.warn("ProgramMeta 없음 (cancelled). programId={}", command.programId()) + ); + + // queuetoken Aggregate 에 이벤트 발급 + programEvents.publishProgramCancelled(command.programId()); + } + +} diff --git a/src/main/java/com/firstticket/queueservice/programmeta/application/dto/CancelProgramCommand.java b/src/main/java/com/firstticket/queueservice/programmeta/application/dto/CancelProgramCommand.java new file mode 100644 index 0000000..7a6501c --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/programmeta/application/dto/CancelProgramCommand.java @@ -0,0 +1,21 @@ +package com.firstticket.queueservice.programmeta.application.dto; + +import com.firstticket.queueservice.programmeta.domain.ProgramStatus; +import com.firstticket.queueservice.programmeta.domain.vo.ProgramId; + +import java.util.UUID; + +/** + * program.cancelled 이벤트 처리용 Command. + */ +public record CancelProgramCommand( + ProgramId programId, + ProgramStatus status +) { + public static CancelProgramCommand of(UUID programId, String status) { + return new CancelProgramCommand( + ProgramId.of(programId), + ProgramStatus.valueOf(status) + ); + } +} diff --git a/src/main/java/com/firstticket/queueservice/programmeta/application/dto/CreateProgramMetaCommand.java b/src/main/java/com/firstticket/queueservice/programmeta/application/dto/CreateProgramMetaCommand.java new file mode 100644 index 0000000..a397a30 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/programmeta/application/dto/CreateProgramMetaCommand.java @@ -0,0 +1,34 @@ +package com.firstticket.queueservice.programmeta.application.dto; + +import com.firstticket.queueservice.programmeta.domain.ProgramStatus; +import com.firstticket.queueservice.programmeta.domain.vo.ProgramId; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * program.created 이벤트 처리용 Command. + * Kafka Consumer 가 외부 Payload 를 변환하여 application 에 전달한다. + * + *

openAt / closeAt 은 생성 시점엔 스케줄 미정이라 null 가능.

+ */ +public record CreateProgramMetaCommand( + ProgramId programId, + LocalDateTime openAt, + LocalDateTime closeAt, + ProgramStatus status +) { + public static CreateProgramMetaCommand of( + UUID programId, + LocalDateTime openAt, + LocalDateTime closeAt, + String status + ) { + return new CreateProgramMetaCommand( + ProgramId.of(programId), + openAt, + closeAt, + ProgramStatus.valueOf(status) + ); + } +} diff --git a/src/main/java/com/firstticket/queueservice/programmeta/application/dto/UpdateProgramTimeCommand.java b/src/main/java/com/firstticket/queueservice/programmeta/application/dto/UpdateProgramTimeCommand.java new file mode 100644 index 0000000..ea8be73 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/programmeta/application/dto/UpdateProgramTimeCommand.java @@ -0,0 +1,28 @@ +package com.firstticket.queueservice.programmeta.application.dto; + +import com.firstticket.queueservice.programmeta.domain.vo.ProgramId; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * program.time.updated 이벤트 처리용 Command. + * 스케줄 (openAt / closeAt) 등록 / 변경 시 사용. + */ +public record UpdateProgramTimeCommand( + ProgramId programId, + LocalDateTime openAt, + LocalDateTime closeAt +) { + public static UpdateProgramTimeCommand of( + UUID programId, + LocalDateTime openAt, + LocalDateTime closeAt + ) { + return new UpdateProgramTimeCommand( + ProgramId.of(programId), + openAt, + closeAt + ); + } +} diff --git a/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMeta.java b/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMeta.java new file mode 100644 index 0000000..e109e3a --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMeta.java @@ -0,0 +1,82 @@ +package com.firstticket.queueservice.programmeta.domain; + +import com.firstticket.queueservice.programmeta.domain.vo.ProgramId; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Program 의 메타 정보 (Aggregate Root). + * program-service 의 이벤트로 갱신되는 캐시 / 읽기 모델. + * + *

원본은 program-service 가 소유하므로 queue-service 는 이 객체를 + * 영구 저장하지 않으며, 필요 시 program 토픽의 처음부터 재구독으로 복구한다.

+ */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ProgramMeta { + + private ProgramId programId; + private LocalDateTime openAt; + private LocalDateTime closeAt; + private ProgramStatus status; + + /** + * 새 ProgramMeta 생성. 일반적으로 program.created 이벤트 처리 시 호출. + * + * @param programId UUID 형태의 program 식별자 + * @param openAt 예매 오픈 시각 (생성 시점엔 null 가능) + * @param closeAt 예매 종료 시각 (생성 시점엔 null 가능) + * @param status program-service 가 전달한 상태 문자열 ("DRAFT" / "CANCELLED") + */ + public static ProgramMeta of(ProgramId programId, LocalDateTime openAt, + LocalDateTime closeAt, ProgramStatus status) { + return new ProgramMeta( + programId, + openAt, + closeAt, + status + ); + } + + /** + * 스케줄 갱신. program.time.updated 이벤트 처리 시 호출. + */ + public void updateTime(LocalDateTime newOpenAt, LocalDateTime newCloseAt) { + this.openAt = newOpenAt; + this.closeAt = newCloseAt; + } + + /** + * 프로그램 취소 처리. program.cancelled 이벤트 처리 시 호출. + */ + public void cancel() { + this.status = ProgramStatus.CANCELLED; + } + + /** + * 현재 시각 기준 활성 여부. + * CANCELLED 가 아니고, 스케줄이 설정됐고, 현재 시각이 openAt ~ closeAt 사이일 때 true. + */ + public boolean isActiveAt(LocalDateTime now) { + if (status == ProgramStatus.CANCELLED) return false; + if (openAt == null || closeAt == null) return false; + return !openAt.isAfter(now) && !closeAt.isBefore(now); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ProgramMeta that)) return false; + return programId.equals(that.programId); + } + + @Override + public int hashCode() { + return programId.hashCode(); + } +} diff --git a/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMetaRepository.java b/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMetaRepository.java new file mode 100644 index 0000000..7e22090 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMetaRepository.java @@ -0,0 +1,32 @@ +package com.firstticket.queueservice.programmeta.domain; + +import com.firstticket.queueservice.programmeta.domain.vo.ProgramId; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * ProgramMeta 영속화 인터페이스. + * 구현체는 인프라 계층 (infrastructure/persistence) 에 위치한다. + */ +public interface ProgramMetaRepository { + + /** + * ProgramMeta 저장 (overwrite). + */ + void save(ProgramMeta programMeta); + + Optional findById(ProgramId programId); + + List findAll(); + + void deleteById(ProgramId programId); + + /** + * 현재 시각 기준 활성 (CANCELLED 아니고 openAt ~ closeAt 사이) 인 + * 모든 program 의 ID 목록을 반환한다. + * AdmissionScheduler 등에서 활성 프로그램 순회 시 사용. + */ + List findActiveProgramIds(LocalDateTime now); +} diff --git a/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramStatus.java b/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramStatus.java new file mode 100644 index 0000000..82a11c7 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramStatus.java @@ -0,0 +1,23 @@ +package com.firstticket.queueservice.programmeta.domain; + +/** + * Program 의 생명주기 상태. + * program-service 의 이벤트로 갱신되며, queue-service 는 이 상태를 캐시한다. + * + * 활성/비활성 (OPEN / CLOSED) 은 별도 상태로 관리하지 않고 + * openAt / closeAt 시간으로 동적 판단한다. + */ +public enum ProgramStatus { + /** + * 프로그램이 생성된 기본 상태. + * 스케줄 (openAt / closeAt) 등록 / 변경과 무관하게 유지된다. + * 시간 조건이 맞으면 토큰 발급 / 입장 허용. + */ + DRAFT, + + /** + * 프로그램이 취소된 상태. + * 모든 대기 토큰을 정리하고 신규 토큰 발급을 거부한다. + */ + CANCELLED +} diff --git a/src/main/java/com/firstticket/queueservice/programmeta/domain/event/ProgramEvents.java b/src/main/java/com/firstticket/queueservice/programmeta/domain/event/ProgramEvents.java new file mode 100644 index 0000000..86ed106 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/programmeta/domain/event/ProgramEvents.java @@ -0,0 +1,17 @@ +package com.firstticket.queueservice.programmeta.domain.event; + +import com.firstticket.queueservice.programmeta.domain.vo.ProgramId; + +/** + * Program 도메인 이벤트 발행 인터페이스. + * + *

구현체는 Spring 의 ApplicationEventPublisher 를 위임 사용하며, + * 인프라 계층 (infrastructure/event) 에 위치한다.

+ */ +public interface ProgramEvents { + /** + * Program 취소 이벤트 발행. + * queuetoken Aggregate 의 EventListener 가 수신해 토큰을 정리한다. + */ + void publishProgramCancelled(ProgramId programId); +} diff --git a/src/main/java/com/firstticket/queueservice/domain/vo/ProgramId.java b/src/main/java/com/firstticket/queueservice/programmeta/domain/vo/ProgramId.java similarity index 69% rename from src/main/java/com/firstticket/queueservice/domain/vo/ProgramId.java rename to src/main/java/com/firstticket/queueservice/programmeta/domain/vo/ProgramId.java index 181f119..11ae84e 100644 --- a/src/main/java/com/firstticket/queueservice/domain/vo/ProgramId.java +++ b/src/main/java/com/firstticket/queueservice/programmeta/domain/vo/ProgramId.java @@ -1,13 +1,13 @@ -package com.firstticket.queueservice.domain.vo; +package com.firstticket.queueservice.programmeta.domain.vo; import java.util.Objects; import java.util.UUID; /** - * 프로그램(공연) ID를 표현하는 VO. + * Program 을 식별하는 Value Object. + * 원본은 program-service 가 소유하며, queue-service 는 동일한 UUID 를 참조한다. */ public record ProgramId(UUID id) { - public ProgramId { Objects.requireNonNull(id, "ProgramId는 null일 수 없습니다"); } diff --git a/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/event/ProgramEventsImpl.java b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/event/ProgramEventsImpl.java new file mode 100644 index 0000000..ba3fb0e --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/event/ProgramEventsImpl.java @@ -0,0 +1,25 @@ +package com.firstticket.queueservice.programmeta.infrastructure.event; + +import com.firstticket.queueservice.programmeta.domain.event.ProgramEvents; +import com.firstticket.queueservice.programmeta.domain.vo.ProgramId; +import com.firstticket.queueservice.shared.event.ProgramCancelledEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * ProgramEvents 의 Spring 구현체. + * 도메인이 Spring 에 직접 의존하지 않도록 인프라 계층에서 위임만 수행한다. + */ +@Component +@RequiredArgsConstructor +public class ProgramEventsImpl implements ProgramEvents { + + private final ApplicationEventPublisher applicationEventPublisher; + @Override + public void publishProgramCancelled(ProgramId programId) { + applicationEventPublisher.publishEvent( + new ProgramCancelledEvent(programId.id()) + ); + } +} diff --git a/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/ProgramKafkaConsumer.java b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/ProgramKafkaConsumer.java new file mode 100644 index 0000000..1c3d761 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/ProgramKafkaConsumer.java @@ -0,0 +1,83 @@ +package com.firstticket.queueservice.programmeta.infrastructure.messaging; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.firstticket.queueservice.programmeta.application.ProgramMetaService; +import com.firstticket.queueservice.programmeta.application.dto.CancelProgramCommand; +import com.firstticket.queueservice.programmeta.application.dto.CreateProgramMetaCommand; +import com.firstticket.queueservice.programmeta.application.dto.UpdateProgramTimeCommand; +import com.firstticket.queueservice.programmeta.infrastructure.messaging.payload.ProgramCancelledPayload; +import com.firstticket.queueservice.programmeta.infrastructure.messaging.payload.ProgramCreatedPayload; +import com.firstticket.queueservice.programmeta.infrastructure.messaging.payload.ProgramTimeUpdatedPayload; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +/** + * program 도메인 이벤트 Kafka Consumer. + * Payload 역직렬화 + Command 변환 + Application Service 호출의 책임을 가진다. + * + *

처리 실패 시 ack 하지 않아 Kafka 가 재전송하도록 한다 (at-least-once). + * 도메인 액션이 idempotent 하므로 중복 수신해도 안전하다.

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProgramKafkaConsumer { + + private final ProgramMetaService programMetaService; + private final ObjectMapper objectMapper; + + @KafkaListener(topics = "${kafka.topics.program-created}") + public void onProgramCreated(ConsumerRecord record, Acknowledgment ack) { + log.info("Received program.created. key={}", record.key()); + try { + ProgramCreatedPayload payload = objectMapper.readValue( + record.value(), ProgramCreatedPayload.class); + + CreateProgramMetaCommand command = CreateProgramMetaCommand.of( + payload.programId(), payload.openAt(), payload.closeAt(), payload.status()); + + programMetaService.handleCreated(command); + ack.acknowledge(); + } catch (Exception e) { + log.error("program.created 처리 실패. record={}", record, e); + } + } + + @KafkaListener(topics = "${kafka.topics.program-time-updated}") + public void onProgramTimeUpdated(ConsumerRecord record, Acknowledgment ack) { + log.info("Received program.time.updated. key={}", record.key()); + try { + ProgramTimeUpdatedPayload payload = objectMapper.readValue( + record.value(), ProgramTimeUpdatedPayload.class); + + UpdateProgramTimeCommand command = UpdateProgramTimeCommand.of( + payload.programId(), payload.openAt(), payload.closeAt()); + + programMetaService.handleTimeUpdated(command); + ack.acknowledge(); + } catch (Exception e) { + log.error("program.time.updated 처리 실패. record={}", record, e); + } + } + + @KafkaListener(topics = "${kafka.topics.program-cancelled}") + public void onProgramCancelled(ConsumerRecord record, Acknowledgment ack) { + log.info("Received program.cancelled. key={}", record.key()); + try { + ProgramCancelledPayload payload = objectMapper.readValue( + record.value(), ProgramCancelledPayload.class); + + CancelProgramCommand command = CancelProgramCommand.of( + payload.programId(), payload.status()); + + programMetaService.handleCancelled(command); + ack.acknowledge(); + } catch (Exception e) { + log.error("program.cancelled 처리 실패. record={}", record, e); + } + } +} diff --git a/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/payload/ProgramCancelledPayload.java b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/payload/ProgramCancelledPayload.java new file mode 100644 index 0000000..b365c9d --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/payload/ProgramCancelledPayload.java @@ -0,0 +1,13 @@ +package com.firstticket.queueservice.programmeta.infrastructure.messaging.payload; + +import java.util.UUID; + +/** + * program.cancelled 토픽 페이로드. + * 프로그램 취소 시 발행. queue-service 가 활성 토큰 모두 정리. + */ +public record ProgramCancelledPayload( + UUID programId, + String status +) { +} diff --git a/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/payload/ProgramCreatedPayload.java b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/payload/ProgramCreatedPayload.java new file mode 100644 index 0000000..51f6db9 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/payload/ProgramCreatedPayload.java @@ -0,0 +1,16 @@ +package com.firstticket.queueservice.programmeta.infrastructure.messaging.payload; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * program.created 토픽 페이로드. + * 프로그램 생성 시점에는 스케줄 미등록이므로 openAt/closeAt 은 null 가능. + */ +public record ProgramCreatedPayload( + UUID programId, + LocalDateTime openAt, + LocalDateTime closeAt, + String status +) { +} diff --git a/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/payload/ProgramTimeUpdatedPayload.java b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/payload/ProgramTimeUpdatedPayload.java new file mode 100644 index 0000000..855a4fa --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/payload/ProgramTimeUpdatedPayload.java @@ -0,0 +1,15 @@ +package com.firstticket.queueservice.programmeta.infrastructure.messaging.payload; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * program.time.updated 토픽 페이로드. + * 스케줄 등록 / 변경 시 발행. + */ +public record ProgramTimeUpdatedPayload( + UUID programId, + LocalDateTime openAt, + LocalDateTime closeAt +) { +} diff --git a/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.java b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.java new file mode 100644 index 0000000..c8e27fd --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.java @@ -0,0 +1,152 @@ +package com.firstticket.queueservice.programmeta.infrastructure.redis; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.firstticket.queueservice.programmeta.domain.ProgramMeta; +import com.firstticket.queueservice.programmeta.domain.ProgramMetaRepository; +import com.firstticket.queueservice.programmeta.domain.ProgramStatus; +import com.firstticket.queueservice.programmeta.domain.vo.ProgramId; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.*; + +/** + * ProgramMetaRepository 의 Redis 구현체. + * + *

키 패턴: {@code queue:program:meta:{programId}}
+ * 값: ProgramMeta 의 필드를 담은 JSON 문자열.

+ */ +@Slf4j +@Repository +@RequiredArgsConstructor +public class RedisProgramMetaRepository implements ProgramMetaRepository { + + private static final String KEY_PREFIX = "queue:program:meta:"; + private static final String KEY_PATTERN = KEY_PREFIX + "*"; + + // JSON 필드 이름 상수 + private static final String FIELD_PROGRAM_ID = "programId"; + private static final String FIELD_OPEN_AT = "openAt"; + private static final String FIELD_CLOSE_AT = "closeAt"; + private static final String FIELD_STATUS = "status"; + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + /** + * ProgramMeta 저장 (overwrite). + * + *

이벤트 수신 시마다 호출되어 캐시를 갱신. 같은 programId 의 기존 값은 덮어쓴다. + * openAt / closeAt 이 null 이면 빈 문자열로 저장한다 (null 직렬화 회피).

+ */ + @Override + public void save(ProgramMeta programMeta) { + try { + Map data = Map.of( + FIELD_PROGRAM_ID, programMeta.getProgramId().asString(), + FIELD_OPEN_AT, programMeta.getOpenAt() == null ? "" : programMeta.getOpenAt().toString(), + FIELD_CLOSE_AT, programMeta.getCloseAt() == null ? "" : programMeta.getCloseAt().toString(), + FIELD_STATUS, programMeta.getStatus().name() + ); + String json = objectMapper.writeValueAsString(data); + redisTemplate.opsForValue().set(buildKey(programMeta.getProgramId()), json); + } catch (JsonProcessingException e) { + throw new IllegalStateException("ProgramMeta 직렬화 실패", e); + } + } + + /** + * programId 로 ProgramMeta 단건 조회. + */ + @Override + public Optional findById(ProgramId programId) { + String json = redisTemplate.opsForValue().get(buildKey(programId)); + if (json == null) return Optional.empty(); + return Optional.of(deserialize(json)); + } + + /** + * 모든 ProgramMeta 조회. + * SCAN 으로 키 목록을 가져온 후 각 키별 GET. + */ + @Override + public List findAll() { + ArrayList result = new ArrayList<>(); + try (Cursor cursor = redisTemplate.scan( + ScanOptions.scanOptions().match(KEY_PATTERN).count(100).build() + )) { + while (cursor.hasNext()) { + String json = redisTemplate.opsForValue().get(cursor.next()); + if (json != null) { + result.add(deserialize(json)); + } + } + } + return result; + } + + /** + * programId 로 ProgramMeta 삭제. + * 이미 없어도 안전 (Redis DEL 의 멱등성). + */ + @Override + public void deleteById(ProgramId programId) { + redisTemplate.delete(buildKey(programId)); + } + + /** + * 현재 시각 기준 활성 프로그램 ID 목록 조회. + * + *

findAll() 결과를 도메인의 {@link ProgramMeta#isActiveAt} 로 필터링. + * 활성 = CANCELLED 아니고 스케줄 설정됐고 현재 시각이 범위 안.

+ * + *

현재 모든 ProgramMeta 를 메모리로 가져와 필터링하는 본질이라, + * 프로그램 수가 많아지면 비효율. 미래엔 별도 인덱스 키 + * (예: queue:program:active = Set of programIds) 도입 고려.

+ */ + @Override + public List findActiveProgramIds(LocalDateTime now) { + return findAll().stream() + .filter(programMeta -> programMeta.isActiveAt(now)) + .map(ProgramMeta::getProgramId) + .toList(); + } + + // ===== 헬퍼 ===== + + private String buildKey(ProgramId programId) { + return KEY_PREFIX + programId.asString(); + } + + /** + * JSON 문자열을 ProgramMeta 도메인 객체로 복원. + * 빈 문자열은 null 로 변환 (openAt / closeAt 의 미정 상태 표현). + */ + private ProgramMeta deserialize(String json) { + try { + Map data = objectMapper.readValue(json, Map.class); + return ProgramMeta.of( + ProgramId.of(UUID.fromString(data.get(FIELD_PROGRAM_ID))), + parseDateTime(data.get(FIELD_OPEN_AT)), + parseDateTime(data.get(FIELD_CLOSE_AT)), + ProgramStatus.valueOf(data.get(FIELD_STATUS)) + ); + } catch (JsonProcessingException e) { + throw new IllegalStateException("ProgramMeta 역직렬화 실패", e); + } + } + + /** + * 빈 문자열을 null 로, 그 외는 LocalDateTime 으로 변환. + */ + private LocalDateTime parseDateTime(String value) { + if (value == null || value.isEmpty()) return null; + return LocalDateTime.parse(value); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/domain/QueueTokenRepository.java b/src/main/java/com/firstticket/queueservice/queuetoken/domain/QueueTokenRepository.java index 0283b20..74cc35b 100644 --- a/src/main/java/com/firstticket/queueservice/queuetoken/domain/QueueTokenRepository.java +++ b/src/main/java/com/firstticket/queueservice/queuetoken/domain/QueueTokenRepository.java @@ -59,4 +59,10 @@ public interface QueueTokenRepository { * 현재 큐가 존재하는 모든 프로그램 ID 를 조회한다. */ List findActiveProgramIds(); + + /** + * 특정 프로그램의 모든 대기 / 입장 토큰을 삭제한다. + * Program 이 취소되었을 때 호출. + */ + void deleteAllByProgramId(ProgramId programId); } diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/ProgramId.java b/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/ProgramId.java index cef028e..bf760fc 100644 --- a/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/ProgramId.java +++ b/src/main/java/com/firstticket/queueservice/queuetoken/domain/vo/ProgramId.java @@ -4,7 +4,8 @@ import java.util.UUID; /** - * 프로그램(공연) ID를 표현하는 VO. + * Program 을 식별하는 Value Object. + * 원본은 program-service 가 소유하며, queue-service 는 동일한 UUID 를 참조한다. */ public record ProgramId(UUID id) { diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/event/ProgramCancelledEventListener.java b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/event/ProgramCancelledEventListener.java new file mode 100644 index 0000000..317ed7e --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/event/ProgramCancelledEventListener.java @@ -0,0 +1,31 @@ +package com.firstticket.queueservice.queuetoken.infrastructure.event; + +import com.firstticket.queueservice.queuetoken.domain.QueueTokenRepository; +import com.firstticket.queueservice.queuetoken.domain.vo.ProgramId; +import com.firstticket.queueservice.shared.event.ProgramCancelledEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * 도메인 이벤트를 수신하여 queuetoken Aggregate 의 상태를 정리. + * Program 이 취소되면 해당 프로그램의 모든 대기 / 입장 토큰을 삭제한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProgramCancelledEventListener { + + private final QueueTokenRepository queueTokenRepository; + + @EventListener + public void onProgramCancelled(ProgramCancelledEvent event) { + log.info("ProgramCancelledEvent 수신. programId={}", event.programId()); + + // shared 이벤트의 UUID 를 queuetoken 의 ProgramId VO 로 변환 + ProgramId programId = ProgramId.of(event.programId()); + + queueTokenRepository.deleteAllByProgramId(programId); + } +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java index 3494459..971e593 100644 --- a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java +++ b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java @@ -389,6 +389,84 @@ public List findActiveProgramIds() { .toList(); } + /** + * Redis 기반 deleteAllByProgramId 구현. + * + *

Program 취소 시 해당 프로그램의 모든 토큰 (대기 + 입장) 정리. + * + *

처리 단계: + *

    + *
  1. SCAN 으로 모든 token Hash 키 ({@code queue:token:*}) 조회
  2. + *
  3. 각 Hash 의 programId 필드를 확인하여 일치하는 토큰 식별
  4. + *
  5. 일치 토큰의 Hash + 역인덱스 + Sorted Set 자체를 일괄 삭제
  6. + *
+ * + *

주의: 입장 (ADMITTED) 된 토큰은 Sorted Set 에서 이미 빠져 있어 + * Sorted Set 조회로는 못 찾는다. ADMITTED 토큰 정리는 SCAN 으로 추가 처리. + * + *

미래 개선: 프로그램별 토큰 ID Set 을 별도 유지하면 SCAN 불필요. + * 예: {@code queue:program:{programId}:all = {tokenId1, tokenId2, ...}}

+ */ + @Override + public void deleteAllByProgramId(ProgramId programId) { + String programKey = programKey(programId); + String programIdStr = programId.asString(); + + // 1. SCAN 으로 모든 token Hash 키 찾기 (programId 일치하는 것) + Set tokenKeysToDelete = new HashSet<>(); + List userProgramKeysToDelete = new ArrayList<>(); + + String tokenKeyPattern = QUEUE_KEY_PREFIX + TOKEN_KEY_PREFIX + "*"; + Set allTokenKeys = scanKeys(tokenKeyPattern); + + HashOperations hashOps = redisTemplate.opsForHash(); + + for (String tokenKey : allTokenKeys) { + String tokenProgramId = hashOps.get(tokenKey, FIELD_PROGRAM_ID); + if (programIdStr.equals(tokenProgramId)) { + // 일치 → 삭제 대상 + tokenKeysToDelete.add(tokenKey); + + // 역인덱스 키도 만들기 위해 userId 조회 + String userId = hashOps.get(tokenKey, FIELD_USER_ID); + if (userId != null) { + String userProgramKey = QUEUE_KEY_PREFIX + USER_KEY_PREFIX + userId + + ":" + PROGRAM_KEY_PREFIX + programIdStr; + userProgramKeysToDelete.add(userProgramKey); + } + } + } + + // 2. 삭제 대상 모두 모음 (Sorted Set + Hash + 역인덱스) + List allKeysToDelete = new ArrayList<>(); + allKeysToDelete.add(programKey); // Sorted Set + allKeysToDelete.addAll(tokenKeysToDelete); + allKeysToDelete.addAll(userProgramKeysToDelete); + + // 3. 일괄 삭제 + Long deletedCount = redisTemplate.delete(allKeysToDelete); + + log.info("프로그램 토큰 삭제 완료. programId={}, 삭제 키 수={}", + programIdStr, deletedCount); + } + + private Set scanKeys(String pattern) { + return redisTemplate.execute((RedisCallback>) connection -> { + Set result = new HashSet<>(); + ScanOptions options = ScanOptions.scanOptions() + .match(pattern) + .count(100) + .build(); + try (Cursor cursor = connection.scan(options)) { + while (cursor.hasNext()) { + result.add(new String(cursor.next(), StandardCharsets.UTF_8)); + } + } + return result; + }); + } + + // ===== 키 생성 헬퍼 ===== private String programKey(ProgramId programId) { diff --git a/src/main/java/com/firstticket/queueservice/shared/event/ProgramCancelledEvent.java b/src/main/java/com/firstticket/queueservice/shared/event/ProgramCancelledEvent.java new file mode 100644 index 0000000..00e683c --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/shared/event/ProgramCancelledEvent.java @@ -0,0 +1,9 @@ +package com.firstticket.queueservice.shared.event; + +import java.util.UUID; + +/** + * Program 이 취소되었을 때 발행되는 도메인 이벤트. + * programmeta Aggregate 가 발행하고 queuetoken Aggregate 가 수신한다. + */ +public record ProgramCancelledEvent(UUID programId) {} diff --git a/src/test/java/com/firstticket/queueservice/domain/QueueTokenTest.java b/src/test/java/com/firstticket/queueservice/domain/QueueTokenTest.java deleted file mode 100644 index 229d1c5..0000000 --- a/src/test/java/com/firstticket/queueservice/domain/QueueTokenTest.java +++ /dev/null @@ -1,227 +0,0 @@ -package com.firstticket.queueservice.domain; - -import com.firstticket.queueservice.domain.exception.InvalidTokenStateException; -import com.firstticket.queueservice.domain.exception.QueueErrorCode; -import com.firstticket.queueservice.domain.vo.ProgramId; -import com.firstticket.queueservice.domain.vo.UserId; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class QueueTokenTest { - - private static final String DUMMY_ENTRY_TOKEN = "dummy-entry-token"; - - private final UserId userId = UserId.of(UUID.randomUUID()); - private final ProgramId programId = ProgramId.of(UUID.randomUUID()); - - @Nested - @DisplayName("발급(issue)") - class Issue { - - @Test - @DisplayName("발급된 토큰의 초기 상태는 WAITING이다") - void 발급_시_WAITING_상태() { - QueueToken token = QueueToken.issue(userId, programId); - - assertThat(token.getStatus()).isEqualTo(TokenStatus.WAITING); - } - - @Test - @DisplayName("발급 시 entryToken은 null 이다") - void 발급_시_entryToken_null() { - QueueToken token = QueueToken.issue(userId, programId); - - assertThat(token.getEntryToken()).isNull(); - } - - @Test - @DisplayName("발급 시 ID가 자동 생성된다") - void 발급_시_ID_자동_생성() { - QueueToken token1 = QueueToken.issue(userId, programId); - QueueToken token2 = QueueToken.issue(userId, programId); - - assertThat(token1.getId()).isNotEqualTo(token2.getId()); - } - - @Test - @DisplayName("발급 시 IssuedAt이 현재 시각으로 설정된다") - void 발급_시_IssuedAt_설정() { - QueueToken token = QueueToken.issue(userId, programId); - - assertThat(token.getIssuedAt()).isNotNull(); - } - - @Test - @DisplayName("UserId가 null이면 예외가 발생한다") - void userId_null_예외() { - assertThatThrownBy(() -> QueueToken.issue(null, programId)) - .isInstanceOf(NullPointerException.class); - } - - @Test - @DisplayName("ProgramId가 null이면 예외가 발생한다") - void programId_null_예외() { - assertThatThrownBy(() -> QueueToken.issue(userId, null)) - .isInstanceOf(NullPointerException.class); - } - } - - @Nested - @DisplayName("입장 승인(admit)") - class Admit { - - @Test - @DisplayName("WAITING 상태에서 admit 호출 시 ADMITTED로 전이된다") - void waiting에서_admit_성공() { - QueueToken token = QueueToken.issue(userId, programId); - - token.admit(DUMMY_ENTRY_TOKEN); - - assertThat(token.getStatus()).isEqualTo(TokenStatus.ADMITTED); - } - - @Test - @DisplayName("admit 시 entryToken이 저장된다") - void admit_시_entryToken_저장() { - QueueToken token = QueueToken.issue(userId, programId); - - token.admit(DUMMY_ENTRY_TOKEN); - - assertThat(token.getEntryToken()).isEqualTo(DUMMY_ENTRY_TOKEN); - } - - @Test - @DisplayName("이미 ADMITTED 상태에서 admit 시 예외가 발생한다") - void admitted에서_admit_시_예외() { - QueueToken token = QueueToken.issue(userId, programId); - token.admit(DUMMY_ENTRY_TOKEN); - - assertThatThrownBy(() -> token.admit(DUMMY_ENTRY_TOKEN)) - .isInstanceOf(InvalidTokenStateException.class) - .extracting("errorCode") - .isEqualTo(QueueErrorCode.INVALID_TOKEN_STATE); - } - - @Test - @DisplayName("CANCELLED 상태에서 admit 시 예외가 발생한다") - void cancelled에서_admit_시_예외() { - QueueToken token = QueueToken.issue(userId, programId); - token.cancel(); - - assertThatThrownBy(() -> token.admit(DUMMY_ENTRY_TOKEN)) - .isInstanceOf(InvalidTokenStateException.class); - } - - @Test - @DisplayName("EXPIRED 상태에서 admit 시 예외가 발생한다") - void expired에서_admit_시_예외() { - QueueToken token = QueueToken.issue(userId, programId); - token.expire(); - - assertThatThrownBy(() -> token.admit(DUMMY_ENTRY_TOKEN)) - .isInstanceOf(InvalidTokenStateException.class); - } - } - - @Nested - @DisplayName("취소(cancel)") - class Cancel { - - @Test - @DisplayName("WAITING 상태에서 cancel 호출 시 CANCELLED로 전이된다") - void waiting에서_cancel_성공() { - QueueToken token = QueueToken.issue(userId, programId); - - token.cancel(); - - assertThat(token.getStatus()).isEqualTo(TokenStatus.CANCELLED); - } - - @Test - @DisplayName("ADMITTED 상태에서 cancel 시 예외가 발생한다") - void admitted에서_cancel_시_예외() { - QueueToken token = QueueToken.issue(userId, programId); - token.admit(DUMMY_ENTRY_TOKEN); - - assertThatThrownBy(token::cancel) - .isInstanceOf(InvalidTokenStateException.class); - } - } - - @Nested - @DisplayName("만료(expire)") - class Expire { - - @Test - @DisplayName("WAITING 상태에서 expire 호출 시 EXPIRED로 전이된다") - void waiting에서_expire_성공() { - QueueToken token = QueueToken.issue(userId, programId); - - token.expire(); - - assertThat(token.getStatus()).isEqualTo(TokenStatus.EXPIRED); - } - - @Test - @DisplayName("CANCELLED 상태에서 expire 시 예외가 발생한다") - void cancelled에서_expire_시_예외() { - QueueToken token = QueueToken.issue(userId, programId); - token.cancel(); - - assertThatThrownBy(token::expire) - .isInstanceOf(InvalidTokenStateException.class); - } - } - - @Nested - @DisplayName("복원(restore)") - class Restore { - - @Test - @DisplayName("ADMITTED 토큰을 entryToken과 함께 복원한다") - void ADMITTED_토큰_복원() { - QueueToken issued = QueueToken.issue(userId, programId); - issued.admit(DUMMY_ENTRY_TOKEN); - - QueueToken restored = QueueToken.restore( - issued.getId(), - issued.getUserId(), - issued.getProgramId(), - issued.getIssuedAt(), - issued.getStatus(), - issued.getEntryToken() - ); - - assertThat(restored.getId()).isEqualTo(issued.getId()); - assertThat(restored.getUserId()).isEqualTo(issued.getUserId()); - assertThat(restored.getProgramId()).isEqualTo(issued.getProgramId()); - assertThat(restored.getIssuedAt()).isEqualTo(issued.getIssuedAt()); - assertThat(restored.getStatus()).isEqualTo(TokenStatus.ADMITTED); - assertThat(restored.getEntryToken()).isEqualTo(DUMMY_ENTRY_TOKEN); - } - - @Test - @DisplayName("WAITING 토큰은 entryToken null 로 복원된다") - void WAITING_토큰_복원() { - QueueToken issued = QueueToken.issue(userId, programId); - - QueueToken restored = QueueToken.restore( - issued.getId(), - issued.getUserId(), - issued.getProgramId(), - issued.getIssuedAt(), - issued.getStatus(), - null - ); - - assertThat(restored.getStatus()).isEqualTo(TokenStatus.WAITING); - assertThat(restored.getEntryToken()).isNull(); - } - } -} diff --git a/src/test/java/com/firstticket/queueservice/infrastructure/redis/RedisQueueTokenRepositoryTest.java b/src/test/java/com/firstticket/queueservice/infrastructure/redis/RedisQueueTokenRepositoryTest.java deleted file mode 100644 index f5f410f..0000000 --- a/src/test/java/com/firstticket/queueservice/infrastructure/redis/RedisQueueTokenRepositoryTest.java +++ /dev/null @@ -1,470 +0,0 @@ -package com.firstticket.queueservice.infrastructure.redis; - -import com.firstticket.queueservice.domain.QueueToken; -import com.firstticket.queueservice.domain.TokenStatus; -import com.firstticket.queueservice.domain.exception.DuplicateTokenException; -import com.firstticket.queueservice.domain.vo.ProgramId; -import com.firstticket.queueservice.domain.vo.UserId; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.springframework.data.redis.core.RedisCallback; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.test.context.ActiveProfiles; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * RedisQueueTokenRepository 통합 테스트. - * - *

Testcontainers로 실제 Redis를 띄워 테스트한다. - * 매 테스트 후 flushAll로 데이터를 초기화하여 격리를 보장한다. - */ -@SpringBootTest -@ActiveProfiles("test") -@Testcontainers -class RedisQueueTokenRepositoryTest { - - @Container - @ServiceConnection(name = "redis") - @SuppressWarnings("resource") - static GenericContainer redis = new GenericContainer<>("redis:7-alpine") - .withExposedPorts(6379); - - @Autowired - private RedisQueueTokenRepository repository; - - @Autowired - private StringRedisTemplate redisTemplate; - - @AfterEach - void cleanUp() { - redisTemplate.execute((RedisCallback) connection -> { - connection.serverCommands().flushAll(); - return null; - }); - } - - @Nested - @DisplayName("enqueue") - class Enqueue { - - @Test - @DisplayName("새 토큰을 등록하면 조회할 수 있다") - void 새_토큰_등록() { - QueueToken token = newToken(); - - repository.enqueue(token); - - Optional found = repository.findById(token.getId()); - assertThat(found).isPresent(); - assertThat(found.get().getStatus()).isEqualTo(TokenStatus.WAITING); - assertThat(found.get().getUserId()).isEqualTo(token.getUserId()); - assertThat(found.get().getProgramId()).isEqualTo(token.getProgramId()); - } - - @Test - @DisplayName("같은 사용자가 같은 프로그램에 중복 진입 시 DuplicateTokenException 발생") - void 중복_진입_예외() { - UserId userId = UserId.of(UUID.randomUUID()); - ProgramId programId = ProgramId.of(UUID.randomUUID()); - - QueueToken first = QueueToken.issue(userId, programId); - repository.enqueue(first); - - QueueToken second = QueueToken.issue(userId, programId); - assertThatThrownBy(() -> repository.enqueue(second)) - .isInstanceOf(DuplicateTokenException.class); - } - - @Test - @DisplayName("같은 사용자라도 다른 프로그램이면 진입 가능") - void 다른_프로그램_진입() { - UserId userId = UserId.of(UUID.randomUUID()); - - QueueToken first = QueueToken.issue(userId, ProgramId.of(UUID.randomUUID())); - QueueToken second = QueueToken.issue(userId, ProgramId.of(UUID.randomUUID())); - - repository.enqueue(first); - repository.enqueue(second); - - assertThat(repository.findById(first.getId())).isPresent(); - assertThat(repository.findById(second.getId())).isPresent(); - } - } - - @Nested - @DisplayName("findById") - class FindById { - - @Test - @DisplayName("저장된 토큰의 모든 필드가 정확히 복원된다") - void 토큰_필드_복원() { - QueueToken token = newToken(); - repository.enqueue(token); - - QueueToken found = repository.findById(token.getId()).orElseThrow(); - - assertThat(found.getId()).isEqualTo(token.getId()); - assertThat(found.getUserId()).isEqualTo(token.getUserId()); - assertThat(found.getProgramId()).isEqualTo(token.getProgramId()); - assertThat(found.getStatus()).isEqualTo(token.getStatus()); - } - - @Test - @DisplayName("저장 안 된 ID는 빈 Optional 반환") - void 미저장_ID() { - Optional found = repository.findById( - com.firstticket.queueservice.domain.vo.QueueTokenId.of() - ); - - assertThat(found).isEmpty(); - } - } - - @Nested - @DisplayName("findByUserIdAndProgramId") - class FindByUserIdAndProgramId { - - @Test - @DisplayName("저장된 사용자 토큰을 조회할 수 있다") - void 저장된_토큰_조회() { - UserId userId = UserId.of(UUID.randomUUID()); - ProgramId programId = ProgramId.of(UUID.randomUUID()); - QueueToken token = QueueToken.issue(userId, programId); - repository.enqueue(token); - - Optional found = repository.findByUserIdAndProgramId(userId, programId); - - assertThat(found).isPresent(); - assertThat(found.get().getId()).isEqualTo(token.getId()); - } - - @Test - @DisplayName("저장 안 된 사용자는 빈 Optional 반환") - void 미저장_사용자() { - Optional found = repository.findByUserIdAndProgramId( - UserId.of(UUID.randomUUID()), - ProgramId.of(UUID.randomUUID()) - ); - - assertThat(found).isEmpty(); - } - } - - @Nested - @DisplayName("findRank") - class FindRank { - - @Test - @DisplayName("첫 번째 진입자의 순번은 1이다") - void 첫_번째_진입자() { - ProgramId programId = ProgramId.of(UUID.randomUUID()); - UserId userId = UserId.of(UUID.randomUUID()); - - QueueToken token = QueueToken.issue(userId, programId); - repository.enqueue(token); - - Optional rank = repository.findPosition(userId, programId); - - assertThat(rank).isPresent(); - assertThat(rank.get()).isEqualTo(1L); - } - - @Test - @DisplayName("두 번째 진입자의 순번은 2이다") - void 두_번째_진입자() throws InterruptedException { - ProgramId programId = ProgramId.of(UUID.randomUUID()); - - QueueToken first = QueueToken.issue(UserId.of(UUID.randomUUID()), programId); - repository.enqueue(first); - - // epoch milli 차이 보장 (Sorted Set score 충돌 방지) - Thread.sleep(2); - - UserId secondUser = UserId.of(UUID.randomUUID()); - QueueToken second = QueueToken.issue(secondUser, programId); - repository.enqueue(second); - - Optional rank = repository.findPosition(secondUser, programId); - - assertThat(rank).isPresent(); - assertThat(rank.get()).isEqualTo(2L); - } - - @Test - @DisplayName("미진입 사용자는 빈 Optional 반환") - void 미진입_사용자() { - Optional rank = repository.findPosition( - UserId.of(UUID.randomUUID()), - ProgramId.of(UUID.randomUUID()) - ); - - assertThat(rank).isEmpty(); - } - } - - @Nested - @DisplayName("delete") - class Delete { - - @Test - @DisplayName("삭제하면 모든 키에서 조회되지 않는다") - void 삭제_후_조회_불가() { - QueueToken token = newToken(); - repository.enqueue(token); - - repository.delete(token); - - assertThat(repository.findById(token.getId())).isEmpty(); - assertThat(repository.findByUserIdAndProgramId( - token.getUserId(), token.getProgramId())).isEmpty(); - assertThat(repository.findPosition( - token.getUserId(), token.getProgramId())).isEmpty(); - } - - @Test - @DisplayName("삭제 후 같은 사용자가 재진입 가능 (DuplicateTokenException X)") - void 삭제_후_재진입() { - UserId userId = UserId.of(UUID.randomUUID()); - ProgramId programId = ProgramId.of(UUID.randomUUID()); - - QueueToken first = QueueToken.issue(userId, programId); - repository.enqueue(first); - repository.delete(first); - - QueueToken second = QueueToken.issue(userId, programId); - repository.enqueue(second); // 예외 발생 안 해야 함 - - assertThat(repository.findByUserIdAndProgramId(userId, programId)) - .isPresent(); - } - - @Test - @DisplayName("이미 없는 토큰을 삭제해도 예외 없음 (멱등)") - void 멱등성() { - QueueToken token = newToken(); - - // enqueue 없이 바로 delete - repository.delete(token); // 예외 없어야 함 - } - - @Test - @DisplayName("역인덱스가 다른 토큰을 가리키면 역인덱스는 유지된다 (race 방어)") - void 역인덱스_compare_and_delete() { - UserId userId = UserId.of(UUID.randomUUID()); - ProgramId programId = ProgramId.of(UUID.randomUUID()); - - // 1. tokenA 발급 후 강제 삭제 (Sorted Set, Hash, 역인덱스 모두 사라짐) - QueueToken tokenA = QueueToken.issue(userId, programId); - repository.enqueue(tokenA); - - // 2. 같은 사용자가 tokenB 로 재진입 (역인덱스 = tokenB) - repository.delete(tokenA); - QueueToken tokenB = QueueToken.issue(userId, programId); - repository.enqueue(tokenB); - - // 3. 늦게 도착한 tokenA 의 delete (race 시뮬) - repository.delete(tokenA); - - // 4. tokenB 의 역인덱스는 그대로 — findByUserIdAndProgramId 로 tokenB 조회 가능 - Optional found = repository.findByUserIdAndProgramId(userId, programId); - assertThat(found).isPresent(); - assertThat(found.get().getId()).isEqualTo(tokenB.getId()); - } - } - - @Nested - @DisplayName("findAdmissionCandidates") - class FindAdmissionCandidates { - - @Test - @DisplayName("앞에서 batchSize명을 진입 시각 순으로 반환") - void 앞_N명_조회() throws InterruptedException { - ProgramId programId = ProgramId.of(UUID.randomUUID()); - - QueueToken first = QueueToken.issue(UserId.of(UUID.randomUUID()), programId); - repository.enqueue(first); - Thread.sleep(2); - - QueueToken second = QueueToken.issue(UserId.of(UUID.randomUUID()), programId); - repository.enqueue(second); - Thread.sleep(2); - - QueueToken third = QueueToken.issue(UserId.of(UUID.randomUUID()), programId); - repository.enqueue(third); - - List candidates = repository.findAdmissionCandidates(programId, 2); - - assertThat(candidates).hasSize(2); - assertThat(candidates.get(0).getId()).isEqualTo(first.getId()); - assertThat(candidates.get(1).getId()).isEqualTo(second.getId()); - } - - @Test - @DisplayName("토큰이 없으면 빈 리스트 반환") - void 빈_프로그램() { - List candidates = repository.findAdmissionCandidates( - ProgramId.of(UUID.randomUUID()), 10); - - assertThat(candidates).isEmpty(); - } - - @Test - @DisplayName("batchSize가 실제 인원보다 크면 있는 만큼 반환") - void 적은_인원() { - ProgramId programId = ProgramId.of(UUID.randomUUID()); - - QueueToken token = QueueToken.issue(UserId.of(UUID.randomUUID()), programId); - repository.enqueue(token); - - List candidates = repository.findAdmissionCandidates(programId, 100); - - assertThat(candidates).hasSize(1); - } - } - - @Nested - @DisplayName("admit") - class Admit { - - @Test - @DisplayName("admit 후 Sorted Set 에서 제거되어 position 조회되지 않는다") - void admit_후_position_없음() { - // given - UserId userId = UserId.of(UUID.randomUUID()); - ProgramId programId = ProgramId.of(UUID.randomUUID()); - QueueToken token = QueueToken.issue(userId, programId); - repository.enqueue(token); - - // when - token.admit("dummy-jwt-token"); - repository.admit(token); - - // then - Optional position = repository.findPosition(userId, programId); - assertThat(position).isEmpty(); - } - - @Test - @DisplayName("admit 후 status 와 entryToken 이 영속된다") - void admit_후_status_entryToken_저장() { - // given - UserId userId = UserId.of(UUID.randomUUID()); - ProgramId programId = ProgramId.of(UUID.randomUUID()); - QueueToken token = QueueToken.issue(userId, programId); - repository.enqueue(token); - - // when - String entryToken = "dummy-jwt-token"; - token.admit(entryToken); - repository.admit(token); - - // then - Optional found = repository.findById(token.getId()); - assertThat(found).isPresent(); - assertThat(found.get().getStatus()).isEqualTo(TokenStatus.ADMITTED); - assertThat(found.get().getEntryToken()).isEqualTo(entryToken); - } - - @Test - @DisplayName("admit 후에도 사용자가 자기 토큰 조회 가능 (역인덱스 유지)") - void admit_후_사용자_조회_가능() { - // given - UserId userId = UserId.of(UUID.randomUUID()); - ProgramId programId = ProgramId.of(UUID.randomUUID()); - QueueToken token = QueueToken.issue(userId, programId); - repository.enqueue(token); - - // when - token.admit("dummy-jwt-token"); - repository.admit(token); - - // then - Optional found = repository.findByUserIdAndProgramId(userId, programId); - assertThat(found).isPresent(); - assertThat(found.get().getStatus()).isEqualTo(TokenStatus.ADMITTED); - } - } - - @Nested - @DisplayName("findActiveProgramIds") - class FindActiveProgramIds { - - @Test - @DisplayName("큐가 없으면 빈 리스트 반환") - void 큐_없음_빈_리스트() { - List result = repository.findActiveProgramIds(); - - assertThat(result).isEmpty(); - } - - @Test - @DisplayName("한 프로그램에 토큰이 있으면 그 프로그램 ID 1 개 반환") - void 한_프로그램_1_개() { - // given - UserId userId = UserId.of(UUID.randomUUID()); - ProgramId programId = ProgramId.of(UUID.randomUUID()); - repository.enqueue(QueueToken.issue(userId, programId)); - - // when - List result = repository.findActiveProgramIds(); - - // then - assertThat(result).containsExactly(programId); - } - - @Test - @DisplayName("여러 프로그램에 토큰이 있으면 모든 프로그램 ID 반환") - void 여러_프로그램_모두() { - // given - ProgramId program1 = ProgramId.of(UUID.randomUUID()); - ProgramId program2 = ProgramId.of(UUID.randomUUID()); - ProgramId program3 = ProgramId.of(UUID.randomUUID()); - repository.enqueue(QueueToken.issue(UserId.of(UUID.randomUUID()), program1)); - repository.enqueue(QueueToken.issue(UserId.of(UUID.randomUUID()), program2)); - repository.enqueue(QueueToken.issue(UserId.of(UUID.randomUUID()), program3)); - - // when - List result = repository.findActiveProgramIds(); - - // then - assertThat(result).containsExactlyInAnyOrder(program1, program2, program3); - } - - @Test - @DisplayName("같은 프로그램에 여러 토큰이 있어도 프로그램 ID 1 개만 반환") - void 같은_프로그램_중복_X() { - // given - ProgramId programId = ProgramId.of(UUID.randomUUID()); - repository.enqueue(QueueToken.issue(UserId.of(UUID.randomUUID()), programId)); - repository.enqueue(QueueToken.issue(UserId.of(UUID.randomUUID()), programId)); - repository.enqueue(QueueToken.issue(UserId.of(UUID.randomUUID()), programId)); - - // when - List result = repository.findActiveProgramIds(); - - // then - assertThat(result).containsExactly(programId); - } - } - - private QueueToken newToken() { - return QueueToken.issue( - UserId.of(UUID.randomUUID()), - ProgramId.of(UUID.randomUUID()) - ); - } -} diff --git a/src/test/java/com/firstticket/queueservice/presentation/QueueTokenControllerTest.java b/src/test/java/com/firstticket/queueservice/presentation/QueueTokenControllerTest.java deleted file mode 100644 index f194ae9..0000000 --- a/src/test/java/com/firstticket/queueservice/presentation/QueueTokenControllerTest.java +++ /dev/null @@ -1,404 +0,0 @@ -package com.firstticket.queueservice.presentation; - -import com.firstticket.common.exception.BusinessException; -import com.firstticket.common.exception.GlobalExceptionHandler; -import com.firstticket.common.response.CommonErrorCode; -import com.firstticket.common.web.AuthContext; -import com.firstticket.queueservice.application.QueueTokenService; -import com.firstticket.queueservice.application.dto.QueueTokenResult; -import com.firstticket.queueservice.domain.QueueToken; -import com.firstticket.queueservice.domain.exception.DuplicateTokenException; -import com.firstticket.queueservice.domain.exception.InvalidTokenStateException; -import com.firstticket.queueservice.domain.exception.TokenNotFoundException; -import com.firstticket.queueservice.domain.vo.ProgramId; -import com.firstticket.queueservice.domain.vo.UserId; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -import java.util.UUID; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; -import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; -import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyHeaders; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; -import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; -import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -/** - * 대기열 API 통합 테스트. - * - *

WebMvcTest 슬라이스로 Controller 만 로드하고 Service 는 mock 한다. - * 테스트 통과 시 REST Docs snippet 이 자동 생성되며, - * AsciiDoc 빌드를 거쳐 build/docs/asciidoc/index.html 로 문서화된다. - * - *

인증 처리: - *

    - *
  • 실제 운영 환경에선 Gateway 가 Authorization Bearer 토큰 검증 후 - * 사용자 ID 를 Filter 통해 ThreadLocal (AuthContext) 에 주입한다.
  • - *
  • 테스트에선 AuthContext.getUserId() 를 mockStatic 으로 직접 mock 하므로 - * 실제 헤더는 의미 없다. 단, REST Docs 문서화를 위해 외부 클라이언트 - * 시각의 Authorization 헤더를 dummy 값으로 보낸다.
  • - *
- * - *

주요 검증: - *

    - *
  • HTTP 메서드별 정상 동작 (POST 201, GET 200, DELETE 200)
  • - *
  • 인증 실패 시 401
  • - *
  • 도메인 예외 → HTTP status 매핑 (404, 400, 409)
  • - *
- */ -@WebMvcTest(QueueTokenController.class) -@AutoConfigureRestDocs -@ActiveProfiles("test") -@Import(GlobalExceptionHandler.class) -class QueueTokenControllerTest { - - private static final String AUTHORIZATION_HEADER = "Authorization"; - private static final String DUMMY_BEARER_TOKEN = "Bearer dummy-access-token"; - - @Autowired - private MockMvc mockMvc; - - @MockitoBean - private QueueTokenService queueTokenService; - - // ===== 성공 케이스 ===== - - @Test - @DisplayName("대기열 진입 성공") - void 대기열_진입_성공() throws Exception { - // given - UUID userId = UUID.randomUUID(); - UUID programId = UUID.randomUUID(); - QueueToken token = QueueToken.issue(UserId.of(userId), ProgramId.of(programId)); - QueueTokenResult result = QueueTokenResult.of(token, 1L); - - when(queueTokenService.issueToken(any())).thenReturn(result); - - try (MockedStatic mocked = mockStatic(AuthContext.class)) { - mocked.when(AuthContext::getUserId).thenReturn(userId); - - // when & then - mockMvc.perform(post("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isCreated()) - .andDo(document("queue-token-issue", - preprocessRequest( - prettyPrint(), - modifyHeaders().remove("Content-Type") - ), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("programId").description("프로그램 ID") - ), - requestHeaders( - headerWithName("Authorization") - .description("Bearer access token (Keycloak 발급)") - ), - responseFields( - fieldWithPath("success").description("요청 성공 여부"), - fieldWithPath("code").description("응답 코드"), - fieldWithPath("message").description("응답 메시지"), - fieldWithPath("timestamp").description("응답 시각"), - fieldWithPath("data.tokenId").description("발급된 토큰 ID"), - fieldWithPath("data.status").description("토큰 상태 (WAITING)"), - fieldWithPath("data.issuedAt").description("발급 시각"), - fieldWithPath("data.position").description("현재 순번") - ) - )); - } - } - - @Test - @DisplayName("대기 정보 조회 성공") - void 대기_정보_조회_성공() throws Exception { - // given - UUID userId = UUID.randomUUID(); - UUID programId = UUID.randomUUID(); - QueueToken token = QueueToken.issue(UserId.of(userId), ProgramId.of(programId)); - QueueTokenResult result = QueueTokenResult.of(token, 50L); - - when(queueTokenService.getToken(any())).thenReturn(result); - - try (MockedStatic mocked = mockStatic(AuthContext.class)) { - mocked.when(AuthContext::getUserId).thenReturn(userId); - - // when & then - mockMvc.perform(get("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isOk()) - .andDo(document("queue-token-get", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("programId").description("프로그램 ID") - ), - requestHeaders( - headerWithName("Authorization") - .description("Bearer access token (Keycloak 발급)") - ), - responseFields( - fieldWithPath("success").description("요청 성공 여부"), - fieldWithPath("code").description("응답 코드"), - fieldWithPath("message").description("응답 메시지"), - fieldWithPath("timestamp").description("응답 시각"), - fieldWithPath("data.tokenId").description("토큰 ID"), - fieldWithPath("data.status").description("토큰 상태 (WAITING / ADMITTED / EXPIRED)"), - fieldWithPath("data.issuedAt").description("발급 시각"), - fieldWithPath("data.position").description("현재 순번. ADMITTED 등 큐에서 빠진 상태면 null").optional() - ) - )); - } - } - - @Test - @DisplayName("대기 취소 성공") - void 대기_취소_성공() throws Exception { - // given - UUID userId = UUID.randomUUID(); - UUID programId = UUID.randomUUID(); - - doNothing().when(queueTokenService).cancelToken(any()); - - try (MockedStatic mocked = mockStatic(AuthContext.class)) { - mocked.when(AuthContext::getUserId).thenReturn(userId); - - // when & then - mockMvc.perform(delete("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isOk()) - .andDo(document("queue-token-cancel", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("programId").description("프로그램 ID") - ), - requestHeaders( - headerWithName("Authorization") - .description("Bearer access token (Keycloak 발급)") - ), - responseFields( - fieldWithPath("success").description("요청 성공 여부"), - fieldWithPath("code").description("응답 코드 (QUEUE_TOKEN_CANCELLED)"), - fieldWithPath("message").description("응답 메시지"), - fieldWithPath("timestamp").description("응답 시각") - ) - )); - } - } - - // ===== 에러 케이스 ===== - - @Test - @DisplayName("인증 실패 시 401 Unauthorized") - void 인증_실패_401() throws Exception { - // given - UUID programId = UUID.randomUUID(); - - try (MockedStatic mocked = mockStatic(AuthContext.class)) { - mocked.when(AuthContext::getUserId) - .thenThrow(new BusinessException(CommonErrorCode.UNAUTHORIZED)); - - // when & then - mockMvc.perform(post("/api/v1/queues/programs/{programId}", programId)) - .andExpect(status().isUnauthorized()) - .andDo(document("queue-token-unauthorized", - preprocessRequest( - prettyPrint(), - modifyHeaders().remove("Content-Type") - ), - preprocessResponse(prettyPrint()), - responseFields( - fieldWithPath("success").description("요청 성공 여부 (false)"), - fieldWithPath("code").description("에러 코드 (UNAUTHORIZED)"), - fieldWithPath("message").description("에러 메시지"), - fieldWithPath("timestamp").description("응답 시각") - ) - )); - } - } - - @Test - @DisplayName("동시 진입 시 race — 409 Conflict") - void 중복_진입_409() throws Exception { - // given - UUID userId = UUID.randomUUID(); - UUID programId = UUID.randomUUID(); - - when(queueTokenService.issueToken(any())).thenThrow(new DuplicateTokenException()); - - try (MockedStatic mocked = mockStatic(AuthContext.class)) { - mocked.when(AuthContext::getUserId).thenReturn(userId); - - mockMvc.perform(post("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isConflict()) - .andDo(document("queue-token-duplicate", - preprocessRequest( - prettyPrint(), - modifyHeaders().remove("Content-Type") - ), - preprocessResponse(prettyPrint()), - responseFields( - fieldWithPath("success").description("요청 성공 여부 (false)"), - fieldWithPath("code").description("에러 코드 (DUPLICATE_TOKEN)"), - fieldWithPath("message").description("에러 메시지"), - fieldWithPath("timestamp").description("응답 시각") - ) - )); - } - } - - @Test - @DisplayName("토큰 없음 — 조회 시 404") - void 토큰_없음_조회_404() throws Exception { - // given - UUID userId = UUID.randomUUID(); - UUID programId = UUID.randomUUID(); - - when(queueTokenService.getToken(any())).thenThrow(new TokenNotFoundException()); - - try (MockedStatic mocked = mockStatic(AuthContext.class)) { - mocked.when(AuthContext::getUserId).thenReturn(userId); - - // when & then - mockMvc.perform(get("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isNotFound()) - .andDo(document("queue-token-get-not-found", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - responseFields( - fieldWithPath("success").description("요청 성공 여부 (false)"), - fieldWithPath("code").description("에러 코드 (TOKEN_NOT_FOUND)"), - fieldWithPath("message").description("에러 메시지"), - fieldWithPath("timestamp").description("응답 시각") - ) - )); - } - } - - @Test - @DisplayName("토큰 없음 — 취소 시 404") - void 토큰_없음_취소_404() throws Exception { - // given - UUID userId = UUID.randomUUID(); - UUID programId = UUID.randomUUID(); - - doThrow(new TokenNotFoundException()).when(queueTokenService).cancelToken(any()); - - try (MockedStatic mocked = mockStatic(AuthContext.class)) { - mocked.when(AuthContext::getUserId).thenReturn(userId); - - // when & then - mockMvc.perform(delete("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isNotFound()) - .andDo(document("queue-token-cancel-not-found", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - responseFields( - fieldWithPath("success").description("요청 성공 여부 (false)"), - fieldWithPath("code").description("에러 코드 (TOKEN_NOT_FOUND)"), - fieldWithPath("message").description("에러 메시지"), - fieldWithPath("timestamp").description("응답 시각") - ) - )); - } - } - - @Test - @DisplayName("WAITING 이 아닌 상태 취소 시도 — 400") - void 취소_불가_상태_400() throws Exception { - // given - UUID userId = UUID.randomUUID(); - UUID programId = UUID.randomUUID(); - - doThrow(new InvalidTokenStateException()).when(queueTokenService).cancelToken(any()); - - try (MockedStatic mocked = mockStatic(AuthContext.class)) { - mocked.when(AuthContext::getUserId).thenReturn(userId); - - // when & then - mockMvc.perform(delete("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isBadRequest()) - .andDo(document("queue-token-cancel-invalid-state", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - responseFields( - fieldWithPath("success").description("요청 성공 여부 (false)"), - fieldWithPath("code").description("에러 코드 (INVALID_TOKEN_STATE)"), - fieldWithPath("message").description("에러 메시지"), - fieldWithPath("timestamp").description("응답 시각") - ) - )); - } - } - - @Test - @DisplayName("ADMITTED 상태 토큰 조회 시 entryToken 응답에 포함") - void ADMITTED_조회_entryToken_포함() throws Exception { - // given - UUID userId = UUID.randomUUID(); - UUID programId = UUID.randomUUID(); - QueueToken token = QueueToken.issue(UserId.of(userId), ProgramId.of(programId)); - String entryToken = "eyJhbGc.dummy.jwt"; - token.admit(entryToken); - QueueTokenResult result = QueueTokenResult.of(token, null); // position null - - when(queueTokenService.getToken(any())).thenReturn(result); - - try (MockedStatic mocked = mockStatic(AuthContext.class)) { - mocked.when(AuthContext::getUserId).thenReturn(userId); - - // when & then - mockMvc.perform(get("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isOk()) - .andDo(document("queue-token-get-admitted", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("programId").description("프로그램 ID") - ), - requestHeaders( - headerWithName("Authorization") - .description("Bearer access token (Keycloak 발급)") - ), - responseFields( - fieldWithPath("success").description("요청 성공 여부"), - fieldWithPath("code").description("응답 코드"), - fieldWithPath("message").description("응답 메시지"), - fieldWithPath("timestamp").description("응답 시각"), - fieldWithPath("data.tokenId").description("토큰 ID"), - fieldWithPath("data.status").description("토큰 상태 (ADMITTED)"), - fieldWithPath("data.issuedAt").description("발급 시각"), - fieldWithPath("data.entryToken").description("입장 토큰 (JWT) — ADMITTED 상태일 때만 포함") - ) - )); - } - } -} From 964412327506ea41c4e26bcf292a97fd4d7e81a7 Mon Sep 17 00:00:00 2001 From: rlaxxwls13 Date: Sun, 17 May 2026 17:33:49 +0900 Subject: [PATCH 4/9] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EC=A0=95=EC=A0=95=20(testcontainers=20+?= =?UTF-8?q?=20Kafka=20=EB=8D=94=EB=AF=B8=20=EC=84=A4=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to #27 --- build.gradle | 1 + src/test/resources/application-test.yml | 34 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/build.gradle b/build.gradle index d68b443..9c7161f 100644 --- a/build.gradle +++ b/build.gradle @@ -112,6 +112,7 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.testcontainers:postgresql' testImplementation 'org.springframework.boot:spring-boot-testcontainers' testImplementation 'org.testcontainers:junit-jupiter' testImplementation 'org.springframework.kafka:spring-kafka-test' diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 31248bb..eddcdc6 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -5,10 +5,44 @@ spring: config: import: "" + datasource: + url: jdbc:tc:postgresql:16-alpine:///testdb + driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver + + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + default_schema: queue_schema + open-in-view: false + + flyway: + enabled: false + + kafka: + consumer: + auto-startup: false + group-id: test-group + listener: + missing-topics-fatal: false + eureka: client: enabled: false +# Outbox 스케줄러 비활성 (운영과 일관) +common: + messaging: + scheduler: + enabled: false + +kafka: + topics: + program-created: program.created + program-time-updated: program.time.updated + program-cancelled: program.cancelled + queue: token: waiting-ttl: PT30M From b95195bc55018ec93596be883ed3de7be6777695 Mon Sep 17 00:00:00 2001 From: rlaxxwls13 Date: Sun, 17 May 2026 18:36:19 +0900 Subject: [PATCH 5/9] =?UTF-8?q?chore:=20common=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=98=EC=98=81=20-=20inbox=EB=A7=8C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20jpaConfig=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to: #27 --- .../java/com/firstticket/queueservice/config/JpaConfig.java | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/main/java/com/firstticket/queueservice/config/JpaConfig.java diff --git a/src/main/java/com/firstticket/queueservice/config/JpaConfig.java b/src/main/java/com/firstticket/queueservice/config/JpaConfig.java new file mode 100644 index 0000000..576e3d7 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/config/JpaConfig.java @@ -0,0 +1,4 @@ +package com.firstticket.queueservice.config; + +public class JpaConfig { +} From c17d8e19b9f07bd09fd209703c1a2accf03bd897 Mon Sep 17 00:00:00 2001 From: rlaxxwls13 Date: Sun, 17 May 2026 21:23:43 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=EB=B0=98=EC=98=81=20-handleCreated=20?= =?UTF-8?q?=EC=9D=98=20=EC=B7=A8=EC=86=8C=20=ED=9A=8C=EA=B7=80=20=EA=B0=80?= =?UTF-8?q?=EB=93=9C=20-ProgramMeta=20=EB=B6=88=EB=B3=80=EC=8B=9D=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to #27 --- build.gradle | 4 ++-- .../queueservice/config/JpaConfig.java | 13 ++++++++++++ .../application/ProgramMetaService.java | 20 ++++++++++++------- .../programmeta/domain/ProgramMeta.java | 11 ++++++++++ 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/build.gradle b/build.gradle index 9c7161f..117560c 100644 --- a/build.gradle +++ b/build.gradle @@ -71,8 +71,8 @@ ext { dependencies { // 공통 모듈 implementation 'com.first-ticket:common:0.0.4-SNAPSHOT' - implementation 'com.first-ticket:common-jpa:0.0.1-SNAPSHOT' - implementation 'com.first-ticket:common-messaging:0.0.1-SNAPSHOT' + implementation 'com.first-ticket:common-jpa:0.0.2-SNAPSHOT' + implementation 'com.first-ticket:common-messaging:0.0.2-SNAPSHOT' // Spring Boot implementation 'org.springframework.boot:spring-boot-starter-actuator' diff --git a/src/main/java/com/firstticket/queueservice/config/JpaConfig.java b/src/main/java/com/firstticket/queueservice/config/JpaConfig.java index 576e3d7..6b50027 100644 --- a/src/main/java/com/firstticket/queueservice/config/JpaConfig.java +++ b/src/main/java/com/firstticket/queueservice/config/JpaConfig.java @@ -1,4 +1,17 @@ package com.firstticket.queueservice.config; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@EntityScan(basePackages = { + "com.firstticket.queueservice", + "com.firstticket.common.messaging.inbox" +}) +@EnableJpaRepositories(basePackages = { + "com.firstticket.queueservice", + "com.firstticket.common.messaging.inbox" +}) public class JpaConfig { } diff --git a/src/main/java/com/firstticket/queueservice/programmeta/application/ProgramMetaService.java b/src/main/java/com/firstticket/queueservice/programmeta/application/ProgramMetaService.java index a6ae847..4756952 100644 --- a/src/main/java/com/firstticket/queueservice/programmeta/application/ProgramMetaService.java +++ b/src/main/java/com/firstticket/queueservice/programmeta/application/ProgramMetaService.java @@ -29,13 +29,19 @@ public class ProgramMetaService { public void handleCreated(CreateProgramMetaCommand command) { log.info("Program created. programId={}, status={}", command.programId(), command.status()); - ProgramMeta programMeta = ProgramMeta.of( - command.programId(), - command.openAt(), - command.closeAt(), - command.status() - ); - programMetaRepository.save(programMeta); + programMetaRepository.findById(command.programId()) + .ifPresentOrElse( + existing -> log.info("ProgramMeta already exists. skip created. programId={}", command.programId()), + () -> { + ProgramMeta programMeta = ProgramMeta.of( + command.programId(), + command.openAt(), + command.closeAt(), + command.status() + ); + programMetaRepository.save(programMeta); + } + ); } /** diff --git a/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMeta.java b/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMeta.java index e109e3a..f43a925 100644 --- a/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMeta.java +++ b/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramMeta.java @@ -7,6 +7,7 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.Objects; /** * Program 의 메타 정보 (Aggregate Root). @@ -35,6 +36,9 @@ public class ProgramMeta { */ public static ProgramMeta of(ProgramId programId, LocalDateTime openAt, LocalDateTime closeAt, ProgramStatus status) { + Objects.requireNonNull(programId, "programId는 null일 수 없습니다."); + Objects.requireNonNull(status, "status는 null일 수 없습니다."); + validateSchedule(openAt, closeAt); return new ProgramMeta( programId, openAt, @@ -47,10 +51,17 @@ public static ProgramMeta of(ProgramId programId, LocalDateTime openAt, * 스케줄 갱신. program.time.updated 이벤트 처리 시 호출. */ public void updateTime(LocalDateTime newOpenAt, LocalDateTime newCloseAt) { + validateSchedule(newOpenAt, newCloseAt); this.openAt = newOpenAt; this.closeAt = newCloseAt; } + private static void validateSchedule(LocalDateTime openAt, LocalDateTime closeAt) { + if (openAt != null && closeAt != null && openAt.isAfter(closeAt)) { + throw new IllegalArgumentException("openAt은 closeAt보다 늦을 수 없습니다"); + } + } + /** * 프로그램 취소 처리. program.cancelled 이벤트 처리 시 호출. */ From 67cd7289302dcbb4486244913b430ff8ea4203ee Mon Sep 17 00:00:00 2001 From: rlaxxwls13 Date: Sun, 17 May 2026 22:05:56 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 역직렬화 예외 처리 범위 확대 - TypeReference>을 사용하여 제네릭 타입 정보 보존 Related to #27 --- .../infrastructure/redis/RedisProgramMetaRepository.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.java b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.java index c8e27fd..c1a2b1f 100644 --- a/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.java +++ b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.java @@ -1,6 +1,7 @@ package com.firstticket.queueservice.programmeta.infrastructure.redis; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.firstticket.queueservice.programmeta.domain.ProgramMeta; import com.firstticket.queueservice.programmeta.domain.ProgramMetaRepository; @@ -130,14 +131,17 @@ private String buildKey(ProgramId programId) { */ private ProgramMeta deserialize(String json) { try { - Map data = objectMapper.readValue(json, Map.class); + Map data = objectMapper.readValue( + json, + new TypeReference>() {} + ); return ProgramMeta.of( ProgramId.of(UUID.fromString(data.get(FIELD_PROGRAM_ID))), parseDateTime(data.get(FIELD_OPEN_AT)), parseDateTime(data.get(FIELD_CLOSE_AT)), ProgramStatus.valueOf(data.get(FIELD_STATUS)) ); - } catch (JsonProcessingException e) { + } catch (Exception e) { throw new IllegalStateException("ProgramMeta 역직렬화 실패", e); } } From 3153b68143b2d6d8c73fd0a2ea8f8fc97b153183 Mon Sep 17 00:00:00 2001 From: rlaxxwls13 Date: Sun, 17 May 2026 22:20:56 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - application-test.yml 카프카 비활성화 설정 수정 - Status 정규화 로직 추가 Related to #27 --- .../application/dto/CancelProgramCommand.java | 2 +- .../dto/CreateProgramMetaCommand.java | 2 +- .../programmeta/domain/ProgramStatus.java | 22 ++++++++++++++++++- .../redis/RedisProgramMetaRepository.java | 2 +- src/test/resources/application-test.yml | 2 +- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/firstticket/queueservice/programmeta/application/dto/CancelProgramCommand.java b/src/main/java/com/firstticket/queueservice/programmeta/application/dto/CancelProgramCommand.java index 7a6501c..7d22890 100644 --- a/src/main/java/com/firstticket/queueservice/programmeta/application/dto/CancelProgramCommand.java +++ b/src/main/java/com/firstticket/queueservice/programmeta/application/dto/CancelProgramCommand.java @@ -15,7 +15,7 @@ public record CancelProgramCommand( public static CancelProgramCommand of(UUID programId, String status) { return new CancelProgramCommand( ProgramId.of(programId), - ProgramStatus.valueOf(status) + ProgramStatus.parse(status) ); } } diff --git a/src/main/java/com/firstticket/queueservice/programmeta/application/dto/CreateProgramMetaCommand.java b/src/main/java/com/firstticket/queueservice/programmeta/application/dto/CreateProgramMetaCommand.java index a397a30..4b83d4f 100644 --- a/src/main/java/com/firstticket/queueservice/programmeta/application/dto/CreateProgramMetaCommand.java +++ b/src/main/java/com/firstticket/queueservice/programmeta/application/dto/CreateProgramMetaCommand.java @@ -28,7 +28,7 @@ public static CreateProgramMetaCommand of( ProgramId.of(programId), openAt, closeAt, - ProgramStatus.valueOf(status) + ProgramStatus.parse(status) ); } } diff --git a/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramStatus.java b/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramStatus.java index 82a11c7..ea8e7ac 100644 --- a/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramStatus.java +++ b/src/main/java/com/firstticket/queueservice/programmeta/domain/ProgramStatus.java @@ -1,5 +1,8 @@ package com.firstticket.queueservice.programmeta.domain; +import java.util.Locale; +import java.util.Objects; + /** * Program 의 생명주기 상태. * program-service 의 이벤트로 갱신되며, queue-service 는 이 상태를 캐시한다. @@ -19,5 +22,22 @@ public enum ProgramStatus { * 프로그램이 취소된 상태. * 모든 대기 토큰을 정리하고 신규 토큰 발급을 거부한다. */ - CANCELLED + CANCELLED; + + /** + * 문자열을 ProgramStatus 로 변환. + * 외부 (Kafka 페이로드 등) 에서 받은 문자열의 포맷 편차를 정정한다 + * (공백 / 대소문자 정규화 후 변환). + * + * @throws NullPointerException value 가 null 일 때 + * @throws IllegalArgumentException 지원하지 않는 status 일 때 + */ + public static ProgramStatus parse(String value) { + Objects.requireNonNull(value, "status는 null일 수 없습니다"); + try { + return ProgramStatus.valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("지원하지 않는 program status: " + value, e); + } + } } diff --git a/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.java b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.java index c1a2b1f..32f6ae2 100644 --- a/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.java +++ b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/redis/RedisProgramMetaRepository.java @@ -139,7 +139,7 @@ private ProgramMeta deserialize(String json) { ProgramId.of(UUID.fromString(data.get(FIELD_PROGRAM_ID))), parseDateTime(data.get(FIELD_OPEN_AT)), parseDateTime(data.get(FIELD_CLOSE_AT)), - ProgramStatus.valueOf(data.get(FIELD_STATUS)) + ProgramStatus.parse(data.get(FIELD_STATUS)) ); } catch (Exception e) { throw new IllegalStateException("ProgramMeta 역직렬화 실패", e); diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index eddcdc6..2a12fb7 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -22,9 +22,9 @@ spring: kafka: consumer: - auto-startup: false group-id: test-group listener: + auto-startup: false missing-topics-fatal: false eureka: From 0a9fdf34bdacb12046ce15096ab2071ad7679cb7 Mon Sep 17 00:00:00 2001 From: rlaxxwls13 Date: Sun, 17 May 2026 22:48:56 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:Kafka=20Consumer=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드래빗 리뷰 반영 - ProgramKafkaConsumer: 재시도 가능/불가능 예외 분리 (poison message 방어) Related to #27 --- .../messaging/ProgramKafkaConsumer.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/ProgramKafkaConsumer.java b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/ProgramKafkaConsumer.java index 1c3d761..9fd5d65 100644 --- a/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/ProgramKafkaConsumer.java +++ b/src/main/java/com/firstticket/queueservice/programmeta/infrastructure/messaging/ProgramKafkaConsumer.java @@ -1,5 +1,6 @@ package com.firstticket.queueservice.programmeta.infrastructure.messaging; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.firstticket.queueservice.programmeta.application.ProgramMetaService; import com.firstticket.queueservice.programmeta.application.dto.CancelProgramCommand; @@ -42,7 +43,13 @@ public void onProgramCreated(ConsumerRecord record, Acknowledgme programMetaService.handleCreated(command); ack.acknowledge(); + } catch (JsonProcessingException | IllegalArgumentException e) { + // 메시지 자체가 잘못됨 → 건너뜀 (재시도해도 같은 결과) + log.error("잘못된 메시지. 건너뜀. topic={}, key={}, value={}", + record.topic(), record.key(), record.value(), e); + ack.acknowledge(); } catch (Exception e) { + // 일시 장애 → 재전송 기다림 log.error("program.created 처리 실패. record={}", record, e); } } @@ -59,6 +66,11 @@ public void onProgramTimeUpdated(ConsumerRecord record, Acknowle programMetaService.handleTimeUpdated(command); ack.acknowledge(); + } catch (JsonProcessingException | IllegalArgumentException e) { + log.error("잘못된 메시지. 건너뜀. topic={}, key={}, value={}", + record.topic(), record.key(), record.value(), e); + ack.acknowledge(); + } catch (Exception e) { log.error("program.time.updated 처리 실패. record={}", record, e); } @@ -76,6 +88,11 @@ public void onProgramCancelled(ConsumerRecord record, Acknowledg programMetaService.handleCancelled(command); ack.acknowledge(); + } catch (JsonProcessingException | IllegalArgumentException e) { + log.error("잘못된 메시지. 건너뜀. topic={}, key={}, value={}", + record.topic(), record.key(), record.value(), e); + ack.acknowledge(); + } catch (Exception e) { log.error("program.cancelled 처리 실패. record={}", record, e); }