feat: 실시간 인앱 채팅 (STOMP over WebSocket)

- 스팟별 채팅방(/topic/spots/{id}), 전송 /app/spots/{id}/chat, 엔드포인트 /ws
- STOMP CONNECT Bearer 토큰 인증(ChannelInterceptor) → 미인증 전송 무시
- ChatRoom/ChatMessage 도메인, 방 없으면 최초 메시지 시 생성, 발신자 닉네임 포함
- GET /api/chat/spots/{id}/messages 히스토리(보호), 전역 WebSocket 브로커(인메모리)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-11 09:17:25 +09:00
parent c6cd754857
commit c2fc408fcc
13 changed files with 398 additions and 1 deletions
@@ -0,0 +1,42 @@
package com.dog.dognation.chat;
import com.dog.dognation.api.dto.ChatDtos.ChatMessageResponse;
import com.dog.dognation.api.dto.ChatDtos.ChatSendRequest;
import com.dog.dognation.domain.chat.ChatService;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import java.security.Principal;
/** 스팟 채팅 메시지 수신 → 저장 → 구독자에게 브로드캐스트. */
@Controller
public class ChatSocketController {
private static final Logger log = LoggerFactory.getLogger(ChatSocketController.class);
private final ChatService chatService;
private final SimpMessagingTemplate messagingTemplate;
public ChatSocketController(ChatService chatService, SimpMessagingTemplate messagingTemplate) {
this.chatService = chatService;
this.messagingTemplate = messagingTemplate;
}
@MessageMapping("/spots/{spotId}/chat")
public void send(@DestinationVariable Long spotId,
@Valid @Payload ChatSendRequest req,
Principal principal) {
if (!(principal instanceof StompPrincipal user)) {
log.warn("[chat] 미인증 전송 시도 spot={}", spotId);
return; // 인증되지 않은 연결은 무시
}
ChatMessageResponse res = chatService.post(spotId, user.userId(), req.content());
messagingTemplate.convertAndSend("/topic/spots/" + spotId, res);
}
}
@@ -0,0 +1,40 @@
package com.dog.dognation.chat;
import com.dog.dognation.auth.AuthService;
import com.dog.dognation.auth.SessionUser;
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.stereotype.Component;
/**
* STOMP CONNECT 시 Authorization: Bearer 토큰을 검증해 인증 주체를 세션에 심는다.
* 이후 SEND 프레임에서 Principal 로 발신자를 식별한다.
*/
@Component
public class StompAuthChannelInterceptor implements ChannelInterceptor {
private final AuthService authService;
public StompAuthChannelInterceptor(AuthService authService) {
this.authService = authService;
}
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor =
MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
String bearer = accessor.getFirstNativeHeader("Authorization");
String token = (bearer != null && bearer.startsWith("Bearer ")) ? bearer.substring(7) : null;
SessionUser user = authService.getSession(token);
if (user != null) {
accessor.setUser(new StompPrincipal(String.valueOf(user.id()), user.role()));
}
}
return message;
}
}
@@ -0,0 +1,16 @@
package com.dog.dognation.chat;
import java.security.Principal;
/** WebSocket 세션의 인증 주체. name 에 userId 문자열을 담는다. */
public record StompPrincipal(String name, String role) implements Principal {
@Override
public String getName() {
return name;
}
public Long userId() {
return Long.valueOf(name);
}
}
@@ -0,0 +1,44 @@
package com.dog.dognation.chat;
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;
/**
* STOMP over WebSocket 설정.
* 엔드포인트 : /ws
* 구독 : /topic/spots/{spotId}
* 전송 : /app/spots/{spotId}/chat
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final StompAuthChannelInterceptor authChannelInterceptor;
public WebSocketConfig(StompAuthChannelInterceptor authChannelInterceptor) {
this.authChannelInterceptor = authChannelInterceptor;
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns(
"http://localhost:9000", "http://localhost",
"capacitor://localhost", "ionic://localhost");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic"); // 인메모리 브로커
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(authChannelInterceptor); // CONNECT 인증
}
}