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