Skip to content

Commit 3cd4bc7

Browse files
[Feature] 실시간 채팅 기능 구현 (#125)
* feat: websocket 의존성 추가 (#95) * feat: Chat 관련 엔티티 추가 (#95) * feat: WebSocketConfig 추가 (#95) * feat: 웹소켓 초기 세팅 (#95) * feat: Chat 관련 엔티티 구현 (#95) * feat: Chat 관련 DTO 구현 (#95) * fix: Security 설정 수정 (#98) * refactor: Security 로직 개선 (#98) * fix: OAuth 설정 충돌 해결 (#98) * feat: 웹소켓 실시간 채팅 기능 구현 (#95) * feat: 웹소켓 실시간 채팅 기능 구현 (#95) * feat: 웹소켓 실시간 채팅 기능 구현 (#95) * feat: Websocket TLS 적용 (#95) * feat: 상대방 ID 구분 (#95) * feat: 채팅방 조회 예외 처리 (#95) --------- Co-authored-by: junseoplee <[email protected]>
1 parent c3de3a6 commit 3cd4bc7

23 files changed

+721
-3
lines changed

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ dependencies {
6767
// AWS S3
6868
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
6969

70+
// WebSocket
71+
implementation 'org.springframework.boot:spring-boot-starter-websocket'
72+
7073
implementation 'javax.annotation:javax.annotation-api:1.3.2'
7174
}
7275

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package com.danthis.backend.api;
2+
3+
import com.danthis.backend.application.chat.ChatMessageService;
4+
import com.danthis.backend.application.chat.implement.ChatMessageReader;
5+
import com.danthis.backend.application.chat.request.ChatMessageDTO;
6+
import com.danthis.backend.application.chat.response.ChatMessageResponseDTO;
7+
import com.danthis.backend.domain.user.User;
8+
import com.fasterxml.jackson.databind.ObjectMapper;
9+
import java.time.LocalDateTime;
10+
import java.util.HashMap;
11+
import java.util.HashSet;
12+
import java.util.Map;
13+
import java.util.Set;
14+
import lombok.RequiredArgsConstructor;
15+
import lombok.extern.slf4j.Slf4j;
16+
import org.springframework.stereotype.Component;
17+
import org.springframework.web.socket.CloseStatus;
18+
import org.springframework.web.socket.TextMessage;
19+
import org.springframework.web.socket.WebSocketSession;
20+
import org.springframework.web.socket.handler.TextWebSocketHandler;
21+
22+
@Slf4j
23+
@Component
24+
@RequiredArgsConstructor
25+
public class WebSocketChatHandler extends TextWebSocketHandler {
26+
27+
private final ObjectMapper objectMapper;
28+
private final ChatMessageService chatMessageService;
29+
private final ChatMessageReader chatMessageReader;
30+
31+
/**
32+
* 채팅방별 세션 목록
33+
*/
34+
private final Map<Long, Set<WebSocketSession>> chatRoomSessions = new HashMap<>();
35+
36+
@Override
37+
public void afterConnectionEstablished(WebSocketSession session) {
38+
log.info(" WebSocket 연결됨: {}", session.getId());
39+
}
40+
41+
@Override
42+
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
43+
ChatMessageDTO incoming = objectMapper.readValue(message.getPayload(), ChatMessageDTO.class);
44+
45+
Long userId = (Long) session.getAttributes().get("userId");
46+
if (userId == null) {
47+
throw new IllegalStateException("WebSocket userId not found (handshake not authenticated).");
48+
}
49+
50+
User sender = chatMessageReader.readUserById(userId);
51+
handleChatMessage(session, incoming, sender);
52+
}
53+
54+
private void handleChatMessage(WebSocketSession session, ChatMessageDTO message, User sender) {
55+
Long roomId = message.getChatRoomId();
56+
57+
switch (message.getType()) {
58+
case ENTER -> {
59+
log.info(" ENTER: userId={}, opponentId={}", sender.getId(), message.getOpponentId());
60+
61+
// 1) 방 없으면 생성
62+
if (roomId == null) {
63+
if (message.getOpponentId() == null) {
64+
sendToSession(session, error("ENTER requires opponentId"));
65+
return;
66+
}
67+
roomId = chatMessageService.ensureChatRoomExists(sender.getId(), message.getOpponentId());
68+
}
69+
70+
// 2) 세션 방에 등록
71+
chatRoomSessions.computeIfAbsent(roomId, k -> new HashSet<>()).add(session);
72+
73+
Long opponentId = (message.getOpponentId() != null)
74+
? message.getOpponentId()
75+
: chatMessageService.resolveOpponentId(roomId, sender.getId());
76+
77+
ChatMessageResponseDTO enterAck = ChatMessageResponseDTO.builder()
78+
.type(ChatMessageDTO.MessageType.ENTER)
79+
.chatRoomId(roomId)
80+
.opponentId(opponentId)
81+
.senderId(sender.getId())
82+
.senderNickname(sender.getNickname())
83+
.message("ENTER_OK")
84+
.sentAt(LocalDateTime.now())
85+
.system(true)
86+
.build();
87+
88+
sendToSession(session, enterAck);
89+
90+
sendSystemMessage(roomId, opponentId, sender.getNickname() + "님이 입장했습니다.");
91+
}
92+
93+
case TALK -> {
94+
if (roomId == null) {
95+
sendToSession(session, error("TALK requires chatRoomId"));
96+
return;
97+
}
98+
99+
Long opponentId = (message.getOpponentId() != null)
100+
? message.getOpponentId()
101+
: chatMessageService.resolveOpponentId(roomId, sender.getId());
102+
103+
log.info(" TALK: roomId={}, sender={}", roomId, sender.getNickname());
104+
105+
ChatMessageResponseDTO saved =
106+
chatMessageService.saveMessage(sender.getId(), roomId, message.getMessage());
107+
108+
ChatMessageResponseDTO talk = ChatMessageResponseDTO.builder()
109+
.type(ChatMessageDTO.MessageType.TALK)
110+
.chatRoomId(saved.getChatRoomId())
111+
.opponentId(opponentId)
112+
.messageId(saved.getMessageId())
113+
.senderId(saved.getSenderId())
114+
.senderNickname(saved.getSenderNickname())
115+
.message(saved.getMessage())
116+
.sentAt(saved.getSentAt())
117+
.system(false)
118+
.build();
119+
120+
sendMessageToRoom(roomId, talk);
121+
}
122+
123+
case OUT -> {
124+
if (roomId == null) {
125+
sendToSession(session, error("OUT requires chatRoomId"));
126+
return;
127+
}
128+
129+
Long opponentId = (message.getOpponentId() != null)
130+
? message.getOpponentId()
131+
: chatMessageService.resolveOpponentId(roomId, sender.getId());
132+
133+
log.info(" OUT: roomId={}, sender={}", roomId, sender.getNickname());
134+
135+
chatRoomSessions.getOrDefault(roomId, new HashSet<>()).remove(session);
136+
137+
ChatMessageResponseDTO outEvent = ChatMessageResponseDTO.builder()
138+
.type(ChatMessageDTO.MessageType.OUT)
139+
.chatRoomId(roomId)
140+
.opponentId(opponentId)
141+
.senderId(sender.getId())
142+
.senderNickname(sender.getNickname())
143+
.message("OUT_OK")
144+
.sentAt(LocalDateTime.now())
145+
.system(true)
146+
.build();
147+
148+
sendMessageToRoom(roomId, outEvent);
149+
150+
sendSystemMessage(roomId, opponentId, sender.getNickname() + "님이 퇴장했습니다.");
151+
}
152+
}
153+
}
154+
155+
private void sendToSession(WebSocketSession session, Object payload) {
156+
try {
157+
if (!session.isOpen()) return;
158+
String json = objectMapper.writeValueAsString(payload);
159+
session.sendMessage(new TextMessage(json));
160+
} catch (Exception e) {
161+
log.error(" 세션 메시지 전송 실패", e);
162+
}
163+
}
164+
165+
private void sendMessageToRoom(Long roomId, Object payload) {
166+
try {
167+
String json = objectMapper.writeValueAsString(payload);
168+
Set<WebSocketSession> roomSessions = chatRoomSessions.get(roomId);
169+
if (roomSessions != null) {
170+
for (WebSocketSession ws : roomSessions) {
171+
if (ws.isOpen()) {
172+
ws.sendMessage(new TextMessage(json));
173+
}
174+
}
175+
}
176+
} catch (Exception e) {
177+
log.error(" 메시지 전송 실패", e);
178+
}
179+
}
180+
181+
private void sendSystemMessage(Long roomId, Long opponentId, String content) {
182+
ChatMessageResponseDTO system = ChatMessageResponseDTO.builder()
183+
.type(ChatMessageDTO.MessageType.TALK)
184+
.chatRoomId(roomId)
185+
.opponentId(opponentId)
186+
.message(content)
187+
.sentAt(LocalDateTime.now())
188+
.system(true)
189+
.build();
190+
191+
sendMessageToRoom(roomId, system);
192+
}
193+
194+
private ChatMessageResponseDTO error(String msg) {
195+
return ChatMessageResponseDTO.builder()
196+
.type(ChatMessageDTO.MessageType.TALK)
197+
.message("[ERROR] " + msg)
198+
.sentAt(LocalDateTime.now())
199+
.system(true)
200+
.build();
201+
}
202+
203+
@Override
204+
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
205+
log.info(" 연결 종료: {}", session.getId());
206+
chatRoomSessions.values().forEach(room -> room.remove(session));
207+
}
208+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.danthis.backend.api.chat;
2+
3+
import com.danthis.backend.api.ApiResponse;
4+
import com.danthis.backend.api.chat.response.ChatRoomCreateResponseDTO;
5+
import com.danthis.backend.application.chat.ChatMessageService;
6+
import com.danthis.backend.common.security.aop.AssignCurrentUserInfo;
7+
import com.danthis.backend.common.security.aop.CurrentUserInfo;
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.tags.Tag;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.web.bind.annotation.PathVariable;
12+
import org.springframework.web.bind.annotation.PostMapping;
13+
import org.springframework.web.bind.annotation.RequestMapping;
14+
import org.springframework.web.bind.annotation.RestController;
15+
16+
@RestController
17+
@RequestMapping("/messages")
18+
@RequiredArgsConstructor
19+
@Tag(name = "채팅", description = "1:1 실시간 채팅방 관리 API")
20+
public class ChatMessageController {
21+
22+
private final ChatMessageService chatMessageService;
23+
24+
/**
25+
* 유저가 댄서에게 1:1 채팅방을 생성 (이미 있으면 기존 방 반환)
26+
*/
27+
@Operation(summary = "채팅방 생성(또는 기존 방 반환)", description = "유저가 특정 댄서와 1:1 채팅을 시작하거나 기존 채팅방을 조회합니다.")
28+
@PostMapping("/{dancerId}/start")
29+
@AssignCurrentUserInfo
30+
public ApiResponse<ChatRoomCreateResponseDTO> startChat(@PathVariable Long dancerId,
31+
CurrentUserInfo userInfo
32+
) {
33+
ChatRoomCreateResponseDTO response = chatMessageService.startChat(userInfo.getUserId(), dancerId);
34+
return ApiResponse.OK(response);
35+
}
36+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.danthis.backend.api.chat.response;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
@Getter
9+
@Builder
10+
@AllArgsConstructor
11+
@NoArgsConstructor
12+
public class ChatRoomCreateResponseDTO {
13+
14+
private Long chatRoomId;
15+
private Long dancerId;
16+
private String dancerNickname;
17+
private String dancerProfileImage;
18+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.danthis.backend.application.chat;
2+
3+
import com.danthis.backend.api.chat.response.ChatRoomCreateResponseDTO;
4+
import com.danthis.backend.application.chat.implement.ChatMessageManager;
5+
import com.danthis.backend.application.chat.implement.ChatMessageMapper;
6+
import com.danthis.backend.application.chat.implement.ChatMessageReader;
7+
import com.danthis.backend.application.chat.response.ChatMessageResponseDTO;
8+
import com.danthis.backend.domain.chat.ChatMessage;
9+
import com.danthis.backend.domain.chat.ChatRoom;
10+
import com.danthis.backend.domain.dancer.Dancer;
11+
import com.danthis.backend.domain.user.User;
12+
import lombok.RequiredArgsConstructor;
13+
import lombok.extern.slf4j.Slf4j;
14+
import org.springframework.stereotype.Service;
15+
16+
@Slf4j
17+
@Service
18+
@RequiredArgsConstructor
19+
public class ChatMessageService {
20+
21+
private final ChatMessageManager chatMessageManager;
22+
private final ChatMessageMapper chatMessageMapper;
23+
private final ChatMessageReader chatMessageReader;
24+
25+
public ChatRoomCreateResponseDTO startChat(Long userId, Long dancerId) {
26+
User user = chatMessageReader.readUserById(userId);
27+
Dancer dancer = chatMessageReader.readDancerById(dancerId);
28+
29+
ChatRoom chatRoom = chatMessageManager.createChatRoomIfNotExists(user, dancer);
30+
31+
return chatMessageMapper.toChatRoomCreateResponseDTO(chatRoom);
32+
}
33+
34+
public Long ensureChatRoomExists(Long userId, Long dancerId) {
35+
User user = chatMessageReader.readUserById(userId);
36+
Dancer dancer = chatMessageReader.readDancerById(dancerId);
37+
38+
return chatMessageManager.createChatRoomIfNotExists(user, dancer).getId();
39+
}
40+
41+
public ChatMessageResponseDTO saveMessage(Long senderId, Long chatRoomId, String content) {
42+
User sender = chatMessageReader.readUserById(senderId);
43+
ChatRoom chatRoom = chatMessageManager.getChatRoomById(chatRoomId);
44+
45+
ChatMessage chatMessage = chatMessageManager.saveChatMessage(sender, chatRoom, content);
46+
47+
return ChatMessageResponseDTO.builder()
48+
.messageId(chatMessage.getId())
49+
.senderId(sender.getId())
50+
.chatRoomId(chatRoomId)
51+
.senderNickname(sender.getNickname())
52+
.message(content)
53+
.sentAt(chatMessage.getCreatedAt())
54+
.build();
55+
}
56+
57+
public Long resolveOpponentId(Long chatRoomId, Long senderId) {
58+
ChatRoom chatRoom = chatMessageManager.getChatRoomById(chatRoomId);
59+
60+
if (chatRoom.getUser().getId().equals(senderId)) {
61+
return chatRoom.getDancer().getId();
62+
}
63+
return chatRoom.getUser().getId();
64+
}
65+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.danthis.backend.application.chat.implement;
2+
3+
import com.danthis.backend.common.exception.BusinessException;
4+
import com.danthis.backend.common.exception.ErrorCode;
5+
import com.danthis.backend.domain.chat.ChatMessage;
6+
import com.danthis.backend.domain.chat.ChatRoom;
7+
import com.danthis.backend.domain.chat.repository.ChatMessageRepository;
8+
import com.danthis.backend.domain.chat.repository.ChatRoomRepository;
9+
import com.danthis.backend.domain.dancer.Dancer;
10+
import com.danthis.backend.domain.user.User;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.stereotype.Component;
13+
14+
@Component
15+
@RequiredArgsConstructor
16+
public class ChatMessageManager {
17+
18+
private final ChatRoomRepository chatRoomRepository;
19+
private final ChatMessageRepository chatMessageRepository;
20+
21+
// 유저-댄서 1:1 채팅방 생성
22+
public ChatRoom createChatRoomIfNotExists(User user, Dancer dancer) {
23+
return chatRoomRepository.findByUserAndDancer(user, dancer)
24+
.orElseGet(() -> chatRoomRepository.save(ChatRoom.builder()
25+
.user(user)
26+
.dancer(dancer)
27+
.build()
28+
));
29+
}
30+
31+
// 채팅 메시지 저장
32+
public ChatMessage saveChatMessage(User sender, ChatRoom chatRoom, String content) {
33+
ChatMessage chatMessage = ChatMessage.builder()
34+
.chatRoom(chatRoom)
35+
.user(sender)
36+
.content(content)
37+
.build();
38+
39+
return chatMessageRepository.save(chatMessage);
40+
}
41+
42+
// 채팅방 조회
43+
public ChatRoom getChatRoomById(Long chatRoomId) {
44+
return chatRoomRepository.findById(chatRoomId)
45+
.orElseThrow(() -> new BusinessException(ErrorCode.CHATROOM_NOT_FOUND));
46+
}
47+
}

0 commit comments

Comments
 (0)