-
Notifications
You must be signed in to change notification settings - Fork 8
feat: 채팅 메시지 송수신 구현 #423
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: 채팅 메시지 송수신 구현 #423
Changes from all commits
b5c8884
678ef9d
ecf92e3
4928e0c
2e1db36
500b664
560e13c
482b594
db8f107
5b1304d
865e520
ab6422f
338780f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package com.example.solidconnection.chat.config; | ||
|
|
||
| import java.security.Principal; | ||
| import java.util.Map; | ||
| import org.springframework.http.server.ServerHttpRequest; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.socket.WebSocketHandler; | ||
| import org.springframework.web.socket.server.support.DefaultHandshakeHandler; | ||
|
|
||
| // WebSocket 세션의 Principal을 결정한다. | ||
| @Component | ||
| public class CustomHandshakeHandler extends DefaultHandshakeHandler { | ||
|
|
||
| @Override | ||
| protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, | ||
| Map<String, Object> attributes) { | ||
|
|
||
| Object userAttribute = attributes.get("user"); | ||
|
|
||
| if (userAttribute instanceof Principal) { | ||
| Principal principal = (Principal) userAttribute; | ||
| return principal; | ||
| } | ||
|
|
||
| return super.determineUser(request, wsHandler, attributes); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,10 +2,14 @@ | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| import static com.example.solidconnection.common.exception.ErrorCode.AUTHENTICATION_FAILED; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import com.example.solidconnection.auth.token.JwtTokenProvider; | ||||||||||||||||||||||
| import com.example.solidconnection.chat.service.ChatService; | ||||||||||||||||||||||
| import com.example.solidconnection.common.exception.CustomException; | ||||||||||||||||||||||
| import com.example.solidconnection.common.exception.ErrorCode; | ||||||||||||||||||||||
| import io.jsonwebtoken.Claims; | ||||||||||||||||||||||
| import com.example.solidconnection.security.authentication.TokenAuthentication; | ||||||||||||||||||||||
| import com.example.solidconnection.security.userdetails.SiteUserDetails; | ||||||||||||||||||||||
| import java.security.Principal; | ||||||||||||||||||||||
| import java.util.regex.Matcher; | ||||||||||||||||||||||
| import java.util.regex.Pattern; | ||||||||||||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||||||||||||
| import org.springframework.messaging.Message; | ||||||||||||||||||||||
| import org.springframework.messaging.MessageChannel; | ||||||||||||||||||||||
|
|
@@ -18,47 +22,48 @@ | |||||||||||||||||||||
| @RequiredArgsConstructor | ||||||||||||||||||||||
| public class StompHandler implements ChannelInterceptor { | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private final JwtTokenProvider jwtTokenProvider; | ||||||||||||||||||||||
| private static final Pattern ROOM_ID_PATTERN = Pattern.compile("^/topic/chat/(\\d+)$"); | ||||||||||||||||||||||
| private final ChatService chatService; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| @Override | ||||||||||||||||||||||
| public Message<?> preSend(Message<?> message, MessageChannel channel) { | ||||||||||||||||||||||
| final StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (StompCommand.CONNECT.equals(accessor.getCommand())) { | ||||||||||||||||||||||
| Claims claims = validateAndExtractClaims(accessor, AUTHENTICATION_FAILED); | ||||||||||||||||||||||
| Principal user = accessor.getUser(); | ||||||||||||||||||||||
| if (user == null) { | ||||||||||||||||||||||
| throw new CustomException(AUTHENTICATION_FAILED); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) { | ||||||||||||||||||||||
| Claims claims = validateAndExtractClaims(accessor, AUTHENTICATION_FAILED); | ||||||||||||||||||||||
| Principal user = accessor.getUser(); | ||||||||||||||||||||||
| if (user == null) { | ||||||||||||||||||||||
| throw new CustomException(AUTHENTICATION_FAILED); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| String email = claims.getSubject(); | ||||||||||||||||||||||
| String destination = accessor.getDestination(); | ||||||||||||||||||||||
| TokenAuthentication tokenAuthentication = (TokenAuthentication) user; | ||||||||||||||||||||||
| SiteUserDetails siteUserDetails = (SiteUserDetails) tokenAuthentication.getPrincipal(); | ||||||||||||||||||||||
|
Comment on lines
+45
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 타입 캐스팅 안전성을 개선해주세요. ChatMessageController와 동일하게, 안전한 타입 검증을 추가하세요: - TokenAuthentication tokenAuthentication = (TokenAuthentication) user;
- SiteUserDetails siteUserDetails = (SiteUserDetails) tokenAuthentication.getPrincipal();
+ if (!(user instanceof TokenAuthentication tokenAuthentication)) {
+ throw new CustomException(AUTHENTICATION_FAILED);
+ }
+
+ Object principalObject = tokenAuthentication.getPrincipal();
+ if (!(principalObject instanceof SiteUserDetails siteUserDetails)) {
+ throw new CustomException(AUTHENTICATION_FAILED);
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| String roomId = extractRoomId(destination); | ||||||||||||||||||||||
| String destination = accessor.getDestination(); | ||||||||||||||||||||||
| long roomId = Long.parseLong(extractRoomId(destination)); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // todo: roomId 기반 실제 구독 권한 검사 로직 추가 | ||||||||||||||||||||||
| chatService.validateChatRoomParticipant(siteUserDetails.getSiteUser().getId(), roomId); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return message; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private Claims validateAndExtractClaims(StompHeaderAccessor accessor, ErrorCode errorCode) { | ||||||||||||||||||||||
| String bearerToken = accessor.getFirstNativeHeader("Authorization"); | ||||||||||||||||||||||
| if (bearerToken == null || !bearerToken.startsWith("Bearer ")) { | ||||||||||||||||||||||
| throw new CustomException(errorCode); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| String token = bearerToken.substring(7); | ||||||||||||||||||||||
| return jwtTokenProvider.parseClaims(token); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private String extractRoomId(String destination) { | ||||||||||||||||||||||
| if (destination == null) { | ||||||||||||||||||||||
| throw new CustomException(ErrorCode.INVALID_ROOM_ID); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| String[] parts = destination.split("/"); | ||||||||||||||||||||||
| if (parts.length < 3 || !parts[1].equals("topic")) { | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Matcher matcher = ROOM_ID_PATTERN.matcher(destination); | ||||||||||||||||||||||
| if (!matcher.matches()) { | ||||||||||||||||||||||
| throw new CustomException(ErrorCode.INVALID_ROOM_ID); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return parts[2]; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return matcher.group(1); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package com.example.solidconnection.chat.config; | ||
|
|
||
| import java.security.Principal; | ||
| import java.util.Map; | ||
| import org.springframework.http.server.ServerHttpRequest; | ||
| import org.springframework.http.server.ServerHttpResponse; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.socket.WebSocketHandler; | ||
| import org.springframework.web.socket.server.HandshakeInterceptor; | ||
|
|
||
| // Principal을 WebSocket 세션에 저장하는 것에만 집중한다. | ||
| @Component | ||
| public class WebSocketHandshakeInterceptor implements HandshakeInterceptor { | ||
|
|
||
| @Override | ||
| public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, | ||
| WebSocketHandler wsHandler, Map<String, Object> attributes) { | ||
| Principal principal = request.getPrincipal(); | ||
|
|
||
| if (principal != null) { | ||
| attributes.put("user", principal); | ||
| return true; | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| @Override | ||
| public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, | ||
| WebSocketHandler wsHandler, Exception exception) { | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package com.example.solidconnection.chat.controller; | ||
|
|
||
| import com.example.solidconnection.chat.dto.ChatMessageSendRequest; | ||
| import com.example.solidconnection.chat.service.ChatService; | ||
| import com.example.solidconnection.security.authentication.TokenAuthentication; | ||
| import com.example.solidconnection.security.userdetails.SiteUserDetails; | ||
| import jakarta.validation.Valid; | ||
| import java.security.Principal; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.messaging.handler.annotation.DestinationVariable; | ||
| import org.springframework.messaging.handler.annotation.MessageMapping; | ||
| import org.springframework.messaging.handler.annotation.Payload; | ||
| import org.springframework.stereotype.Controller; | ||
|
|
||
| @Controller | ||
| @RequiredArgsConstructor | ||
| public class ChatMessageController { | ||
|
|
||
| private final ChatService chatService; | ||
|
|
||
| @MessageMapping("/chat/{roomId}") | ||
| public void sendChatMessage( | ||
| @DestinationVariable Long roomId, | ||
| @Valid @Payload ChatMessageSendRequest chatMessageSendRequest, | ||
| Principal principal | ||
| ) { | ||
| TokenAuthentication tokenAuthentication = (TokenAuthentication) principal; | ||
| SiteUserDetails siteUserDetails = (SiteUserDetails) tokenAuthentication.getPrincipal(); | ||
|
Comment on lines
+27
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 타입 캐스팅의 안전성을 보장해주세요.
다음과 같이 안전한 타입 검증을 추가하는 것을 권장합니다: - TokenAuthentication tokenAuthentication = (TokenAuthentication) principal;
- SiteUserDetails siteUserDetails = (SiteUserDetails) tokenAuthentication.getPrincipal();
+ if (!(principal instanceof TokenAuthentication tokenAuthentication)) {
+ throw new CustomException(ErrorCode.AUTHENTICATION_FAILED);
+ }
+
+ Object principalObject = tokenAuthentication.getPrincipal();
+ if (!(principalObject instanceof SiteUserDetails siteUserDetails)) {
+ throw new CustomException(ErrorCode.AUTHENTICATION_FAILED);
+ }🤖 Prompt for AI Agents |
||
|
|
||
| chatService.sendChatMessage(chatMessageSendRequest, siteUserDetails.getSiteUser().getId(), roomId); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.example.solidconnection.chat.dto; | ||
|
|
||
| import jakarta.validation.constraints.NotNull; | ||
| import jakarta.validation.constraints.Size; | ||
|
|
||
| public record ChatMessageSendRequest( | ||
| @NotNull(message = "메시지를 입력해주세요.") | ||
| @Size(max = 500, message = "메시지는 500자를 초과할 수 없습니다") | ||
| String content | ||
| ) { | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| package com.example.solidconnection.chat.dto; | ||
|
|
||
| import com.example.solidconnection.chat.domain.ChatMessage; | ||
|
|
||
| public record ChatMessageSendResponse( | ||
| long messageId, | ||
| String content, | ||
| long senderId | ||
| ) { | ||
|
|
||
| public static ChatMessageSendResponse from(ChatMessage chatMessage) { | ||
| return new ChatMessageSendResponse( | ||
| chatMessage.getId(), | ||
| chatMessage.getContent(), | ||
| chatMessage.getSenderId() | ||
| ); | ||
| } | ||
|
|
||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
attributes.get("user")를 그대로 리턴하는 게 어떤 의미인지 알려주실 수 있나요!?
지금 보기에는 super.determineUser()와 똑같아보여서요..!
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
<3줄 요약>
attributes.get("user")를 사용하여Principal을 리턴한 이유는,Principal객체를 안전하게 전달하기 위해attributes에 담았고, 그걸 꺼내 리턴한 것입니다.super.determineUser()는 혹시나 꺼낸 값이Principal이 아닌 경우 예외 터뜨리지 않고 기본 동작을 수행하도록 하기 위해 작성하였습니다.저 코드 구현 당시 제 생각은 ...
Principal객체를 어떻게 WebSocket 세션에 전달할지 찾아보았고, 커스텀 핸들러에서determineUser메서드를 구현하여 전달한다는 것을 알게 되었습니다.determineUser메서드에 대해 잘 몰라서 정의를 살펴보았고,attribute파라미터가 WebSocket 세션에 전달하고 싶은 데이터를 저장하는 임시 저장소 역할을 한다는 것을 알게 되었습니다.그래서 인터셉터에서 핸드셰이크 전
Principal을user라는 키로attributes에 저장하였고, 핸들러에서user에 해당하는 값을 꺼내 간단히Principal인지 검증 후 리턴하도록 구현하였습니다.혹시 만약에 꺼낸 값이
Principal이 아닌 경우 관련 처리도 필요했고, 그 경우 부모 클래스의 기본 동작을 따르도록 하였습니다.제가 첨부한 블로그는 어쨌거나
attributes인자를 사용하지 않았고, 잘 동작한 것 같습니다. 그럼attributes는 사용 안 해도 되는 거 아닐까 ? 해서 찾아보았는데, WebSocket 핸드셰이크를 수행하는 쓰레드와 실제 WebSocket을 사용하여 메시지를 주고받는 쓰레드가 다를 수 있으며, 이 경우SecurityContextHolder로부터Principal을 가져올 수 없습니다. 생성된Principal은 같은 쓰레드 내에서만 유효합니다. [참고]