Skip to content

Commit bb15fd7

Browse files
committed
Feat: STOMP/REST API 컨트롤러 역할별 분리
1 parent a2dc4bd commit bb15fd7

File tree

4 files changed

+153
-33
lines changed

4 files changed

+153
-33
lines changed

src/main/java/com/back/global/websocket/controller/WebSocketTestController.java renamed to src/main/java/com/back/global/websocket/controller/WebSocketApiController.java

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,70 @@
11
package com.back.global.websocket.controller;
22

33
import com.back.global.common.dto.RsData;
4+
import com.back.global.websocket.service.WebSocketSessionManager;
45
import io.swagger.v3.oas.annotations.Operation;
56
import io.swagger.v3.oas.annotations.tags.Tag;
6-
import lombok.extern.slf4j.Slf4j;
7+
import lombok.RequiredArgsConstructor;
78
import org.springframework.http.HttpStatus;
89
import org.springframework.http.ResponseEntity;
9-
import org.springframework.web.bind.annotation.GetMapping;
10-
import org.springframework.web.bind.annotation.RequestMapping;
11-
import org.springframework.web.bind.annotation.RestController;
10+
import org.springframework.web.bind.annotation.*;
1211

1312
import java.time.LocalDateTime;
1413
import java.util.HashMap;
1514
import java.util.Map;
1615

