Skip to content

Commit 76136fa

Browse files
committed
feat: 채팅방 실시간 접속자 수 집계
1 parent bcf0075 commit 76136fa

9 files changed

Lines changed: 353 additions & 0 deletions

File tree

src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatMessageController.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
package com.back.web7_9_codecrete_be.domain.chats.controller;
22

33
import java.security.Principal;
4+
import java.time.Duration;
5+
import java.util.Map;
46

7+
import org.springframework.data.redis.core.RedisTemplate;
58
import org.springframework.messaging.handler.annotation.MessageMapping;
9+
import org.springframework.messaging.handler.annotation.Payload;
10+
import org.springframework.security.core.Authentication;
611
import org.springframework.stereotype.Controller;
712

813
import com.back.web7_9_codecrete_be.domain.chats.dto.request.ChatMessageRequest;
914
import com.back.web7_9_codecrete_be.domain.chats.service.ChatMessageService;
15+
import com.back.web7_9_codecrete_be.global.security.CustomUserDetail;
16+
import com.back.web7_9_codecrete_be.global.websocket.ServerInstanceId;
1017

1118
import lombok.RequiredArgsConstructor;
1219
import lombok.extern.slf4j.Slf4j;
@@ -17,6 +24,7 @@
1724
public class ChatMessageController {
1825

1926
private final ChatMessageService chatMessageService;
27+
private final RedisTemplate<String, Object> redisTemplate;
2028

2129
@MessageMapping("/chat/send")
2230
public void send(ChatMessageRequest message, Principal principal) {
@@ -25,7 +33,30 @@ public void send(ChatMessageRequest message, Principal principal) {
2533
throw new IllegalStateException("Unauthenticated WebSocket access");
2634
}
2735

36+
CustomUserDetail userDetail = (CustomUserDetail)
37+
((Authentication) principal).getPrincipal();
38+
39+
Long userId = userDetail.getUser().getId();
40+
41+
String sessionSetKey =
42+
"chat:server:" + ServerInstanceId.ID + ":user:" + userId + ":sessionIds";
43+
44+
redisTemplate.expire(sessionSetKey, Duration.ofHours(2));
45+
2846
chatMessageService.sendMessage(message, principal);
2947
}
48+
49+
@MessageMapping("/chat/status")
50+
public void getInitialCount(@Payload Map<String, Object> payload) {
51+
52+
Object concertIdObj = payload.get("concertId");
53+
54+
if (concertIdObj != null) {
55+
Long concertId = Long.valueOf(concertIdObj.toString());
56+
log.info("[CHAT STATUS] 인원수 초기 요청 수신: concertId={}", concertId);
57+
58+
chatMessageService.broadcastUserCount(concertId);
59+
}
60+
}
3061
}
3162

src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatStompDocsController.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,5 +92,58 @@ public void stompChatGuide() {}
9292
public ChatMessageResponse messageSchema() {
9393
return null; // 실제 반환 목적 X
9494
}
95+
96+
97+
@Operation(
98+
summary = "실시간 채팅 접속자 수 집계 (STOMP 전용, 문서용 API. 사용X)",
99+
description = """
100+
### 👥 실시간 채팅 접속자 수 집계
101+
102+
- 동일 유저의 여러 탭은 **1명으로 집계**
103+
- 모든 탭이 닫혔을 때만 접속자 수 감소
104+
- 접속 / 퇴장 / 초기 요청 시 자동 브로드캐스트
105+
106+
---
107+
108+
### 1️⃣ CONNECT 시 자동 집계
109+
- STOMP CONNECT 시 `concertId` 기준으로 접속자 등록
110+
- 접속 즉시 현재 인원 수가 브로드캐스트됩니다.
111+
112+
### 2️⃣ 초기 접속자 수 요청 (SEND)
113+
```
114+
/app/chat/status
115+
```
116+
117+
#### SEND Payload
118+
- 접속자 수 집계를 위해 `concertId`를 CONNECT 헤더로 전달
119+
```json
120+
{
121+
"concertId": 1
122+
}
123+
```
124+
125+
- 채팅방 최초 진입 시 프론트에서 1회 호출
126+
- 서버가 Redis 기준 현재 접속자 수 계산 후 브로드캐스트
127+
128+
### 3️⃣ SUBSCRIBE Destination
129+
```
130+
/topic/chat/{concertId}/count
131+
```
132+
133+
#### SUBSCRIBE Response
134+
```json
135+
5
136+
```
137+
138+
- 숫자(Number) 형태의 현재 접속자 수
139+
- 접속 / 퇴장 / 초기 요청 시마다 자동 수신
140+
141+
### 4️⃣ DISCONNECT 처리
142+
- STOMP DISCONNECT 시 자동 처리
143+
- 동일 유저의 모든 탭이 닫힌 경우에만 접속자 수 감소
144+
"""
145+
)
146+
@GetMapping("/user-count")
147+
public void chatUserCountGuide() {}
95148
}
96149

