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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
public class SessionInfo {
private final String sessionId;
private final LocalDateTime createdAt;
private SessionState state;
private volatile SessionState state;

public static SessionInfo create() {
return SessionInfo.builder()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.dongsoop.dongsoop.blinddate.executor;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

/**
* 과팅(BlindDate) 상태 변경 이벤트를 순서대로 처리하는 단일 스레드 큐.
* <p>
* 입장/재접속/세션 시작 판단/퇴장/인원 브로드캐스트 등 참가자·세션 상태를 건드리는 작업은
* 전부 이 큐를 통해 제출되어 하나의 스레드에서 순서대로 처리된다. 처리를 담당하는 스레드가
* 항상 하나뿐이므로, 이 상태들에 대해서는 별도의 락이 필요 없다.
* (BlindDateMatchingLock, BlindDateMemberLock, BlindDateSessionLock 대체)
*/
@Slf4j
@Component
public class BlindDateEventQueue {

private final ExecutorService executor = Executors.newSingleThreadExecutor();

/**
* 이벤트를 큐에 넣는다. 이미 큐에 있는 다른 이벤트들이 처리된 뒤 순서대로 실행된다.
* <p>
* executor가 이미 종료된 상태에서 호출되어도(RejectedExecutionException 등) 호출자 스레드로
* 예외가 전파되지 않도록 방어한다.
*/
public void submit(Runnable event) {
try {
executor.execute(() -> {
try {
event.run();
} catch (Exception e) {
log.error("[BlindDate] Event processing failed", e);
}
});
} catch (Exception e) {
log.error("[BlindDate] Failed to submit event to queue", e);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 애플리케이션(빈) 종료 시 큐 정리. 과팅 라운드 종료(scheduleAutoClose)와는 다른 시점이다 —
* 한 번 호출되면 이 executor는 다시 못 쓰게 되므로, 다음 과팅 라운드를 위해 라운드 종료
* 시점에는 절대 호출하면 안 된다.
*/
public void shutdown() {
executor.shutdownNow();
}

/**
* 지금까지 제출된 이벤트가 모두 처리될 때까지 대기한다.
* <p>
* 큐가 단일 스레드로 순서대로(FIFO) 처리되므로, 트리비얼 작업을 제출해 그 작업이 끝날 때까지
* 기다리면 그 이전에 제출된 모든 이벤트의 처리가 끝났음을 보장할 수 있다. 테스트에서 비동기
* 처리 완료를 기다릴 때 사용한다.
*/
public void awaitIdle() {
try {
executor.submit(() -> null).get();
} catch (Exception e) {
throw new IllegalStateException("[BlindDate] Failed to await queue idle", e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@
import com.dongsoop.dongsoop.blinddate.entity.ParticipantInfo;
import com.dongsoop.dongsoop.blinddate.entity.SessionInfo;
import com.dongsoop.dongsoop.blinddate.exception.SessionTerminatedException;
import com.dongsoop.dongsoop.blinddate.lock.BlindDateMatchingLock;
import com.dongsoop.dongsoop.blinddate.lock.BlindDateMemberLock;
import com.dongsoop.dongsoop.blinddate.lock.BlindDateSessionLock;
import com.dongsoop.dongsoop.blinddate.executor.BlindDateEventQueue;
import com.dongsoop.dongsoop.blinddate.repository.BlindDateParticipantStorage;
import com.dongsoop.dongsoop.blinddate.repository.BlindDateSessionStorage;
import com.dongsoop.dongsoop.blinddate.repository.BlindDateStorage;
Expand All @@ -32,9 +30,8 @@ public class BlindDateConnectHandler {
private final BlindDateSessionService sessionService;
private final BlindDateSessionScheduler sessionScheduler;
private final SimpMessagingTemplate messagingTemplate;
private final BlindDateMatchingLock blindDateMatchingLock;
private final BlindDateMemberLock blindDateMemberLock;
private final BlindDateSessionLock sessionLock;
private final BlindDateEventQueue eventQueue;
private final BlindDateDisconnectHandler disconnectHandler;

/**
* 세션 참여 및 세션 id 반환
Expand All @@ -46,8 +43,24 @@ public void execute(String socketId, Long memberId, Map<String, Object> sessionA
// 과팅 운영 중이 아닌 경우 종료
this.validateBlindDateAvailability();

// 참가자/세션 상태를 건드리는 처리는 전부 큐에서 순서대로 처리
eventQueue.submit(() -> handle(socketId, memberId, sessionAttributes));
}

private void handle(String socketId, Long memberId, Map<String, Object> sessionAttributes) {
// 이미 참여 중인 경우 소켓만 추가 후 종료
String existingSessionId = this.tryHandleReconnection(socketId, memberId);
String existingSessionId;
try {
existingSessionId = this.tryHandleReconnection(socketId, memberId);
} catch (SessionTerminatedException e) {
// 재접속하려는 세션이 이미 종료된 경우: 참가자 기록은 재입장 방지를 위해 그대로 두고
// 클라이언트에만 알림 (BlindDateSessionSchedulerImpl.finalizeSession 참고)
log.info("[BlindDate] Reconnect target session already terminated: memberId={}", memberId);
sessionAttributes.remove("sessionId");
sendSessionTerminatedEvent(memberId);
return;
}

if (existingSessionId != null) {
sessionAttributes.put("sessionId", existingSessionId);
return;
Expand All @@ -63,26 +76,18 @@ public void execute(String socketId, Long memberId, Map<String, Object> sessionA

String sessionId = joinResult.sessionId();

// 세션 시작 시 연결 해제 및 추가 연결을 막기 위한 세션 락
this.sessionLock.lockBySessionId(sessionId);

try {
// 마지막 참여자인지 검증 후 과팅 세션 시작 시도
if (tryStart(sessionId)) {
// 마지막으로 입장한 사용자의 소켓 수신을 위해 현재 스레드를 종료하고 새 스레드에서 처리
new Thread(() -> sessionScheduler.start(sessionId)).start();
return;
}
} finally {
// 세션 락 해제
this.sessionLock.unlockBySessionId(sessionId);
// 마지막 참여자인지 검증 후 과팅 세션 시작 시도
if (tryStart(sessionId)) {
// 마지막으로 입장한 사용자의 소켓 수신을 위해 현재 스레드를 종료하고 새 스레드에서 처리
new Thread(() -> sessionScheduler.start(sessionId)).start();
return;
}

// 마지막 참여자가 아닌 경우 인원 업데이트 브로드캐스트
blindDateService.broadcastJoinedCount(joinResult.sessionId(), joinResult.currentCount());
}

private synchronized boolean tryStart(String sessionId) {
private boolean tryStart(String sessionId) {
// 마지막 참여자인 경우 세션 시작
if (sessionService.isSessionFull(sessionId)) {
if (!sessionStorage.isWaiting(sessionId)) {
Expand All @@ -99,49 +104,50 @@ private synchronized boolean tryStart(String sessionId) {
}

private BlindDateJoinResult join(String socketId, Long memberId, Map<String, Object> sessionAttributes) {
// 처음 입장 시 포인터 할당을 위해 매칭 획득
blindDateMatchingLock.lock();

BlindDateJoinResult joinResult;
String sessionId;
ParticipantInfo participant;

try {
// 과팅 세션 할당 (Pointer 기반, Lock으로 동시성 보장)
String sessionId = assignSession();
// 과팅 세션 할당 (Pointer 기반, 큐에서 순서대로 처리되므로 동시성 보장)
sessionId = assignSession();

// 과팅 세션 id 세션 속성에 저장
sessionAttributes.put("sessionId", sessionId);

// 참여 정보 추가 (assignSession에서 편입 가능한 과팅 세션 여부를 확인했기에 바로 저장)
ParticipantInfo participant = participantStorage.addParticipant(sessionId, memberId, socketId);

// 과팅 세션 편입 후 참가자 수 조회
List<ParticipantInfo> participantInfos = participantStorage.findAllBySessionId(sessionId);
int currentCount = participantInfos.size();
int maxCount = blindDateStorage.getMaxSessionMemberCount();

joinResult = new BlindDateJoinResult(participant, sessionId, currentCount, maxCount);
participant = participantStorage.addParticipant(sessionId, memberId, socketId);
} catch (Exception e) {
// 입장 과정에서 오류 발생 시 회원 제거
// addParticipant는 compute() 기반이라 예외 발생 시 참가자 맵에 아무 것도 반영되지 않는다.
// 따라서 여기서 회원을 제거하면, 다른 세션에 이미 정상 등록된 참가자 정보를 잘못 지울 수 있다.
log.error("[BlindDate] Exception from enter process: memberId={}", memberId, e);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
this.participantStorage.removeParticipant(memberId);
sessionAttributes.remove("sessionId");

return null;
} finally {
// 회원 편입 후 회원 락 해제
blindDateMatchingLock.unlock();
}

try {
// 과팅 세션 편입 후 참가자 수 조회
List<ParticipantInfo> participantInfos = participantStorage.findAllBySessionId(sessionId);
int currentCount = participantInfos.size();
int maxCount = blindDateStorage.getMaxSessionMemberCount();

BlindDateJoinResult joinResult = new BlindDateJoinResult(participant, sessionId, currentCount, maxCount);

// 입장한 사용자에게 정보 전달
sendJoinEvent(joinResult);

return joinResult;
} catch (Exception e) {
// 입장한 사용자에게 정보 전달 실패 시 소켓 연결 해지로 보고 Disconnect에서 처리하도록 종료
log.info("[BlindDate] Failed to send JOIN event, rolling back participant: memberId={}", memberId, e);
// 이 시점엔 addParticipant가 이미 성공해 참가자가 등록된 상태다. 등록 이후 단계에서
// 실패하면(정원 조회 실패, 알림 전송 실패 등) 참가자가 고아로 남으므로, 방금 추가한
// 소켓을 그대로 퇴장 처리(큐에 위임)해 되돌리고, 클라이언트에는 재시도를 요청한다.
log.error("[BlindDate] Post-registration failure, rolling back: memberId={}", memberId, e);
sessionAttributes.remove("sessionId");
sendJoinFailedEvent(memberId);
disconnectHandler.execute(socketId, memberId, sessionId);

return null;
}

return joinResult;
}

/**
Expand All @@ -161,32 +167,28 @@ private void validateBlindDateAvailability() {
* @return 기존 세션 ID (재연결인 경우), null (첫 연결인 경우)
*/
private String tryHandleReconnection(String socketId, Long memberId) {
// 이미 참여중인 시나리오에 대해 안전한 소켓 추가를 위해 회원 락 획득
blindDateMemberLock.lockByMemberId(memberId);

try {
ParticipantInfo existingParticipant = participantStorage.getByMemberId(memberId);
// 첫 매칭인 경우
if (existingParticipant == null) {
return null;
}

String existingSessionId = existingParticipant.getSessionId();
ParticipantInfo existingParticipant = participantStorage.getByMemberId(memberId);
// 첫 매칭인 경우
if (existingParticipant == null) {
return null;
}

// 재연결된 세션이 존재하지 않는 경우 (세션 종료 후 재연결 시도 등) 예외 처리
if (this.sessionStorage.getState(existingSessionId) == null) {
throw new SessionTerminatedException();
}
String existingSessionId = existingParticipant.getSessionId();

existingParticipant.addSocket(socketId);
return existingSessionId;
} finally {
blindDateMemberLock.unlockByMemberId(memberId); // 회원 락 해제
// 재연결된 세션이 존재하지 않는 경우 (세션 종료 후 재연결 시도 등) 예외 처리
if (this.sessionStorage.getState(existingSessionId) == null) {
throw new SessionTerminatedException();
}

// addParticipant는 같은 세션이면 소켓 추가 + socketId->memberId 역인덱스 갱신까지 함께 처리한다.
// existingParticipant.addSocket()을 직접 호출하면 역인덱스가 안 갱신되어, 이 소켓이
// 나중에 연결을 끊어도 removeSocket()이 찾지 못해 참가자가 정리되지 않는 문제가 있었다.
participantStorage.addParticipant(existingSessionId, memberId, socketId);
return existingSessionId;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 세션 할당 (Lock으로 동시성 보장)
* 세션 할당 (큐에서 순서대로 처리되어 동시성 보장)
*
* @return 할당된 세션 ID
*/
Expand Down Expand Up @@ -244,4 +246,42 @@ private void sendJoinEvent(BlindDateJoinResult joinResult) {
throw e;
}
}

/**
* 등록 이후 단계(정원 조회, 알림 전송 등) 실패로 입장을 되돌렸음을 클라이언트에 알리고 재시도를 요청
*
* @param memberId 알림 대상 회원 id
*/
private void sendJoinFailedEvent(Long memberId) {
Map<String, Object> event = Map.of("state", "FAILED");

try {
messagingTemplate.convertAndSendToUser(
memberId.toString(),
"/queue/blinddate/join",
event
);
} catch (Exception e) {
log.error("Failed to send JOIN_FAILED event: memberId={}", memberId, e);
}
}

/**
* 재접속하려는 세션이 이미 종료되었음을 클라이언트에 알림
*
* @param memberId 알림 대상 회원 id
*/
private void sendSessionTerminatedEvent(Long memberId) {
Map<String, Object> event = Map.of("state", "TERMINATED");

try {
messagingTemplate.convertAndSendToUser(
memberId.toString(),
"/queue/blinddate/join",
event
);
} catch (Exception e) {
log.error("Failed to send SESSION_TERMINATED event: memberId={}", memberId, e);
}
}
}
Loading
Loading