Skip to content

Commit 5a2ad88

Browse files
authored
feat: 채팅 메시지 송수신 구현 (#423)
* chore: 토픽 주소 변경 - topic/{roomId} -> topic/chat/{roomId} - 의미적 명확성을 위해 * feat: 메시지 전송 DTO 작성 * feat: 메시지 전송 Service 작성 * feat: 메시지 전송 Controller 작성 * chore: 메시지 전송에 대한 컨트롤러 어노테이션을 RestController에서 Controller로 변경 * chore: WebSocket 초기 연결을 위한 HTTP 핸드셰이크에서 인증을 수행하도록 * fix: 핸드셰이크 후 Principal을 WebSocket 세션에 전달하도록 수정 - 이에 컨트롤러 인자로 siteUserId를 받도록 하고, DTO에 senderId를 삭제한다. * fix: 컨트롤러 파라미터 인자로 Principal를 받고, 이후 SiteUserDetails에서 siteUserId를 추출하도록 변경 * fix: DTO를 통해 순환참조 문제 해결 * chore: 실제 구독 권한 TODO 구현 - 검증 로직이 핸들러에서 사용됨에 따라 발생하는 순환 참조를 막기 위해 Lazy 어노테이션을 사용한 생성자를 직접 작성 * chore: 코드 리포매팅 * chore: 미사용 SiteUserPrincipal 제거 외 - 정규표현식을 사용하여 채팅방 ID 추출 - DTO 검증 추가 - 구체화 클래스가 아닌 인터페이스 사용하도록 (DIP) - senderId가 siteUserId가 아니라 chatParticipantId로 설정되도록 변경 * chore: withSockJS 추가
1 parent 3f4a24a commit 5a2ad88

File tree

9 files changed

+204
-32
lines changed

9 files changed

+204
-32
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.example.solidconnection.chat.config;
2+
3+
import java.security.Principal;
4+
import java.util.Map;
5+
import org.springframework.http.server.ServerHttpRequest;
6+
import org.springframework.stereotype.Component;
7+
import org.springframework.web.socket.WebSocketHandler;
8+
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
9+
10+
// WebSocket 세션의 Principal을 결정한다.
11+
@Component
12+
public class CustomHandshakeHandler extends DefaultHandshakeHandler {
13+
14+
@Override
15+
protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler,
16+
Map<String, Object> attributes) {
17+
18+
Object userAttribute = attributes.get("user");
19+
20+
if (userAttribute instanceof Principal) {
21+
Principal principal = (Principal) userAttribute;
22+
return principal;
23+
}
24+
25+
return super.determineUser(request, wsHandler, attributes);
26+
}
27+
}

src/main/java/com/example/solidconnection/chat/config/StompHandler.java

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22

33
import static com.example.solidconnection.common.exception.ErrorCode.AUTHENTICATION_FAILED;
44

5-
import com.example.solidconnection.auth.token.JwtTokenProvider;
5+
import com.example.solidconnection.chat.service.ChatService;
66
import com.example.solidconnection.common.exception.CustomException;
77
import com.example.solidconnection.common.exception.ErrorCode;
8-
import io.jsonwebtoken.Claims;
8+
import com.example.solidconnection.security.authentication.TokenAuthentication;
9+
import com.example.solidconnection.security.userdetails.SiteUserDetails;
10+
import java.security.Principal;
11+
import java.util.regex.Matcher;
12+
import java.util.regex.Pattern;
913
import lombok.RequiredArgsConstructor;
1014
import org.springframework.messaging.Message;
1115
import org.springframework.messaging.MessageChannel;
@@ -18,47 +22,48 @@
1822
@RequiredArgsConstructor
1923
public class StompHandler implements ChannelInterceptor {
2024

21-
private final JwtTokenProvider jwtTokenProvider;
25+
private static final Pattern ROOM_ID_PATTERN = Pattern.compile("^/topic/chat/(\\d+)$");
26+
private final ChatService chatService;
2227

2328
@Override
2429
public Message<?> preSend(Message<?> message, MessageChannel channel) {
2530
final StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
2631

2732
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
28-
Claims claims = validateAndExtractClaims(accessor, AUTHENTICATION_FAILED);
33+
Principal user = accessor.getUser();
34+
if (user == null) {
35+
throw new CustomException(AUTHENTICATION_FAILED);
36+
}
2937
}
3038

3139
if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
32-
Claims claims = validateAndExtractClaims(accessor, AUTHENTICATION_FAILED);
40+
Principal user = accessor.getUser();
41+
if (user == null) {
42+
throw new CustomException(AUTHENTICATION_FAILED);
43+
}
3344

34-
String email = claims.getSubject();
35-
String destination = accessor.getDestination();
45+
TokenAuthentication tokenAuthentication = (TokenAuthentication) user;
46+
SiteUserDetails siteUserDetails = (SiteUserDetails) tokenAuthentication.getPrincipal();
3647

37-
String roomId = extractRoomId(destination);
48+
String destination = accessor.getDestination();
49+
long roomId = Long.parseLong(extractRoomId(destination));
3850

39-
// todo: roomId 기반 실제 구독 권한 검사 로직 추가
51+
chatService.validateChatRoomParticipant(siteUserDetails.getSiteUser().getId(), roomId);
4052
}
4153