src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatMessageService.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
import java.security.Principal;
44
import java.time.LocalDateTime;
55

6+
import org.springframework.data.redis.core.RedisTemplate;
67
import org.springframework.messaging.simp.SimpMessagingTemplate;
78
import org.springframework.stereotype.Service;
89

910
import com.back.web7_9_codecrete_be.domain.chats.dto.request.ChatMessageRequest;
1011
import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatMessageResponse;
1112
import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatUserCache;
1213
import com.back.web7_9_codecrete_be.domain.chats.repository.ChatStreamRepository;
14+
import com.back.web7_9_codecrete_be.global.websocket.ChatCountBroadcaster;
1315

1416
import lombok.RequiredArgsConstructor;
1517
import lombok.extern.slf4j.Slf4j;
@@ -23,6 +25,9 @@ public class ChatMessageService {
2325
private final ChatStreamRepository chatStreamRepository;
2426
private final ChatUserCacheService chatUserCacheService;
2527

28+
private final RedisTemplate<String, Object> redisTemplate;
29+
private final ChatCountBroadcaster chatCountBroadcaster;
30+
2631
public void sendMessage(ChatMessageRequest request, Principal principal) {
2732

2833
String email = principal.getName();
@@ -47,4 +52,15 @@ public void sendMessage(ChatMessageRequest request, Principal principal) {
4752
response
4853
);
4954
}
55+
56+
public void broadcastUserCount(Long concertId) {
57+
58+
String key = "chat:concert:" + concertId + ":users";
59+
Long count = redisTemplate.opsForSet().size(key);
60+
long userCount = (count != null) ? count : 0;
61+
62+
chatCountBroadcaster.broadcast(concertId, userCount);
63+
64+
log.info("[CHAT STATUS] 인원수 응답 완료: concertId={}, count={}", concertId, userCount);
65+
}
5066
}

src/main/java/com/back/web7_9_codecrete_be/global/config/WebSocketConfig.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package com.back.web7_9_codecrete_be.global.config;
22

33
import org.springframework.context.annotation.Configuration;
4+
import org.springframework.messaging.simp.config.ChannelRegistration;
45
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
56
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
67
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
78
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
89

10+
import com.back.web7_9_codecrete_be.global.websocket.ChatStompHandler;
11+
import com.back.web7_9_codecrete_be.global.websocket.CustomHandshakeInterceptor;
912
import com.back.web7_9_codecrete_be.global.websocket.JwtHandshakeHandler;
1013

1114
import lombok.RequiredArgsConstructor;
@@ -18,6 +21,8 @@
1821
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
1922

2023
private final JwtHandshakeHandler jwtHandshakeHandler;
24+
private final ChatStompHandler chatStompHandler;
25+
private final CustomHandshakeInterceptor customHandshakeInterceptor;
2126

