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 @@ -100,14 +100,15 @@ public JoinRoomResponse joinRoom(Long roomId, Long memberId) {
.findByBattleRoomAndMember(room, member)
.orElseThrow(() -> new IllegalArgumentException("해당 방의 참여자가 아닙니다."));

if (participant.getStatus() == BattleParticipantStatus.READY
|| participant.getStatus() == BattleParticipantStatus.ABANDONED) {
if (participant.getStatus() == BattleParticipantStatus.ABANDONED) {
boolean wasAbandoned = participant.getStatus() == BattleParticipantStatus.ABANDONED;

if (participant.getStatus() == BattleParticipantStatus.READY || wasAbandoned) {
if (wasAbandoned) {
reconnectStore.cancelGracePeriod(memberId);
}
publishPlaying = true;
participant.join();
battleParticipantRepository.save(participant);
publishPlaying = true;
} else {
throw new IllegalStateException("입장할 수 없는 참여자 상태입니다. 현재 상태: " + participant.getStatus());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.back.global.websocket;

import java.security.Principal;
import java.util.Map;

import org.springframework.context.event.EventListener;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
Expand All @@ -15,7 +14,6 @@
import com.back.domain.battle.battleparticipant.repository.BattleParticipantRepository;
import com.back.domain.battle.battleroom.entity.BattleRoomStatus;
import com.back.global.security.SecurityUser;
import com.back.global.websocket.pubsub.WebSocketMessagePublisher;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -27,7 +25,6 @@ public class BattleDisconnectHandler {

private final BattleParticipantRepository battleParticipantRepository;
private final BattleReconnectStore reconnectStore;
private final WebSocketMessagePublisher publisher;

/**
* WebSocket 연결이 끊길 때 Spring이 자동으로 발생시키는 이벤트 처리.
Expand Down Expand Up @@ -65,18 +62,9 @@ public void handleDisconnect(SessionDisconnectEvent event) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
publisher.publish(
"/topic/room/" + roomId,
Map.of(
"type",
"PARTICIPANT_STATUS_CHANGED",
"userId",
memberId,
"status",
BattleParticipantStatus.ABANDONED.name()));
// PARTICIPANT_LEFT를 즉시 보내지 않고 15초 유예 기간 부여.
// 새로고침 등 의도치 않은 끊김 시 재연결 기회를 준다.
// 유예 기간 만료 후 미복귀 시 GracePeriodConsumer가 브로드캐스트.
// ABANDONED를 즉시 publish하지 않고 15초 유예 기간 부여.
// 재연결 시 grace를 취소하면 외부에는 아무 이벤트도 가지 않는다.
// 유예 기간 만료 후 미복귀 시 GracePeriodConsumer가 PARTICIPANT_STATUS_CHANGED(ABANDONED) 발행.
reconnectStore.startGracePeriod(memberId);
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
* RDelayedQueue (ZSET) - offer 시 score = 지금+15초 로 저장
* ↓ 15초 후 Redisson 내부 폴링(100ms)이 이동
* RBlockingQueue (List) - GracePeriodConsumer.take()가 꺼냄
*
*/
@Component
@RequiredArgsConstructor
Expand Down Expand Up @@ -60,8 +61,8 @@ public void startGracePeriod(Long memberId) {
* 타이밍에 따라 메시지가 ZSET 또는 List에 있을 수 있으므로 양쪽 모두 제거 시도한다.
*/
public void cancelGracePeriod(Long memberId) {
delayedQueue().remove(memberId.toString()); // ZSET에 있으면 제거 (offer 직후 ~ 14.9s)
blockingQueue().remove(memberId.toString()); // 이미 List로 이동했으면 거기서도 제거 (15s ~ take() 전)
delayedQueue().remove(memberId.toString());
blockingQueue().remove(memberId.toString());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* battle:timer:queue ZSET → List — Redisson DelayedQueue 내부 저장소
*
* BattleReconnectStore(grace period)와 동일한 패턴:
* - BattleReconnectStore : memberId → 15초 후 PARTICIPANT_LEFT
* - BattleReconnectStore : memberId → 15초 후 PARTICIPANT_STATUS_CHANGED(ABANDONED)
* - BattleTimerStore : roomId → 30분 후 settle()
*
* 기존 BattleScheduler(폴링)와의 역할 분담:
Expand Down
20 changes: 15 additions & 5 deletions src/main/java/com/back/global/websocket/GracePeriodConsumer.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,18 @@
* Grace Period 만료 메시지를 소비하는 백그라운드 컨슈머.
*
* Redisson DelayedQueue에 등록된 메시지가 15초 후 BlockingQueue로 이동하면
* 이 컨슈머가 꺼내서 PARTICIPANT_LEFT 브로드캐스트 여부를 결정한다.
* 이 컨슈머가 꺼내서 PARTICIPANT_STATUS_CHANGED(ABANDONED) 브로드캐스트 여부를 결정한다.
*
* 처리 흐름:
* blockingQueue.take() → memberId 수신
* → DB 조회: 아직 ABANDONED 상태인지 확인
* → ABANDONED → PARTICIPANT_LEFT 브로드캐스트
* → ABANDONED → PARTICIPANT_STATUS_CHANGED(ABANDONED) 브로드캐스트
* → PLAYING → 이미 재접속함, 스킵
*
*
* DB 조회를 거치는 이유:
* cancelGracePeriod()가 타이밍 상 blockingQueue에서 항목을 제거하지 못한 경우에도
* 이미 재접속한 참여자에게 PARTICIPANT_LEFT가 잘못 발행되는 것을 방지한다.
* 이미 재접속한 참여자에게 PARTICIPANT_STATUS_CHANGED(ABANDONED)가 잘못 발행되는 것을 방지한다.
*/
@Slf4j
@Component
Expand Down Expand Up @@ -73,9 +73,19 @@ void handle(Long memberId) {
.ifPresentOrElse(
p -> {
Long roomId = p.getBattleRoom().getId();
log.info("grace period 만료 - PARTICIPANT_LEFT 전송 memberId={}, roomId={}", memberId, roomId);
log.info(
"grace period 만료 - PARTICIPANT_STATUS_CHANGED(ABANDONED) 전송 memberId={}, roomId={}",
memberId,
roomId);
publisher.publish(
"/topic/room/" + roomId, Map.of("type", "PARTICIPANT_LEFT", "userId", memberId));
"/topic/room/" + roomId,
Map.of(
"type",
"PARTICIPANT_STATUS_CHANGED",
"userId",
memberId,
"status",
"ABANDONED"));
},
() -> log.debug("grace period 만료 - 이미 재접속함, 스킵 memberId={}", memberId));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ void joinRoom_readyParticipant_broadcastsPlayingStatus() {
}

@Test
@DisplayName("ABANDONED 참여자가 재입장하면 grace period를 취소하고 PLAYING 상태 이벤트를 발행한다")
void joinRoom_abandonedParticipant_cancelsGracePeriodAndBroadcastsPlayingStatus() {
@DisplayName("ABANDONED 참여자가 재입장하면 grace를 취소하고 PLAYING 이벤트를 발행한다")
void joinRoom_abandonedParticipant_cancelsGraceAndBroadcastsPlayingStatus() {
BattleRoom room = playingRoom();
Member member = member();
BattleParticipant participant = BattleParticipant.create(room, member);
Expand All @@ -102,8 +102,8 @@ void joinRoom_abandonedParticipant_cancelsGracePeriodAndBroadcastsPlayingStatus(

withAfterCommit(() -> sut.joinRoom(ROOM_ID, MEMBER_ID));

verify(reconnectStore).cancelGracePeriod(MEMBER_ID);
assertThat(participant.getStatus()).isEqualTo(BattleParticipantStatus.PLAYING);
verify(reconnectStore).cancelGracePeriod(MEMBER_ID);
verify(publisher)
.publish(
eq("/topic/room/" + ROOM_ID),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import static org.mockito.Mockito.when;

import java.util.Collections;
import java.util.Map;
import java.util.Optional;

import org.junit.jupiter.api.DisplayName;
Expand All @@ -34,7 +33,7 @@ class BattleDisconnectHandlerTest {
private final WebSocketMessagePublisher publisher = mock(WebSocketMessagePublisher.class);

private final BattleDisconnectHandler sut =
new BattleDisconnectHandler(battleParticipantRepository, reconnectStore, publisher);
new BattleDisconnectHandler(battleParticipantRepository, reconnectStore);

private static final Long MEMBER_ID = 10L;
private static final Long ROOM_ID = 1L;
Expand All @@ -57,20 +56,11 @@ class BattleDisconnectHandlerTest {

withAfterCommit(() -> sut.handleDisconnect(event));

com.back.domain.battle.battleparticipant.entity.BattleParticipantStatus status = participant.getStatus();
org.assertj.core.api.Assertions.assertThat(status).isEqualTo(BattleParticipantStatus.ABANDONED);
verify(reconnectStore).startGracePeriod(MEMBER_ID);
org.assertj.core.api.Assertions.assertThat(participant.getStatus())
.isEqualTo(BattleParticipantStatus.ABANDONED);
verify(battleParticipantRepository).save(participant);
verify(publisher)
.publish(
"/topic/room/" + ROOM_ID,
Map.of(
"type",
"PARTICIPANT_STATUS_CHANGED",
"userId",
MEMBER_ID,
"status",
BattleParticipantStatus.ABANDONED.name()));
verify(reconnectStore).startGracePeriod(MEMBER_ID);
verify(publisher, never()).publish(any(), any());
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ class GracePeriodConsumerTest {
private static final Long ROOM_ID = 1L;

@Test
@DisplayName("grace period 만료 시 참여자가 ABANDONED이면 PARTICIPANT_LEFT를 브로드캐스트한다")
void handle_ABANDONED참여자_PARTICIPANT_LEFT발행() {
@DisplayName("grace period 만료 시 참여자가 ABANDONED이면 PARTICIPANT_STATUS_CHANGED(ABANDONED)를 브로드캐스트한다")
void handle_ABANDONED참여자_PARTICIPANT_STATUS_CHANGED발행() {
BattleRoom room = mock(BattleRoom.class);
when(room.getId()).thenReturn(ROOM_ID);

Expand All @@ -50,7 +50,7 @@ class GracePeriodConsumerTest {
}

@Test
@DisplayName("grace period 만료 시 참여자가 이미 재접속했으면 PARTICIPANT_LEFT를 발행하지 않는다")
@DisplayName("grace period 만료 시 참여자가 이미 재접속했으면 publish하지 않는다")
void handle_이미재접속한참여자_발행안함() {
when(battleParticipantRepository.findAbandonedParticipantByMemberId(
MEMBER_ID, BattleParticipantStatus.ABANDONED, BattleRoomStatus.PLAYING))
Expand All @@ -62,7 +62,7 @@ class GracePeriodConsumerTest {
}

@Test
@DisplayName("PARTICIPANT_LEFT 발행 시 올바른 roomId와 타입이 포함된다")
@DisplayName("PARTICIPANT_STATUS_CHANGED(ABANDONED) 발행 시 올바른 roomId, 타입, 상태가 포함된다")
void handle_발행메시지에_올바른roomId포함() {
BattleRoom room = mock(BattleRoom.class);
when(room.getId()).thenReturn(ROOM_ID);
Expand All @@ -79,6 +79,9 @@ class GracePeriodConsumerTest {
verify(publisher)
.publish(
eq("/topic/room/" + ROOM_ID),
eq(java.util.Map.of("type", "PARTICIPANT_LEFT", "userId", MEMBER_ID)));
eq(java.util.Map.of(
"type", "PARTICIPANT_STATUS_CHANGED",
"userId", MEMBER_ID,
"status", "ABANDONED")));
}
}
Loading