Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public void getInitialCount(@Payload Map<String, Object> payload) {
Long concertId = Long.valueOf(concertIdObj.toString());
log.info("[CHAT STATUS] 인원수 초기 요청 수신: concertId={}", concertId);

chatMessageService.broadcastUserCount(concertId);
chatMessageService.broadcastUser(concertId);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;

Expand All @@ -21,57 +22,59 @@ public class ChatStompDocsController {
@Operation(
summary = "채팅 메시지 전송 (WebSocket / STOMP 채팅 프로토콜 문서. 문서용 API. 사용X)",
description = """
### 📡 WebSocket STOMP 채팅 메시지 전송

#### 1️⃣ WebSocket Endpoint
```
ws://localhost:8080/ws-chat
or
wss://api.naeconcertbutakhae.shop/ws-chat
```

#### 2️⃣ SEND Destination
```
/app/chat/send
```

#### 3️⃣ SUBSCRIBE Destination
```
/topic/chat/{concertId}
```

#### 4️⃣ SEND Payload
```json
{
"concertId": 1,
"content": "안녕하세요!"
}
```

#### 5️⃣ SUBSCRIBE Response
```json
{
"concertId": 1,
"senderId": 10,
"senderName": "테스트 유저",
"content": "안녕하세요!",
"sentAt": "2025-12-23T15:30:00"
}
```
"""
### 📡 WebSocket STOMP 채팅 메시지 전송

#### 1️⃣ WebSocket Endpoint
```
ws://localhost:8080/ws-chat
or
wss://api.naeconcertbutakhae.shop/ws-chat
```

#### 2️⃣ SEND Destination
```
/app/chat/send
```

#### 3️⃣ SUBSCRIBE Destination
```
/topic/chat/{concertId}
```

#### 4️⃣ SEND Payload
```json
{
concertId: 1,
content: "안녕하세요!"
}
```

#### 5️⃣ SUBSCRIBE Response
```json
{
concertId: 1,
senderId: 2,
senderName: "테스트 유저",
content: "안녕하세요!",
sentDate: "2026-01-02T12:13:18.4422905",
profileImage: "https://example.com/profile.jpg"
}
```
"""
)
@GetMapping("/stomp")
public void stompChatGuide() {}
public void stompChatGuide() {
}

@Operation(
summary = "STOMP 채팅 메시지 전송 규격(문서용 API. 사용X)",
description = """
WebSocket + STOMP 기반 채팅 메시지 전송 규격입니다.
WebSocket + STOMP 기반 채팅 메시지 전송 규격입니다.

- 실제 사용되는 HTTP API 아닙니다.
- Swagger 문서용
""",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
- 실제 사용되는 HTTP API 아닙니다.
- Swagger 문서용
""",
requestBody = @RequestBody(
description = "STOMP 메세지 SEND하면 전달되는 요청 데이터",
required = true,
content = @Content(
Expand All @@ -93,57 +96,93 @@ public ChatMessageResponse messageSchema() {
return null; // 실제 반환 목적 X
}


@Operation(
summary = "실시간 채팅 접속자 수 집계 (STOMP 전용, 문서용 API. 사용X)",
description = """
### 👥 실시간 채팅 접속자 수 집계

- 동일 유저의 여러 탭은 **1명으로 집계**
- 모든 탭이 닫혔을 때만 접속자 수 감소
- 접속 / 퇴장 / 초기 요청 시 자동 브로드캐스트

---

### 1️⃣ CONNECT 시 자동 집계
- STOMP CONNECT 시 `concertId` 기준으로 접속자 등록
- 접속 즉시 현재 인원 수가 브로드캐스트됩니다.

### 2️⃣ 초기 접속자 수 요청 (SEND)
```
/app/chat/status
```

#### SEND Payload
- 접속자 수 집계를 위해 `concertId`를 CONNECT 헤더로 전달
```json
{
"concertId": 1
}
```

- 채팅방 최초 진입 시 프론트에서 1회 호출
- 서버가 Redis 기준 현재 접속자 수 계산 후 브로드캐스트

### 3️⃣ SUBSCRIBE Destination
```
/topic/chat/{concertId}/count
```

#### SUBSCRIBE Response
```json
5
```

- 숫자(Number) 형태의 현재 접속자 수
- 접속 / 퇴장 / 초기 요청 시마다 자동 수신

### 4️⃣ DISCONNECT 처리
- STOMP DISCONNECT 시 자동 처리
- 동일 유저의 모든 탭이 닫힌 경우에만 접속자 수 감소
"""
### 👥 실시간 채팅 접속자 수 집계
- 동일 유저의 여러 탭은 **1명으로 집계**
- 모든 탭이 닫혔을 때만 접속자 수 감소
- 접속 / 퇴장 / 초기 요청 시 자동 브로드캐스트

#### 1️⃣ CONNECT 시 자동 집계
- STOMP CONNECT 시 `concertId` 기준으로 접속자 등록
- 접속 즉시 현재 인원 수가 브로드캐스트됩니다.

#### CONNECT Header
- 접속자 수 집계를 위해 `concertId`를 CONNECT 헤더로 전달
```text
concertId: 1
```

#### 2️⃣ 초기 접속자 수 요청 (SEND)
```
/app/chat/status
```

#### 3️⃣ SUBSCRIBE Destination
```
/topic/chat/{concertId}/count
```

#### SUBSCRIBE Response
```json
5
```

- 숫자(Number) 형태의 현재 접속자 수
- 접속 / 퇴장 / 초기 요청 시마다 자동 수신

#### 4️⃣ DISCONNECT 처리
- STOMP DISCONNECT 시 자동 처리
- 동일 유저의 모든 탭이 닫힌 경우에만 접속자 수 감소
"""
)
@GetMapping("/user-count")
public void chatUserCountGuide() {}
public void chatUserCountGuide() {
}

@Operation(
summary = "실시간 채팅 참여자 목록 조회 (STOMP 전용, 문서용 API. 사용X)",
description = """
### 👤 실시간 채팅 참여자 목록
- 현재 채팅방에 **접속 중인 유저 목록**
- 동일 유저의 여러 탭은 **1명으로 집계**
- 접속 / 퇴장 / 초기 진입 시 자동 브로드캐스트

#### 1️⃣ CONNECT
- STOMP CONNECT 시 `concertId` 헤더 전달
- 서버는 해당 채팅방 Presence에 유저를 등록

#### CONNECT Headers
```text
concertId: number
```

#### 2️⃣ SUBSCRIBE Destination
```
/topic/chat/{concertId}/users
```

#### 3️⃣ SUBSCRIBE Response
```json
[
{
"userId": 1,
"nickname": "어드민유저",
"profileImage": "https://example.com/profile/1.png"
},
{
"userId": 2,
"nickname": "테스트유저",
"profileImage": "https://example.com/profile/2.png"
}
]
```
- Array 형태의 참여자 목록
"""
)
@GetMapping("/users")
public void chatUserListGuide() {
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,19 @@ public class ChatMessageResponse {

@Schema(description = "공연 ID", example = "1")
private Long concertId;

@Schema(description = "발신자 ID", example = "2")
private Long senderId;

@Schema(description = "발신자 닉네임", example = "테스트 유저")
private String senderName;

@Schema(description = "메시지 내용", example = "안녕하세요")
private String content;

@Schema(description = "전송 시각", example = "2025-12-23T16:28:07.8806432")
private LocalDateTime sentDate;

@Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg")
private String profileImage;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.back.web7_9_codecrete_be.domain.chats.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class ChatParticipantResponse {

@Schema(description = "유저 ID", example = "1")
private Long userId;

@Schema(description = "유저 닉네임", example = "테스트 유저")
private String nickname;

@Schema(description = "프로필 이미지 URL", example = "https://example.com/profile/1.png")
private String profileImage;
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public class ChatReadResponse {
@Schema(description = "메시지 내용", example = "안녕하세요")
private String content;

@Schema(description = "전송 시각")
@Schema(description = "전송 시각", example = "2026-01-02T12:13:13.1588173")
private LocalDateTime sentDate;

@Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg")
private String profileImage;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ public class ChatUserCache {

private Long userId;
private String nickname;
private String profileImage;
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public void save(ChatMessageResponse message) {
fields.put("senderName", message.getSenderName());
fields.put("content", message.getContent());
fields.put("sentDate", message.getSentDate().toString());
fields.put("profileImage", message.getProfileImage());

redisTemplate.opsForStream().add(
StreamRecords.newRecord()
Expand Down Expand Up @@ -106,7 +107,8 @@ private List<ChatReadResponse> toResponses(
Long.valueOf(v.get("senderId").toString()),
v.get("senderName").toString(),
v.get("content").toString(),
LocalDateTime.parse(v.get("sentDate").toString())
LocalDateTime.parse(v.get("sentDate").toString()),
v.get("profileImage").toString()
);
})
.filter(Objects::nonNull)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatUserCache;
import com.back.web7_9_codecrete_be.domain.chats.repository.ChatStreamRepository;
import com.back.web7_9_codecrete_be.global.security.CustomUserDetail;
import com.back.web7_9_codecrete_be.global.websocket.ChatCountBroadcaster;
import com.back.web7_9_codecrete_be.global.websocket.ServerInstanceId;

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

private final RedisTemplate<String, Object> redisTemplate;
private final ChatCountBroadcaster chatCountBroadcaster;
private final ChatPresenceService chatPresenceService;

/**
* 채팅 메시지 전송 + 세션 TTL 갱신
Expand Down Expand Up @@ -63,7 +62,8 @@ private void sendMessage(ChatMessageRequest request, String email) {
chatUser.getUserId(),
chatUser.getNickname(),
request.getContent(),
LocalDateTime.now()
LocalDateTime.now(),
chatUser.getProfileImage()
);

log.info("[SEND MESSAGE] From User ID: {}, Content: {}",
Expand Down Expand Up @@ -96,14 +96,8 @@ private void extendUserSessionTtl(Long userId) {
/**
* 채팅방 접속자 수 브로드캐스트
*/
public void broadcastUserCount(Long concertId) {
public void broadcastUser(Long concertId) {

String key = "chat:concert:" + concertId + ":users";
Long count = redisTemplate.opsForSet().size(key);
long userCount = (count != null) ? count : 0;

chatCountBroadcaster.broadcast(concertId, userCount);

log.info("[CHAT STATUS] 인원수 응답 완료: concertId={}, count={}", concertId, userCount);
chatPresenceService.broadcast(concertId);
}
}
Loading