Skip to content

Commit 40f564c

Browse files
committed
battleRoom 이탈 관련 grace period 개선
1 parent 0d08666 commit 40f564c

8 files changed

Lines changed: 43 additions & 50 deletions

File tree

src/main/java/com/back/domain/battle/battleroom/service/BattleRoomService.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,15 @@ public JoinRoomResponse joinRoom(Long roomId, Long memberId) {
100100
.findByBattleRoomAndMember(room, member)
101101
.orElseThrow(() -> new IllegalArgumentException("해당 방의 참여자가 아닙니다."));
102102

103-
if (participant.getStatus() == BattleParticipantStatus.READY
104-
|| participant.getStatus() == BattleParticipantStatus.ABANDONED) {
105-
if (participant.getStatus() == BattleParticipantStatus.ABANDONED) {
103+
boolean wasAbandoned = participant.getStatus() == BattleParticipantStatus.ABANDONED;
104+
105+
if (participant.getStatus() == BattleParticipantStatus.READY || wasAbandoned) {
106+
if (wasAbandoned) {
106107
reconnectStore.cancelGracePeriod(memberId);
107108
}
109+
publishPlaying = true;
108110
participant.join();
109111
battleParticipantRepository.save(participant);
110-
publishPlaying = true;
111112
} else {
112113
throw new IllegalStateException("입장할 수 없는 참여자 상태입니다. 현재 상태: " + participant.getStatus());
113114
}

src/main/java/com/back/global/websocket/BattleDisconnectHandler.java

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.back.global.websocket;
22

33
import java.security.Principal;
4-
import java.util.Map;
54

65
import org.springframework.context.event.EventListener;
76
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -15,7 +14,6 @@
1514
import com.back.domain.battle.battleparticipant.repository.BattleParticipantRepository;
1615
import com.back.domain.battle.battleroom.entity.BattleRoomStatus;
1716
import com.back.global.security.SecurityUser;
18-
import com.back.global.websocket.pubsub.WebSocketMessagePublisher;
1917

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

2826
private final BattleParticipantRepository battleParticipantRepository;
2927
private final BattleReconnectStore reconnectStore;
30-
private final WebSocketMessagePublisher publisher;
3128

3229
/**
3330
* WebSocket 연결이 끊길 때 Spring이 자동으로 발생시키는 이벤트 처리.
@@ -65,18 +62,9 @@ public void handleDisconnect(SessionDisconnectEvent event) {
6562
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
6663
@Override
6764
public void afterCommit() {
68-
publisher.publish(
69-
"/topic/room/" + roomId,
70-
Map.of(
71-
"type",
72-
"PARTICIPANT_STATUS_CHANGED",
73-
"userId",
74-
memberId,
75-
"status",
76-
BattleParticipantStatus.ABANDONED.name()));
77-
// PARTICIPANT_LEFT를 즉시 보내지 않고 15초 유예 기간 부여.
78-
// 새로고침 등 의도치 않은 끊김 시 재연결 기회를 준다.
79-
// 유예 기간 만료 후 미복귀 시 GracePeriodConsumer가 브로드캐스트.
65+
// ABANDONED를 즉시 publish하지 않고 15초 유예 기간 부여.
66+
// 재연결 시 grace를 취소하면 외부에는 아무 이벤트도 가지 않는다.
67+
// 유예 기간 만료 후 미복귀 시 GracePeriodConsumer가 PARTICIPANT_STATUS_CHANGED(ABANDONED) 발행.
8068
reconnectStore.startGracePeriod(memberId);
8169
}
8270
});

src/main/java/com/back/global/websocket/BattleReconnectStore.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
* RDelayedQueue (ZSET) - offer 시 score = 지금+15초 로 저장
2828
* ↓ 15초 후 Redisson 내부 폴링(100ms)이 이동
2929
* RBlockingQueue (List) - GracePeriodConsumer.take()가 꺼냄
30+
*
3031
*/
3132
@Component
3233
@RequiredArgsConstructor
@@ -60,8 +61,8 @@ public void startGracePeriod(Long memberId) {
6061
* 타이밍에 따라 메시지가 ZSET 또는 List에 있을 수 있으므로 양쪽 모두 제거 시도한다.
6162
*/
6263
public void cancelGracePeriod(Long memberId) {
63-
delayedQueue().remove(memberId.toString()); // ZSET에 있으면 제거 (offer 직후 ~ 14.9s)
64-
blockingQueue().remove(memberId.toString()); // 이미 List로 이동했으면 거기서도 제거 (15s ~ take() 전)
64+
delayedQueue().remove(memberId.toString());
65+
blockingQueue().remove(memberId.toString());
6566
}
6667

6768
/**

src/main/java/com/back/global/websocket/BattleTimerStore.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* battle:timer:queue ZSET → List — Redisson DelayedQueue 내부 저장소
1818
*
1919
* BattleReconnectStore(grace period)와 동일한 패턴:
20-
* - BattleReconnectStore : memberId → 15초 후 PARTICIPANT_LEFT
20+
* - BattleReconnectStore : memberId → 15초 후 PARTICIPANT_STATUS_CHANGED(ABANDONED)
2121
* - BattleTimerStore : roomId → 30분 후 settle()
2222
*
2323
* 기존 BattleScheduler(폴링)와의 역할 분담:

src/main/java/com/back/global/websocket/GracePeriodConsumer.java

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,18 @@
1919
* Grace Period 만료 메시지를 소비하는 백그라운드 컨슈머.
2020
*
2121
* Redisson DelayedQueue에 등록된 메시지가 15초 후 BlockingQueue로 이동하면
22-
* 이 컨슈머가 꺼내서 PARTICIPANT_LEFT 브로드캐스트 여부를 결정한다.
22+
* 이 컨슈머가 꺼내서 PARTICIPANT_STATUS_CHANGED(ABANDONED) 브로드캐스트 여부를 결정한다.
2323
*
2424
* 처리 흐름:
2525
* blockingQueue.take() → memberId 수신
2626
* → DB 조회: 아직 ABANDONED 상태인지 확인
27-
* → ABANDONED → PARTICIPANT_LEFT 브로드캐스트
27+
* → ABANDONED → PARTICIPANT_STATUS_CHANGED(ABANDONED) 브로드캐스트
2828
* → PLAYING → 이미 재접속함, 스킵
2929
*
3030
*
3131
* DB 조회를 거치는 이유:
3232
* cancelGracePeriod()가 타이밍 상 blockingQueue에서 항목을 제거하지 못한 경우에도
33-
* 이미 재접속한 참여자에게 PARTICIPANT_LEFT가 잘못 발행되는 것을 방지한다.
33+
* 이미 재접속한 참여자에게 PARTICIPANT_STATUS_CHANGED(ABANDONED)가 잘못 발행되는 것을 방지한다.
3434
*/
3535
@Slf4j
3636
@Component
@@ -73,9 +73,19 @@ void handle(Long memberId) {
7373
.ifPresentOrElse(
7474
p -> {
7575
Long roomId = p.getBattleRoom().getId();
76-
log.info("grace period 만료 - PARTICIPANT_LEFT 전송 memberId={}, roomId={}", memberId, roomId);
76+
log.info(
77+
"grace period 만료 - PARTICIPANT_STATUS_CHANGED(ABANDONED) 전송 memberId={}, roomId={}",
78+
memberId,
79+
roomId);
7780
publisher.publish(
78-
"/topic/room/" + roomId, Map.of("type", "PARTICIPANT_LEFT", "userId", memberId));
81+
"/topic/room/" + roomId,
82+
Map.of(
83+
"type",
84+
"PARTICIPANT_STATUS_CHANGED",
85+
"userId",
86+
memberId,
87+
"status",
88+
"ABANDONED"));
7989
},
8090
() -> log.debug("grace period 만료 - 이미 재접속함, 스킵 memberId={}", memberId));
8191
}

src/test/java/com/back/domain/battle/battleroom/service/BattleRoomServiceJoinRoomTest.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ void joinRoom_readyParticipant_broadcastsPlayingStatus() {
8989
}
9090

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

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

105-
verify(reconnectStore).cancelGracePeriod(MEMBER_ID);
106105
assertThat(participant.getStatus()).isEqualTo(BattleParticipantStatus.PLAYING);
106+
verify(reconnectStore).cancelGracePeriod(MEMBER_ID);
107107
verify(publisher)
108108
.publish(
109109
eq("/topic/room/" + ROOM_ID),

src/test/java/com/back/global/websocket/BattleDisconnectHandlerTest.java

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import static org.mockito.Mockito.when;
99

1010
import java.util.Collections;
11-
import java.util.Map;
1211
import java.util.Optional;
1312

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

3635
private final BattleDisconnectHandler sut =
37-
new BattleDisconnectHandler(battleParticipantRepository, reconnectStore, publisher);
36+
new BattleDisconnectHandler(battleParticipantRepository, reconnectStore);
3837

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

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

60-
com.back.domain.battle.battleparticipant.entity.BattleParticipantStatus status = participant.getStatus();
61-
org.assertj.core.api.Assertions.assertThat(status).isEqualTo(BattleParticipantStatus.ABANDONED);
62-
verify(reconnectStore).startGracePeriod(MEMBER_ID);
59+
org.assertj.core.api.Assertions.assertThat(participant.getStatus())
60+
.isEqualTo(BattleParticipantStatus.ABANDONED);
6361
verify(battleParticipantRepository).save(participant);
64-
verify(publisher)
65-
.publish(
66-
"/topic/room/" + ROOM_ID,
67-
Map.of(
68-
"type",
69-
"PARTICIPANT_STATUS_CHANGED",
70-
"userId",
71-
MEMBER_ID,
72-
"status",
73-
BattleParticipantStatus.ABANDONED.name()));
62+
verify(reconnectStore).startGracePeriod(MEMBER_ID);
63+
verify(publisher, never()).publish(any(), any());
7464
}
7565

7666
@Test

src/test/java/com/back/global/websocket/GracePeriodConsumerTest.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ class GracePeriodConsumerTest {
3232
private static final Long ROOM_ID = 1L;
3333

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

@@ -50,7 +50,7 @@ class GracePeriodConsumerTest {
5050
}
5151

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

6464
@Test
65-
@DisplayName("PARTICIPANT_LEFT 발행 시 올바른 roomId와 타입이 포함된다")
65+
@DisplayName("PARTICIPANT_STATUS_CHANGED(ABANDONED) 발행 시 올바른 roomId, 타입, 상태가 포함된다")
6666
void handle_발행메시지에_올바른roomId포함() {
6767
BattleRoom room = mock(BattleRoom.class);
6868
when(room.getId()).thenReturn(ROOM_ID);
@@ -79,6 +79,9 @@ class GracePeriodConsumerTest {
7979
verify(publisher)
8080
.publish(
8181
eq("/topic/room/" + ROOM_ID),
82-
eq(java.util.Map.of("type", "PARTICIPANT_LEFT", "userId", MEMBER_ID)));
82+
eq(java.util.Map.of(
83+
"type", "PARTICIPANT_STATUS_CHANGED",
84+
"userId", MEMBER_ID,
85+
"status", "ABANDONED")));
8386
}
8487
}

0 commit comments

Comments
 (0)