Skip to content

Commit 57c818b

Browse files
committed
feat: 채팅 참여자 목록 브로드캐스트
1 parent 8e41c8c commit 57c818b

10 files changed

Lines changed: 247 additions & 118 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public void getInitialCount(@Payload Map<String, Object> payload) {
3939
Long concertId = Long.valueOf(concertIdObj.toString());
4040
log.info("[CHAT STATUS] 인원수 초기 요청 수신: concertId={}", concertId);
4141

42-
chatMessageService.broadcastUserCount(concertId);
42+
chatMessageService.broadcastUser(concertId);
4343
}
4444
}
4545
}

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

Lines changed: 128 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -22,57 +22,58 @@ public class ChatStompDocsController {
2222
@Operation(
2323
summary = "채팅 메시지 전송 (WebSocket / STOMP 채팅 프로토콜 문서. 문서용 API. 사용X)",
2424
description = """
25-
### 📡 WebSocket STOMP 채팅 메시지 전송
26-
27-
#### 1️⃣ WebSocket Endpoint
28-
```
29-
ws://localhost:8080/ws-chat
30-
or
31-
wss://api.naeconcertbutakhae.shop/ws-chat
32-
```
33-
34-
#### 2️⃣ SEND Destination
35-
```
36-
/app/chat/send
37-
```
38-
39-
#### 3️⃣ SUBSCRIBE Destination
40-
```
41-
/topic/chat/{concertId}
42-
```
43-
44-
#### 4️⃣ SEND Payload
45-
```json
46-
{
47-
concertId: 1,
48-
content: "안녕하세요!"
49-
}
50-
```
51-
52-
#### 5️⃣ SUBSCRIBE Response
53-
```json
54-
{
55-
concertId: 1,
56-
senderId: 2,
57-
senderName: "테스트 유저",
58-
content: "안녕하세요!",
59-
sentDate: "2026-01-02T12:13:18.4422905",
60-
profileImage: "https://example.com/profile.jpg"
61-
}
62-
```
63-
"""
25+
### 📡 WebSocket STOMP 채팅 메시지 전송
26+
27+
#### 1️⃣ WebSocket Endpoint
28+
```
29+
ws://localhost:8080/ws-chat
30+
or
31+
wss://api.naeconcertbutakhae.shop/ws-chat
32+
```
33+
34+
#### 2️⃣ SEND Destination
35+
```
36+
/app/chat/send
37+
```
38+
39+
#### 3️⃣ SUBSCRIBE Destination
40+
```
41+
/topic/chat/{concertId}
42+
```
43+
44+
#### 4️⃣ SEND Payload
45+
```json
46+
{
47+
concertId: 1,
48+
content: "안녕하세요!"
49+
}
50+
```
51+
52+
#### 5️⃣ SUBSCRIBE Response
53+
```json
54+
{
55+
concertId: 1,
56+
senderId: 2,
57+
senderName: "테스트 유저",
58+
content: "안녕하세요!",
59+
sentDate: "2026-01-02T12:13:18.4422905",
60+
profileImage: "https://example.com/profile.jpg"
61+
}
62+
```
63+
"""
6464
)
6565
@GetMapping("/stomp")
66-
public void stompChatGuide() {}
66+
public void stompChatGuide() {
67+
}
6768

6869
@Operation(
6970
summary = "STOMP 채팅 메시지 전송 규격(문서용 API. 사용X)",
7071
description = """
71-
WebSocket + STOMP 기반 채팅 메시지 전송 규격입니다.
72+
WebSocket + STOMP 기반 채팅 메시지 전송 규격입니다.
7273
73-
- 실제 사용되는 HTTP API 아닙니다.
74-
- Swagger 문서용
75-
""",
74+
- 실제 사용되는 HTTP API 아닙니다.
75+
- Swagger 문서용
76+
""",
7677
requestBody = @RequestBody(
7778
description = "STOMP 메세지 SEND하면 전달되는 요청 데이터",
7879
required = true,
@@ -95,57 +96,93 @@ public ChatMessageResponse messageSchema() {
9596
return null; // 실제 반환 목적 X
9697
}
9798

98-
9999
@Operation(
100100
summary = "실시간 채팅 접속자 수 집계 (STOMP 전용, 문서용 API. 사용X)",
101101
description = """
102-
### 👥 실시간 채팅 접속자 수 집계
103-
104-
- 동일 유저의 여러 탭은 **1명으로 집계**
105-
- 모든 탭이 닫혔을 때만 접속자 수 감소
106-
- 접속 / 퇴장 / 초기 요청 시 자동 브로드캐스트
107-
108-
---
109-
110-
### 1️⃣ CONNECT 시 자동 집계
111-
- STOMP CONNECT 시 `concertId` 기준으로 접속자 등록
112-
- 접속 즉시 현재 인원 수가 브로드캐스트됩니다.
113-
114-
### 2️⃣ 초기 접속자 수 요청 (SEND)
115-
```
116-
/app/chat/status
117-
```
118-
119-
#### CONNECT Header
120-
- 접속자 수 집계를 위해 `concertId`를 CONNECT 헤더로 전달
121-
```text
122-
{
123-
concertId: 1
124-
}
125-
```
126-
127-
- 채팅방 최초 진입 시 프론트에서 1회 호출
128-
- 서버가 Redis 기준 현재 접속자 수 계산 후 브로드캐스트
129-
130-
### 3️⃣ SUBSCRIBE Destination
131-
```
132-
/topic/chat/{concertId}/count
133-
```
134-
135-
#### SUBSCRIBE Response
136-
```json
137-
5
138-
```
139-
140-
- 숫자(Number) 형태의 현재 접속자 수
141-
- 접속 / 퇴장 / 초기 요청 시마다 자동 수신
142-
143-
### 4️⃣ DISCONNECT 처리
144-
- STOMP DISCONNECT 시 자동 처리
145-
- 동일 유저의 모든 탭이 닫힌 경우에만 접속자 수 감소
146-
"""
102+
### 👥 실시간 채팅 접속자 수 집계
103+
- 동일 유저의 여러 탭은 **1명으로 집계**
104+
- 모든 탭이 닫혔을 때만 접속자 수 감소
105+
- 접속 / 퇴장 / 초기 요청 시 자동 브로드캐스트
106+
107+
#### 1️⃣ CONNECT 시 자동 집계
108+
- STOMP CONNECT 시 `concertId` 기준으로 접속자 등록
109+
- 접속 즉시 현재 인원 수가 브로드캐스트됩니다.
110+
111+
#### CONNECT Header
112+
- 접속자 수 집계를 위해 `concertId`를 CONNECT 헤더로 전달
113+
```text
114+
concertId: 1
115+
```
116+
117+
#### 2️⃣ 초기 접속자 수 요청 (SEND)
118+
```
119+
/app/chat/status
120+
```
121+
122+
#### 3️⃣ SUBSCRIBE Destination
123+
```
124+
/topic/chat/{concertId}/count
125+
```
126+
127+
#### SUBSCRIBE Response
128+
```json
129+
5
130+
```
131+
132+
- 숫자(Number) 형태의 현재 접속자 수
133+
- 접속 / 퇴장 / 초기 요청 시마다 자동 수신
134+
135+
#### 4️⃣ DISCONNECT 처리
136+
- STOMP DISCONNECT 시 자동 처리
137+
- 동일 유저의 모든 탭이 닫힌 경우에만 접속자 수 감소
138+
"""
147139
)
148140
@GetMapping("/user-count")
149-
public void chatUserCountGuide() {}
141+
public void chatUserCountGuide() {
142+
}
143+
144+
@Operation(
145+
summary = "실시간 채팅 참여자 목록 조회 (STOMP 전용, 문서용 API. 사용X)",
146+
description = """
147+
### 👤 실시간 채팅 참여자 목록
148+
- 현재 채팅방에 **접속 중인 유저 목록**
149+
- 동일 유저의 여러 탭은 **1명으로 집계**
150+
- 접속 / 퇴장 / 초기 진입 시 자동 브로드캐스트
151+
152+
#### 1️⃣ CONNECT
153+
- STOMP CONNECT 시 `concertId` 헤더 전달
154+
- 서버는 해당 채팅방 Presence에 유저를 등록
155+
156+
#### CONNECT Headers
157+
```text
158+
concertId: number
159+
```
160+
161+
#### 2️⃣ SUBSCRIBE Destination
162+
```
163+
/topic/chat/{concertId}/users
164+
```
165+
166+
#### 3️⃣ SUBSCRIBE Response
167+
```json
168+
[
169+
{
170+
"userId": 1,
171+
"nickname": "어드민유저",
172+
"profileImage": "https://example.com/profile/1.png"
173+
},
174+
{
175+
"userId": 2,
176+
"nickname": "테스트유저",
177+
"profileImage": "https://example.com/profile/2.png"
178+
}
179+
]
180+
```
181+
- Array 형태의 참여자 목록
182+
"""
183+
)
184+
@GetMapping("/users")
185+
public void chatUserListGuide() {
186+
}
150187
}
151188

src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatMessageResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,6 @@ public class ChatMessageResponse {
2828
@Schema(description = "전송 시각", example = "2025-12-23T16:28:07.8806432")
2929
private LocalDateTime sentDate;
3030

31-
@Schema(description = "프로필 이미지", example = "https://example.com/profile.jpg")
31+
@Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg")
3232
private String profileImage;
3333
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.back.web7_9_codecrete_be.domain.chats.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Getter;
6+
7+
@Getter
8+
@AllArgsConstructor
9+
public class ChatParticipantResponse {
10+
11+
@Schema(description = "유저 ID", example = "1")
12+
private Long userId;
13+
14+
@Schema(description = "유저 닉네임", example = "테스트 유저")
15+
private String nickname;
16+
17+
@Schema(description = "프로필 이미지 URL", example = "https://example.com/profile/1.png")
18+
private String profileImage;
19+
}

src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatReadResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,6 @@ public class ChatReadResponse {
3333
@Schema(description = "전송 시각", example = "2026-01-02T12:13:13.1588173")
3434
private LocalDateTime sentDate;
3535

36-
@Schema(description = "프로필 이미지", example = "https://example.com/profile.jpg")
36+
@Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg")
3737
private String profileImage;
3838
}

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

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatUserCache;
1515
import com.back.web7_9_codecrete_be.domain.chats.repository.ChatStreamRepository;
1616
import com.back.web7_9_codecrete_be.global.security.CustomUserDetail;
17-
import com.back.web7_9_codecrete_be.global.websocket.ChatCountBroadcaster;
1817
import com.back.web7_9_codecrete_be.global.websocket.ServerInstanceId;
1918

2019
import lombok.RequiredArgsConstructor;
@@ -30,7 +29,7 @@ public class ChatMessageService {
3029
private final ChatUserCacheService chatUserCacheService;
3130

3231
private final RedisTemplate<String, Object> redisTemplate;
33-
private final ChatCountBroadcaster chatCountBroadcaster;
32+
private final ChatPresenceService chatPresenceService;
3433

3534
/**
3635
* 채팅 메시지 전송 + 세션 TTL 갱신
@@ -97,14 +96,8 @@ private void extendUserSessionTtl(Long userId) {
9796
/**
9897
* 채팅방 접속자 수 브로드캐스트
9998
*/
100-
public void broadcastUserCount(Long concertId) {
99+
public void broadcastUser(Long concertId) {
101100

102-
String key = "chat:concert:" + concertId + ":users";
103-
Long count = redisTemplate.opsForSet().size(key);
104-
long userCount = (count != null) ? count : 0;
105-
106-
chatCountBroadcaster.broadcast(concertId, userCount);
107-
108-
log.info("[CHAT STATUS] 인원수 응답 완료: concertId={}, count={}", concertId, userCount);
101+
chatPresenceService.broadcast(concertId);
109102
}
110103
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.back.web7_9_codecrete_be.domain.chats.service;
2+
3+
import java.util.List;
4+
import java.util.Set;
5+
import java.util.stream.Collectors;
6+
7+
import org.springframework.data.redis.core.RedisTemplate;
8+
import org.springframework.stereotype.Service;
9+
10+
import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatParticipantResponse;
11+
import com.back.web7_9_codecrete_be.domain.users.entity.User;
12+
import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository;
13+
14+
import lombok.RequiredArgsConstructor;
15+
16+
@Service
17+
@RequiredArgsConstructor
18+
public class ChatParticipantService {
19+
20+
private final RedisTemplate<String, Object> redisTemplate;
21+
private final UserRepository userRepository;
22+
23+
private String concertUserKey(Long concertId) {
24+
return "chat:concert:" + concertId + ":users";
25+
}
26+
27+
public List<ChatParticipantResponse> getParticipants(Long concertId) {
28+
29+
Set<Object> userIds =
30+
redisTemplate.opsForSet().members(concertUserKey(concertId));
31+
32+
if (userIds == null || userIds.isEmpty()) {
33+
return List.of();
34+
}
35+
36+
List<Long> ids = userIds.stream()
37+
.map(id -> Long.valueOf(id.toString()))
38+
.toList();
39+
40+
List<User> users = userRepository.findAllById(ids);
41+
42+
return users.stream()
43+
.map(user -> new ChatParticipantResponse(
44+
user.getId(),
45+
user.getNickname(),
46+
user.getProfileImage()
47+
))
48+
.collect(Collectors.toList());
49+
}
50+
}

0 commit comments

Comments
 (0)