-
Notifications
You must be signed in to change notification settings - Fork 1
feat: 1:1 실시간 채팅 시스템 구현 #126
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
base: develop
Are you sure you want to change the base?
Changes from all commits
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,47 @@ | ||
| package hanium.modic.backend.common.websocket.config; | ||
|
|
||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.messaging.simp.config.ChannelRegistration; | ||
| import org.springframework.messaging.simp.config.MessageBrokerRegistry; | ||
| import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; | ||
| import org.springframework.web.socket.config.annotation.StompEndpointRegistry; | ||
| import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; | ||
|
|
||
| import hanium.modic.backend.common.jwt.JwtTokenProvider; | ||
| import hanium.modic.backend.common.websocket.interceptor.StompChannelInterceptor; | ||
| import hanium.modic.backend.common.websocket.interceptor.WebSocketAuthInterceptor; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @Configuration | ||
| @EnableWebSocketMessageBroker | ||
| @RequiredArgsConstructor | ||
| public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { | ||
|
|
||
| private final JwtTokenProvider jwtTokenProvider; | ||
| private final StompChannelInterceptor stompChannelInterceptor; | ||
|
|
||
| @Override | ||
| public void configureMessageBroker(MessageBrokerRegistry config) { | ||
| config.enableSimpleBroker("/topic"); // 브로커가 발행한 메세지를 클라이언트가 구독 | ||
| config.setApplicationDestinationPrefixes("/app"); // 클라이언트가 보낼 메세지를 Controller로 전달 | ||
| } | ||
|
|
||
| @Override | ||
| public void registerStompEndpoints(StompEndpointRegistry registry) { | ||
| registry.addEndpoint("/ws/chat") | ||
| .setAllowedOriginPatterns("*") // TODO: 실제 도메인으로 변경 필요 | ||
| .addInterceptors(webSocketAuthInterceptor()) // 핸드세이크 과정 인터셉터, 인증용 | ||
| .withSockJS(); | ||
| } | ||
|
|
||
| @Override | ||
| public void configureClientInboundChannel(ChannelRegistration registration) { | ||
| registration.interceptors(stompChannelInterceptor); | ||
| } | ||
|
|
||
| @Bean | ||
| public WebSocketAuthInterceptor webSocketAuthInterceptor() { | ||
| return new WebSocketAuthInterceptor(jwtTokenProvider); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| package hanium.modic.backend.common.websocket.interceptor; | ||
|
|
||
| import static hanium.modic.backend.common.error.ErrorCode.*; | ||
|
|
||
| import java.security.Principal; | ||
| import java.util.Optional; | ||
|
|
||
| import org.springframework.messaging.Message; | ||
| import org.springframework.messaging.MessageChannel; | ||
| import org.springframework.messaging.simp.stomp.StompCommand; | ||
| import org.springframework.messaging.simp.stomp.StompHeaderAccessor; | ||
| import org.springframework.messaging.support.ChannelInterceptor; | ||
| import org.springframework.messaging.support.MessageHeaderAccessor; | ||
| import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import hanium.modic.backend.common.error.exception.AppException; | ||
| import hanium.modic.backend.common.jwt.JwtTokenProvider; | ||
| import hanium.modic.backend.domain.user.entity.UserEntity; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class StompChannelInterceptor implements ChannelInterceptor { | ||
|
|
||
| private final JwtTokenProvider jwtTokenProvider; | ||
|
|
||
| @Override | ||
| public Message<?> preSend(Message<?> message, MessageChannel channel) { | ||
| StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); | ||
|
|
||
| // STOMP CONNECT 명령어가 아닌 경우, 메시지를 그대로 반환 | ||
| if (accessor == null || !StompCommand.CONNECT.equals(accessor.getCommand())) { | ||
| return message; | ||
| } | ||
|
|
||
| // Authorization 헤더에서 Bearer 토큰 추출 | ||
| String token = Optional.ofNullable(accessor.getFirstNativeHeader("Authorization")) | ||
| .filter(auth -> auth.startsWith("Bearer ")) | ||
| .map(auth -> auth.substring(7)) | ||
| .orElse(null); | ||
|
|
||
| if (token == null) { | ||
| log.warn("StompChannelInterceptor - STOMP 연결 실패 - Authorization 헤더 누락 또는 형식 오류"); | ||
| return null; | ||
| } | ||
|
Comment on lines
+45
to
+48
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. 🛠️ Refactor suggestion 인증 실패 시 더 명확한 에러 처리 필요
if (token == null) {
log.warn("StompChannelInterceptor - STOMP 연결 실패 - Authorization 헤더 누락 또는 형식 오류");
- return null;
+ throw new MessageHandlingException("인증 토큰이 필요합니다.");
} } catch (Exception e) {
log.warn("StompChannelInterceptor - STOMP 연결 인증 실패: {}", e.getMessage());
- return null;
+ throw new MessageHandlingException("인증에 실패했습니다: " + e.getMessage());
}Also applies to: 62-65 🤖 Prompt for AI Agents |
||
|
|
||
| // 토큰 유효성 검사 및 사용자 정보 추출 | ||
| try { | ||
| jwtTokenProvider.validateToken(token); | ||
|
|
||
| UserEntity user = jwtTokenProvider.getUser(token) | ||
| .orElseThrow(() -> new AppException(USER_NOT_FOUND_EXCEPTION)); | ||
|
|
||
| Principal principal = new UsernamePasswordAuthenticationToken(user.getId().toString(), null); | ||
| accessor.setUser(principal); | ||
|
|
||
| log.debug("StompChannelInterceptor - STOMP 연결 인증 성공 - 사용자 ID: {}", user.getId()); | ||
| return message; | ||
| } catch (Exception e) { | ||
| log.warn("StompChannelInterceptor - STOMP 연결 인증 실패: {}", e.getMessage()); | ||
| return null; | ||
|
Comment on lines
+62
to
+64
Contributor
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. Catching the generic |
||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| package hanium.modic.backend.common.websocket.interceptor; | ||
|
|
||
| import static hanium.modic.backend.common.error.ErrorCode.*; | ||
|
|
||
| import java.security.Principal; | ||
|
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. 사용하지 않는 import 제거 사용하지 않는 -import java.security.Principal;
import java.util.Map;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
-import hanium.modic.backend.common.error.ErrorCode;
import hanium.modic.backend.common.error.exception.AppException;Also applies to: 13-13 🤖 Prompt for AI Agents |
||
| import java.util.Map; | ||
|
|
||
| import org.springframework.http.server.ServerHttpRequest; | ||
| import org.springframework.http.server.ServerHttpResponse; | ||
| import org.springframework.web.socket.WebSocketHandler; | ||
| import org.springframework.web.socket.server.HandshakeInterceptor; | ||
|
|
||
| import hanium.modic.backend.common.error.ErrorCode; | ||
| import hanium.modic.backend.common.error.exception.AppException; | ||
| import hanium.modic.backend.common.jwt.JwtTokenProvider; | ||
| import hanium.modic.backend.domain.user.entity.UserEntity; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| @Slf4j | ||
| @RequiredArgsConstructor | ||
| public class WebSocketAuthInterceptor implements HandshakeInterceptor { | ||
|
|
||
| private final JwtTokenProvider jwtTokenProvider; | ||
|
|
||
| // WebSocket 핸드세이킹 허용 여부 결정 메서드 | ||
| @Override | ||
| public boolean beforeHandshake( | ||
| ServerHttpRequest request, | ||
| ServerHttpResponse response, | ||
| WebSocketHandler wsHandler, | ||
| Map<String, Object> attributes | ||
| ) throws Exception { | ||
| String token = extractTokenFromRequest(request); | ||
|
|
||
| if (token != null) { | ||
| try { | ||
| jwtTokenProvider.validateToken(token); | ||
|
|
||
| UserEntity user = jwtTokenProvider.getUser(token) | ||
| .orElseThrow(() -> new AppException(USER_NOT_FOUND_EXCEPTION)); | ||
| attributes.put("userId", user.getId()); | ||
| log.debug("WebSocket 연결 인증 성공 - 사용자 ID: {}", user.getId()); | ||
| return true; | ||
| } catch (Exception e) { | ||
| log.error("WebSocket 토큰 파싱 실패: {}", e.getMessage()); | ||
| } | ||
|
Comment on lines
+45
to
+47
Contributor
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. Similar to the |
||
| } | ||
|
|
||
| log.warn("WebSocket 연결 인증 실패"); | ||
| return false; | ||
| } | ||
|
|
||
| @Override | ||
| public void afterHandshake( | ||
| ServerHttpRequest request, | ||
| ServerHttpResponse response, | ||
| WebSocketHandler wsHandler, | ||
| Exception exception | ||
| ) { | ||
| // 핸드셰이크 후 처리 로직 | ||
| } | ||
|
|
||
| // 추출된 토큰을 요청에서 가져오는 메서드 | ||
| private String extractTokenFromRequest(ServerHttpRequest request) { | ||
| // 헤더에서 Bearer 토큰 추출 시도 | ||
| String token = request.getHeaders().getFirst("Authorization"); | ||
| if (token != null && token.startsWith("Bearer ")) { | ||
| return token.substring(7); | ||
| } | ||
|
|
||
| // 실패 시 쿼리 파라미터에서 token 추출 | ||
| String queryToken = request.getURI().getQuery(); | ||
| if (queryToken != null && queryToken.contains("token=")) { | ||
| String[] params = queryToken.split("&"); | ||
| for (String param : params) { | ||
| if (param.startsWith("token=")) { | ||
| return param.substring(6); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| package hanium.modic.backend.domain.chat.entity; | ||
|
|
||
| import hanium.modic.backend.common.entity.BaseEntity; | ||
| import hanium.modic.backend.domain.user.entity.UserEntity; | ||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.FetchType; | ||
| import jakarta.persistence.GeneratedValue; | ||
| import jakarta.persistence.GenerationType; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.JoinColumn; | ||
| import jakarta.persistence.Lob; | ||
| import jakarta.persistence.ManyToOne; | ||
| import jakarta.persistence.Table; | ||
| import lombok.AccessLevel; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Table(name = "chat_messages") | ||
| @Entity | ||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class ChatMessageEntity extends BaseEntity { | ||
|
|
||
| @Id | ||
| @Column(name = "id") | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @Column(name = "message_id", unique = true, nullable = false) | ||
| private String messageId; | ||
|
|
||
| @Column(name = "chatRoom_id", nullable = false) | ||
|
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. 컬럼명 네이밍 일관성 문제
- @Column(name = "chatRoom_id", nullable = false)
+ @Column(name = "chat_room_id", nullable = false)🤖 Prompt for AI Agents |
||
| private Long chatRoomId; | ||
|
|
||
| @Column(name = "sender_id", nullable = false) | ||
| private Long senderId; | ||
|
|
||
| @Lob | ||
| @Column(name = "message", nullable = false) | ||
| private String message; | ||
|
|
||
| @Column(name = "is_read", nullable = false) | ||
| private Boolean isRead = false; | ||
|
|
||
| @Builder | ||
| private ChatMessageEntity(String messageId, ChatRoomEntity chatRoom, UserEntity sender, String message) { | ||
| this.messageId = messageId; | ||
| this.chatRoomId = chatRoom.getId(); | ||
| this.senderId = sender.getId(); | ||
| this.message = message; | ||
| this.isRead = false; | ||
| } | ||
|
|
||
| // 메시지 읽음 처리 | ||
| public void markAsRead() { | ||
| this.isRead = true; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,78 @@ | ||||||||||||||||||||||||||||||||||||
| package hanium.modic.backend.domain.chat.entity; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| import hanium.modic.backend.common.entity.BaseEntity; | ||||||||||||||||||||||||||||||||||||
| import hanium.modic.backend.domain.user.entity.UserEntity; | ||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.Column; | ||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.Entity; | ||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.GeneratedValue; | ||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.GenerationType; | ||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.Id; | ||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.Table; | ||||||||||||||||||||||||||||||||||||
| import lombok.AccessLevel; | ||||||||||||||||||||||||||||||||||||
| import lombok.Builder; | ||||||||||||||||||||||||||||||||||||
| import lombok.Getter; | ||||||||||||||||||||||||||||||||||||
| import lombok.NoArgsConstructor; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| @Table(name = "chat_rooms") | ||||||||||||||||||||||||||||||||||||
| @Entity | ||||||||||||||||||||||||||||||||||||
| @Getter | ||||||||||||||||||||||||||||||||||||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||||||||||||||||||||||||||||||||||||
| public class ChatRoomEntity extends BaseEntity { | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| @Id | ||||||||||||||||||||||||||||||||||||
| @Column(name = "id") | ||||||||||||||||||||||||||||||||||||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||||||||||||||||||||||||||||||||||||
| private Long id; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| @Column(name = "user1_id", nullable = false) | ||||||||||||||||||||||||||||||||||||
| private Long user1Id; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| @Column(name = "user2_id", nullable = false) | ||||||||||||||||||||||||||||||||||||
| private Long user2Id; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| @Column(name = "user1_deleted", nullable = false) | ||||||||||||||||||||||||||||||||||||
| private Boolean user1Deleted = false; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| @Column(name = "user2_deleted", nullable = false) | ||||||||||||||||||||||||||||||||||||
| private Boolean user2Deleted = false; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| @Builder | ||||||||||||||||||||||||||||||||||||
| private ChatRoomEntity(UserEntity user1, UserEntity user2) { | ||||||||||||||||||||||||||||||||||||
| this.user1Id = user1.getId(); | ||||||||||||||||||||||||||||||||||||
| this.user2Id = user2.getId(); | ||||||||||||||||||||||||||||||||||||
| this.user1Deleted = false; | ||||||||||||||||||||||||||||||||||||
| this.user2Deleted = false; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+39
to
+45
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. 자기 자신과의 채팅방 생성 방지 필요 Builder에서 같은 사용자끼리 채팅방을 만드는 것을 방지하는 검증이 필요합니다. @Builder
private ChatRoomEntity(UserEntity user1, UserEntity user2) {
+ if (user1.getId().equals(user2.getId())) {
+ throw new IllegalArgumentException("같은 사용자끼리 채팅방을 만들 수 없습니다.");
+ }
this.user1Id = user1.getId();
this.user2Id = user2.getId();
this.user1Deleted = false;
this.user2Deleted = false;
}🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // 유저 소프트 삭제 | ||||||||||||||||||||||||||||||||||||
| public void deleteForUser(Long userId) { | ||||||||||||||||||||||||||||||||||||
| if (user1Id.equals(userId)) { | ||||||||||||||||||||||||||||||||||||
| this.user1Deleted = true; | ||||||||||||||||||||||||||||||||||||
| } else if (user2Id.equals(userId)) { | ||||||||||||||||||||||||||||||||||||
| this.user2Deleted = true; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // 상대방 유저 반환 | ||||||||||||||||||||||||||||||||||||
| public Long getOpponent(Long userId) { | ||||||||||||||||||||||||||||||||||||
| if (user1Id.equals(userId)) { | ||||||||||||||||||||||||||||||||||||
| return user2Id; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| return user1Id; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+56
to
+62
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. 🛠️ Refactor suggestion getOpponent 메서드 개선 필요 userId가 채팅방 참여자가 아닌 경우에 대한 처리가 필요합니다. public Long getOpponent(Long userId) {
if (user1Id.equals(userId)) {
return user2Id;
+ } else if (user2Id.equals(userId)) {
+ return user1Id;
+ } else {
+ throw new IllegalArgumentException("사용자가 이 채팅방의 참여자가 아닙니다.");
}
- return user1Id;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // 채팅방을 모든 유저가 삭제했는지 확인 | ||||||||||||||||||||||||||||||||||||
| public boolean isDeleted() { | ||||||||||||||||||||||||||||||||||||
| return user1Deleted && user2Deleted; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // 특정 유저가 채팅방을 삭제했는지 확인 | ||||||||||||||||||||||||||||||||||||
| public boolean isDeletedForUser(Long userId) { | ||||||||||||||||||||||||||||||||||||
| if (user1Id.equals(userId)) { | ||||||||||||||||||||||||||||||||||||
| return user1Deleted; | ||||||||||||||||||||||||||||||||||||
| } else if (user2Id.equals(userId)) { | ||||||||||||||||||||||||||||||||||||
| return user2Deleted; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
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.
Using
"*"for allowed origins is a significant security risk as it allows any website to connect to your WebSocket endpoint. This can lead to Cross-Site WebSocket Hijacking (CSWH) attacks. For production environments, you should restrict this to a specific list of allowed domains for your frontend application.