Skip to content

Commit 8f8bc24

Browse files
authored
Merge pull request #294 from prgrms-web-devcourse-final-project/Feat/289
Feat: 웹소켓 이벤트 기반으로 스터디룸 멤버 동기화 개선 (#289)
2 parents 0a127be + 40ed115 commit 8f8bc24

File tree

7 files changed

+221
-825
lines changed

7 files changed

+221
-825
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.back.global.websocket.event;
2+
3+
import lombok.Getter;
4+
import org.springframework.context.ApplicationEvent;
5+
6+
/**
7+
* WebSocket 세션 연결이 종료되었을 때 발생하는 내부 이벤트.
8+
* 이 이벤트는 SessionManager가 발행하고, ParticipantService가 구독하여 처리합니다.
9+
*/
10+
@Getter
11+
public class SessionDisconnectedEvent extends ApplicationEvent {
12+
13+
private final Long userId;
14+
15+
public SessionDisconnectedEvent(Object source, Long userId) {
16+
super(source);
17+
this.userId = userId;
18+
}
19+
}
20+
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.back.global.websocket.event;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
6+
@Getter
7+
@AllArgsConstructor
8+
public class UserJoinedEvent {
9+
private final String type = "USER_JOINED";
10+
private Long userId;
11+
private String nickname;
12+
private String profileImageUrl;
13+
private Long avatarId;
14+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.back.global.websocket.event;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
6+
@Getter
7+
@AllArgsConstructor
8+
public class UserLeftEvent {
9+
private final String type = "USER_LEFT";
10+
private Long userId;
11+
}

src/main/java/com/back/global/websocket/service/RoomParticipantService.java

Lines changed: 67 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
package com.back.global.websocket.service;
22

3+
import com.back.domain.user.common.entity.User;
4+
import com.back.domain.user.common.repository.UserRepository;
35
import com.back.global.exception.CustomException;
46
import com.back.global.exception.ErrorCode;
57
import com.back.global.websocket.dto.WebSocketSessionInfo;
8+
import com.back.global.websocket.event.SessionDisconnectedEvent;
9+
import com.back.global.websocket.event.UserJoinedEvent;
10+
import com.back.global.websocket.event.UserLeftEvent;
611
import com.back.global.websocket.store.RedisSessionStore;
712
import lombok.RequiredArgsConstructor;
813
import lombok.extern.slf4j.Slf4j;
14+
import org.springframework.context.event.EventListener;
15+
import org.springframework.messaging.simp.SimpMessagingTemplate;
916
import org.springframework.stereotype.Service;
17+
import org.springframework.transaction.annotation.Transactional;
1018

19+
import java.util.Map;
1120
import java.util.Set;
1221

1322
/**
@@ -22,6 +31,17 @@
2231
public class RoomParticipantService {
2332

2433
private final RedisSessionStore redisSessionStore;
34+
private final SimpMessagingTemplate messagingTemplate;
35+
private final UserRepository userRepository;
36+
37+
// 세션 종료 이벤트 리스너
38+
@EventListener
39+
@Transactional
40+
public void handleSessionDisconnected(SessionDisconnectedEvent event) {
41+
Long userId = event.getUserId();
42+
log.info("[이벤트 수신] 세션 종료 이벤트 수신하여 퇴장 처리 시작 - 사용자: {}", userId);
43+
exitAllRooms(userId);
44+
}
2545

2646
// 사용자 방 입장 (아바타 정보 포함)
2747
public void enterRoom(Long userId, Long roomId, Long avatarId) {
@@ -32,7 +52,7 @@ public void enterRoom(Long userId, Long roomId, Long avatarId) {
3252
throw new CustomException(ErrorCode.WS_SESSION_NOT_FOUND);
3353
}
3454

35-
if (sessionInfo.currentRoomId() != null) {
55+
if (sessionInfo.currentRoomId() != null && !sessionInfo.currentRoomId().equals(roomId)) {
3656
exitRoom(userId, sessionInfo.currentRoomId());
3757
log.debug("기존 방에서 퇴장 처리 완료 - 사용자: {}, 이전 방: {}",
3858
userId, sessionInfo.currentRoomId());
@@ -46,6 +66,8 @@ public void enterRoom(Long userId, Long roomId, Long avatarId) {
4666
saveUserAvatar(roomId, userId, avatarId);
4767

4868
log.info("방 입장 완료 - 사용자: {}, 방: {}, 아바타: {}", userId, roomId, avatarId);
69+
70+
broadcastUserJoined(roomId, userId, avatarId);
4971
}
5072

5173
// 기존 메서드 호환성 유지 (아바타 없이 입장)
@@ -56,16 +78,25 @@ public void enterRoom(Long userId, Long roomId) {
5678
// 사용자 방 퇴장
5779
public void exitRoom(Long userId, Long roomId) {
5880
WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId);
59-
60-
if (sessionInfo == null) {
61-
log.warn("세션 정보가 없지만 방 퇴장 처리 계속 진행 - 사용자: {}, 방: {}", userId, roomId);
62-
} else {
81+
if (sessionInfo != null) {
6382
WebSocketSessionInfo updatedSession = sessionInfo.withoutRoom();
6483
redisSessionStore.saveUserSession(userId, updatedSession);
6584
}
66-
6785
redisSessionStore.removeUserFromRoom(roomId, userId);
6886
log.info("방 퇴장 완료 - 사용자: {}, 방: {}", userId, roomId);
87+
broadcastUserLeft(roomId, userId);
88+
}
89+
90+
public void exitAllRooms(Long userId) {
91+
try {
92+
Long currentRoomId = getCurrentRoomId(userId);
93+
if (currentRoomId != null) {
94+
exitRoom(userId, currentRoomId);
95+
log.info("모든 방에서 퇴장 처리 완료 - 사용자: {}", userId);
96+
}
97+
} catch (Exception e) {
98+
log.error("모든 방 퇴장 처리 실패 - 사용자: {}", userId, e);
99+
}
69100
}
70101

71102
// 사용자의 현재 방 ID 조회
@@ -90,29 +121,13 @@ public boolean isUserInRoom(Long userId, Long roomId) {
90121
return currentRoomId != null && currentRoomId.equals(roomId);
91122
}
92123

93-
// 모든 방에서 사용자 퇴장 처리 (세션 종료 시 사용)
94-
public void exitAllRooms(Long userId) {
95-
try {
96-
Long currentRoomId = getCurrentRoomId(userId);
97-
98-
if (currentRoomId != null) {
99-
exitRoom(userId, currentRoomId);
100-
log.info("모든 방에서 퇴장 처리 완료 - 사용자: {}", userId);
101-
}
102-
103-
} catch (Exception e) {
104-
log.error("모든 방 퇴장 처리 실패 - 사용자: {}", userId, e);
105-
// 에러를 던지지 않고 로그만 남김 (세션 종료는 계속 진행되어야 함)
106-
}
107-
}
108-
109124
/**
110125
* 여러 방의 온라인 참가자 수를 일괄 조회
111126
* N+1 문제 해결을 위한 일괄 조회 메서드
112127
* @param roomIds 방 ID 목록
113128
* @return 방 ID → 참가자 수 맵
114129
*/
115-
public java.util.Map<Long, Long> getParticipantCounts(java.util.List<Long> roomIds) {
130+
public Map<Long, Long> getParticipantCounts(java.util.List<Long> roomIds) {
116131
return redisSessionStore.getRoomUserCounts(roomIds);
117132
}
118133

@@ -125,18 +140,12 @@ public java.util.Map<Long, Long> getParticipantCounts(java.util.List<Long> roomI
125140
* @param avatarId 아바타 ID
126141
*/
127142
private void saveUserAvatar(Long roomId, Long userId, Long avatarId) {
128-
if (avatarId == null) {
129-
return; // 아바타 정보가 없으면 저장하지 않음
130-
}
131-
143+
if (avatarId == null) return;
132144
String avatarKey = buildAvatarKey(roomId, userId);
133-
redisSessionStore.saveValue(avatarKey, avatarId.toString(),
134-
java.time.Duration.ofMinutes(6));
135-
136-
log.debug("아바타 정보 저장 - RoomId: {}, UserId: {}, AvatarId: {}",
137-
roomId, userId, avatarId);
145+
redisSessionStore.saveValue(avatarKey, avatarId.toString(), java.time.Duration.ofMinutes(6));
146+
log.debug("아바타 정보 저장 - RoomId: {}, UserId: {}, AvatarId: {}", roomId, userId, avatarId);
138147
}
139-
148+
140149
/**
141150
* 사용자의 아바타 ID 조회
142151
* @param roomId 방 ID
@@ -154,21 +163,19 @@ public Long getUserAvatar(Long roomId, Long userId) {
154163
try {
155164
return Long.parseLong(avatarIdStr);
156165
} catch (NumberFormatException e) {
157-
log.warn("아바타 ID 파싱 실패 - RoomId: {}, UserId: {}, Value: {}",
158-
roomId, userId, avatarIdStr);
166+
log.warn("아바타 ID 파싱 실패 - RoomId: {}, UserId: {}, Value: {}", roomId, userId, avatarIdStr);
159167
return null;
160168
}
161169
}
162-
170+
163171
/**
164172
* 여러 사용자의 아바타 ID 일괄 조회 (N+1 방지)
165173
* @param roomId 방 ID
166174
* @param userIds 사용자 ID 목록
167175
* @return 사용자 ID → 아바타 ID 맵
168176
*/
169-
public java.util.Map<Long, Long> getUserAvatars(Long roomId, Set<Long> userIds) {
170-
java.util.Map<Long, Long> result = new java.util.HashMap<>();
171-
177+
public Map<Long, Long> getUserAvatars(Long roomId, Set<Long> userIds) {
178+
Map<Long, Long> result = new java.util.HashMap<>();
172179
for (Long userId : userIds) {
173180
Long avatarId = getUserAvatar(roomId, userId);
174181
if (avatarId != null) {
@@ -194,15 +201,28 @@ private String buildAvatarKey(Long roomId, Long userId) {
194201
* @param avatarId 새 아바타 ID
195202
*/
196203
public void updateUserAvatar(Long roomId, Long userId, Long avatarId) {
197-
if (avatarId == null) {
204+
if (avatarId == null) return;
205+
String avatarKey = buildAvatarKey(roomId, userId);
206+
redisSessionStore.saveValue(avatarKey, avatarId.toString(), java.time.Duration.ofMinutes(6));
207+
log.info("아바타 업데이트 (Redis) - RoomId: {}, UserId: {}, AvatarId: {}", roomId, userId, avatarId);
208+
}
209+
210+
private void broadcastUserJoined(Long roomId, Long userId, Long avatarId) {
211+
User user = userRepository.findById(userId).orElse(null);
212+
if (user == null) {
213+
log.error("📢 [방송 실패] 사용자 정보를 찾을 수 없어 입장 알림을 보낼 수 없습니다. userId: {}", userId);
198214
return;
199215
}
200-
201-
String avatarKey = buildAvatarKey(roomId, userId);
202-
redisSessionStore.saveValue(avatarKey, avatarId.toString(),
203-
java.time.Duration.ofMinutes(6));
204-
205-
log.info("아바타 업데이트 (Redis) - RoomId: {}, UserId: {}, AvatarId: {}",
206-
roomId, userId, avatarId);
216+
UserJoinedEvent event = new UserJoinedEvent(user.getId(), user.getNickname(), user.getProfileImageUrl(), avatarId);
217+
String destination = "/topic/room/" + roomId + "/events";
218+
messagingTemplate.convertAndSend(destination, event);
219+
log.info("📢 [방송] 사용자 입장 알림 - 방: {}, 사용자: {}", roomId, userId);
220+
}
221+
222+
private void broadcastUserLeft(Long roomId, Long userId) {
223+
UserLeftEvent event = new UserLeftEvent(userId);
224+
String destination = "/topic/room/" + roomId + "/events";
225+
messagingTemplate.convertAndSend(destination, event);
226+
log.info("📢 [방송] 사용자 퇴장 알림 - 방: {}, 사용자: {}", roomId, userId);
207227
}
208228
}
Lines changed: 6 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
package com.back.global.websocket.service;
22

33
import com.back.global.websocket.dto.WebSocketSessionInfo;
4+
import com.back.global.websocket.event.SessionDisconnectedEvent;
45
import lombok.RequiredArgsConstructor;
56
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.context.ApplicationEventPublisher;
68
import org.springframework.stereotype.Service;
79

8-
import java.util.Set;
9-
1010
@Slf4j
1111
@Service
1212
@RequiredArgsConstructor
1313
public class WebSocketSessionManager {
1414

1515
private final UserSessionService userSessionService;
16-
private final RoomParticipantService roomParticipantService;
16+
private final ApplicationEventPublisher eventPublisher;
1717

1818
// 사용자 세션 추가 (WebSocket 연결 시 호출)
1919
public void addSession(Long userId, String username, String sessionId) {
@@ -25,10 +25,10 @@ public void removeSession(String sessionId) {
2525
Long userId = userSessionService.getUserIdBySessionId(sessionId);
2626

2727
if (userId != null) {
28-
// 1. 모든 방에서 퇴장
29-
roomParticipantService.exitAllRooms(userId);
28+
// 세션 종료 이벤트 발행
29+
eventPublisher.publishEvent(new SessionDisconnectedEvent(this, userId));
3030

31-
// 2. 세션 종료
31+
// 세션 종료 처리
3232
userSessionService.terminateSession(sessionId);
3333
} else {
3434
log.warn("종료할 세션을 찾을 수 없음 - 세션: {}", sessionId);
@@ -54,43 +54,4 @@ public void updateLastActivity(Long userId) {
5454
public long getTotalOnlineUserCount() {
5555
return userSessionService.getTotalOnlineUserCount();
5656
}
57-
58-
// 사용자가 방에 입장
59-
public void joinRoom(Long userId, Long roomId) {
60-
roomParticipantService.enterRoom(userId, roomId);
61-
}
62-
63-
// 사용자가 방에서 퇴장
64-
public void leaveRoom(Long userId, Long roomId) {
65-
roomParticipantService.exitRoom(userId, roomId);
66-
}
67-
68-
// 방의 온라인 사용자 수 조회
69-
public long getRoomOnlineUserCount(Long roomId) {
70-
return roomParticipantService.getParticipantCount(roomId);
71-
}
72-
73-
// 방의 온라인 사용자 목록 조회
74-
public Set<Long> getOnlineUsersInRoom(Long roomId) {
75-
return roomParticipantService.getParticipants(roomId);
76-
}
77-
78-
// 특정 사용자의 현재 방 조회
79-
public Long getUserCurrentRoomId(Long userId) {
80-
return roomParticipantService.getCurrentRoomId(userId);
81-
}
82-
83-
// 사용자가 특정 방에 참여 중인지 확인
84-
public boolean isUserInRoom(Long userId, Long roomId) {
85-
return roomParticipantService.isUserInRoom(userId, roomId);
86-
}
87-
88-
// 여러 방의 온라인 사용자 수 일괄 조회 (N+1 방지)
89-
public java.util.Map<Long, Long> getBulkRoomOnlineUserCounts(java.util.List<Long> roomIds) {
90-
return roomIds.stream()
91-
.collect(java.util.stream.Collectors.toMap(
92-
roomId -> roomId,
93-
this::getRoomOnlineUserCount
94-
));
95-
}
9657
}

0 commit comments

Comments
 (0)