17-
@Slf4j
1816
@RestController
19-
@RequestMapping("/api/websocket")
20-
@Tag(name = "WebSocket Test API", description = "WebSocket 서버 상태 확인 및 연결 정보 제공 API")
21-
public class WebSocketTestController { // WebSocket 기능 테스트용 REST 컨트롤러
17+
@RequestMapping("/ws")
18+
@RequiredArgsConstructor
19+
@Tag(name = "WebSocket REST API", description = "WebSocket 서버 상태 확인 및 실시간 연결 정보 제공 API")
20+
public class WebSocketApiController {
21+
22+
private final WebSocketSessionManager sessionManager;
2223

23-
// WebSocket 서버 상태 확인
2424
@GetMapping("/health")
2525
@Operation(summary = "WebSocket 서버 헬스체크", description = "WebSocket 서비스의 현재 상태를 확인합니다.")
2626
public ResponseEntity<RsData<Map<String, Object>>> healthCheck() {
27-
log.info("WebSocket 헬스체크 요청");
2827

2928
Map<String, Object> data = new HashMap<>();
3029
data.put("service", "WebSocket");
3130
data.put("status", "running");
3231
data.put("timestamp", LocalDateTime.now());
32+
data.put("sessionTTL", "10분 (Heartbeat 방식)");
33+
data.put("heartbeatInterval", "5분");
34+
data.put("totalOnlineUsers", sessionManager.getTotalOnlineUserCount());
3335
data.put("endpoints", Map.of(
3436
"websocket", "/ws",
35-
"chat", "/app/rooms/{roomId}/chat",
36-
"join", "/app/rooms/{roomId}/join",
37-
"leave", "/app/rooms/{roomId}/leave"
37+
"heartbeat", "/app/heartbeat",
38+
"activity", "/app/activity",
39+
"joinRoom", "/app/rooms/{roomId}/join",
40+
"leaveRoom", "/app/rooms/{roomId}/leave"
3841
));
3942

4043
return ResponseEntity
4144
.status(HttpStatus.OK)
4245
.body(RsData.success("WebSocket 서비스가 정상 동작중입니다.", data));
4346
}
4447

45-
// WebSocket 연결 정보 제공
4648
@GetMapping("/info")
4749
@Operation(summary = "WebSocket 연결 정보 조회", description = "클라이언트가 WebSocket에 연결하기 위해 필요한 정보를 제공합니다.")
4850
public ResponseEntity<RsData<Map<String, Object>>> getConnectionInfo() {
49-
log.info("WebSocket 연결 정보 요청");
5051

5152
Map<String, Object> connectionInfo = new HashMap<>();
5253
connectionInfo.put("websocketUrl", "/ws");
5354
connectionInfo.put("sockjsSupport", true);
5455
connectionInfo.put("stompVersion", "1.2");
56+
connectionInfo.put("heartbeatInterval", "5분");
57+
connectionInfo.put("sessionTTL", "10분");
58+
connectionInfo.put("description", "RoomController와 협력하여 실시간 온라인 상태 관리");
5559
connectionInfo.put("subscribeTopics", Map.of(
5660
"roomChat", "/topic/rooms/{roomId}/chat",
5761
"privateMessage", "/user/queue/messages",
5862
"notifications", "/user/queue/notifications"
5963
));
6064
connectionInfo.put("sendDestinations", Map.of(
61-
"roomChat", "/app/rooms/{roomId}/chat",
62-
"joinRoom", "/app/rooms/{roomId}/join",
63-
"leaveRoom", "/app/rooms/{roomId}/leave"
65+
"heartbeat", "/app/heartbeat",
66+
"activity", "/app/activity",
67+
"roomChat", "/app/rooms/{roomId}/chat"
6468
));
6569

6670
return ResponseEntity
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.back.global.websocket.controller;
2+
3+
import com.back.global.exception.CustomException;
4+
import com.back.global.websocket.dto.HeartbeatMessage;
5+
import com.back.global.websocket.service.WebSocketSessionManager;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.messaging.handler.annotation.MessageMapping;
9+
import org.springframework.messaging.handler.annotation.DestinationVariable;
10+
import org.springframework.messaging.handler.annotation.Payload;
11+
import org.springframework.stereotype.Controller;
12+
13+
@Slf4j
14+
@Controller
15+
@RequiredArgsConstructor
16+
public class WebSocketMessageController {
17+
18+
private final WebSocketSessionManager sessionManager;
19+
20+
// Heartbeat 처리
21+
@MessageMapping("/heartbeat")
22+
public void handleHeartbeat(@Payload HeartbeatMessage message) {
23+
try {
24+
if (message.getUserId() != null) {
25+
// TTL 10분으로 연장
26+
sessionManager.updateLastActivity(message.getUserId());
27+
log.debug("Heartbeat 처리 완료 - 사용자: {}", message.getUserId());
28+
} else {
29+
log.warn("유효하지 않은 Heartbeat 메시지 수신: userId가 null");
30+
}
31+
} catch (CustomException e) {
32+
log.error("Heartbeat 처리 실패: {}", e.getMessage());
33+
// STOMP에서는 에러 응답을 보내지 않고 로깅만 (연결 유지)
34+
} catch (Exception e) {
35+
log.error("Heartbeat 처리 중 예상치 못한 오류", e);
36+
}
37+
}
38+
39+
// 방 입장 처리
40+
@MessageMapping("/rooms/{roomId}/join")
41+
public void handleJoinRoom(@DestinationVariable Long roomId, @Payload HeartbeatMessage message) {
42+
try {
43+
if (message.getUserId() != null) {
44+
sessionManager.joinRoom(message.getUserId(), roomId);
45+
log.info("STOMP 방 입장 처리 완료 - 사용자: {}, 방: {}", message.getUserId(), roomId);
46+
} else {
47+
log.warn("유효하지 않은 방 입장 요청: userId가 null");
48+
}
49+
} catch (CustomException e) {
50+
log.error("방 입장 처리 실패 - 방: {}, 에러: {}", roomId, e.getMessage());
51+
} catch (Exception e) {
52+
log.error("방 입장 처리 중 예상치 못한 오류 - 방: {}", roomId, e);
53+
}
54+
}
55+
56+
// 방 퇴장 처리
57+
@MessageMapping("/rooms/{roomId}/leave")
58+
public void handleLeaveRoom(@DestinationVariable Long roomId, @Payload HeartbeatMessage message) {
59+
try {
60+
if (message.getUserId() != null) {
61+
sessionManager.leaveRoom(message.getUserId(), roomId);
62+
log.info("STOMP 방 퇴장 처리 완료 - 사용자: {}, 방: {}", message.getUserId(), roomId);
63+
} else {
64+
log.warn("유효하지 않은 방 퇴장 요청: userId가 null");
65+
}
66+
} catch (CustomException e) {
67+
log.error("방 퇴장 처리 실패 - 방: {}, 에러: {}", roomId, e.getMessage());
68+
} catch (Exception e) {
69+
log.error("방 퇴장 처리 중 예상치 못한 오류 - 방: {}", roomId, e);
70+
}
71+
}
72+
73+
// 활동 신호 처리
74+
@MessageMapping("/activity")
75+
public void handleActivity(@Payload HeartbeatMessage message) {
76+
try {
77+
if (message.getUserId() != null) {
78+
sessionManager.updateLastActivity(message.getUserId());
79+
log.debug("사용자 활동 신호 처리 완료 - 사용자: {}", message.getUserId());
80+
}
81+
} catch (CustomException e) {
82+
log.error("활동 신호 처리 실패: {}", e.getMessage());
83+
} catch (Exception e) {
84+
log.error("활동 신호 처리 중 예상치 못한 오류", e);
85+
}
86+
}
87+
}

src/main/java/com/back/global/websocket/event/WebSocketEventListener.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212
import org.springframework.web.socket.messaging.SessionConnectEvent;
1313
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
1414

15-
import java.net.http.WebSocket;
16-
1715
@Slf4j
1816
@Component
1917
@RequiredArgsConstructor

src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
import java.time.Duration;
1212
import java.time.LocalDateTime;
13+
import java.util.Set;
14+
import java.util.stream.Collectors;
1315

1416
@Slf4j
1517
@Service
@@ -117,7 +119,7 @@ public void updateLastActivity(Long userId) {
117119
}
118120
}
119121

120-
// 방 입장
122+
// 사용자가 방에 입장 (WebSocket 전용)
121123
public void joinRoom(Long userId, Long roomId) {
122124
try {
123125
WebSocketSessionInfo sessionInfo = getSessionInfo(userId);
@@ -139,7 +141,7 @@ public void joinRoom(Long userId, Long roomId) {
139141
redisTemplate.opsForSet().add(roomUsersKey, userId);
140142
redisTemplate.expire(roomUsersKey, Duration.ofMinutes(SESSION_TTL_MINUTES));
141143

142-
log.info("사용자 방 입장 완료 - 사용자: {}, 방: {}", userId, roomId);
144+
log.info("WebSocket 방 입장 완료 - 사용자: {}, 방: {}", userId, roomId);
143145
} else {
144146
log.warn("세션 정보가 없어 방 입장 처리 실패 - 사용자: {}, 방: {}", userId, roomId);
145147
}
@@ -151,7 +153,7 @@ public void joinRoom(Long userId, Long roomId) {
151153
}
152154
}
153155

154-
// 방 퇴장
156+
// 사용자가 방에서 퇴장 (WebSocket 전용)
155157
public void leaveRoom(Long userId, Long roomId) {
156158
try {
157159
WebSocketSessionInfo sessionInfo = getSessionInfo(userId);
@@ -166,7 +168,7 @@ public void leaveRoom(Long userId, Long roomId) {
166168
String roomUsersKey = ROOM_USERS_KEY.replace("{}", roomId.toString());
167169
redisTemplate.opsForSet().remove(roomUsersKey, userId);
168170

169-
log.info("사용자 방 퇴장 완료 - 사용자: {}, 방: {}", userId, roomId);
171+
log.info("WebSocket 방 퇴장 완료 - 사용자: {}, 방: {}", userId, roomId);
170172
}
171173
} catch (CustomException e) {
172174
throw e;
@@ -176,30 +178,59 @@ public void leaveRoom(Long userId, Long roomId) {
176178
}
177179
}
178180

179-
// 방의 참여자 수 조회
180-
public long getRoomUserCount(Long roomId) {
181+
// 방의 온라인 사용자 수 조회
182+
public long getRoomOnlineUserCount(Long roomId) {
181183
try {
182184
String roomUsersKey = ROOM_USERS_KEY.replace("{}", roomId.toString());
183185
Long count = redisTemplate.opsForSet().size(roomUsersKey);
184186
return count != null ? count : 0;
185187
} catch (Exception e) {
186-
log.error("방 참여자 수 조회 실패 - 방: {}", roomId, e);
188+
log.error("방 온라인 사용자 수 조회 실패 - 방: {}", roomId, e);
187189
throw new CustomException(ErrorCode.WS_REDIS_ERROR);
188190
}
189191
}
190192

191-
// 방의 참여자 목록 조회
192-
public java.util.Set<Object> getRoomUsers(Long roomId) {
193+
// 방의 온라인 사용자 목록 조회
194+
public Set<Long> getOnlineUsersInRoom(Long roomId) {
193195
try {
194196
String roomUsersKey = ROOM_USERS_KEY.replace("{}", roomId.toString());
195-
return redisTemplate.opsForSet().members(roomUsersKey);
197+
Set<Object> userIds = redisTemplate.opsForSet().members(roomUsersKey);
198+
199+
if (userIds != null) {
200+
return userIds.stream()
201+
.map(obj -> (Long) obj)
202+
.collect(Collectors.toSet());
203+
}
204+
return Set.of();
196205
} catch (Exception e) {
197-
log.error("방 참여자 목록 조회 실패 - 방: {}", roomId, e);
206+
log.error("방 온라인 사용자 목록 조회 실패 - 방: {}", roomId, e);
198207
throw new CustomException(ErrorCode.WS_REDIS_ERROR);
199208
}
200209
}
201210

202-
// 내부적으로 세션 제거 처리
211+
// 전체 온라인 사용자 수 조회
212+
public long getTotalOnlineUserCount() {
213+
try {
214+
Set<String> userKeys = redisTemplate.keys(USER_SESSION_KEY.replace("{}", "*"));
215+
return userKeys != null ? userKeys.size() : 0;
216+
} catch (Exception e) {
217+
log.error("전체 온라인 사용자 수 조회 실패", e);
218+
return 0;
219+
}
220+
}
221+
222+
// 특정 사용자의 현재 방 조회
223+
public Long getUserCurrentRoomId(Long userId) {
224+
try {
225+
WebSocketSessionInfo sessionInfo = getSessionInfo(userId);
226+
return sessionInfo != null ? sessionInfo.getCurrentRoomId() : null;
227+
} catch (CustomException e) {
228+
log.error("사용자 현재 방 조회 실패 - 사용자: {}", userId, e);
229+
return null; // 조회용이므로 예외 대신 null 반환
230+
}
231+
}
232+
233+
// 내부적으로 세션 제거 처리
203234
private void removeSessionInternal(String sessionId) {
204235
String sessionKey = SESSION_USER_KEY.replace("{}", sessionId);
205236
Long userId = (Long) redisTemplate.opsForValue().get(sessionKey);
@@ -218,4 +249,4 @@ private void removeSessionInternal(String sessionId) {
218249
redisTemplate.delete(sessionKey);
219250
}
220251
}
221-
}
252+
}

0 commit comments

Comments
 (0)