4254
return message;
4355
}
4456

45-
private Claims validateAndExtractClaims(StompHeaderAccessor accessor, ErrorCode errorCode) {
46-
String bearerToken = accessor.getFirstNativeHeader("Authorization");
47-
if (bearerToken == null || !bearerToken.startsWith("Bearer ")) {
48-
throw new CustomException(errorCode);
49-
}
50-
String token = bearerToken.substring(7);
51-
return jwtTokenProvider.parseClaims(token);
52-
}
53-
5457
private String extractRoomId(String destination) {
5558
if (destination == null) {
5659
throw new CustomException(ErrorCode.INVALID_ROOM_ID);
5760
}
58-
String[] parts = destination.split("/");
59-
if (parts.length < 3 || !parts[1].equals("topic")) {
61+
62+
Matcher matcher = ROOM_ID_PATTERN.matcher(destination);
63+
if (!matcher.matches()) {
6064
throw new CustomException(ErrorCode.INVALID_ROOM_ID);
6165
}
62-
return parts[2];
66+
67+
return matcher.group(1);
6368
}
6469
}

src/main/java/com/example/solidconnection/chat/config/StompWebSocketConfig.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,18 @@ public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
2222
private final StompHandler stompHandler;
2323
private final StompProperties stompProperties;
2424
private final CorsProperties corsProperties;
25+
private final WebSocketHandshakeInterceptor webSocketHandshakeInterceptor;
26+
private final CustomHandshakeHandler customHandshakeHandler;
2527

2628
@Override
2729
public void registerStompEndpoints(StompEndpointRegistry registry) {
2830
List<String> strings = corsProperties.allowedOrigins();
2931
String[] allowedOrigins = strings.toArray(String[]::new);
30-
registry.addEndpoint("/connect").setAllowedOrigins(allowedOrigins).withSockJS();
32+
registry.addEndpoint("/connect")
33+
.setAllowedOrigins(allowedOrigins)
34+
.addInterceptors(webSocketHandshakeInterceptor)
35+
.setHandshakeHandler(customHandshakeHandler)
36+
.withSockJS();
3137
}
3238

