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
@@ -0,0 +1,20 @@
package com.back.global.websocket.event;

import lombok.Getter;
import org.springframework.context.ApplicationEvent;

/**
* WebSocket 세션 연결이 종료되었을 때 발생하는 내부 이벤트.
* 이 이벤트는 SessionManager가 발행하고, ParticipantService가 구독하여 처리합니다.
*/
@Getter
public class SessionDisconnectedEvent extends ApplicationEvent {

private final Long userId;

public SessionDisconnectedEvent(Object source, Long userId) {
super(source);
this.userId = userId;
}
}

14 changes: 14 additions & 0 deletions src/main/java/com/back/global/websocket/event/UserJoinedEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.back.global.websocket.event;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class UserJoinedEvent {
private final String type = "USER_JOINED";
private Long userId;
private String nickname;
private String profileImageUrl;
private Long avatarId;
}
11 changes: 11 additions & 0 deletions src/main/java/com/back/global/websocket/event/UserLeftEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.back.global.websocket.event;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class UserLeftEvent {
private final String type = "USER_LEFT";
private Long userId;
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
package com.back.global.websocket.service;

import com.back.domain.user.common.entity.User;
import com.back.domain.user.common.repository.UserRepository;
import com.back.global.exception.CustomException;
import com.back.global.exception.ErrorCode;
import com.back.global.websocket.dto.WebSocketSessionInfo;
import com.back.global.websocket.event.SessionDisconnectedEvent;
import com.back.global.websocket.event.UserJoinedEvent;
import com.back.global.websocket.event.UserLeftEvent;
import com.back.global.websocket.store.RedisSessionStore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Map;
import java.util.Set;

/**
Expand All @@ -22,6 +31,17 @@
public class RoomParticipantService {

private final RedisSessionStore redisSessionStore;
private final SimpMessagingTemplate messagingTemplate;
private final UserRepository userRepository;

// 세션 종료 이벤트 리스너
@EventListener
@Transactional
public void handleSessionDisconnected(SessionDisconnectedEvent event) {
Long userId = event.getUserId();
log.info("[이벤트 수신] 세션 종료 이벤트 수신하여 퇴장 처리 시작 - 사용자: {}", userId);
exitAllRooms(userId);
}

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

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

log.info("방 입장 완료 - 사용자: {}, 방: {}, 아바타: {}", userId, roomId, avatarId);

broadcastUserJoined(roomId, userId, avatarId);
}

// 기존 메서드 호환성 유지 (아바타 없이 입장)
Expand All @@ -56,16 +78,25 @@ public void enterRoom(Long userId, Long roomId) {
// 사용자 방 퇴장
public void exitRoom(Long userId, Long roomId) {
WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId);

if (sessionInfo == null) {
log.warn("세션 정보가 없지만 방 퇴장 처리 계속 진행 - 사용자: {}, 방: {}", userId, roomId);
} else {
if (sessionInfo != null) {
WebSocketSessionInfo updatedSession = sessionInfo.withoutRoom();
redisSessionStore.saveUserSession(userId, updatedSession);
}

redisSessionStore.removeUserFromRoom(roomId, userId);
log.info("방 퇴장 완료 - 사용자: {}, 방: {}", userId, roomId);
broadcastUserLeft(roomId, userId);
}

public void exitAllRooms(Long userId) {
try {
Long currentRoomId = getCurrentRoomId(userId);
if (currentRoomId != null) {
exitRoom(userId, currentRoomId);
log.info("모든 방에서 퇴장 처리 완료 - 사용자: {}", userId);
}
} catch (Exception e) {
log.error("모든 방 퇴장 처리 실패 - 사용자: {}", userId, e);
}
}

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

// 모든 방에서 사용자 퇴장 처리 (세션 종료 시 사용)
public void exitAllRooms(Long userId) {
try {
Long currentRoomId = getCurrentRoomId(userId);

if (currentRoomId != null) {
exitRoom(userId, currentRoomId);
log.info("모든 방에서 퇴장 처리 완료 - 사용자: {}", userId);
}

} catch (Exception e) {
log.error("모든 방 퇴장 처리 실패 - 사용자: {}", userId, e);
// 에러를 던지지 않고 로그만 남김 (세션 종료는 계속 진행되어야 함)
}
}

/**
* 여러 방의 온라인 참가자 수를 일괄 조회
* N+1 문제 해결을 위한 일괄 조회 메서드
* @param roomIds 방 ID 목록
* @return 방 ID → 참가자 수 맵
*/
public java.util.Map<Long, Long> getParticipantCounts(java.util.List<Long> roomIds) {
public Map<Long, Long> getParticipantCounts(java.util.List<Long> roomIds) {
return redisSessionStore.getRoomUserCounts(roomIds);
}

Expand All @@ -125,18 +140,12 @@ public java.util.Map<Long, Long> getParticipantCounts(java.util.List<Long> roomI
* @param avatarId 아바타 ID
*/
private void saveUserAvatar(Long roomId, Long userId, Long avatarId) {
if (avatarId == null) {
return; // 아바타 정보가 없으면 저장하지 않음
}

if (avatarId == null) return;
String avatarKey = buildAvatarKey(roomId, userId);
redisSessionStore.saveValue(avatarKey, avatarId.toString(),
java.time.Duration.ofMinutes(6));

log.debug("아바타 정보 저장 - RoomId: {}, UserId: {}, AvatarId: {}",
roomId, userId, avatarId);
redisSessionStore.saveValue(avatarKey, avatarId.toString(), java.time.Duration.ofMinutes(6));
log.debug("아바타 정보 저장 - RoomId: {}, UserId: {}, AvatarId: {}", roomId, userId, avatarId);
}

/**
* 사용자의 아바타 ID 조회
* @param roomId 방 ID
Expand All @@ -154,21 +163,19 @@ public Long getUserAvatar(Long roomId, Long userId) {
try {
return Long.parseLong(avatarIdStr);
} catch (NumberFormatException e) {
log.warn("아바타 ID 파싱 실패 - RoomId: {}, UserId: {}, Value: {}",
roomId, userId, avatarIdStr);
log.warn("아바타 ID 파싱 실패 - RoomId: {}, UserId: {}, Value: {}", roomId, userId, avatarIdStr);
return null;
}
}

/**
* 여러 사용자의 아바타 ID 일괄 조회 (N+1 방지)
* @param roomId 방 ID
* @param userIds 사용자 ID 목록
* @return 사용자 ID → 아바타 ID 맵
*/
public java.util.Map<Long, Long> getUserAvatars(Long roomId, Set<Long> userIds) {
java.util.Map<Long, Long> result = new java.util.HashMap<>();

public Map<Long, Long> getUserAvatars(Long roomId, Set<Long> userIds) {
Map<Long, Long> result = new java.util.HashMap<>();
for (Long userId : userIds) {
Long avatarId = getUserAvatar(roomId, userId);
if (avatarId != null) {
Expand All @@ -194,15 +201,28 @@ private String buildAvatarKey(Long roomId, Long userId) {
* @param avatarId 새 아바타 ID
*/
public void updateUserAvatar(Long roomId, Long userId, Long avatarId) {
if (avatarId == null) {
if (avatarId == null) return;
String avatarKey = buildAvatarKey(roomId, userId);
redisSessionStore.saveValue(avatarKey, avatarId.toString(), java.time.Duration.ofMinutes(6));
log.info("아바타 업데이트 (Redis) - RoomId: {}, UserId: {}, AvatarId: {}", roomId, userId, avatarId);
}

private void broadcastUserJoined(Long roomId, Long userId, Long avatarId) {
User user = userRepository.findById(userId).orElse(null);
if (user == null) {
log.error("📢 [방송 실패] 사용자 정보를 찾을 수 없어 입장 알림을 보낼 수 없습니다. userId: {}", userId);
return;
}

String avatarKey = buildAvatarKey(roomId, userId);
redisSessionStore.saveValue(avatarKey, avatarId.toString(),
java.time.Duration.ofMinutes(6));

log.info("아바타 업데이트 (Redis) - RoomId: {}, UserId: {}, AvatarId: {}",
roomId, userId, avatarId);
UserJoinedEvent event = new UserJoinedEvent(user.getId(), user.getNickname(), user.getProfileImageUrl(), avatarId);
String destination = "/topic/room/" + roomId + "/events";
messagingTemplate.convertAndSend(destination, event);
log.info("📢 [방송] 사용자 입장 알림 - 방: {}, 사용자: {}", roomId, userId);
}

private void broadcastUserLeft(Long roomId, Long userId) {
UserLeftEvent event = new UserLeftEvent(userId);
String destination = "/topic/room/" + roomId + "/events";
messagingTemplate.convertAndSend(destination, event);
log.info("📢 [방송] 사용자 퇴장 알림 - 방: {}, 사용자: {}", roomId, userId);
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
package com.back.global.websocket.service;

import com.back.global.websocket.dto.WebSocketSessionInfo;
import com.back.global.websocket.event.SessionDisconnectedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

import java.util.Set;

@Slf4j
@Service
@RequiredArgsConstructor
public class WebSocketSessionManager {

private final UserSessionService userSessionService;
private final RoomParticipantService roomParticipantService;
private final ApplicationEventPublisher eventPublisher;

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

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

// 2. 세션 종료
// 세션 종료 처리
userSessionService.terminateSession(sessionId);
} else {
log.warn("종료할 세션을 찾을 수 없음 - 세션: {}", sessionId);
Expand All @@ -54,43 +54,4 @@ public void updateLastActivity(Long userId) {
public long getTotalOnlineUserCount() {
return userSessionService.getTotalOnlineUserCount();
}

// 사용자가 방에 입장
public void joinRoom(Long userId, Long roomId) {
roomParticipantService.enterRoom(userId, roomId);
}

// 사용자가 방에서 퇴장
public void leaveRoom(Long userId, Long roomId) {
roomParticipantService.exitRoom(userId, roomId);
}

// 방의 온라인 사용자 수 조회
public long getRoomOnlineUserCount(Long roomId) {
return roomParticipantService.getParticipantCount(roomId);
}

// 방의 온라인 사용자 목록 조회
public Set<Long> getOnlineUsersInRoom(Long roomId) {
return roomParticipantService.getParticipants(roomId);
}

// 특정 사용자의 현재 방 조회
public Long getUserCurrentRoomId(Long userId) {
return roomParticipantService.getCurrentRoomId(userId);
}

// 사용자가 특정 방에 참여 중인지 확인
public boolean isUserInRoom(Long userId, Long roomId) {
return roomParticipantService.isUserInRoom(userId, roomId);
}

// 여러 방의 온라인 사용자 수 일괄 조회 (N+1 방지)
public java.util.Map<Long, Long> getBulkRoomOnlineUserCounts(java.util.List<Long> roomIds) {
return roomIds.stream()
.collect(java.util.stream.Collectors.toMap(
roomId -> roomId,
this::getRoomOnlineUserCount
));
}
}
Loading