2227
@Override
2328
public void configureMessageBroker(MessageBrokerRegistry config) {
@@ -34,9 +39,14 @@ public void registerStompEndpoints(StompEndpointRegistry registry) {
3439

3540
registry.addEndpoint("/ws-chat")
3641
.setHandshakeHandler(jwtHandshakeHandler)
42+
.addInterceptors(customHandshakeInterceptor)
3743
.setAllowedOriginPatterns("http://localhost:3000",
3844
"https://www.naeconcertbutakhae.shop");
3945

4046
}
4147

48+
@Override
49+
public void configureClientInboundChannel(ChannelRegistration registration) {
50+
registration.interceptors(chatStompHandler);
51+
}
4252
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.back.web7_9_codecrete_be.global.websocket;
2+
3+
import org.springframework.beans.factory.ObjectProvider;
4+
import org.springframework.messaging.simp.SimpMessagingTemplate;
5+
import org.springframework.stereotype.Component;
6+
7+
import lombok.RequiredArgsConstructor;
8+
9+
@Component
10+
@RequiredArgsConstructor
11+
public class ChatCountBroadcaster {
12+
13+
private final ObjectProvider<SimpMessagingTemplate> messagingTemplateProvider;
14+
15+
public void broadcast(Long concertId, long count) {
16+
17+
SimpMessagingTemplate messagingTemplate =
18+
messagingTemplateProvider.getIfAvailable();
19+
20+
if (messagingTemplate == null) {
21+
return; // WebSocket 브로커 아직 준비 안 됨
22+
}
23+
24+
messagingTemplate.convertAndSend(
25+
"/topic/chat/" + concertId + "/count",
26+
count
27+
);
28+
}
29+
}
30+
31+
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.back.web7_9_codecrete_be.global.websocket;
2+
3+
import java.util.Set;
4+
5+
import org.springframework.data.redis.core.RedisTemplate;
6+
import org.springframework.stereotype.Component;
7+
8+
import jakarta.annotation.PostConstruct;
9+
import lombok.RequiredArgsConstructor;
10+
11+
@Component
12+
@RequiredArgsConstructor
13+
public class ChatSessionCleanup {
14+
15+
private final RedisTemplate<String, Object> redisTemplate;
16+
17+
@PostConstruct
18+
public void cleanup() {
19+
String pattern = "chat:server:*";
20+
Set<String> keys = redisTemplate.keys(pattern);
21+
22+
if (keys != null && !keys.isEmpty()) {
23+
redisTemplate.delete(keys);
24+
}
25+
}
26+
}
27+
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package com.back.web7_9_codecrete_be.global.websocket;
2+
3+
import java.time.Duration;
4+
import java.util.Map;
5+
6+
import org.springframework.data.redis.core.RedisTemplate;
7+
import org.springframework.messaging.Message;
8+
import org.springframework.messaging.MessageChannel;
9+
import org.springframework.messaging.simp.stomp.StompCommand;
10+
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
11+
import org.springframework.messaging.support.ChannelInterceptor;
12+
import org.springframework.security.core.Authentication;
13+
import org.springframework.stereotype.Component;
14+
15+
import com.back.web7_9_codecrete_be.global.security.CustomUserDetail;
16+
import com.back.web7_9_codecrete_be.global.security.JwtTokenProvider;
17+
18+
import lombok.RequiredArgsConstructor;
19+
import lombok.extern.slf4j.Slf4j;
20+
21+
@Component
22+
@RequiredArgsConstructor
23+
@Slf4j
24+
public class ChatStompHandler implements ChannelInterceptor {
25+
26+
private final RedisTemplate<String, Object> redisTemplate;
27+
private final ChatCountBroadcaster chatCountBroadcaster;
28+
private final JwtTokenProvider jwtTokenProvider;
29+
30+
private String getConcertUserKey(Long concertId) {
31+
return "chat:concert:" + concertId + ":users";
32+
}
33+
private String getUserSessionsSetKey(Long userId) {
34+
return "chat:server:" + ServerInstanceId.ID + ":user:" + userId + ":sessionIds";
35+
}
36+
37+
@Override
38+
public Message<?> preSend(Message<?> message, MessageChannel channel) {
39+
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
40+
41+
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
42+
handleConnect(accessor);
43+
} else if (StompCommand.DISCONNECT.equals(accessor.getCommand())) {
44+
handleDisconnect(accessor);
45+
}
46+
47+
return message;
48+
}
49+
50+
private void handleConnect(StompHeaderAccessor accessor) {
51+
52+
String token = extractTokenFromCookie(accessor);
53+
54+
if (token == null || !jwtTokenProvider.validateToken(token)) {
55+
log.error("[CHAT CONNECT] 인증 실패: 토큰이 없거나 유효하지 않음");
56+
return;
57+
}
58+
59+
Authentication auth = jwtTokenProvider.getAuthentication(token);
60+
CustomUserDetail userDetail = (CustomUserDetail) auth.getPrincipal();
61+
Long userId = userDetail.getUser().getId();
62+
63+
String concertIdHeader = accessor.getFirstNativeHeader("concertId");
64+
if (concertIdHeader == null) {
65+
log.error("[CHAT CONNECT] concertId 헤더가 없습니다.");
66+
return;
67+
}
68+
Long concertId = Long.valueOf(concertIdHeader);
69+
70+
String sessionId = accessor.getSessionId();
71+
String sessionSetKey = getUserSessionsSetKey(userId);
72+
73+
// 중복 CONNECT 차단
74+
Boolean added = redisTemplate.opsForSet().add(sessionSetKey, sessionId) == 1;
75+
if (!added) {
76+
log.warn("[CHAT CONNECT] 중복 CONNECT 무시 sessionId={}", sessionId);
77+
return;
78+
}
79+
80+
// 세션 TTL (비정상 종료 대비)
81+
redisTemplate.expire(sessionSetKey, Duration.ofHours(2));
82+
83+
// 세션 속성에 저장 (Disconnect 시 Redis 키를 찾기 위해 필요)
84+
// accessor.getSessionAttributes()는 웹소켓 세션 동안 유지되는 Map
85+
if (accessor.getSessionAttributes() != null) {
86+
accessor.getSessionAttributes().put("concertId", concertId);
87+
accessor.getSessionAttributes().put("userId", userId);
88+
}
89+
90+
// 공연별 접속자 유저 ID 목록(Set)에 추가 (Set -> 동일 유저는 한 번만 기록됨)
91+
redisTemplate.opsForSet().add(getConcertUserKey(concertId), userId);
92+
93+
broadcast(concertId);
94+
95+
log.info("[CHAT CONNECT] 성공: concertId={}, userId={}, email={}",
96+
concertId, userId, userDetail.getUsername());
97+
}
98+
99+
private void handleDisconnect(StompHeaderAccessor accessor) {
100+
Map<String, Object> session = accessor.getSessionAttributes();
101+
if (session == null || !session.containsKey("userId")) return;
102+
103+
Long concertId = (Long) session.get("concertId");
104+
Long userId = (Long) session.get("userId");
105+
String sessionId = accessor.getSessionId();
106+
String sessionSetKey = getUserSessionsSetKey(userId);
107+
108+
redisTemplate.opsForSet().remove(sessionSetKey, sessionId);
109+
110+
// 활성화된 세션 개수
111+
Long remainingSessions = redisTemplate.opsForSet().size(sessionSetKey);
112+
113+
// 모든 탭이 닫혔을 때만 실제 접속자 목록에서 삭제
114+
if (remainingSessions == null || remainingSessions <= 0) {
115+
redisTemplate.opsForSet().remove(getConcertUserKey(concertId), userId);
116+
redisTemplate.delete(sessionSetKey);
117+
log.info("[CHAT EXIT] 모든 탭 종료 - concertId={}, userId={}", concertId, userId);
118+
} else {
119+
log.info("[CHAT DISCONNECT] 탭 하나 종료 - userId={}, 남은 세션={}", userId, remainingSessions);
120+
}
121+
122+
broadcast(concertId);
123+
}
124+
125+
private void broadcast(Long concertId) {
126+
Long count = redisTemplate.opsForSet().size(getConcertUserKey(concertId));
127+
chatCountBroadcaster.broadcast(concertId, count != null ? count : 0);
128+
}
129+
130+
private String extractTokenFromCookie(StompHeaderAccessor accessor) {
131+
Map<String, Object> attributes = accessor.getSessionAttributes();
132+
if (attributes == null) return null;
133+
134+
return (String) attributes.get("ACCESS_TOKEN");
135+
}
136+
}

0 commit comments

Comments
 (0)