11package com .back .global .websocket .service ;
22
3+ import com .back .domain .user .common .entity .User ;
4+ import com .back .domain .user .common .repository .UserRepository ;
35import com .back .global .exception .CustomException ;
46import com .back .global .exception .ErrorCode ;
57import 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 ;
611import com .back .global .websocket .store .RedisSessionStore ;
712import lombok .RequiredArgsConstructor ;
813import lombok .extern .slf4j .Slf4j ;
14+ import org .springframework .context .event .EventListener ;
15+ import org .springframework .messaging .simp .SimpMessagingTemplate ;
916import org .springframework .stereotype .Service ;
17+ import org .springframework .transaction .annotation .Transactional ;
1018
19+ import java .util .Map ;
1120import java .util .Set ;
1221
13- /**
14- * 방 참가자 관리 서비스
15- * - 방 입장/퇴장 처리
16- * - 방별 참가자 목록 관리
17- * - 방별 온라인 사용자 통계
18- */
1922@ Slf4j
2023@ Service
2124@ RequiredArgsConstructor
2225public class RoomParticipantService {
2326
2427 private final RedisSessionStore redisSessionStore ;
28+ private final SimpMessagingTemplate messagingTemplate ;
29+ private final UserRepository userRepository ;
30+
31+ // 세션 종료 이벤트 리스너
32+ @ EventListener
33+ @ Transactional
34+ public void handleSessionDisconnected (SessionDisconnectedEvent event ) {
35+ Long userId = event .getUserId ();
36+ log .info ("[이벤트 수신] 세션 종료 이벤트 수신하여 퇴장 처리 시작 - 사용자: {}" , userId );
37+ exitAllRooms (userId );
38+ }
2539
26- // 사용자 방 입장 (아바타 정보 포함)
2740 public void enterRoom (Long userId , Long roomId , Long avatarId ) {
2841 WebSocketSessionInfo sessionInfo = redisSessionStore .getUserSession (userId );
2942
@@ -32,7 +45,7 @@ public void enterRoom(Long userId, Long roomId, Long avatarId) {
3245 throw new CustomException (ErrorCode .WS_SESSION_NOT_FOUND );
3346 }
3447
35- if (sessionInfo .currentRoomId () != null ) {
48+ if (sessionInfo .currentRoomId () != null && ! sessionInfo . currentRoomId (). equals ( roomId ) ) {
3649 exitRoom (userId , sessionInfo .currentRoomId ());
3750 log .debug ("기존 방에서 퇴장 처리 완료 - 사용자: {}, 이전 방: {}" ,
3851 userId , sessionInfo .currentRoomId ());
@@ -41,168 +54,119 @@ public void enterRoom(Long userId, Long roomId, Long avatarId) {
4154 WebSocketSessionInfo updatedSession = sessionInfo .withRoomId (roomId );
4255 redisSessionStore .saveUserSession (userId , updatedSession );
4356 redisSessionStore .addUserToRoom (roomId , userId );
44-
45- // 아바타 정보 저장
4657 saveUserAvatar (roomId , userId , avatarId );
4758
4859 log .info ("방 입장 완료 - 사용자: {}, 방: {}, 아바타: {}" , userId , roomId , avatarId );
60+
61+ broadcastUserJoined (roomId , userId , avatarId );
4962 }
50-
51- // 기존 메서드 호환성 유지 (아바타 없이 입장)
63+
5264 public void enterRoom (Long userId , Long roomId ) {
5365 enterRoom (userId , roomId , null );
5466 }
5567
56- // 사용자 방 퇴장
5768 public void exitRoom (Long userId , Long roomId ) {
5869 WebSocketSessionInfo sessionInfo = redisSessionStore .getUserSession (userId );
59-
60- if (sessionInfo == null ) {
61- log .warn ("세션 정보가 없지만 방 퇴장 처리 계속 진행 - 사용자: {}, 방: {}" , userId , roomId );
62- } else {
70+ if (sessionInfo != null ) {
6371 WebSocketSessionInfo updatedSession = sessionInfo .withoutRoom ();
6472 redisSessionStore .saveUserSession (userId , updatedSession );
6573 }
66-
6774 redisSessionStore .removeUserFromRoom (roomId , userId );
6875 log .info ("방 퇴장 완료 - 사용자: {}, 방: {}" , userId , roomId );
76+ broadcastUserLeft (roomId , userId );
77+ }
78+
79+ public void exitAllRooms (Long userId ) {
80+ try {
81+ Long currentRoomId = getCurrentRoomId (userId );
82+ if (currentRoomId != null ) {
83+ exitRoom (userId , currentRoomId );
84+ log .info ("모든 방에서 퇴장 처리 완료 - 사용자: {}" , userId );
85+ }
86+ } catch (Exception e ) {
87+ log .error ("모든 방 퇴장 처리 실패 - 사용자: {}" , userId , e );
88+ }
6989 }
7090
71- // 사용자의 현재 방 ID 조회
7291 public Long getCurrentRoomId (Long userId ) {
7392 WebSocketSessionInfo sessionInfo = redisSessionStore .getUserSession (userId );
7493 return sessionInfo != null ? sessionInfo .currentRoomId () : null ;
7594 }
7695
77- // 방의 온라인 참가자 목록 조회
7896 public Set <Long > getParticipants (Long roomId ) {
7997 return redisSessionStore .getRoomUsers (roomId );
8098 }
8199
82- // 방의 온라인 참가자 수 조회
83100 public long getParticipantCount (Long roomId ) {
84101 return redisSessionStore .getRoomUserCount (roomId );
85102 }
86103
87- // 사용자가 특정 방에 참여 중인지 확인
88104 public boolean isUserInRoom (Long userId , Long roomId ) {
89105 Long currentRoomId = getCurrentRoomId (userId );
90106 return currentRoomId != null && currentRoomId .equals (roomId );
91107 }
92108
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-
109- /**
110- * 여러 방의 온라인 참가자 수를 일괄 조회
111- * N+1 문제 해결을 위한 일괄 조회 메서드
112- * @param roomIds 방 ID 목록
113- * @return 방 ID → 참가자 수 맵
114- */
115- public java .util .Map <Long , Long > getParticipantCounts (java .util .List <Long > roomIds ) {
109+ public Map <Long , Long > getParticipantCounts (java .util .List <Long > roomIds ) {
116110 return redisSessionStore .getRoomUserCounts (roomIds );
117111 }
118-
119- // ==================== 아바타 관련 메서드 ====================
120-
121- /**
122- * 사용자의 아바타 정보 저장 (Redis)
123- * @param roomId 방 ID
124- * @param userId 사용자 ID
125- * @param avatarId 아바타 ID
126- */
112+
127113 private void saveUserAvatar (Long roomId , Long userId , Long avatarId ) {
128- if (avatarId == null ) {
129- return ; // 아바타 정보가 없으면 저장하지 않음
130- }
131-
114+ if (avatarId == null ) return ;
132115 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 );
138- }
139-
140- /**
141- * 사용자의 아바타 ID 조회
142- * @param roomId 방 ID
143- * @param userId 사용자 ID
144- * @return 아바타 ID (없으면 null)
145- */
116+ redisSessionStore .saveValue (avatarKey , avatarId .toString (), java .time .Duration .ofMinutes (6 ));
117+ log .debug ("아바타 정보 저장 - RoomId: {}, UserId: {}, AvatarId: {}" , roomId , userId , avatarId );
118+ }
119+
146120 public Long getUserAvatar (Long roomId , Long userId ) {
147121 String avatarKey = buildAvatarKey (roomId , userId );
148122 String avatarIdStr = redisSessionStore .getValue (avatarKey );
149-
150- if (avatarIdStr == null ) {
151- return null ;
152- }
153-
123+ if (avatarIdStr == null ) return null ;
154124 try {
155125 return Long .parseLong (avatarIdStr );
156126 } catch (NumberFormatException e ) {
157- log .warn ("아바타 ID 파싱 실패 - RoomId: {}, UserId: {}, Value: {}" ,
158- roomId , userId , avatarIdStr );
127+ log .warn ("아바타 ID 파싱 실패 - RoomId: {}, UserId: {}, Value: {}" , roomId , userId , avatarIdStr );
159128 return null ;
160129 }
161130 }
162-
163- /**
164- * 여러 사용자의 아바타 ID 일괄 조회 (N+1 방지)
165- * @param roomId 방 ID
166- * @param userIds 사용자 ID 목록
167- * @return 사용자 ID → 아바타 ID 맵
168- */
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-
131+
132+ public Map <Long , Long > getUserAvatars (Long roomId , Set <Long > userIds ) {
133+ Map <Long , Long > result = new java .util .HashMap <>();
172134 for (Long userId : userIds ) {
173135 Long avatarId = getUserAvatar (roomId , userId );
174136 if (avatarId != null ) {
175137 result .put (userId , avatarId );
176138 }
177139 }
178-
179140 return result ;
180141 }
181-
182- /**
183- * 아바타 Redis Key 생성
184- */
142+
185143 private String buildAvatarKey (Long roomId , Long userId ) {
186144 return "ws:room:" + roomId + ":user:" + userId + ":avatar" ;
187145 }
188-
189- /**
190- * 아바타 정보 업데이트 (외부에서 호출 가능)
191- * VISITOR가 아바타를 변경할 때 사용
192- * @param roomId 방 ID
193- * @param userId 사용자 ID
194- * @param avatarId 새 아바타 ID
195- */
146+
196147 public void updateUserAvatar (Long roomId , Long userId , Long avatarId ) {
197- if (avatarId == null ) {
148+ if (avatarId == null ) return ;
149+ String avatarKey = buildAvatarKey (roomId , userId );
150+ redisSessionStore .saveValue (avatarKey , avatarId .toString (), java .time .Duration .ofMinutes (6 ));
151+ log .info ("아바타 업데이트 (Redis) - RoomId: {}, UserId: {}, AvatarId: {}" , roomId , userId , avatarId );
152+ }
153+
154+ private void broadcastUserJoined (Long roomId , Long userId , Long avatarId ) {
155+ User user = userRepository .findById (userId ).orElse (null );
156+ if (user == null ) {
157+ log .error ("📢 [방송 실패] 사용자 정보를 찾을 수 없어 입장 알림을 보낼 수 없습니다. userId: {}" , userId );
198158 return ;
199159 }
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 );
160+ UserJoinedEvent event = new UserJoinedEvent (user .getId (), user .getNickname (), user .getProfileImageUrl (), avatarId );
161+ String destination = "/topic/room/" + roomId + "/events" ;
162+ messagingTemplate .convertAndSend (destination , event );
163+ log .info ("📢 [방송] 사용자 입장 알림 - 방: {}, 사용자: {}" , roomId , userId );
164+ }
165+
166+ private void broadcastUserLeft (Long roomId , Long userId ) {
167+ UserLeftEvent event = new UserLeftEvent (userId );
168+ String destination = "/topic/room/" + roomId + "/events" ;
169+ messagingTemplate .convertAndSend (destination , event );
170+ log .info ("📢 [방송] 사용자 퇴장 알림 - 방: {}, 사용자: {}" , roomId , userId );
207171 }
208172}
0 commit comments