Skip to content

Commit 0dececb

Browse files
committed
Feat: 실시간 입/퇴장 이벤트 기능 추가 및 구조 개선
1 parent 07bf9c9 commit 0dececb

File tree

5 files changed

+124
-154
lines changed

5 files changed

+124
-154
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+
}
Lines changed: 73 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,42 @@
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

13-
/**
14-
* 방 참가자 관리 서비스
15-
* - 방 입장/퇴장 처리
16-
* - 방별 참가자 목록 관리
17-
* - 방별 온라인 사용자 통계
18-
*/
1922
@Slf4j
2023
@Service
2124
@RequiredArgsConstructor
2225
public 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

Comments
 (0)