3339
@Override
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.example.solidconnection.chat.config;
2+
3+
import java.security.Principal;
4+
import java.util.Map;
5+
import org.springframework.http.server.ServerHttpRequest;
6+
import org.springframework.http.server.ServerHttpResponse;
7+
import org.springframework.stereotype.Component;
8+
import org.springframework.web.socket.WebSocketHandler;
9+
import org.springframework.web.socket.server.HandshakeInterceptor;
10+
11+
// Principal을 WebSocket 세션에 저장하는 것에만 집중한다.
12+
@Component
13+
public class WebSocketHandshakeInterceptor implements HandshakeInterceptor {
14+
15+
@Override
16+
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
17+
WebSocketHandler wsHandler, Map<String, Object> attributes) {
18+
Principal principal = request.getPrincipal();
19+
20+
if (principal != null) {
21+
attributes.put("user", principal);
22+
return true;
23+
}
24+
25+
return false;
26+
}
27+
28+
@Override
29+
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
30+
WebSocketHandler wsHandler, Exception exception) {
31+
}
32+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.example.solidconnection.chat.controller;
2+
3+
import com.example.solidconnection.chat.dto.ChatMessageSendRequest;
4+
import com.example.solidconnection.chat.service.ChatService;
5+
import com.example.solidconnection.security.authentication.TokenAuthentication;
6+
import com.example.solidconnection.security.userdetails.SiteUserDetails;
7+
import jakarta.validation.Valid;
8+
import java.security.Principal;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.messaging.handler.annotation.DestinationVariable;
11+
import org.springframework.messaging.handler.annotation.MessageMapping;
12+
import org.springframework.messaging.handler.annotation.Payload;
13+
import org.springframework.stereotype.Controller;
14+
15+
@Controller
16+
@RequiredArgsConstructor
17+
public class ChatMessageController {
18+
19+
private final ChatService chatService;
20+
21+
@MessageMapping("/chat/{roomId}")
22+
public void sendChatMessage(
23+
@DestinationVariable Long roomId,
24+
@Valid @Payload ChatMessageSendRequest chatMessageSendRequest,
25+
Principal principal
26+
) {
27+
TokenAuthentication tokenAuthentication = (TokenAuthentication) principal;
28+
SiteUserDetails siteUserDetails = (SiteUserDetails) tokenAuthentication.getPrincipal();
29+
30+
chatService.sendChatMessage(chatMessageSendRequest, siteUserDetails.getSiteUser().getId(), roomId);
31+
}
32+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.example.solidconnection.chat.dto;
2+
3+
import jakarta.validation.constraints.NotNull;
4+
import jakarta.validation.constraints.Size;
5+
6+
public record ChatMessageSendRequest(
7+
@NotNull(message = "메시지를 입력해주세요.")
8+
@Size(max = 500, message = "메시지는 500자를 초과할 수 없습니다")
9+
String content
10+
) {
11+
12+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.example.solidconnection.chat.dto;
2+
3+
import com.example.solidconnection.chat.domain.ChatMessage;
4+
5+
public record ChatMessageSendResponse(
6+
long messageId,
7+
String content,
8+
long senderId
9+
) {
10+
11+
public static ChatMessageSendResponse from(ChatMessage chatMessage) {
12+
return new ChatMessageSendResponse(
13+
chatMessage.getId(),
14+
chatMessage.getContent(),
15+
chatMessage.getSenderId()
16+
);
17+
}
18+
19+
}

src/main/java/com/example/solidconnection/chat/service/ChatService.java

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import com.example.solidconnection.chat.domain.ChatRoom;
1111
import com.example.solidconnection.chat.dto.ChatAttachmentResponse;
1212
import com.example.solidconnection.chat.dto.ChatMessageResponse;
13+
import com.example.solidconnection.chat.dto.ChatMessageSendRequest;
14+
import com.example.solidconnection.chat.dto.ChatMessageSendResponse;
1315
import com.example.solidconnection.chat.dto.ChatParticipantResponse;
1416
import com.example.solidconnection.chat.dto.ChatRoomListResponse;
1517
import com.example.solidconnection.chat.dto.ChatRoomResponse;
@@ -24,13 +26,13 @@
2426
import java.time.ZonedDateTime;
2527
import java.util.List;
2628
import java.util.Optional;
27-
import lombok.RequiredArgsConstructor;
29+
import org.springframework.context.annotation.Lazy;
2830
import org.springframework.data.domain.Pageable;
2931
import org.springframework.data.domain.Slice;
32+
import org.springframework.messaging.simp.SimpMessageSendingOperations;
3033
import org.springframework.stereotype.Service;
3134
import org.springframework.transaction.annotation.Transactional;
3235

33-
@RequiredArgsConstructor
3436
@Service
3537
public class ChatService {
3638

@@ -40,6 +42,22 @@ public class ChatService {
4042
private final ChatReadStatusRepository chatReadStatusRepository;
4143
private final SiteUserRepository siteUserRepository;
4244

45+
private final SimpMessageSendingOperations simpMessageSendingOperations;
46+
47+
public ChatService(ChatRoomRepository chatRoomRepository,
48+
ChatMessageRepository chatMessageRepository,
49+
ChatParticipantRepository chatParticipantRepository,
50+
ChatReadStatusRepository chatReadStatusRepository,
51+
SiteUserRepository siteUserRepository,
52+
@Lazy SimpMessageSendingOperations simpMessageSendingOperations) {
53+
this.chatRoomRepository = chatRoomRepository;
54+
this.chatMessageRepository = chatMessageRepository;
55+
this.chatParticipantRepository = chatParticipantRepository;
56+
this.chatReadStatusRepository = chatReadStatusRepository;
57+
this.siteUserRepository = siteUserRepository;
58+
this.simpMessageSendingOperations = simpMessageSendingOperations;
59+
}
60+
4361
@Transactional(readOnly = true)
4462
public ChatRoomListResponse getChatRooms(long siteUserId) {
4563
// todo : n + 1 문제 해결 필요!
@@ -89,6 +107,13 @@ public SliceResponse<ChatMessageResponse> getChatMessages(long siteUserId, long
89107
return SliceResponse.of(content, chatMessages);
90108
}
91109

110+
public void validateChatRoomParticipant(long siteUserId, long roomId) {
111+
boolean isParticipant = chatParticipantRepository.existsByChatRoomIdAndSiteUserId(roomId, siteUserId);
112+
if (!isParticipant) {
113+
throw new CustomException(CHAT_PARTICIPANT_NOT_FOUND);
114+
}
115+
}
116+
92117
private ChatMessageResponse toChatMessageResponse(ChatMessage message) {
93118
List<ChatAttachmentResponse> attachments = message.getChatAttachments().stream()
94119
.map(attachment -> ChatAttachmentResponse.of(
@@ -109,13 +134,6 @@ private ChatMessageResponse toChatMessageResponse(ChatMessage message) {
109134
);
110135
}
111136

112-
private void validateChatRoomParticipant(long siteUserId, long roomId) {
113-
boolean isParticipant = chatParticipantRepository.existsByChatRoomIdAndSiteUserId(roomId, siteUserId);
114-
if (!isParticipant) {
115-
throw new CustomException(CHAT_PARTICIPANT_NOT_FOUND);
116-
}
117-
}
118-
119137
@Transactional
120138
public void markChatMessagesAsRead(long siteUserId, long roomId) {
121139
ChatParticipant participant = chatParticipantRepository
@@ -124,4 +142,24 @@ public void markChatMessagesAsRead(long siteUserId, long roomId) {
124142

125143
chatReadStatusRepository.upsertReadStatus(roomId, participant.getId());
126144
}
145+
146+
@Transactional
147+
public void sendChatMessage(ChatMessageSendRequest chatMessageSendRequest, long siteUserId, long roomId) {
148+
long senderId = chatParticipantRepository.findByChatRoomIdAndSiteUserId(roomId, siteUserId)
149+
.orElseThrow(() -> new CustomException(CHAT_PARTICIPANT_NOT_FOUND))
150+
.getId();
151+
152+
ChatMessage chatMessage = new ChatMessage(
153+
chatMessageSendRequest.content(),
154+
senderId,
155+
chatRoomRepository.findById(roomId)
156+
.orElseThrow(() -> new CustomException(INVALID_CHAT_ROOM_STATE))
157+
);
158+
159+
chatMessageRepository.save(chatMessage);
160+
161+
ChatMessageSendResponse chatMessageResponse = ChatMessageSendResponse.from(chatMessage);
162+
163+
simpMessageSendingOperations.convertAndSend("/topic/chat/" + roomId, chatMessageResponse);
164+
}
127165
}

src/main/java/com/example/solidconnection/security/config/SecurityConfiguration.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
import com.example.solidconnection.common.exception.CustomAccessDeniedHandler;
66
import com.example.solidconnection.common.exception.CustomAuthenticationEntryPoint;
77
import com.example.solidconnection.security.filter.ExceptionHandlerFilter;
8-
import com.example.solidconnection.security.filter.TokenAuthenticationFilter;
98
import com.example.solidconnection.security.filter.SignOutCheckFilter;
9+
import com.example.solidconnection.security.filter.TokenAuthenticationFilter;
1010
import lombok.RequiredArgsConstructor;
1111
import org.springframework.context.annotation.Bean;
1212
import org.springframework.context.annotation.Configuration;
@@ -62,6 +62,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
6262
.cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource()))
6363
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
6464
.authorizeHttpRequests(auth -> auth
65+
.requestMatchers("/connect/**").authenticated()
6566
.requestMatchers("/admin/**").hasRole(ADMIN.name())
6667
.anyRequest().permitAll()
6768
)

0 commit comments

Comments
 (0)