Skip to content

Commit 4398a6d

Browse files
committed
Feat: Heartbeat 메시지 DTO + Redis 기반 세션 매니저 추가
1 parent 59270e3 commit 4398a6d

File tree

2 files changed

+233
-0
lines changed

2 files changed

+233
-0
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.back.global.websocket.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Data;
5+
import lombok.NoArgsConstructor;
6+
7+
@Data
8+
@NoArgsConstructor
9+
@AllArgsConstructor
10+
public class HeartbeatMessage {
11+
private Long userId;
12+
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
package com.back.global.websocket.service;
2+
3+
import com.back.global.exception.CustomException;
4+
import com.back.global.exception.ErrorCode;
5+
import com.back.global.websocket.dto.WebSocketSessionInfo;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.data.redis.core.RedisTemplate;
9+
import org.springframework.stereotype.Service;
10+
11+
import java.time.Duration;
12+
import java.time.LocalDateTime;
13+
14+
@Slf4j
15+
@Service
16+
@RequiredArgsConstructor
17+
public class WebSocketSessionManager {
18+
19+
private final RedisTemplate<String, Object> redisTemplate;
20+
21+
// Redis Key 패턴
22+
private static final String USER_SESSION_KEY = "ws:user:{}";
23+
private static final String SESSION_USER_KEY = "ws:session:{}";
24+
private static final String ROOM_USERS_KEY = "ws:room:{}:users";
25+
26+
// TTL 설정 (10분) - Heartbeat와 함께 사용하여 정확한 상태 관리
27+
private static final int SESSION_TTL_MINUTES = 10;
28+
29+
// 사용자 세션 추가 (연결 시 호출)
30+
public void addSession(Long userId, String sessionId) {
31+
try {
32+
WebSocketSessionInfo sessionInfo = WebSocketSessionInfo.builder()
33+
.userId(userId)
34+
.sessionId(sessionId)
35+
.connectedAt(LocalDateTime.now())
36+
.lastActiveAt(LocalDateTime.now())
37+
.build();
38+
39+
String userKey = USER_SESSION_KEY.replace("{}", userId.toString());
40+
String sessionKey = SESSION_USER_KEY.replace("{}", sessionId);
41+
42+
// 기존 세션이 있다면 제거 (중복 연결 방지)
43+
WebSocketSessionInfo existingSession = getSessionInfo(userId);
44+
if (existingSession != null) {
45+
removeSessionInternal(existingSession.getSessionId());
46+
log.info("기존 세션 제거 후 새 세션 등록 - 사용자: {}", userId);
47+
}
48+
49+
// 새 세션 등록 (TTL 10분)
50+
redisTemplate.opsForValue().set(userKey, sessionInfo, Duration.ofMinutes(SESSION_TTL_MINUTES));
51+
redisTemplate.opsForValue().set(sessionKey, userId, Duration.ofMinutes(SESSION_TTL_MINUTES));
52+
53+
log.info("WebSocket 세션 등록 완료 - 사용자: {}, 세션: {}, TTL: {}분",
54+
userId, sessionId, SESSION_TTL_MINUTES);
55+
56+
} catch (Exception e) {
57+
log.error("WebSocket 세션 등록 실패 - 사용자: {}", userId, e);
58+
throw new CustomException(ErrorCode.WS_CONNECTION_FAILED);
59+
}
60+
}
61+
62+
// 사용자 연결 상태 확인
63+
public boolean isUserConnected(Long userId) {
64+
try {
65+
String userKey = USER_SESSION_KEY.replace("{}", userId.toString());
66+
return Boolean.TRUE.equals(redisTemplate.hasKey(userKey));
67+
} catch (Exception e) {
68+
log.error("사용자 연결 상태 확인 실패 - 사용자: {}", userId, e);
69+
throw new CustomException(ErrorCode.WS_REDIS_ERROR);
70+
}
71+
}
72+
73+
// 사용자 세션 정보 조회
74+
public WebSocketSessionInfo getSessionInfo(Long userId) {
75+
try {
76+
String userKey = USER_SESSION_KEY.replace("{}", userId.toString());
77+
return (WebSocketSessionInfo) redisTemplate.opsForValue().get(userKey);
78+
} catch (Exception e) {
79+
log.error("세션 정보 조회 실패 - 사용자: {}", userId, e);
80+
throw new CustomException(ErrorCode.WS_REDIS_ERROR);
81+
}
82+
}
83+
84+
// 세션 제거 (연결 종료 시 호출)
85+
public void removeSession(String sessionId) {
86+
try {
87+
removeSessionInternal(sessionId);
88+
log.info("WebSocket 세션 제거 완료 - 세션: {}", sessionId);
89+
} catch (Exception e) {
90+
log.error("WebSocket 세션 제거 실패 - 세션: {}", sessionId, e);
91+
throw new CustomException(ErrorCode.WS_REDIS_ERROR);
92+
}
93+
}
94+
95+
// 사용자 활동 시간 업데이트 및 TTL 연장 (Heartbeat 시 호출)
96+
public void updateLastActivity(Long userId) {
97+
try {
98+
WebSocketSessionInfo sessionInfo = getSessionInfo(userId);
99+
if (sessionInfo != null) {
100+
// 마지막 활동 시간 업데이트
101+
sessionInfo.setLastActiveAt(LocalDateTime.now());
102+
103+
String userKey = USER_SESSION_KEY.replace("{}", userId.toString());
104+
// TTL 10분으로 연장 (Heartbeat의 핵심!)
105+
redisTemplate.opsForValue().set(userKey, sessionInfo, Duration.ofMinutes(SESSION_TTL_MINUTES));
106+
107+
log.debug("사용자 활동 시간 업데이트 완료 - 사용자: {}, TTL 연장", userId);
108+
} else {
109+
log.warn("세션 정보가 없어 활동 시간 업데이트 실패 - 사용자: {}", userId);
110+
}
111+
} catch (CustomException e) {
112+
// 이미 처리된 CustomException은 다시 던짐
113+
throw e;
114+
} catch (Exception e) {
115+
log.error("사용자 활동 시간 업데이트 실패 - 사용자: {}", userId, e);
116+
throw new CustomException(ErrorCode.WS_ACTIVITY_UPDATE_FAILED);
117+
}
118+
}
119+
120+
// 방 입장
121+
public void joinRoom(Long userId, Long roomId) {
122+
try {
123+
WebSocketSessionInfo sessionInfo = getSessionInfo(userId);
124+
if (sessionInfo != null) {
125+
// 기존 방에서 퇴장
126+
if (sessionInfo.getCurrentRoomId() != null) {
127+
leaveRoom(userId, sessionInfo.getCurrentRoomId());
128+
}
129+
130+
// 새 방 정보 업데이트
131+
sessionInfo.setCurrentRoomId(roomId);
132+
sessionInfo.setLastActiveAt(LocalDateTime.now());
133+
134+
String userKey = USER_SESSION_KEY.replace("{}", userId.toString());
135+
redisTemplate.opsForValue().set(userKey, sessionInfo, Duration.ofMinutes(SESSION_TTL_MINUTES));
136+
137+
// 방 참여자 목록에 추가
138+
String roomUsersKey = ROOM_USERS_KEY.replace("{}", roomId.toString());
139+
redisTemplate.opsForSet().add(roomUsersKey, userId);
140+
redisTemplate.expire(roomUsersKey, Duration.ofMinutes(SESSION_TTL_MINUTES));
141+
142+
log.info("사용자 방 입장 완료 - 사용자: {}, 방: {}", userId, roomId);
143+
} else {
144+
log.warn("세션 정보가 없어 방 입장 처리 실패 - 사용자: {}, 방: {}", userId, roomId);
145+
}
146+
} catch (CustomException e) {
147+
throw e;
148+
} catch (Exception e) {
149+
log.error("사용자 방 입장 실패 - 사용자: {}, 방: {}", userId, roomId, e);
150+
throw new CustomException(ErrorCode.WS_ROOM_JOIN_FAILED);
151+
}
152+
}
153+
154+
// 방 퇴장
155+
public void leaveRoom(Long userId, Long roomId) {
156+
try {
157+
WebSocketSessionInfo sessionInfo = getSessionInfo(userId);
158+
if (sessionInfo != null) {
159+
sessionInfo.setCurrentRoomId(null);
160+
sessionInfo.setLastActiveAt(LocalDateTime.now());
161+
162+
String userKey = USER_SESSION_KEY.replace("{}", userId.toString());
163+
redisTemplate.opsForValue().set(userKey, sessionInfo, Duration.ofMinutes(SESSION_TTL_MINUTES));
164+
165+
// 방 참여자 목록에서 제거
166+
String roomUsersKey = ROOM_USERS_KEY.replace("{}", roomId.toString());
167+
redisTemplate.opsForSet().remove(roomUsersKey, userId);
168+
169+
log.info("사용자 방 퇴장 완료 - 사용자: {}, 방: {}", userId, roomId);
170+
}
171+
} catch (CustomException e) {
172+
throw e;
173+
} catch (Exception e) {
174+
log.error("사용자 방 퇴장 실패 - 사용자: {}, 방: {}", userId, roomId, e);
175+
throw new CustomException(ErrorCode.WS_ROOM_LEAVE_FAILED);
176+
}
177+
}
178+
179+
// 방의 참여자 수 조회
180+
public long getRoomUserCount(Long roomId) {
181+
try {
182+
String roomUsersKey = ROOM_USERS_KEY.replace("{}", roomId.toString());
183+
Long count = redisTemplate.opsForSet().size(roomUsersKey);
184+
return count != null ? count : 0;
185+
} catch (Exception e) {
186+
log.error("방 참여자 수 조회 실패 - 방: {}", roomId, e);
187+
throw new CustomException(ErrorCode.WS_REDIS_ERROR);
188+
}
189+
}
190+
191+
// 방의 참여자 목록 조회
192+
public java.util.Set<Object> getRoomUsers(Long roomId) {
193+
try {
194+
String roomUsersKey = ROOM_USERS_KEY.replace("{}", roomId.toString());
195+
return redisTemplate.opsForSet().members(roomUsersKey);
196+
} catch (Exception e) {
197+
log.error("방 참여자 목록 조회 실패 - 방: {}", roomId, e);
198+
throw new CustomException(ErrorCode.WS_REDIS_ERROR);
199+
}
200+
}
201+
202+
// 내부적으로 세션 제거 처리
203+
private void removeSessionInternal(String sessionId) {
204+
String sessionKey = SESSION_USER_KEY.replace("{}", sessionId);
205+
Long userId = (Long) redisTemplate.opsForValue().get(sessionKey);
206+
207+
if (userId != null) {
208+
WebSocketSessionInfo sessionInfo = getSessionInfo(userId);
209+
210+
// 방에서 퇴장 처리
211+
if (sessionInfo != null && sessionInfo.getCurrentRoomId() != null) {
212+
leaveRoom(userId, sessionInfo.getCurrentRoomId());
213+
}
214+
215+
// 세션 데이터 삭제
216+
String userKey = USER_SESSION_KEY.replace("{}", userId.toString());
217+
redisTemplate.delete(userKey);
218+
redisTemplate.delete(sessionKey);
219+
}
220+
}
221+
}

0 commit comments

Comments
 (0)