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,27 @@
package com.dog.dognation.api;
import com.dog.dognation.api.dto.ChatDtos.ChatMessageResponse;
import com.dog.dognation.domain.chat.ChatService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 스팟 채팅 히스토리 (로그인 필요). 실시간 송수신은 WebSocket(/ws). */
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ChatService chatService;
public ChatController(ChatService chatService) {
this.chatService = chatService;
}
@GetMapping("/spots/{spotId}/messages")
public List<ChatMessageResponse> history(@PathVariable Long spotId) {
return chatService.history(spotId);
}
}
@@ -0,0 +1,28 @@
package com.dog.dognation.api.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.OffsetDateTime;
public final class ChatDtos {
private ChatDtos() {
}
public record ChatMessageResponse(
Long id,
Long spotId,
Long senderId,
String senderNickname,
String content,
OffsetDateTime createdAt) {
}
/** WebSocket 전송 페이로드. */
public record ChatSendRequest(
@NotBlank(message = "내용이 비어있습니다.")
@Size(max = 1000, message = "메시지는 1000자 이하여야 합니다.")
String content) {
}
}
@@ -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 인증
}
}
@@ -40,7 +40,8 @@ public class WebConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
// 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행)
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/admin/**", "/api/dogs/**");
.addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/admin/**",
"/api/dogs/**", "/api/chat/**");
// 인가: 관리자 전용 (authInterceptor 다음에 실행되어 role 검사)
registry.addInterceptor(adminInterceptor)
.addPathPatterns("/api/admin/**");
@@ -0,0 +1,57 @@
package com.dog.dognation.domain.chat;
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 java.time.OffsetDateTime;
@Entity
@Table(name = "chat_messages")
public class ChatMessage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "room_id", nullable = false)
private Long roomId;
@Column(name = "sender_id", nullable = false)
private Long senderId;
@Column(nullable = false, length = 1000)
private String content;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt = OffsetDateTime.now();
protected ChatMessage() {
}
public ChatMessage(Long roomId, Long senderId, String content) {
this.roomId = roomId;
this.senderId = senderId;
this.content = content;
this.createdAt = OffsetDateTime.now();
}
public Long getId() {
return id;
}
public Long getSenderId() {
return senderId;
}
public String getContent() {
return content;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,11 @@
package com.dog.dognation.domain.chat;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {
/** 방의 최근 메시지(오름차순). MVP: 최근 100건. */
List<ChatMessage> findTop100ByRoomIdOrderByCreatedAtAsc(Long roomId);
}
@@ -0,0 +1,42 @@
package com.dog.dognation.domain.chat;
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 java.time.OffsetDateTime;
/** 스팟 기반 오픈 채팅방. 스팟당 1개, 최초 메시지 시 생성. */
@Entity
@Table(name = "chat_rooms")
public class ChatRoom {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "spot_id")
private Long spotId;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt = OffsetDateTime.now();
protected ChatRoom() {
}
public ChatRoom(Long spotId) {
this.spotId = spotId;
this.createdAt = OffsetDateTime.now();
}
public Long getId() {
return id;
}
public Long getSpotId() {
return spotId;
}
}
@@ -0,0 +1,10 @@
package com.dog.dognation.domain.chat;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
Optional<ChatRoom> findBySpotId(Long spotId);
}
@@ -0,0 +1,76 @@
package com.dog.dognation.domain.chat;
import com.dog.dognation.api.dto.ChatDtos.ChatMessageResponse;
import com.dog.dognation.common.exception.ApiException;
import com.dog.dognation.domain.spot.SpotRepository;
import com.dog.dognation.domain.user.User;
import com.dog.dognation.domain.user.UserRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class ChatService {
private final ChatRoomRepository roomRepository;
private final ChatMessageRepository messageRepository;
private final SpotRepository spotRepository;
private final UserRepository userRepository;
public ChatService(ChatRoomRepository roomRepository,
ChatMessageRepository messageRepository,
SpotRepository spotRepository,
UserRepository userRepository) {
this.roomRepository = roomRepository;
this.messageRepository = messageRepository;
this.spotRepository = spotRepository;
this.userRepository = userRepository;
}
/** 스팟 채팅방 확보(없으면 생성). */
@Transactional
public ChatRoom roomForSpot(Long spotId) {
if (!spotRepository.existsById(spotId)) {
throw new ApiException(HttpStatus.NOT_FOUND, "스팟을 찾을 수 없습니다.");
}
return roomRepository.findBySpotId(spotId)
.orElseGet(() -> roomRepository.save(new ChatRoom(spotId)));
}
/** 스팟 채팅 최근 메시지. 방이 없으면 빈 목록. */
@Transactional(readOnly = true)
public List<ChatMessageResponse> history(Long spotId) {
return roomRepository.findBySpotId(spotId)
.map(room -> toResponses(spotId, messageRepository.findTop100ByRoomIdOrderByCreatedAtAsc(room.getId())))
.orElseGet(List::of);
}
/** 메시지 저장 후 브로드캐스트용 응답 반환. */
@Transactional
public ChatMessageResponse post(Long spotId, Long senderId, String content) {
ChatRoom room = roomForSpot(spotId);
ChatMessage saved = messageRepository.save(new ChatMessage(room.getId(), senderId, content));
String nickname = userRepository.findById(senderId).map(User::getNickname).orElse("알 수 없음");
return new ChatMessageResponse(saved.getId(), spotId, senderId, nickname,
saved.getContent(), saved.getCreatedAt());
}
private List<ChatMessageResponse> toResponses(Long spotId, List<ChatMessage> messages) {
if (messages.isEmpty()) {
return List.of();
}
List<Long> senderIds = messages.stream().map(ChatMessage::getSenderId).distinct().toList();
Map<Long, String> nicknames = userRepository.findAllById(senderIds).stream()
.collect(Collectors.toMap(User::getId, User::getNickname, (a, b) -> a));
return messages.stream()
.map(m -> new ChatMessageResponse(m.getId(), spotId, m.getSenderId(),
nicknames.getOrDefault(m.getSenderId(), "알 수 없음"),
m.getContent(), m.getCreatedAt()))
.collect(Collectors.toList());
}
}