Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
HELP.md
README.md
.git
.gitignore
Comment thread
LimJinKeon marked this conversation as resolved.
.gradle
build
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
Expand All @@ -22,6 +26,7 @@ bin/
*.iws
*.iml
*.ipr
out
out/
!**/src/main/**/out/
!**/src/test/**/out/
Expand All @@ -35,4 +40,5 @@ out/
/.nb-gradle/

### VS Code ###
.vscode
.vscode/
24 changes: 10 additions & 14 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Comment thread
LimJinKeon marked this conversation as resolved.
2 changes: 1 addition & 1 deletion postgres/init.sql
Original file line number Diff line number Diff line change
@@ -1 +1 @@
CREATE SCHEMA IF NOT EXISTS queue;
CREATE SCHEMA IF NOT EXISTS queue_service;
Original file line number Diff line number Diff line change
@@ -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<UUID> homeFuture = CompletableFuture.supplyAsync(() ->
clubFeignClient.getClub(match.homeClubId(), SERVICE_NAME).adminId()
);
CompletableFuture<UUID> awayFuture = CompletableFuture.supplyAsync(() ->
clubFeignClient.getClub(match.awayClubId(), SERVICE_NAME).adminId()
);

UUID homeAdminId = homeFuture.join();
UUID awayAdminId = awayFuture.join();
Comment on lines +29 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "MatchAuthorizationService.java" | head -20

Repository: 3s-ticketing/queue-service

Length of output: 157


🏁 Script executed:

# Get the file size first to ensure we can read it
wc -l src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java 2>/dev/null || find . -type f -name "MatchAuthorizationService.java" -exec wc -l {} \;

Repository: 3s-ticketing/queue-service

Length of output: 158


🏁 Script executed:

# Read the entire file to see the context
cat -n src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java 2>/dev/null || find . -type f -name "MatchAuthorizationService.java" -exec cat -n {} \;

Repository: 3s-ticketing/queue-service

Length of output: 2317


🏁 Script executed:

# Search for global exception handlers
find . -type f -name "*ExceptionHandler*.java" -o -name "*ControllerAdvice*.java" | head -20

Repository: 3s-ticketing/queue-service

Length of output: 52


🏁 Script executed:

# Search for exception handlers in the codebase
rg -t java "ControllerAdvice|ExceptionHandler|RestControllerAdvice" -A 3 | head -100

Repository: 3s-ticketing/queue-service

Length of output: 52


🏁 Script executed:

# Search for executor beans
rg -t java "Executor|ThreadPoolTaskExecutor|ExecutorService" | grep -i bean | head -20

Repository: 3s-ticketing/queue-service

Length of output: 52


🏁 Script executed:

# Search for CompletionException handling
rg -t java "CompletionException" | head -20

Repository: 3s-ticketing/queue-service

Length of output: 52


🏁 Script executed:

# Search for exception handling in main directory structure
find . -type f \( -name "*.java" -o -name "*.xml" -o -name "*.yml" -o -name "*.yaml" \) | grep -E "(config|exception|handler)" | head -20

Repository: 3s-ticketing/queue-service

Length of output: 1270


🏁 Script executed:

# Check for Spring configuration classes
rg -t java "@Configuration|@Bean" | grep -i "exception|error|handler" | head -20

Repository: 3s-ticketing/queue-service

Length of output: 52


🏁 Script executed:

# Look for where validateClubAdmin is called
rg "validateClubAdmin" -B 2 -A 2

Repository: 3s-ticketing/queue-service

Length of output: 1374


🏁 Script executed:

# Check project structure for any config files
ls -la src/main/resources/ 2>/dev/null || find . -path "*/main/resources/*" -type f | head -20

Repository: 3s-ticketing/queue-service

Length of output: 380


🏁 Script executed:

# Read the QueueController to see the context where validateClubAdmin is called
cat -n src/main/java/org/ticketing/queue/presentation/controller/QueueController.java | head -100

Repository: 3s-ticketing/queue-service

Length of output: 4263


🏁 Script executed:

# Check for exception handling in the controller class
rg -t java "QueueController" -A 50 | grep -A 50 "validateClubAdmin" | head -40

Repository: 3s-ticketing/queue-service

Length of output: 52


🏁 Script executed:

# Check application.yml for error handling
cat src/main/resources/application.yml

Repository: 3s-ticketing/queue-service

Length of output: 1013


🏁 Script executed:

# Check for Feign client and exception handling
cat -n src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClient.java

Repository: 3s-ticketing/queue-service

Length of output: 882


🏁 Script executed:

# Look for any exception handler configuration in infrastructure
find . -type f -name "*.java" -path "*/infrastructure/*" | xargs grep -l "ExceptionHandler\|GlobalExceptionHandler" | head -10

Repository: 3s-ticketing/queue-service

Length of output: 52


🏁 Script executed:

