From f1c14fc1729246136fcfedc6a831206354922b17 Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Wed, 6 May 2026 14:19:44 +0900 Subject: [PATCH 01/16] =?UTF-8?q?Refactor:=20application.yml=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=88=98=EC=A0=95,=20Spring=20Security=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/QueueSecurityConfig.java | 31 +++++++++++++++++++ src/main/resources/application.yml | 29 +++++++++++++---- 2 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/ticketing/queue/infrastructure/config/QueueSecurityConfig.java 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/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 From e4fff743a3a6661c5eceb3507623a8748f3f28e1 Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Wed, 6 May 2026 15:05:09 +0900 Subject: [PATCH 02/16] =?UTF-8?q?Refactor:=20application-docker.yml=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-docker.yml | 32 +++++++++++------------ 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index c9007c0..41e4395 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -1,3 +1,12 @@ +server: + tomcat: + threads: + max: 500 + min-spare: 100 + accept-count: 1000 + max-connections: 8192 + connection-timeout: 5000 + spring: config: activate: @@ -6,7 +15,7 @@ spring: data: redis: host: ticketing-redis - port: 6379 + port: ${REDIS_PORT:6379} timeout: 5000ms lettuce: shutdown-timeout: 100ms @@ -19,9 +28,6 @@ spring: hikari: maximum-pool-size: 30 minimum-idle: 10 - connection-timeout: 3000 - idle-timeout: 600000 - max-lifetime: 1800000 jpa: hibernate: @@ -41,6 +47,9 @@ eureka: defaultZone: ${EUREKA_DEFAULT_ZONE:http://eureka-server:10001/eureka/} # 도커 내부 서비스명 management: + metrics: + tags: + application: ${spring.application.name} # Grafana 필터링 기준 endpoints: web: exposure: @@ -52,15 +61,6 @@ management: 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 +queue: + token: + secret: ${QUEUE_TOKEN_SECRET} From d2d7199b4ffd2e5e170e018ffa63ad1c44d6d39a Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Wed, 6 May 2026 21:36:17 +0900 Subject: [PATCH 03/16] =?UTF-8?q?Refactor:=20application-docker.yml=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-docker.yml | 10 +++---- src/main/resources/logback.xml | 36 ++++++++++++++++++----- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 41e4395..560a1f0 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -14,7 +14,7 @@ spring: data: redis: - host: ticketing-redis + host: ${REDIS_HOST:ticketing-redis} port: ${REDIS_PORT:6379} timeout: 5000ms lettuce: @@ -31,7 +31,7 @@ spring: jpa: hibernate: - ddl-auto: ${JPA_DDL_AUTO:update} # 도커는 update (create면 매번 초기화 위험) + ddl-auto: ${JPA_DDL_AUTO:update} properties: hibernate: default_schema: ${QUEUE_SCHEMA:queue} @@ -39,12 +39,12 @@ spring: 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: @@ -56,7 +56,7 @@ management: include: health,info,prometheus tracing: sampling: - probability: 1.0 + probability: 0.1 zipkin: tracing: endpoint: ${ZIPKIN_URL:http://localhost:9411/api/v2/spans} 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 From 7e64def2d74e9443ee4ecd4210f0e451eeafb6f2 Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Thu, 7 May 2026 12:47:26 +0900 Subject: [PATCH 04/16] =?UTF-8?q?Refactor:=20Dockerfild,=20.gitignore=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 ++++++ Dockerfile | 24 ++++++++++-------------- 2 files changed, 16 insertions(+), 14 deletions(-) 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 From 386cec8bbc01bc44d8f15ecc140c27fae1cc3c74 Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Fri, 8 May 2026 10:32:48 +0900 Subject: [PATCH 05/16] =?UTF-8?q?Refactor:=20application-docker.yml=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-docker.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 560a1f0..e5a38ab 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -21,7 +21,7 @@ spring: shutdown-timeout: 100ms datasource: - url: jdbc:postgresql://${DB_HOST:ticketing-postgres}:${DB_PORT:15432}/${DB_NAME:ticketing}?currentSchema=${QUEUE_SCHEMA:queue} + url: jdbc:postgresql://${DB_HOST:ticketing-postgres}:${DB_PORT:15432}/${DB_NAME:ticketing}?currentSchema=${DB_SCHEMA:queue} username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: org.postgresql.Driver @@ -34,7 +34,7 @@ spring: ddl-auto: ${JPA_DDL_AUTO:update} properties: hibernate: - default_schema: ${QUEUE_SCHEMA:queue} + default_schema: ${DB_SCHEMA:queue} hbm2ddl: create_schemas: true @@ -49,7 +49,7 @@ eureka: management: metrics: tags: - application: ${spring.application.name} # Grafana 필터링 기준 + application: ${spring.application.name} endpoints: web: exposure: From 8ec98eb95fd74b90ca34fb9ceb936edbf3bf08b5 Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Sun, 10 May 2026 00:25:41 +0900 Subject: [PATCH 06/16] =?UTF-8?q?Refactor:=20=EB=8C=80=EA=B8=B0=EC=97=B4?= =?UTF-8?q?=20=EC=A7=84=EC=9E=85,=20SSE=20=EC=88=98=EC=8B=A0=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=ED=96=A5=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/QueueService.java | 95 +++++++++---------- .../queue/domain/model/AcquireResult.java | 11 ++- .../repository/QueueRedisRepository.java | 4 +- .../infrastructure/config/SseConfig.java | 27 +++++- .../persistence/QueueRedisRepositoryImpl.java | 39 +++++--- .../persistence/SseEmitterRepository.java | 32 ++++--- .../redis/pubsub/QueueRedisSubscriber.java | 43 +++++---- .../queue/infrastructure/util/RuaScript.java | 33 ++++--- src/main/resources/application-docker.yml | 20 +++- 9 files changed, 182 insertions(+), 122 deletions(-) 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..0db3134 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,39 @@ 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 ignored) { + emitter.completeWithError(e); + sseEmitterRepository.remove(matchId, userId); } } @@ -191,23 +190,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 +216,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,9 +291,7 @@ 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) { @@ -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/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/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/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/persistence/QueueRedisRepositoryImpl.java b/src/main/java/org/ticketing/queue/infrastructure/persistence/QueueRedisRepositoryImpl.java index addac85..bde7aa0 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; @@ -211,34 +212,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..ff8bef7 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,28 @@ public SseEmitter find(UUID matchId, UUID userId) { public void remove(UUID matchId, UUID userId) { emitters.remove(buildKey(matchId, userId)); + Set userIds = matchUserIndex.get(matchId); + if (userIds != null) { + userIds.remove(userId); + if (userIds.isEmpty()) { + matchUserIndex.remove(matchId, 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/resources/application-docker.yml b/src/main/resources/application-docker.yml index e5a38ab..6aae648 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -1,11 +1,12 @@ server: tomcat: threads: - max: 500 + max: 1000 min-spare: 100 accept-count: 1000 - max-connections: 8192 - connection-timeout: 5000 + max-connections: 10000 + + port: ${SERVER_PORT:20007} spring: config: @@ -16,9 +17,18 @@ spring: redis: host: ${REDIS_HOST:ticketing-redis} port: ${REDIS_PORT:6379} - timeout: 5000ms + timeout: 10000ms lettuce: - shutdown-timeout: 100ms + pool: + max-active: 200 + max-idle: 50 + min-idle: 20 + max-wait: 3000ms + + sql: + init: + mode: always + schema-locations: classpath:db/init-schema.sql datasource: url: jdbc:postgresql://${DB_HOST:ticketing-postgres}:${DB_PORT:15432}/${DB_NAME:ticketing}?currentSchema=${DB_SCHEMA:queue} From b13a8ef5b85d97b9c062183b646352970d44717a Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Sun, 10 May 2026 23:12:06 +0900 Subject: [PATCH 07/16] =?UTF-8?q?Refactor:=20=EB=8C=80=EA=B8=B0=EC=97=B4?= =?UTF-8?q?=20=EC=A7=84=EC=9E=85=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/QueueService.java | 1 - .../queue/domain/model/SlotAcquire.java | 14 ++++++++++++++ .../persistence/QueueRedisRepositoryImpl.java | 18 +++++------------- 3 files changed, 19 insertions(+), 14 deletions(-) create mode 100644 src/main/java/org/ticketing/queue/domain/model/SlotAcquire.java 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 0db3134..f23f6db 100644 --- a/src/main/java/org/ticketing/queue/application/service/QueueService.java +++ b/src/main/java/org/ticketing/queue/application/service/QueueService.java @@ -298,7 +298,6 @@ private void notifyRefreshAndCloseEmitters(UUID matchId) { log.warn("[SSE] 초기화 이벤트 전송 실패. matchId={}, userId={}", matchId, userId); } finally { emitter.complete(); - sseEmitterRepository.remove(matchId, userId); } } } 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/infrastructure/persistence/QueueRedisRepositoryImpl.java b/src/main/java/org/ticketing/queue/infrastructure/persistence/QueueRedisRepositoryImpl.java index bde7aa0..797a586 100644 --- a/src/main/java/org/ticketing/queue/infrastructure/persistence/QueueRedisRepositoryImpl.java +++ b/src/main/java/org/ticketing/queue/infrastructure/persistence/QueueRedisRepositoryImpl.java @@ -79,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); @@ -164,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)); } // 사용가능한 슬롯 수 확인 From fd1b3d784ed544739d7ea7ee7054a4ea3c2b8269 Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Mon, 11 May 2026 10:20:49 +0900 Subject: [PATCH 08/16] =?UTF-8?q?Refactor:=20DB=20schema=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- postgres/init.sql | 2 +- src/main/resources/application-docker.yml | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) 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/resources/application-docker.yml b/src/main/resources/application-docker.yml index 6aae648..9feeba9 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -25,13 +25,8 @@ spring: min-idle: 20 max-wait: 3000ms - sql: - init: - mode: always - schema-locations: classpath:db/init-schema.sql - datasource: - url: jdbc:postgresql://${DB_HOST:ticketing-postgres}:${DB_PORT:15432}/${DB_NAME:ticketing}?currentSchema=${DB_SCHEMA:queue} + url: jdbc:postgresql://${DB_HOST:ticketing-postgres}:${DB_PORT:15432}/${DB_NAME:ticketing}?currentSchema=${DB_SCHEMA:queue_service} username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: org.postgresql.Driver From 987057eee36b7aa72831c9183bf744d0d3f1bb0b Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Mon, 11 May 2026 10:57:24 +0900 Subject: [PATCH 09/16] =?UTF-8?q?Refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../queue/application/service/QueueServiceTest.java | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) 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..bc3bb89 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; @@ -253,8 +252,8 @@ void subscribe_waiting() throws Exception { verify(sseEmitterRepository).save(eq(matchId), eq(userId), any(SseEmitter.class)); verify(queueRedisSubscriber, never()) .pushStatus(any(), any(), any(), any(), any()); - verify(objectMapper).writeValueAsString(any(UserStatusResponse.class)); } + @Test @DisplayName("슬롯 범위 내 즉시 토큰 발급 시도") void subscribe_immediateTokenIssue() throws Exception { @@ -279,7 +278,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()); } } @@ -335,9 +333,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); } } @@ -389,8 +384,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)); } From a597c7cabaaa714e4ed434e9c7a4db1e45f5ecc4 Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Mon, 11 May 2026 11:04:07 +0900 Subject: [PATCH 10/16] =?UTF-8?q?Refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/QueueServiceTest.java | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) 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 bc3bb89..788f346 100644 --- a/src/test/java/org/ticketing/queue/application/service/QueueServiceTest.java +++ b/src/test/java/org/ticketing/queue/application/service/QueueServiceTest.java @@ -232,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)); @@ -250,7 +245,7 @@ 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()); } @@ -261,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)); @@ -313,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); @@ -365,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); From 01c801bb8b7664b2cbec62edfb682e48ef9788df Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Tue, 12 May 2026 14:02:49 +0900 Subject: [PATCH 11/16] =?UTF-8?q?Refactor:=20application-docker.yml=20metr?= =?UTF-8?q?ics=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-docker.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 9feeba9..090a730 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -25,8 +25,13 @@ spring: min-idle: 20 max-wait: 3000ms + sql: + init: + mode: always + schema-locations: classpath:db/init-schema.sql + datasource: - url: jdbc:postgresql://${DB_HOST:ticketing-postgres}:${DB_PORT:15432}/${DB_NAME:ticketing}?currentSchema=${DB_SCHEMA:queue_service} + url: jdbc:postgresql://${DB_HOST:ticketing-postgres}:${DB_PORT:15432}/${DB_NAME:ticketing}?currentSchema=${DB_SCHEMA:queue} username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: org.postgresql.Driver @@ -58,7 +63,7 @@ management: endpoints: web: exposure: - include: health,info,prometheus + include: health,info,prometheus,metrics tracing: sampling: probability: 0.1 From 7076c63e95f532564e0a88d95bf44c3c01dc5c10 Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Wed, 13 May 2026 13:26:32 +0900 Subject: [PATCH 12/16] =?UTF-8?q?Feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=EC=8B=9C=20CLUB=5FADMIN=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=ED=95=B4=EB=8B=B9=20=EA=B2=BD=EA=B8=B0=20?= =?UTF-8?q?=ED=81=B4=EB=9F=BD=EC=9D=98=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=EC=A7=80=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MatchAuthorizationService.java | 62 +++++++++++++++++++ .../exception/NotFoundClubMatchException.java | 13 ++++ .../UnauthorizedClubAdminException.java | 13 ++++ .../infrastructure/feign/ClubFeignClient.java | 15 +++++ .../feign/MatchFeignClient.java | 15 +++++ .../feign/response/ClubResponse.java | 14 +++++ .../feign/response/MatchResponse.java | 16 +++++ .../controller/QueueController.java | 10 ++- 8 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java create mode 100644 src/main/java/org/ticketing/queue/domain/exception/NotFoundClubMatchException.java create mode 100644 src/main/java/org/ticketing/queue/domain/exception/UnauthorizedClubAdminException.java create mode 100644 src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClient.java create mode 100644 src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClient.java create mode 100644 src/main/java/org/ticketing/queue/infrastructure/feign/response/ClubResponse.java create mode 100644 src/main/java/org/ticketing/queue/infrastructure/feign/response/MatchResponse.java 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..0e8accc --- /dev/null +++ b/src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java @@ -0,0 +1,62 @@ +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.NotFoundClubMatchException; +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; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MatchAuthorizationService { + + private final MatchFeignClient matchFeignClient; + private final ClubFeignClient clubFeignClient; + + /** + * CLUB_ADMIN이 해당 경기(홈/어웨이)의 클럽 관리자인지 검증 + */ + public void validateClubAdmin(UUID matchId, UUID requestUserId) { + // 1. 경기 정보 조회 → 홈/어웨이 클럽 ID 추출 + MatchResponse match = fetchMatch(matchId); + + // 2. 홈/어웨이 클럽 관리자 ID 조회 + UUID homeAdminId = fetchAdminId(match.homeClubId()); + UUID awayAdminId = fetchAdminId(match.awayClubId()); + + // 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); + } + + private MatchResponse fetchMatch(UUID matchId) { + try { + return matchFeignClient.getMatch(matchId); + } catch (Exception e) { + log.error("[Auth] 경기 정보 조회 실패. matchId={}", matchId, e); + throw new NotFoundClubMatchException(matchId, null); + } + } + + private UUID fetchAdminId(UUID clubId) { + try { + return clubFeignClient.getClub(clubId).adminId(); + } catch (Exception e) { + log.error("[Auth] 클럽 정보 조회 실패. clubId={}", clubId, e); + throw new NotFoundClubMatchException(null, clubId); + } + } +} \ No newline at end of file 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/infrastructure/feign/ClubFeignClient.java b/src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClient.java new file mode 100644 index 0000000..87346a9 --- /dev/null +++ b/src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClient.java @@ -0,0 +1,15 @@ +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.ticketing.queue.infrastructure.feign.response.ClubResponse; + +import java.util.UUID; + +@FeignClient(name = "club-service") +public interface ClubFeignClient { + + @GetMapping("/internal/clubs/{clubId}") + ClubResponse getClub(@PathVariable("clubId") UUID clubId); +} \ No newline at end of file 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..1987d4b --- /dev/null +++ b/src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClient.java @@ -0,0 +1,15 @@ +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.ticketing.queue.infrastructure.feign.response.MatchResponse; + +import java.util.UUID; + +@FeignClient(name = "match-service") +public interface MatchFeignClient { + + @GetMapping("/api/matches/{matchId}") + MatchResponse getMatch(@PathVariable("matchId") UUID matchId); +} \ 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/presentation/controller/QueueController.java b/src/main/java/org/ticketing/queue/presentation/controller/QueueController.java index cd3b1f9..a7b8841 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,11 @@ 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) { + List roleList = Arrays.asList(roles.split(",")); + if (roleList.contains("CLUB_ADMIN")) { + matchAuthorizationService.validateClubAdmin(matchId, userId); + } queueService.banUser(matchId, userId); } } \ No newline at end of file From d71619f38f5703ab9c890514d4468954ab1d4330 Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Wed, 13 May 2026 13:28:13 +0900 Subject: [PATCH 13/16] =?UTF-8?q?Refactor:=20=EA=B2=BD=EA=B8=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=ED=97=A4=EB=8D=94=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 --- .../queue/application/service/MatchAuthorizationService.java | 2 +- .../queue/infrastructure/feign/MatchFeignClient.java | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java b/src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java index 0e8accc..874d1e0 100644 --- a/src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java +++ b/src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java @@ -44,7 +44,7 @@ public void validateClubAdmin(UUID matchId, UUID requestUserId) { private MatchResponse fetchMatch(UUID matchId) { try { - return matchFeignClient.getMatch(matchId); + return matchFeignClient.getMatch(matchId, "ADMIN"); } catch (Exception e) { log.error("[Auth] 경기 정보 조회 실패. matchId={}", matchId, e); throw new NotFoundClubMatchException(matchId, null); diff --git a/src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClient.java b/src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClient.java index 1987d4b..276fa68 100644 --- a/src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClient.java +++ b/src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClient.java @@ -3,6 +3,7 @@ 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; @@ -11,5 +12,6 @@ public interface MatchFeignClient { @GetMapping("/api/matches/{matchId}") - MatchResponse getMatch(@PathVariable("matchId") UUID matchId); + MatchResponse getMatch(@PathVariable("matchId") UUID matchId, + @RequestHeader("X-User-Roles") String roles); } \ No newline at end of file From 10a883873090d66d90cbe2a26c1cf10b2bdc1c57 Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Wed, 13 May 2026 15:10:50 +0900 Subject: [PATCH 14/16] =?UTF-8?q?Refactor:=20Retry,=20fallback=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20application-docker.yml=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MatchAuthorizationService.java | 46 +++++++------------ .../infrastructure/feign/ClubFeignClient.java | 6 ++- .../feign/ClubFeignClientFallback.java | 19 ++++++++ .../feign/MatchFeignClient.java | 4 +- .../feign/MatchFeignClientFallback.java | 19 ++++++++ src/main/resources/application-docker.yml | 34 +++++++++++--- 6 files changed, 88 insertions(+), 40 deletions(-) create mode 100644 src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClientFallback.java create mode 100644 src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClientFallback.java diff --git a/src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java b/src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java index 874d1e0..d6b5148 100644 --- a/src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java +++ b/src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java @@ -3,13 +3,13 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.ticketing.queue.domain.exception.NotFoundClubMatchException; 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 @@ -19,20 +19,26 @@ public class MatchAuthorizationService { private final MatchFeignClient matchFeignClient; private final ClubFeignClient clubFeignClient; - /** - * CLUB_ADMIN이 해당 경기(홈/어웨이)의 클럽 관리자인지 검증 - */ + private static final String SERVICE_NAME = "queue-service"; + public void validateClubAdmin(UUID matchId, UUID requestUserId) { - // 1. 경기 정보 조회 → 홈/어웨이 클럽 ID 추출 - MatchResponse match = fetchMatch(matchId); + // 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() + ); - // 2. 홈/어웨이 클럽 관리자 ID 조회 - UUID homeAdminId = fetchAdminId(match.homeClubId()); - UUID awayAdminId = fetchAdminId(match.awayClubId()); + UUID homeAdminId = homeFuture.join(); + UUID awayAdminId = awayFuture.join(); - // 3. 요청자가 둘 중 하나의 관리자인지 확인 + // 3. 권한 검증 boolean isAuthorized = requestUserId.equals(homeAdminId) - || requestUserId.equals(awayAdminId); + || requestUserId.equals(awayAdminId); if (!isAuthorized) { log.warn("[Auth] CLUB_ADMIN 권한 없음. matchId={}, requestUserId={}", matchId, requestUserId); @@ -41,22 +47,4 @@ public void validateClubAdmin(UUID matchId, UUID requestUserId) { log.info("[Auth] CLUB_ADMIN 검증 완료. matchId={}, requestUserId={}", matchId, requestUserId); } - - private MatchResponse fetchMatch(UUID matchId) { - try { - return matchFeignClient.getMatch(matchId, "ADMIN"); - } catch (Exception e) { - log.error("[Auth] 경기 정보 조회 실패. matchId={}", matchId, e); - throw new NotFoundClubMatchException(matchId, null); - } - } - - private UUID fetchAdminId(UUID clubId) { - try { - return clubFeignClient.getClub(clubId).adminId(); - } catch (Exception e) { - log.error("[Auth] 클럽 정보 조회 실패. clubId={}", clubId, e); - throw new NotFoundClubMatchException(null, clubId); - } - } } \ No newline at end of file diff --git a/src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClient.java b/src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClient.java index 87346a9..7fefce5 100644 --- a/src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClient.java +++ b/src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClient.java @@ -3,13 +3,15 @@ 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") +@FeignClient(name = "club-service", fallback = ClubFeignClientFallback.class) public interface ClubFeignClient { @GetMapping("/internal/clubs/{clubId}") - ClubResponse getClub(@PathVariable("clubId") UUID 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 index 276fa68..5921f13 100644 --- a/src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClient.java +++ b/src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClient.java @@ -8,10 +8,10 @@ import java.util.UUID; -@FeignClient(name = "match-service") +@FeignClient(name = "match-service", fallback = MatchFeignClientFallback.class) public interface MatchFeignClient { @GetMapping("/api/matches/{matchId}") MatchResponse getMatch(@PathVariable("matchId") UUID matchId, - @RequestHeader("X-User-Roles") String roles); + @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/resources/application-docker.yml b/src/main/resources/application-docker.yml index 090a730..7bbdaea 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -2,7 +2,7 @@ server: tomcat: threads: max: 1000 - min-spare: 100 + min-spare: 500 accept-count: 1000 max-connections: 10000 @@ -25,13 +25,8 @@ spring: min-idle: 20 max-wait: 3000ms - sql: - init: - mode: always - schema-locations: classpath:db/init-schema.sql - datasource: - url: jdbc:postgresql://${DB_HOST:ticketing-postgres}:${DB_PORT:15432}/${DB_NAME:ticketing}?currentSchema=${DB_SCHEMA:queue} + url: jdbc:postgresql://${DB_HOST:ticketing-postgres}:${DB_PORT:15432}/${DB_NAME:ticketing}?currentSchema=${DB_SCHEMA:queue_service} username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: org.postgresql.Driver @@ -60,6 +55,9 @@ management: metrics: tags: application: ${spring.application.name} + distribution: + percentiles-histogram: + http.server.requests: true endpoints: web: exposure: @@ -71,6 +69,28 @@ management: tracing: endpoint: ${ZIPKIN_URL:http://localhost:9411/api/v2/spans} +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: + - java.lang.Exception + + retry: + instances: + default: + max-attempts: 3 + wait-duration: 500ms + retry-exceptions: + - java.lang.Exception + queue: token: secret: ${QUEUE_TOKEN_SECRET} From 71f9c2c3d24c3e4c7c22fa5f9ffcb786364c238e Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Thu, 14 May 2026 11:31:40 +0900 Subject: [PATCH 15/16] =?UTF-8?q?Refactor:=20application-docker.yml=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-docker.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 7bbdaea..c7a0902 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -6,8 +6,6 @@ server: accept-count: 1000 max-connections: 10000 - port: ${SERVER_PORT:20007} - spring: config: activate: @@ -15,8 +13,8 @@ spring: data: redis: - host: ${REDIS_HOST:ticketing-redis} - port: ${REDIS_PORT:6379} + host: ${REDIS_HOST} + port: ${REDIS_PORT} timeout: 10000ms lettuce: pool: @@ -26,7 +24,7 @@ spring: max-wait: 3000ms datasource: - url: jdbc:postgresql://${DB_HOST:ticketing-postgres}:${DB_PORT:15432}/${DB_NAME:ticketing}?currentSchema=${DB_SCHEMA:queue_service} + 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 @@ -39,7 +37,7 @@ spring: ddl-auto: ${JPA_DDL_AUTO:update} properties: hibernate: - default_schema: ${DB_SCHEMA:queue} + default_schema: ${DB_SCHEMA:queue_service} hbm2ddl: create_schemas: true From 7f2acd3ef0826c37e99d5d201ea2fd5c6a0ec18b Mon Sep 17 00:00:00 2001 From: Lim JinKeon <0907john24@gmail.com> Date: Thu, 14 May 2026 13:47:10 +0900 Subject: [PATCH 16/16] =?UTF-8?q?Refactor:=20coderabbit=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../queue/application/service/QueueService.java | 5 +++-- .../persistence/SseEmitterRepository.java | 9 +++------ .../queue/presentation/controller/QueueController.java | 10 +++++++--- src/main/resources/application-docker.yml | 4 +++- 4 files changed, 16 insertions(+), 12 deletions(-) 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 f23f6db..54ba1ae 100644 --- a/src/main/java/org/ticketing/queue/application/service/QueueService.java +++ b/src/main/java/org/ticketing/queue/application/service/QueueService.java @@ -168,9 +168,10 @@ public SseEmitter subscribe(UUID matchId, UUID userId) { .name("error") .data("서버 오류가 발생했습니다.") ); - } catch (IOException ignored) { + } catch (IOException sendEx) { + log.warn("[SSE] 에러 이벤트 전송 실패. matchId={}, userId={}", matchId, userId, sendEx); + } finally { emitter.completeWithError(e); - sseEmitterRepository.remove(matchId, userId); } } 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 ff8bef7..0e0a447 100644 --- a/src/main/java/org/ticketing/queue/infrastructure/persistence/SseEmitterRepository.java +++ b/src/main/java/org/ticketing/queue/infrastructure/persistence/SseEmitterRepository.java @@ -28,13 +28,10 @@ public SseEmitter find(UUID matchId, UUID userId) { public void remove(UUID matchId, UUID userId) { emitters.remove(buildKey(matchId, userId)); - Set userIds = matchUserIndex.get(matchId); - if (userIds != null) { + matchUserIndex.computeIfPresent(matchId, (id, userIds) -> { userIds.remove(userId); - if (userIds.isEmpty()) { - matchUserIndex.remove(matchId, userIds); - } - } + return userIds.isEmpty() ? null : userIds; + }); } public List findUserIdsByMatchId(UUID matchId) { 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 a7b8841..b803639 100644 --- a/src/main/java/org/ticketing/queue/presentation/controller/QueueController.java +++ b/src/main/java/org/ticketing/queue/presentation/controller/QueueController.java @@ -136,9 +136,13 @@ 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, @RequestHeader("X-User-Roles") String roles) { - List roleList = Arrays.asList(roles.split(",")); - if (roleList.contains("CLUB_ADMIN")) { - matchAuthorizationService.validateClubAdmin(matchId, userId); + 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); } diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index c7a0902..d78d5d3 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -79,7 +79,9 @@ resilience4j: wait-duration-in-open-state: 10s permitted-number-of-calls-in-half-open-state: 3 record-exceptions: - - java.lang.Exception + - feign.RetryableException + - java.io.IOException + - java.util.concurrent.TimeoutException retry: instances: