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
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.testcontainers:testcontainers:1.19.3")
testImplementation("net.ttddyy:datasource-proxy:1.8.1")
testImplementation("org.testcontainers:junit-jupiter:1.19.3")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RoomChatService {

private final RoomChatMessageRepository roomChatMessageRepository;
Expand Down Expand Up @@ -60,6 +59,7 @@ public RoomChatMessage saveRoomChatMessage(RoomChatMessageDto roomChatMessageDto
}

// 방 채팅 기록 조회
@Transactional(readOnly = true)
public RoomChatPageResponse getRoomChatHistory(Long roomId, int page, int size, LocalDateTime before) {

// 방 존재 여부 확인
Expand Down Expand Up @@ -125,6 +125,14 @@ public ChatClearedNotification.ClearedByDto clearRoomChat(Long roomId, Long user
}
}

// 방의 현재 채팅 메시지 수 조회
@Transactional(readOnly = true)
public int getRoomChatCount(Long roomId) {
return roomChatMessageRepository.countByRoomId(roomId);
}

// --------------------- 헬퍼 메서드들 ---------------------

// 채팅 관리 권한 확인 (방장 또는 부방장)
private boolean canManageChat(RoomRole role) {
return role == RoomRole.HOST || role == RoomRole.SUB_HOST;
Expand Down Expand Up @@ -153,9 +161,4 @@ private RoomChatMessageDto convertToDto(RoomChatMessage message) {
);
}

// 방의 현재 채팅 메시지 수 조회
public int getRoomChatCount(Long roomId) {
return roomChatMessageRepository.countByRoomId(roomId);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public Page<RoomChatMessage> findMessagesByRoomId(Long roomId, Pageable pageable
.selectFrom(message)
.leftJoin(message.room, room).fetchJoin() // Room 정보 즉시 로딩
.leftJoin(message.user, user).fetchJoin() // User 정보 즉시 로딩
.leftJoin(user.userProfile).fetchJoin() // UserProfile 정보 즉시 로딩
.where(message.room.id.eq(roomId))
.orderBy(message.createdAt.desc()) // 최신순 정렬
.offset(pageable.getOffset())
Expand Down Expand Up @@ -64,6 +65,7 @@ public Page<RoomChatMessage> findMessagesByRoomIdBefore(Long roomId, LocalDateTi
.selectFrom(message)
.leftJoin(message.room, room).fetchJoin()
.leftJoin(message.user, user).fetchJoin()
.leftJoin(user.userProfile).fetchJoin()
.where(whereClause)
.orderBy(message.createdAt.desc())
.offset(pageable.getOffset())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.back.global.config;
package com.back.global.websocket.config;

import com.back.global.security.user.CustomUserDetails;
import com.back.global.security.jwt.JwtTokenProvider;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.back.global.websocket.config;

import java.time.Duration;

public final class WebSocketConstants {

private WebSocketConstants() {
throw new AssertionError("상수 클래스는 인스턴스화할 수 없습니다.");
}

// ===== TTL & Timeout 설정 =====

/**
* WebSocket 세션 TTL (6분)
* - Heartbeat로 연장됨
*/
public static final Duration SESSION_TTL = Duration.ofMinutes(6);

/**
* Heartbeat 권장 간격 (5분)
* - 클라이언트가 이 주기로 Heartbeat 전송 권장
*/
public static final Duration HEARTBEAT_INTERVAL = Duration.ofMinutes(5);

// ===== Redis Key 패턴 =====

/**
* 사용자 세션 정보 저장 Key
* - 패턴: ws:user:{userId}
* - 값: WebSocketSessionInfo
*/
public static final String USER_SESSION_KEY_PREFIX = "ws:user:";

/**
* 세션 → 사용자 매핑 Key
* - 패턴: ws:session:{sessionId}
* - 값: userId (Long)
*/
public static final String SESSION_USER_KEY_PREFIX = "ws:session:";

/**
* 방별 참가자 목록 Key
* - 패턴: ws:room:{roomId}:users
* - 값: Set<userId>
*/
public static final String ROOM_USERS_KEY_PREFIX = "ws:room:";
public static final String ROOM_USERS_KEY_SUFFIX = ":users";

// ===== Key 빌더 헬퍼 메서드 =====

public static String buildUserSessionKey(Long userId) {
return USER_SESSION_KEY_PREFIX + userId;
}

public static String buildSessionUserKey(String sessionId) {
return SESSION_USER_KEY_PREFIX + sessionId;
}

public static String buildRoomUsersKey(Long roomId) {
return ROOM_USERS_KEY_PREFIX + roomId + ROOM_USERS_KEY_SUFFIX;
}

public static String buildUserSessionKeyPattern() {
return USER_SESSION_KEY_PREFIX + "*";
}

// ===== API 응답용 =====

public static String getSessionTTLDescription() {
return SESSION_TTL.toMinutes() + "분 (Heartbeat 방식)";
}

public static String getHeartbeatIntervalDescription() {
return HEARTBEAT_INTERVAL.toMinutes() + "분";
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.back.global.websocket.controller;

import com.back.global.common.dto.RsData;
import com.back.global.websocket.config.WebSocketConstants;
import com.back.global.websocket.service.WebSocketSessionManager;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
Expand Down Expand Up @@ -29,8 +30,8 @@ public ResponseEntity<RsData<Map<String, Object>>> healthCheck() {
data.put("service", "WebSocket");
data.put("status", "running");
data.put("timestamp", LocalDateTime.now());
data.put("sessionTTL", "10분 (Heartbeat 방식)");
data.put("heartbeatInterval", "5분");
data.put("sessionTTL", WebSocketConstants.getSessionTTLDescription());
data.put("heartbeatInterval", WebSocketConstants.getHeartbeatIntervalDescription());
data.put("totalOnlineUsers", sessionManager.getTotalOnlineUserCount());
data.put("endpoints", Map.of(
"websocket", "/ws",
Expand All @@ -53,8 +54,8 @@ public ResponseEntity<RsData<Map<String, Object>>> getConnectionInfo() {
connectionInfo.put("websocketUrl", "/ws");
connectionInfo.put("sockjsSupport", true);
connectionInfo.put("stompVersion", "1.2");
connectionInfo.put("heartbeatInterval", "5분");
connectionInfo.put("sessionTTL", "10분");
connectionInfo.put("heartbeatInterval", WebSocketConstants.getHeartbeatIntervalDescription());
connectionInfo.put("sessionTTL", WebSocketConstants.getSessionTTLDescription());
connectionInfo.put("subscribeTopics", Map.of(
"roomChat", "/topic/rooms/{roomId}/chat",
"privateMessage", "/user/queue/messages",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.back.global.websocket.service;

import com.back.global.exception.CustomException;
import com.back.global.exception.ErrorCode;
import com.back.global.websocket.dto.WebSocketSessionInfo;
import com.back.global.websocket.store.RedisSessionStore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Set;

/**
* 방 참가자 관리 서비스
* - 방 입장/퇴장 처리
* - 방별 참가자 목록 관리
* - 방별 온라인 사용자 통계
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RoomParticipantService {

private final RedisSessionStore redisSessionStore;

// 사용자 방 입장
public void enterRoom(Long userId, Long roomId) {
WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId);

if (sessionInfo == null) {
log.warn("세션 정보가 없어 방 입장 실패 - 사용자: {}, 방: {}", userId, roomId);
throw new CustomException(ErrorCode.WS_SESSION_NOT_FOUND);
}

if (sessionInfo.currentRoomId() != null) {
exitRoom(userId, sessionInfo.currentRoomId());
log.debug("기존 방에서 퇴장 처리 완료 - 사용자: {}, 이전 방: {}",
userId, sessionInfo.currentRoomId());
}

WebSocketSessionInfo updatedSession = sessionInfo.withRoomId(roomId);
redisSessionStore.saveUserSession(userId, updatedSession);
redisSessionStore.addUserToRoom(roomId, userId);

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

// 사용자 방 퇴장
public void exitRoom(Long userId, Long roomId) {
WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId);

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

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

// 사용자의 현재 방 ID 조회
public Long getCurrentRoomId(Long userId) {
WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId);
return sessionInfo != null ? sessionInfo.currentRoomId() : null;
}

// 방의 온라인 참가자 목록 조회
public Set<Long> getParticipants(Long roomId) {
return redisSessionStore.getRoomUsers(roomId);
}

// 방의 온라인 참가자 수 조회
public long getParticipantCount(Long roomId) {
return redisSessionStore.getRoomUserCount(roomId);
}

// 사용자가 특정 방에 참여 중인지 확인
public boolean isUserInRoom(Long userId, Long roomId) {
Long currentRoomId = getCurrentRoomId(userId);
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);
// 에러를 던지지 않고 로그만 남김 (세션 종료는 계속 진행되어야 함)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.back.global.websocket.service;

import com.back.global.exception.CustomException;
import com.back.global.exception.ErrorCode;
import com.back.global.websocket.dto.WebSocketSessionInfo;
import com.back.global.websocket.store.RedisSessionStore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

/**
* 사용자 세션 관리 서비스
* - 세션 생명주기 관리 (등록, 종료)
* - Heartbeat 처리
* - 중복 연결 방지
* - 연결 상태 조회
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserSessionService {

private final RedisSessionStore redisSessionStore;

// 세션 등록
public void registerSession(Long userId, String sessionId) {
WebSocketSessionInfo existingSession = redisSessionStore.getUserSession(userId);
if (existingSession != null) {
terminateSession(existingSession.sessionId());
log.info("기존 세션 제거 후 새 세션 등록 - 사용자: {}", userId);
}

WebSocketSessionInfo newSession = WebSocketSessionInfo.createNewSession(userId, sessionId);
redisSessionStore.saveUserSession(userId, newSession);
redisSessionStore.saveSessionUserMapping(sessionId, userId);

log.info("WebSocket 세션 등록 완료 - 사용자: {}, 세션: {}", userId, sessionId);
}

// 세션 종료
public void terminateSession(String sessionId) {
Long userId = redisSessionStore.getUserIdBySession(sessionId);

if (userId != null) {
redisSessionStore.deleteUserSession(userId);
redisSessionStore.deleteSessionUserMapping(sessionId);
log.info("WebSocket 세션 종료 완료 - 세션: {}, 사용자: {}", sessionId, userId);
} else {
log.warn("종료할 세션을 찾을 수 없음 - 세션: {}", sessionId);
}
}

// Heartbeat 처리 (활동 시간 업데이트 및 TTL 연장)
public void processHeartbeat(Long userId) {
WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId);

if (sessionInfo == null) {
log.warn("세션 정보가 없어 Heartbeat 처리 실패 - 사용자: {}", userId);
return;
}

WebSocketSessionInfo updatedSession = sessionInfo.withUpdatedActivity();
redisSessionStore.saveUserSession(userId, updatedSession);

log.debug("Heartbeat 처리 완료 - 사용자: {}, TTL 연장", userId);
}

// 사용자 연결 상태 확인
public boolean isConnected(Long userId) {
return redisSessionStore.existsUserSession(userId);
}

// 사용자 세션 정보 조회
public WebSocketSessionInfo getSessionInfo(Long userId) {
WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId);

if (sessionInfo == null) {
log.debug("세션 정보 없음 - 사용자: {}", userId);
}

return sessionInfo;
}

// 세션ID로 사용자ID 조회
public Long getUserIdBySessionId(String sessionId) {
return redisSessionStore.getUserIdBySession(sessionId);
}

// 사용자의 현재 방 ID 조회
public Long getCurrentRoomId(Long userId) {
WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId);
return sessionInfo != null ? sessionInfo.currentRoomId() : null;
}

// 전체 온라인 사용자 수 조회
public long getTotalOnlineUserCount() {
return redisSessionStore.getTotalOnlineUserCount();
}
}
Loading