# Search for Feign exception handling
rg -t java "FeignException|feign" -A 3 | head -50

Repository: 3s-ticketing/queue-service

Length of output: 5010


🏁 Script executed:

# Check the ClubFeignClientFallback implementation
cat -n src/main/java/org/ticketing/queue/infrastructure/feign/ClubFeignClientFallback.java

Repository: 3s-ticketing/queue-service

Length of output: 849


🏁 Script executed:

# Check the MatchFeignClientFallback implementation
cat -n src/main/java/org/ticketing/queue/infrastructure/feign/MatchFeignClientFallback.java

Repository: 3s-ticketing/queue-service

Length of output: 858


🏁 Script executed:

# Look for `@ExceptionHandler` anywhere in the codebase
rg -t java "@ExceptionHandler" -B 3 -A 5

Repository: 3s-ticketing/queue-service

Length of output: 52


🏁 Script executed:

# Check if NotFoundClubMatchException is a RuntimeException
cat -n src/main/java/org/ticketing/queue/domain/exception/NotFoundClubMatchException.java

Repository: 3s-ticketing/queue-service

Length of output: 584


🏁 Script executed:

# Search for authorizationExecutor or any executor bean that might handle this
rg -t java "authorizationExecutor"

Repository: 3s-ticketing/queue-service

Length of output: 52


🏁 Script executed:

# Search for any TaskExecutor or ExecutorService beans
rg -t java "@Bean.*[Ee]xecutor|@Bean.*[Ee]xecutor" -A 2

Repository: 3s-ticketing/queue-service

Length of output: 52


Use dedicated executor and unwrap exceptions from CompletableFuture operations for blocking Feign calls.

The current code uses CompletableFuture.supplyAsync() without an executor, which defaults to the common ForkJoinPool for blocking HTTP calls. This creates thread pool contention under load. Additionally, join() wraps all exceptions—including NotFoundClubMatchException—in CompletionException, masking the original domain exception in error responses.

Configure a dedicated executor and unwrap completion exceptions to preserve proper error handling:

Suggested approach
- CompletableFuture<UUID> homeFuture = CompletableFuture.supplyAsync(() ->
+ CompletableFuture<UUID> homeFuture = CompletableFuture.supplyAsync(() ->
         clubFeignClient.getClub(match.homeClubId(), SERVICE_NAME).adminId()
- );
+ , authorizationExecutor);

- CompletableFuture<UUID> awayFuture = CompletableFuture.supplyAsync(() ->
+ CompletableFuture<UUID> awayFuture = CompletableFuture.supplyAsync(() ->
         clubFeignClient.getClub(match.awayClubId(), SERVICE_NAME).adminId()
- );
+ , authorizationExecutor);

- UUID homeAdminId = homeFuture.join();
- UUID awayAdminId = awayFuture.join();
+ UUID homeAdminId;
+ UUID awayAdminId;
+ try {
+     homeAdminId = homeFuture.join();
+     awayAdminId = awayFuture.join();
+ } catch (CompletionException e) {
+     throw (e.getCause() instanceof RuntimeException re) ? re : e;
+ }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/org/ticketing/queue/application/service/MatchAuthorizationService.java`
around lines 29 - 37, The code is creating blocking Feign calls with
CompletableFuture.supplyAsync(...) without a dedicated executor and is using
join() which wraps domain exceptions in CompletionException; fix by supplying a
dedicated Executor (e.g., an injected or created ExecutorService) to
CompletableFuture.supplyAsync(...) when calling clubFeignClient.getClub(...,
SERVICE_NAME) for both home and away, and replace the direct use of
homeFuture.join() / awayFuture.join() with an unwrap pattern that catches
CompletionException and rethrows its cause (preserving
NotFoundClubMatchException and other domain exceptions) or a small helper that
returns future.join() but unwraps CompletionException.getCause() before
propagating.


// 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Comment thread
LimJinKeon marked this conversation as resolved.
} 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);
}
}
Comment thread
LimJinKeon marked this conversation as resolved.

Expand All @@ -191,23 +191,23 @@ public void pushStatusToAll() {
List<UUID> userIds = sseEmitterRepository.findUserIdsByMatchId(matchId);
if (userIds.isEmpty()) continue;

// Pipeline으로 모든 유저 순위 한 번에 조회
Map<UUID, Long> 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);
});
}
}

Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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 등)
}
14 changes: 14 additions & 0 deletions src/main/java/org/ticketing/queue/domain/model/SlotAcquire.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -36,7 +36,7 @@ public interface QueueRedisRepository {

void releaseSlot(UUID matchId);

AcquireResult acquireSlotAndToken(UUID matchId, UUID userId);
SlotAcquire acquireSlotAndToken(UUID matchId, UUID userId);

// ── 통과 토큰 관리 ───────────────────────────────────────────────────

Expand Down
Loading
Loading