diff --git a/.gitignore b/.gitignore index 4ff88f9..f7a7c5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ HELP.md +README.md +.git +.gitignore .gradle +build build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ @@ -22,6 +26,7 @@ bin/ *.iws *.iml *.ipr +out out/ !**/src/main/**/out/ !**/src/test/**/out/ @@ -35,4 +40,5 @@ out/ /.nb-gradle/ ### VS Code ### +.vscode .vscode/ diff --git a/Dockerfile b/Dockerfile index d55aedb..458d952 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,23 @@ -FROM eclipse-temurin:17-jdk AS build +FROM gradle:8.12-jdk17 AS build + WORKDIR /app ARG GPR_USER ARG GPR_TOKEN -COPY gradlew . -COPY gradle gradle -COPY build.gradle . -COPY settings.gradle . - -RUN chmod +x gradlew +COPY build.gradle settings.gradle ./ +COPY gradle ./gradle -RUN ./gradlew dependencies --no-daemon \ - -PGPR_USER=${GPR_USER} \ - -PGPR_TOKEN=${GPR_TOKEN} || true +RUN GPR_USER=${GPR_USER} GPR_TOKEN=${GPR_TOKEN} gradle dependencies --no-daemon || true -COPY src src +COPY src ./src -RUN ./gradlew bootJar -x test --no-daemon \ - -PGPR_USER=${GPR_USER} \ - -PGPR_TOKEN=${GPR_TOKEN} +RUN GPR_USER=${GPR_USER} GPR_TOKEN=${GPR_TOKEN} gradle bootJar --no-daemon -x test FROM eclipse-temurin:17-jre + WORKDIR /app + COPY --from=build /app/build/libs/*.jar app.jar + ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/postgres/init.sql b/postgres/init.sql index 8b4884c..88ecec0 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -1 +1 @@ -CREATE SCHEMA IF NOT EXISTS queue; \ No newline at end of file +CREATE SCHEMA IF NOT EXISTS queue_service; \ No newline at end of file diff --git a/src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java b/src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java new file mode 100644 index 0000000..d6b5148 --- /dev/null +++ b/src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java @@ -0,0 +1,50 @@ +package org.ticketing.queue.application.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.ticketing.queue.domain.exception.UnauthorizedClubAdminException; +import org.ticketing.queue.infrastructure.feign.ClubFeignClient; +import org.ticketing.queue.infrastructure.feign.MatchFeignClient; +import org.ticketing.queue.infrastructure.feign.response.MatchResponse; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MatchAuthorizationService { + + private final MatchFeignClient matchFeignClient; + private final ClubFeignClient clubFeignClient; + + private static final String SERVICE_NAME = "queue-service"; + + public void validateClubAdmin(UUID matchId, UUID requestUserId) { + // 1. 경기 정보 조회 + MatchResponse match = matchFeignClient.getMatch(matchId, SERVICE_NAME); + + // 2. 홈/어웨이 클럽 관리자 ID 병렬 조회 + CompletableFuture homeFuture = CompletableFuture.supplyAsync(() -> + clubFeignClient.getClub(match.homeClubId(), SERVICE_NAME).adminId() + ); + CompletableFuture awayFuture = CompletableFuture.supplyAsync(() -> + clubFeignClient.getClub(match.awayClubId(), SERVICE_NAME).adminId() + ); + + UUID homeAdminId = homeFuture.join(); + UUID awayAdminId = awayFuture.join(); + + // 3. 권한 검증 + boolean isAuthorized = requestUserId.equals(homeAdminId) + || requestUserId.equals(awayAdminId); + + if (!isAuthorized) { + log.warn("[Auth] CLUB_ADMIN 권한 없음. matchId={}, requestUserId={}", matchId, requestUserId); + throw new UnauthorizedClubAdminException(matchId, requestUserId); + } + + log.info("[Auth] CLUB_ADMIN 검증 완료. matchId={}, requestUserId={}", matchId, requestUserId); + } +} \ No newline at end of file diff --git a/src/main/java/org/ticketing/queue/application/service/QueueService.java b/src/main/java/org/ticketing/queue/application/service/QueueService.java index 4386ac5..54ba1ae 100644 --- a/src/main/java/org/ticketing/queue/application/service/QueueService.java +++ b/src/main/java/org/ticketing/queue/application/service/QueueService.java @@ -1,6 +1,5 @@ package org.ticketing.queue.application.service; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -51,8 +50,6 @@ public class QueueService { private final QueueRedisSubscriber queueRedisSubscriber; private final BannedUserRepository bannedUserRepository; - private final ObjectMapper objectMapper; - // SSE 타임아웃: 15분 (대기열 최대 대기 시간 기준) private static final long SSE_TIMEOUT_MS = 15 * 60 * 1000L; @@ -133,7 +130,6 @@ public SseEmitter subscribe(UUID matchId, UUID userId) { LocalDateTime enteredAt = queueRedisRepository.getEnteredAt(matchId, userId); queueHistoryService.record(matchId, userId, enteredAt, QueueExitReason.TIMEOUT); sseEmitterRepository.remove(matchId, userId); - emitter.complete(); }); emitter.onError(e -> { log.warn("[SSE] 연결 에러. matchId={}, userId={}", matchId, userId); @@ -142,36 +138,40 @@ public SseEmitter subscribe(UUID matchId, UUID userId) { sseEmitterRepository.save(matchId, userId, emitter); - // 토큰 보유 유저 → 즉시 토큰 전송 후 대기열 제거 - String existingToken = queueRedisRepository.getPassToken(matchId, userId); - if (existingToken != null) { - log.info("[SSE] 토큰 보유 유저 재접속. 즉시 토큰 전송. matchId={}, userId={}", matchId, userId); - try { - sendEvent(emitter, UserStatusResponse.ofIssued(existingToken)); - } catch (IOException e) { - log.warn("[SSE] 토큰 즉시 전송 실패. matchId={}, userId={}", matchId, userId); - } finally { - queueRedisRepository.exit(matchId, userId); // 대기열 잔류 제거 - emitter.complete(); - sseEmitterRepository.remove(matchId, userId); + try { + // 토큰 보유 유저 → 즉시 토큰 전송 후 대기열 제거 + String existingToken = queueRedisRepository.getPassToken(matchId, userId); + if (existingToken != null) { + log.info("[SSE] 토큰 보유 유저 재접속. 즉시 토큰 전송. matchId={}, userId={}", matchId, userId); + try { + sendEvent(emitter, UserStatusResponse.ofIssued(existingToken)); + } catch (IOException e) { + log.warn("[SSE] 토큰 즉시 전송 실패. matchId={}, userId={}", matchId, userId); + } finally { + queueRedisRepository.exit(matchId, userId); + emitter.complete(); // onCompletion → remove() + } + return emitter; } - return emitter; - } - // 구독 즉시 슬롯 비교 → 범위 내면 바로 토큰 발급 - Long rank = queueRedisRepository.getRank(matchId, userId); - Long totalCount = queueRedisRepository.getTotalCount(matchId); - Long availableSlots = queueRedisRepository.getAvailableSlots(matchId); + // 구독 즉시 슬롯 비교 → 범위 내면 바로 토큰 발급 + Long rank = queueRedisRepository.getRank(matchId, userId); + Long totalCount = queueRedisRepository.getTotalCount(matchId); - if (rank != null && availableSlots != null && rank <= availableSlots) { - // 슬롯 범위 내 → 즉시 토큰 발급 시도 queueRedisSubscriber.pushStatus(matchId, userId, emitter, rank, totalCount); - } else { - // 슬롯 범위 밖 → 현재 순위만 전송하고 대기 + + } catch (Exception e) { + log.error("[SSE] 구독 처리 중 예외 발생. matchId={}, userId={}", matchId, userId, e); try { - sendEvent(emitter, UserStatusResponse.ofWaiting(rank, totalCount)); - } catch (IOException e) { - log.warn("[SSE] 초기 상태 전송 실패. matchId={}, userId={}", matchId, userId); + emitter.send( + SseEmitter.event() + .name("error") + .data("서버 오류가 발생했습니다.") + ); + } catch (IOException sendEx) { + log.warn("[SSE] 에러 이벤트 전송 실패. matchId={}, userId={}", matchId, userId, sendEx); + } finally { + emitter.completeWithError(e); } } @@ -191,23 +191,23 @@ public void pushStatusToAll() { List userIds = sseEmitterRepository.findUserIdsByMatchId(matchId); if (userIds.isEmpty()) continue; - // Pipeline으로 모든 유저 순위 한 번에 조회 Map ranks = queueRedisRepository.getRankBatch(matchId, userIds); - for (UUID userId : userIds) { + // parallel stream으로 변경 + userIds.parallelStream().forEach(userId -> { SseEmitter emitter = sseEmitterRepository.find(matchId, userId); - if (emitter == null) continue; + if (emitter == null) return; - try { - // 순위 업데이트만 - 토큰 발급 로직 없음 - Long rank = ranks.get(userId); - sendEvent(emitter, UserStatusResponse.ofWaiting(rank, totalCount)); - } catch (IOException e) { - log.warn("[SSE] 순위 업데이트 전송 실패. matchId={}, userId={}", matchId, userId); - sseEmitterRepository.remove(matchId, userId); - emitter.completeWithError(e); + // rank == null, 이미 exit() 됐는데 emitter만 남은 경우 → 정리 + Long rank = ranks.get(userId); + if (rank == null) { + emitter.complete(); // onCompletion → remove() + return; } - } + + // 무조건 pushStatus → Lua 스크립트가 슬롯 판단 + queueRedisSubscriber.pushStatus(matchId, userId, emitter, rank, totalCount); + }); } } @@ -217,13 +217,13 @@ private void sendEvent(SseEmitter emitter, UserStatusResponse response) throws I emitter.send( SseEmitter.event() .name("queue-status") - .data(objectMapper.writeValueAsString(response)) + .data(response) .id(String.valueOf(System.currentTimeMillis())) .reconnectTime(3000) ); } catch (IllegalStateException e) { - // 이미 완료된 emitter → 무시 - log.warn("[SSE] 이미 완료된 emitter. 전송 스킵"); + // 이미 완료된 emitter → IOException으로 변환해 호출부에서 처리 + throw new IOException("Emitter already completed", e); } } @@ -292,16 +292,13 @@ private void notifyRefreshAndCloseEmitters(UUID matchId) { emitter.send( SseEmitter.event() .name("queue-refresh") - .data(objectMapper.writeValueAsString( - UserStatusResponse.ofRefreshed() - )) + .data(UserStatusResponse.ofRefreshed()) .id(String.valueOf(System.currentTimeMillis())) ); } catch (IOException e) { log.warn("[SSE] 초기화 이벤트 전송 실패. matchId={}, userId={}", matchId, userId); } finally { emitter.complete(); - sseEmitterRepository.remove(matchId, userId); } } } @@ -376,9 +373,7 @@ private void notifyBannedAndCloseEmitter(UUID matchId, UUID userId) { emitter.send( SseEmitter.event() .name("queue-banned") - .data(objectMapper.writeValueAsString( - UserStatusResponse.ofBanned() - )) + .data(UserStatusResponse.ofBanned()) .id(String.valueOf(System.currentTimeMillis())) ); } catch (IOException e) { diff --git a/src/main/java/org/ticketing/queue/domain/exception/NotFoundClubMatchException.java b/src/main/java/org/ticketing/queue/domain/exception/NotFoundClubMatchException.java new file mode 100644 index 0000000..107acbe --- /dev/null +++ b/src/main/java/org/ticketing/queue/domain/exception/NotFoundClubMatchException.java @@ -0,0 +1,13 @@ +package org.ticketing.queue.domain.exception; + +import org.springframework.http.HttpStatus; +import org.ticketing.common.exception.CustomException; + +import java.util.UUID; + +public class NotFoundClubMatchException extends CustomException { + + public NotFoundClubMatchException(UUID matchId, UUID clubId) { + super(String.format("해당 경기/클럽 조회 실패. matchId=%s, clubId=%s", matchId, clubId), HttpStatus.NOT_FOUND); + } +} diff --git a/src/main/java/org/ticketing/queue/domain/exception/UnauthorizedClubAdminException.java b/src/main/java/org/ticketing/queue/domain/exception/UnauthorizedClubAdminException.java new file mode 100644 index 0000000..a87c9ea --- /dev/null +++ b/src/main/java/org/ticketing/queue/domain/exception/UnauthorizedClubAdminException.java @@ -0,0 +1,13 @@ +package org.ticketing.queue.domain.exception; + +import org.springframework.http.HttpStatus; +import org.ticketing.common.exception.CustomException; + +import java.util.UUID; + +public class UnauthorizedClubAdminException extends CustomException { + + public UnauthorizedClubAdminException(UUID matchId, UUID userId) { + super(String.format("해당 경기의 클럽 관리자가 아닙니다. matchId=%s, userId=%s", matchId, userId), HttpStatus.UNAUTHORIZED); + } +} \ No newline at end of file diff --git a/src/main/java/org/ticketing/queue/domain/model/AcquireResult.java b/src/main/java/org/ticketing/queue/domain/model/AcquireResult.java index 6cdb081..8dbd08b 100644 --- a/src/main/java/org/ticketing/queue/domain/model/AcquireResult.java +++ b/src/main/java/org/ticketing/queue/domain/model/AcquireResult.java @@ -1,9 +1,10 @@ package org.ticketing.queue.domain.model; public enum AcquireResult { - SUCCESS, // 1: 슬롯+토큰 선점 성공 - NO_SLOT, // -1: 슬롯 없음 - NOT_INITIALIZED, // -2: 슬롯 미초기화 - PENDING, // -3: 다른 스레드 발급 중 - ALREADY_ISSUED // -4: 이미 발급 완료 + SUCCESS, // 1: 슬롯+토큰 선점 성공 + NO_SLOT, // -1: 슬롯 없음 + NOT_INITIALIZED, // -2: 슬롯 미초기화 + PENDING, // -3: 다른 스레드 발급 중 + ALREADY_ISSUED, // -4: 이미 발급 완료 + USER_NOT_IN_QUEUE // -5: 유저가 이미 대기열에서 제거됨 (ban/refresh/rollback 등) } diff --git a/src/main/java/org/ticketing/queue/domain/model/SlotAcquire.java b/src/main/java/org/ticketing/queue/domain/model/SlotAcquire.java new file mode 100644 index 0000000..c9c15ae --- /dev/null +++ b/src/main/java/org/ticketing/queue/domain/model/SlotAcquire.java @@ -0,0 +1,14 @@ +package org.ticketing.queue.domain.model; + +import java.time.LocalDateTime; + +public record SlotAcquire(AcquireResult status, LocalDateTime enteredAt) { + + public static SlotAcquire of(AcquireResult status) { + return new SlotAcquire(status, null); + } + + public static SlotAcquire success(LocalDateTime enteredAt) { + return new SlotAcquire(AcquireResult.SUCCESS, enteredAt); + } +} \ No newline at end of file diff --git a/src/main/java/org/ticketing/queue/domain/repository/QueueRedisRepository.java b/src/main/java/org/ticketing/queue/domain/repository/QueueRedisRepository.java index b7eb84b..fdb7158 100644 --- a/src/main/java/org/ticketing/queue/domain/repository/QueueRedisRepository.java +++ b/src/main/java/org/ticketing/queue/domain/repository/QueueRedisRepository.java @@ -1,6 +1,6 @@ package org.ticketing.queue.domain.repository; -import org.ticketing.queue.domain.model.AcquireResult; +import org.ticketing.queue.domain.model.SlotAcquire; import java.time.LocalDateTime; import java.time.OffsetDateTime; @@ -36,7 +36,7 @@ public interface QueueRedisRepository { void releaseSlot(UUID matchId); - AcquireResult acquireSlotAndToken(UUID matchId, UUID userId); + SlotAcquire acquireSlotAndToken(UUID matchId, UUID userId); // ── 통과 토큰 관리 ─────────────────────────────────────────────────── diff --git a/src/main/java/org/ticketing/queue/infrastructure/config/QueueSecurityConfig.java b/src/main/java/org/ticketing/queue/infrastructure/config/QueueSecurityConfig.java new file mode 100644 index 0000000..883c798 --- /dev/null +++ b/src/main/java/org/ticketing/queue/infrastructure/config/QueueSecurityConfig.java @@ -0,0 +1,31 @@ +package org.ticketing.queue.infrastructure.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class QueueSecurityConfig { + + // MVP 통합 테스트용 임시 SecurityConfig + @Bean + @Order(1) + public SecurityFilterChain queueFilterChain(HttpSecurity http) throws Exception { + return http + .securityMatcher("/**") + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/ticketing/queue/infrastructure/config/SseConfig.java b/src/main/java/org/ticketing/queue/infrastructure/config/SseConfig.java index 2ae2cc7..13c904a 100644 --- a/src/main/java/org/ticketing/queue/infrastructure/config/SseConfig.java +++ b/src/main/java/org/ticketing/queue/infrastructure/config/SseConfig.java @@ -2,24 +2,41 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.util.concurrent.ThreadPoolExecutor; + @Configuration -@EnableAsync @EnableScheduling // @Scheduled 활성화 public class SseConfig implements WebMvcConfigurer { /** - * SSE는 비동기 요청 처리 방식이므로 MVC async timeout 설정 필요 - * 기본값(30초)을 SSE 타임아웃보다 충분히 크게 설정 + * SSE 비동기 요청 MVC timeout 설정 (30분) */ @Override public void configureAsyncSupport(AsyncSupportConfigurer configurer) { - configurer.setDefaultTimeout(30 * 60 * 1000L); // 30분 + configurer.setDefaultTimeout(15 * 60 * 1000L); + configurer.setTaskExecutor(mvcAsyncExecutor()); + } + + /** + * MVC Async 전용 executor (SSE 요청 처리) + */ + @Bean + public ThreadPoolTaskExecutor mvcAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(500); + executor.setMaxPoolSize(1000); + executor.setQueueCapacity(2000); + executor.setKeepAliveSeconds(60); + executor.setThreadNamePrefix("mvc-async-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; } /** diff --git a/src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClient.java b/src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClient.java new file mode 100644 index 0000000..7fefce5 --- /dev/null +++ b/src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClient.java @@ -0,0 +1,17 @@ +package org.ticketing.queue.infrastructure.feign; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.ticketing.queue.infrastructure.feign.response.ClubResponse; + +import java.util.UUID; + +@FeignClient(name = "club-service", fallback = ClubFeignClientFallback.class) +public interface ClubFeignClient { + + @GetMapping("/internal/clubs/{clubId}") + ClubResponse getClub(@PathVariable("clubId") UUID clubId, + @RequestHeader("X-Internal-Service") String internalService); +} \ No newline at end of file diff --git a/src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClientFallback.java b/src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClientFallback.java new file mode 100644 index 0000000..e80704d --- /dev/null +++ b/src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClientFallback.java @@ -0,0 +1,19 @@ +package org.ticketing.queue.infrastructure.feign; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.ticketing.queue.domain.exception.NotFoundClubMatchException; +import org.ticketing.queue.infrastructure.feign.response.ClubResponse; + +import java.util.UUID; + +@Slf4j +@Component +public class ClubFeignClientFallback implements ClubFeignClient { + + @Override + public ClubResponse getClub(UUID clubId, String internalService) { + log.error("[Feign Fallback] club-service 호출 실패. clubId={}, service={}", clubId, internalService); + throw new NotFoundClubMatchException(null, clubId); + } +} diff --git a/src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClient.java b/src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClient.java new file mode 100644 index 0000000..5921f13 --- /dev/null +++ b/src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClient.java @@ -0,0 +1,17 @@ +package org.ticketing.queue.infrastructure.feign; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.ticketing.queue.infrastructure.feign.response.MatchResponse; + +import java.util.UUID; + +@FeignClient(name = "match-service", fallback = MatchFeignClientFallback.class) +public interface MatchFeignClient { + + @GetMapping("/api/matches/{matchId}") + MatchResponse getMatch(@PathVariable("matchId") UUID matchId, + @RequestHeader("X-Internal-Service") String internalService); +} \ No newline at end of file diff --git a/src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClientFallback.java b/src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClientFallback.java new file mode 100644 index 0000000..a1f0e66 --- /dev/null +++ b/src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClientFallback.java @@ -0,0 +1,19 @@ +package org.ticketing.queue.infrastructure.feign; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.ticketing.queue.domain.exception.NotFoundClubMatchException; +import org.ticketing.queue.infrastructure.feign.response.MatchResponse; + +import java.util.UUID; + +@Slf4j +@Component +public class MatchFeignClientFallback implements MatchFeignClient { + + @Override + public MatchResponse getMatch(UUID matchId, String internalService) { + log.error("[Feign Fallback] match-service 호출 실패. matchId={}, service={}", matchId, internalService); + throw new NotFoundClubMatchException(matchId, null); + } +} \ No newline at end of file diff --git a/src/main/java/org/ticketing/queue/infrastructure/feign/response/ClubResponse.java b/src/main/java/org/ticketing/queue/infrastructure/feign/response/ClubResponse.java new file mode 100644 index 0000000..c382ee7 --- /dev/null +++ b/src/main/java/org/ticketing/queue/infrastructure/feign/response/ClubResponse.java @@ -0,0 +1,14 @@ +package org.ticketing.queue.infrastructure.feign.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.UUID; + +public record ClubResponse( + @JsonProperty("club_id") + UUID clubId, + + @JsonProperty("admin_id") + UUID adminId +) { +} \ No newline at end of file diff --git a/src/main/java/org/ticketing/queue/infrastructure/feign/response/MatchResponse.java b/src/main/java/org/ticketing/queue/infrastructure/feign/response/MatchResponse.java new file mode 100644 index 0000000..6a395ff --- /dev/null +++ b/src/main/java/org/ticketing/queue/infrastructure/feign/response/MatchResponse.java @@ -0,0 +1,16 @@ +package org.ticketing.queue.infrastructure.feign.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.UUID; + +public record MatchResponse( + UUID id, + + @JsonProperty("home_club_id") + UUID homeClubId, + + @JsonProperty("away_club_id") + UUID awayClubId +) { +} \ No newline at end of file diff --git a/src/main/java/org/ticketing/queue/infrastructure/persistence/QueueRedisRepositoryImpl.java b/src/main/java/org/ticketing/queue/infrastructure/persistence/QueueRedisRepositoryImpl.java index addac85..797a586 100644 --- a/src/main/java/org/ticketing/queue/infrastructure/persistence/QueueRedisRepositoryImpl.java +++ b/src/main/java/org/ticketing/queue/infrastructure/persistence/QueueRedisRepositoryImpl.java @@ -11,6 +11,7 @@ import org.ticketing.queue.domain.exception.*; import org.ticketing.queue.domain.model.AcquireResult; import org.ticketing.queue.domain.model.Queue; +import org.ticketing.queue.domain.model.SlotAcquire; import org.ticketing.queue.domain.repository.QueueRedisRepository; import org.ticketing.queue.domain.repository.QueueRepository; @@ -78,13 +79,13 @@ private void validateTicketOpenAt(UUID matchId) { String openAtKey = String.format(OPEN_AT_KEY, matchId); String openAtValue = redisTemplate.opsForValue().get(openAtKey); - // null이면 키가 만료된 것 = 이미 오픈 시간이 지남 → 통과 + // null이면 initSlots() 미실행 = 대기열 미초기화 if (openAtValue == null) { - return; + throw new QueueNotFoundException(matchId); } long openAtEpoch = Long.parseLong(openAtValue); - long nowEpoch = OffsetDateTime.now(ZoneOffset.UTC).toEpochSecond(); + long nowEpoch = Instant.now().toEpochMilli(); if (nowEpoch < openAtEpoch) { throw new QueueNotOpenException(matchId); @@ -163,23 +164,15 @@ public void initSlots(UUID matchId, OffsetDateTime ticketOpenAt) { String availableKey = String.format(SLOTS_AVAILABLE_KEY, matchId); String openAtKey = String.format(OPEN_AT_KEY, matchId); - // 예매 시작 시간 저장 + 자동 만료 설정 - long nowEpoch = OffsetDateTime.now(ZoneOffset.UTC).toEpochSecond(); long openAtEpoch = ticketOpenAt.toEpochSecond(); - long ttlSeconds = openAtEpoch - nowEpoch; // 대기열 READY 상태로 변경 queue.ready(); redisTemplate.opsForValue().set(maxKey, String.valueOf(queue.getMaxActiveUsers())); redisTemplate.opsForValue().set(availableKey, String.valueOf(queue.getMaxActiveUsers())); - // 예매 시작 시간 저장 (epoch second로 저장 - 비교 연산 용이) - if (ttlSeconds > 0) { - redisTemplate.opsForValue().set(openAtKey, String.valueOf(openAtEpoch), ttlSeconds, TimeUnit.SECONDS); - } else { - // 이미 오픈 시간이 지난 경우 (혹은 즉시 오픈) - redisTemplate.opsForValue().set(openAtKey, String.valueOf(openAtEpoch)); - } + // 예매 시작 시간 저장 (epoch second로 저장 - 비교 연산 용이, TTL 없이 저장) + redisTemplate.opsForValue().set(openAtKey, String.valueOf(openAtEpoch)); } // 사용가능한 슬롯 수 확인 @@ -211,34 +204,46 @@ public void releaseSlot(UUID matchId) { } /** - * 결과에 따라 분기만 처리 - * 원자적으로 슬롯+토큰 동시 획득 + * 원자적으로 슬롯+토큰 선점 및 enteredAt 조회 + * Lua 스크립트가 enteredAt 존재 여부를 슬롯 획득 전에 체크하므로 + * acquireSlot SUCCESS 이후 별도 getEnteredAt() 호출 불필요 */ @Override - public AcquireResult acquireSlotAndToken(UUID matchId, UUID userId) { + @SuppressWarnings("unchecked") + public SlotAcquire acquireSlotAndToken(UUID matchId, UUID userId) { String tokenKey = getPassTokenKey(matchId, userId); String availableKey = SLOTS_AVAILABLE_KEY.formatted(matchId); + String enteredAtKey = getEnteredAtKey(matchId); long ttlSeconds = TOKEN_TTL_MINUTES * 60; - Long result = redisTemplate.execute( + List result = (List) redisTemplate.execute( ACQUIRE_SLOT_AND_TOKEN_SCRIPT, - List.of(tokenKey, availableKey), + List.of(tokenKey, availableKey, enteredAtKey), userId.toString(), String.valueOf(ttlSeconds), PLACEHOLDER ); - if (result == null) { + if (result == null || result.isEmpty()) { throw new SlotException("슬롯+토큰 선점 결과가 null입니다.", HttpStatus.INTERNAL_SERVER_ERROR); } - return switch (result.intValue()) { - case 1 -> AcquireResult.SUCCESS; - case -1 -> AcquireResult.NO_SLOT; - case -2 -> throw new SlotException("슬롯 미초기화", HttpStatus.INTERNAL_SERVER_ERROR); - case -3 -> AcquireResult.PENDING; - case -4 -> AcquireResult.ALREADY_ISSUED; - default -> throw new SlotException("알 수 없는 결과: " + result, HttpStatus.INTERNAL_SERVER_ERROR); + long code = (Long) result.get(0); + + return switch ((int) code) { + case 1 -> { + String raw = (String) result.get(1); + LocalDateTime enteredAt = Instant.ofEpochMilli(Long.parseLong(raw)) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + yield SlotAcquire.success(enteredAt); + } + case -1 -> SlotAcquire.of(AcquireResult.NO_SLOT); + case -2 -> throw new SlotException("슬롯 미초기화", HttpStatus.INTERNAL_SERVER_ERROR); + case -3 -> SlotAcquire.of(AcquireResult.PENDING); + case -4 -> SlotAcquire.of(AcquireResult.ALREADY_ISSUED); + case -5 -> SlotAcquire.of(AcquireResult.USER_NOT_IN_QUEUE); + default -> throw new SlotException("알 수 없는 결과: " + code, HttpStatus.INTERNAL_SERVER_ERROR); }; } diff --git a/src/main/java/org/ticketing/queue/infrastructure/persistence/SseEmitterRepository.java b/src/main/java/org/ticketing/queue/infrastructure/persistence/SseEmitterRepository.java index 91964e3..0e0a447 100644 --- a/src/main/java/org/ticketing/queue/infrastructure/persistence/SseEmitterRepository.java +++ b/src/main/java/org/ticketing/queue/infrastructure/persistence/SseEmitterRepository.java @@ -3,20 +3,23 @@ import org.springframework.stereotype.Component; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; @Component public class SseEmitterRepository { private final Map emitters = new ConcurrentHashMap<>(); + // matchId → userId Set 역방향 인덱스 (O(1) 조회) + private final Map> matchUserIndex = new ConcurrentHashMap<>(); public void save(UUID matchId, UUID userId, SseEmitter emitter) { emitters.put(buildKey(matchId, userId), emitter); + matchUserIndex.computeIfAbsent(matchId, k -> ConcurrentHashMap.newKeySet()).add(userId); } public SseEmitter find(UUID matchId, UUID userId) { @@ -25,23 +28,25 @@ public SseEmitter find(UUID matchId, UUID userId) { public void remove(UUID matchId, UUID userId) { emitters.remove(buildKey(matchId, userId)); + matchUserIndex.computeIfPresent(matchId, (id, userIds) -> { + userIds.remove(userId); + return userIds.isEmpty() ? null : userIds; + }); } public List findUserIdsByMatchId(UUID matchId) { - String prefix = matchId + ":"; - return emitters.keySet().stream() - .filter(k -> k.startsWith(prefix)) - .map(k -> UUID.fromString(k.split(":")[1])) - .toList(); + Set userIds = matchUserIndex.get(matchId); + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyList(); + } + return List.copyOf(userIds); } - private String buildKey(UUID matchId, UUID userId) { - return matchId + ":" + userId; + public Set getAllMatchIds() { + return matchUserIndex.keySet(); } - public Set getAllMatchIds() { - return emitters.keySet().stream() - .map(key -> UUID.fromString(key.split(":")[0])) - .collect(Collectors.toSet()); + private String buildKey(UUID matchId, UUID userId) { + return matchId + ":" + userId; } } diff --git a/src/main/java/org/ticketing/queue/infrastructure/redis/pubsub/QueueRedisSubscriber.java b/src/main/java/org/ticketing/queue/infrastructure/redis/pubsub/QueueRedisSubscriber.java index 47e5ef3..2876ac8 100644 --- a/src/main/java/org/ticketing/queue/infrastructure/redis/pubsub/QueueRedisSubscriber.java +++ b/src/main/java/org/ticketing/queue/infrastructure/redis/pubsub/QueueRedisSubscriber.java @@ -1,6 +1,5 @@ package org.ticketing.queue.infrastructure.redis.pubsub; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.connection.Message; @@ -8,9 +7,9 @@ import org.springframework.stereotype.Component; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.ticketing.queue.application.service.QueueHistoryService; -import org.ticketing.queue.domain.model.AcquireResult; import org.ticketing.queue.domain.model.QueueExitReason; import org.ticketing.queue.domain.model.QueueToken; +import org.ticketing.queue.domain.model.SlotAcquire; import org.ticketing.queue.domain.repository.QueueRedisRepository; import org.ticketing.queue.domain.service.QueueTokenDomainService; import org.ticketing.queue.infrastructure.persistence.SseEmitterRepository; @@ -30,7 +29,6 @@ public class QueueRedisSubscriber implements MessageListener { private final QueueRedisRepository queueRedisRepository; private final QueueTokenDomainService queueTokenDomainService; private final SseEmitterRepository sseEmitterRepository; - private final ObjectMapper objectMapper; @Override public void onMessage(Message message, byte[] pattern) { @@ -69,9 +67,9 @@ public void pushStatus(UUID matchId, UUID userId, SseEmitter emitter, Long rank, LocalDateTime enteredAt = null; try { - AcquireResult acquireResult = queueRedisRepository.acquireSlotAndToken(matchId, userId); + SlotAcquire acquireResult = queueRedisRepository.acquireSlotAndToken(matchId, userId); - switch (acquireResult) { + switch (acquireResult.status()) { case NO_SLOT -> { // 슬롯 경합 패배 → 다음 스케줄러 주기에 재시도 return; @@ -80,42 +78,50 @@ public void pushStatus(UUID matchId, UUID userId, SseEmitter emitter, Long rank, // 다른 스레드가 발급 중 → 슬롯 획득 자체를 안 했으므로 반환 불필요 return; } + case USER_NOT_IN_QUEUE -> { + // ban/refresh/rollback 등으로 이미 대기열에서 제거된 유저 + // 슬롯 미획득이므로 rollback 불필요, emitter만 정리 + log.warn("[SSE] 슬롯 획득 시점에 유저 없음(이미 퇴장). matchId={}, userId={}", matchId, userId); + emitter.complete(); + return; + } case ALREADY_ISSUED -> { // 이미 발급 완료 → 슬롯 획득 안 했으므로 반환 불필요 String existingToken = queueRedisRepository.getPassToken(matchId, userId); - sendEvent(emitter, UserStatusResponse.ofPassed(rank, totalCount, existingToken)); - sseEmitterRepository.remove(matchId, userId); - emitter.complete(); + try { + sendEvent(emitter, UserStatusResponse.ofPassed(rank, totalCount, existingToken)); + } catch (IOException e) { + log.warn("[SSE] ALREADY_ISSUED 전송 실패. matchId={}, userId={}", matchId, userId); + } + emitter.complete(); // onCompletion → remove() return; } case SUCCESS -> { slotAcquired = true; + // Lua 스크립트가 원자적으로 읽은 값 → 별도 getEnteredAt() 호출 불필요 + enteredAt = acquireResult.enteredAt(); } } // 토큰 발급 및 저장 QueueToken token = queueTokenDomainService.issue(matchId, userId); - enteredAt = queueRedisRepository.getEnteredAt(matchId, userId); queueRedisRepository.savePassToken(matchId, userId, token.getToken()); queueRedisRepository.exit(matchId, userId); queueHistoryService.record(matchId, userId, enteredAt, QueueExitReason.PASSED); sendEvent(emitter, UserStatusResponse.ofPassed(rank, totalCount, token.getToken())); - sseEmitterRepository.remove(matchId, userId); - emitter.complete(); + emitter.complete(); // onCompletion → remove() } catch (IOException e) { log.warn("[SSE] 전송 실패. matchId={}, userId={}", matchId, userId); rollback(matchId, userId, slotAcquired, true, enteredAt, QueueExitReason.IO_ERROR); - sseEmitterRepository.remove(matchId, userId); - emitter.completeWithError(e); + emitter.completeWithError(e); // onError → remove() } catch (Exception e) { log.error("[SSE] 예상치 못한 오류. matchId={}, userId={}", matchId, userId, e); rollback(matchId, userId, slotAcquired, true, enteredAt, QueueExitReason.UNEXPECTED_ERROR); - sseEmitterRepository.remove(matchId, userId); - emitter.completeWithError(e); + emitter.completeWithError(e); // onError → remove() } } @@ -124,12 +130,13 @@ private void sendEvent(SseEmitter emitter, UserStatusResponse response) throws I emitter.send( SseEmitter.event() .name("queue-status") - .data(objectMapper.writeValueAsString(response)) + .data(response) .id(String.valueOf(System.currentTimeMillis())) .reconnectTime(3000) ); } catch (IllegalStateException e) { - log.warn("[SSE] 이미 완료된 emitter. 전송 스킵"); + // 이미 완료된 emitter → IOException으로 변환해 호출부에서 rollback 처리 + throw new IOException("Emitter already completed", e); } } @@ -141,7 +148,7 @@ private void rollback(UUID matchId, UUID userId, boolean slotAcquired, boolean t queueRedisRepository.releaseSlot(matchId); } if (enteredAt == null) { - enteredAt = queueRedisRepository.getEnteredAt(matchId, userId); + enteredAt = LocalDateTime.now(); } queueHistoryService.record(matchId, userId, enteredAt, reason); } diff --git a/src/main/java/org/ticketing/queue/infrastructure/util/RuaScript.java b/src/main/java/org/ticketing/queue/infrastructure/util/RuaScript.java index 423aff6..2a53e31 100644 --- a/src/main/java/org/ticketing/queue/infrastructure/util/RuaScript.java +++ b/src/main/java/org/ticketing/queue/infrastructure/util/RuaScript.java @@ -3,6 +3,8 @@ import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.core.script.RedisScript; +import java.util.List; + public class RuaScript { public static final RedisScript ENTRY_SCRIPT = RedisScript.of(""" @@ -34,42 +36,49 @@ public class RuaScript { """, Long.class); - public static final DefaultRedisScript ACQUIRE_SLOT_AND_TOKEN_SCRIPT = + public static final DefaultRedisScript ACQUIRE_SLOT_AND_TOKEN_SCRIPT = new DefaultRedisScript<>( """ local userTokenKey = KEYS[1] local availableKey = KEYS[2] + local enteredAtKey = KEYS[3] local userId = ARGV[1] local ttl = ARGV[2] local placeholder = ARGV[3] - + -- 이미 토큰 키가 존재하면 슬롯 획득 없이 상태만 반환 local existing = redis.call('GET', userTokenKey) if existing then if existing == placeholder then - return -3 -- PLACEHOLDER: 다른 스레드 발급 중 + return {-3} -- PENDING: 다른 스레드 발급 중 else - return -4 -- 이미 발급 완료된 토큰 존재 + return {-4} -- ALREADY_ISSUED: 이미 발급 완료 end end - + + -- 유저가 여전히 대기열에 있는지 확인 (슬롯 획득 전에 원자적으로 체크) + local enteredAt = redis.call('HGET', enteredAtKey, userId) + if not enteredAt then + return {-5} -- USER_NOT_IN_QUEUE: ban/refresh/rollback 등으로 이미 제거됨 + end + -- 슬롯 확인 local current = redis.call('GET', availableKey) if not current then - return -2 -- 슬롯 미초기화 + return {-2} -- 슬롯 미초기화 end - + current = tonumber(current) if current <= 0 then - return -1 -- 슬롯 없음 + return {-1} -- 슬롯 없음 end - - -- 슬롯 차감 + PLACEHOLDER 세팅 (원자적) + + -- 슬롯 차감 + PLACEHOLDER 세팅 + enteredAt 반환 (원자적) redis.call('DECR', availableKey) redis.call('SET', userTokenKey, placeholder, 'EX', ttl) - return 1 -- 획득 성공 + return {1, enteredAt} -- SUCCESS """, - Long.class + List.class ); diff --git a/src/main/java/org/ticketing/queue/presentation/controller/QueueController.java b/src/main/java/org/ticketing/queue/presentation/controller/QueueController.java index cd3b1f9..b803639 100644 --- a/src/main/java/org/ticketing/queue/presentation/controller/QueueController.java +++ b/src/main/java/org/ticketing/queue/presentation/controller/QueueController.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.ticketing.queue.application.dto.result.QueueListResult; +import org.ticketing.queue.application.service.MatchAuthorizationService; import org.ticketing.queue.application.service.QueueService; import org.ticketing.queue.presentation.dto.request.QueueCreateRequest; import org.ticketing.queue.presentation.dto.request.QueueListGetRequest; @@ -17,6 +18,8 @@ import org.ticketing.queue.presentation.dto.response.QueueListResponse; import org.ticketing.queue.presentation.dto.response.QueueResponse; +import java.util.Arrays; +import java.util.List; import java.util.UUID; @RestController @@ -25,6 +28,7 @@ public class QueueController { private final QueueService queueService; + private final MatchAuthorizationService matchAuthorizationService; /** * 대기열 단일 조회 @@ -131,7 +135,15 @@ public void refreshQueue(@PathVariable("matchId") UUID matchId) { */ @PreAuthorize("hasAnyRole('ADMIN','CLUB_ADMIN')") @PostMapping("/{matchId}/{userId}/banned") - public void banUser(@PathVariable("matchId") UUID matchId, @PathVariable UUID userId) { + public void banUser(@PathVariable("matchId") UUID matchId, @PathVariable UUID userId, @RequestHeader("X-User-Roles") String roles) { + if (roles != null && !roles.isBlank()) { + List roleList = Arrays.stream(roles.split(",")) + .map(String::trim) + .toList(); + if (roleList.contains("CLUB_ADMIN")) { + matchAuthorizationService.validateClubAdmin(matchId, userId); + } + } queueService.banUser(matchId, userId); } } \ No newline at end of file diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index c9007c0..d78d5d3 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -1,3 +1,11 @@ +server: + tomcat: + threads: + max: 1000 + min-spare: 500 + accept-count: 1000 + max-connections: 10000 + spring: config: activate: @@ -5,62 +13,84 @@ spring: data: redis: - host: ticketing-redis - port: 6379 - timeout: 5000ms + host: ${REDIS_HOST} + port: ${REDIS_PORT} + timeout: 10000ms lettuce: - shutdown-timeout: 100ms + pool: + max-active: 200 + max-idle: 50 + min-idle: 20 + max-wait: 3000ms datasource: - url: jdbc:postgresql://${DB_HOST:ticketing-postgres}:${DB_PORT:15432}/${DB_NAME:ticketing}?currentSchema=${QUEUE_SCHEMA:queue} + url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}?currentSchema=${DB_SCHEMA:queue_service} username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: org.postgresql.Driver hikari: maximum-pool-size: 30 minimum-idle: 10 - connection-timeout: 3000 - idle-timeout: 600000 - max-lifetime: 1800000 jpa: hibernate: - ddl-auto: ${JPA_DDL_AUTO:update} # 도커는 update (create면 매번 초기화 위험) + ddl-auto: ${JPA_DDL_AUTO:update} properties: hibernate: - default_schema: ${QUEUE_SCHEMA:queue} + default_schema: ${DB_SCHEMA:queue_service} hbm2ddl: create_schemas: true kafka: - bootstrap-servers: kafka:9092 + bootstrap-servers: ${SPRING_KAFKA_BOOTSTRAP_SERVERS} eureka: client: service-url: - defaultZone: ${EUREKA_DEFAULT_ZONE:http://eureka-server:10001/eureka/} # 도커 내부 서비스명 + defaultZone: ${EUREKA_DEFAULT_ZONE:http://eureka-server:10001/eureka/} management: + metrics: + tags: + application: ${spring.application.name} + distribution: + percentiles-histogram: + http.server.requests: true endpoints: web: exposure: - include: health,info,prometheus + include: health,info,prometheus,metrics tracing: sampling: - probability: 1.0 + probability: 0.1 zipkin: tracing: endpoint: ${ZIPKIN_URL:http://localhost:9411/api/v2/spans} -# data: -# redis: -# password: ${REDIS_PASSWORD} -# sentinel: -# master: mymaster -# nodes: -# - redis-sentinel-1:26379 -# - redis-sentinel-2:26379 -# - redis-sentinel-3:26379 -# timeout: 5000ms -# lettuce: -# shutdown-timeout: 100ms +feign: + circuitbreaker: + enabled: true +resilience4j: + circuitbreaker: + instances: + default: + sliding-window-size: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + record-exceptions: + - feign.RetryableException + - java.io.IOException + - java.util.concurrent.TimeoutException + + retry: + instances: + default: + max-attempts: 3 + wait-duration: 500ms + retry-exceptions: + - java.lang.Exception + +queue: + token: + secret: ${QUEUE_TOKEN_SECRET} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8b4dc03..92c6228 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,10 +3,27 @@ spring: name: queue-service config: - import: optional:configserver:${CONFIG_SERVER_URL:http://localhost:10002} + import: optional:configserver:http://localhost:10002 - cloud: - config: - fail-fast: false # config-server 없어도 로컬 기동 가능 - import-check: - enabled: false + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:19092} + consumer: + group-id: queue-service + auto-offset-reset: latest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer + properties: + spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer + spring.json.trusted.packages: "org.ticketing.queue.domain.event,java.util,java.lang" + spring.json.use.type.headers: false + spring.json.value.default.type: org.ticketing.queue.domain.event.MatchApprovedEvent + +management: + tracing: + enabled: false + sampling: + probability: 0.0 + zipkin: + tracing: + export: + enabled: false \ No newline at end of file diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 565e304..08f7166 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -19,25 +19,45 @@ - + + + + {"service":"queue-service"} + + timestamp + message + logger + thread + level + + + + + logs/queue-service.log - logs/queue-service.%d{yyyy-MM-dd}.log 14 - %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %thread %logger{36} [traceId=%X{traceId}] - %msg%n UTF-8 - - - - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/org/ticketing/queue/application/service/QueueServiceTest.java b/src/test/java/org/ticketing/queue/application/service/QueueServiceTest.java index 0bdab48..788f346 100644 --- a/src/test/java/org/ticketing/queue/application/service/QueueServiceTest.java +++ b/src/test/java/org/ticketing/queue/application/service/QueueServiceTest.java @@ -23,7 +23,6 @@ import org.ticketing.queue.domain.repository.QueueRepository; import org.ticketing.queue.infrastructure.persistence.SseEmitterRepository; import org.ticketing.queue.infrastructure.redis.pubsub.QueueRedisSubscriber; -import org.ticketing.queue.presentation.dto.response.UserStatusResponse; import java.time.LocalDateTime; import java.util.List; @@ -233,13 +232,8 @@ void subscribe_waiting() throws Exception { UUID matchId = UUID.randomUUID(); UUID userId = UUID.randomUUID(); - // rank(10) > availableSlots(5) → else 분기 → sendEvent() 호출 when(queueRedisRepository.getRank(matchId, userId)).thenReturn(10L); when(queueRedisRepository.getTotalCount(matchId)).thenReturn(100L); - when(queueRedisRepository.getAvailableSlots(matchId)).thenReturn(5L); - - when(objectMapper.writeValueAsString(any())) - .thenReturn("{\"status\":\"WAITING\",\"rank\":10,\"totalCount\":100}"); doNothing().when(sseEmitterRepository) .save(eq(matchId), eq(userId), any(SseEmitter.class)); @@ -251,10 +245,10 @@ void subscribe_waiting() throws Exception { assertThat(emitter).isNotNull(); verify(sseEmitterRepository).save(eq(matchId), eq(userId), any(SseEmitter.class)); - verify(queueRedisSubscriber, never()) + verify(queueRedisSubscriber) .pushStatus(any(), any(), any(), any(), any()); - verify(objectMapper).writeValueAsString(any(UserStatusResponse.class)); } + @Test @DisplayName("슬롯 범위 내 즉시 토큰 발급 시도") void subscribe_immediateTokenIssue() throws Exception { @@ -262,10 +256,8 @@ void subscribe_immediateTokenIssue() throws Exception { UUID matchId = UUID.randomUUID(); UUID userId = UUID.randomUUID(); - // rank(3) <= availableSlots(5) → if 분기 → pushStatus() 호출 when(queueRedisRepository.getRank(matchId, userId)).thenReturn(3L); when(queueRedisRepository.getTotalCount(matchId)).thenReturn(100L); - when(queueRedisRepository.getAvailableSlots(matchId)).thenReturn(5L); doNothing().when(sseEmitterRepository) .save(eq(matchId), eq(userId), any(SseEmitter.class)); @@ -279,7 +271,6 @@ void subscribe_immediateTokenIssue() throws Exception { verify(sseEmitterRepository).save(eq(matchId), eq(userId), any(SseEmitter.class)); verify(queueRedisSubscriber) .pushStatus(eq(matchId), eq(userId), any(SseEmitter.class), eq(3L), eq(100L)); - verify(objectMapper, never()).writeValueAsString(any()); } } @@ -315,9 +306,6 @@ void refreshQueue_success() throws Exception { when(queueRedisRepository.getEnteredAt(matchId, user2)) .thenReturn(LocalDateTime.now().minusMinutes(3)); - when(objectMapper.writeValueAsString(any())) - .thenReturn("{\"status\":\"REFRESHED\"}"); - // when queueService.refreshQueue(matchId); @@ -335,9 +323,6 @@ void refreshQueue_success() throws Exception { verify(emitter1).complete(); verify(emitter2).complete(); - verify(sseEmitterRepository).remove(matchId, user1); - verify(sseEmitterRepository).remove(matchId, user2); - verify(queueRedisRepository).refreshQueue(matchId); } } @@ -370,9 +355,6 @@ void banUser_success_waitingUser() throws Exception { when(sseEmitterRepository.find(matchId, userId)) .thenReturn(emitter); - when(objectMapper.writeValueAsString(any())) - .thenReturn("{\"status\":\"BANNED\"}"); - // when queueService.banUser(matchId, userId); @@ -389,8 +371,6 @@ void banUser_success_waitingUser() throws Exception { verify(emitter).send(any(SseEmitter.SseEventBuilder.class)); verify(emitter).complete(); - verify(sseEmitterRepository).remove(matchId, userId); - verify(bannedUserRepository).save(any(BannedUser.class)); }