
💡 Spring Boot와 Stomp를 사용하여 실시간 채팅을 구현하여 보자
앞선 포스팅에서 다루었던 실시간 채팅을 위한 통신 방식 중, 실시간 채팅에 적합한 Websocket을 선택하였다.
하지만 Websocket은 구현해본 결과, 메시지의 데이터 타입 형태에만 집중하여 메시지를 보내는 데에만 집중하지 따로 메시지 내용을 보관하려면, 메시지 저장 메소드를 따로 만들어 줘야한다. ( 참고 포스팅 )
따라서, Stomp와 MongoDB를 사용하여 사용자가 주고 받는 메시지 정보와 채팅방 정보를 저장하여 지속적으로 채팅을 할 수 있는 간단한 채팅 서비스를 구현하여 보았다.
이 블로그 포스팅에서는 완전히 전체 코드를 해석하기 보다는 핵심이 되는 중요한 부분에 대한 설명을 적어보려고 한다.
필요하다면 Github Repo에 방문하여 확인해보길 바란다.

@Configuration
@EnableWebSocketMessageBroker
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/stomp-chat")
.setAllowedOrigins("http://localhost:5173")
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 클라이언트가 메시지를 구독(수신 - subscribe)할 때 사용할 prefix 설정 - /queue는 1대1 , /topic은 1대다 채팅방을 의미
// registry.enableSimpleBroker("/queue", "/topic");
registry.enableSimpleBroker("/topic");
// 메시지를 발행(송신 - publish)할때 사용하는 prefix 설정
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
registration.setMessageSizeLimit(8192)
.setSendTimeLimit(15 * 1000)
.setSendBufferSizeLimit(3 * 512 * 1024);
}
}
기존 Websocket과는 다르게 STOMP를 사용하기 위해서 "@EnableWebSokcetMessageBroker"설정 과 "WebSocketMessageBrokerConfigurer"를 implements 하여, Websocket을 커스텀할 수 있게 하였다.
( STOMP 엔드포인트 등록, 메시지 브로커 설정, 메시지 크기제한 설정 등)
registerStompEndpoints() : STOMP와 연결할 수 있도록 해주는 설정
1. addEndpoint() = Client가 Websocket 연결을 맺을 수 있도록 엔드포인트를 설정한다.
2. setAllowedOrigins() = Websocket이 연결될 수 있도록 허용할 주소를 입력한다.
3. withSockJS() = 일부 브라우저가 Websocket을 지원하지 않을 수 있기 때문에, 대비 사항으로 설정한다.
(Fallback(HTTP기반), XHR(AJAX기반), LongPolling(AJAX기반) 등 )
configureMessageBroker : STOMP는 내부 메시지 브로커를 기반으로 동작하기 때문에 해주는 메시지 브로커 설정
1. enableSimpleBroker(): 메시지를 구독(subscribe)할 때 사용하는 경로를 지정한다. (메시지를 수신할 경로를 지정)
2. setApplicationDestinationPrefixes(): 메시지를 발행(publish)할 경로를 지정한다. (메시지를 송신할 경로를 지정)
=> Client가 메시지를 /app/~ 에 메시지를 송신하면 이 메시지를 보고, /topic/~ 경로로 구독중인 Client에게 메시지가 간다.
configureWebSocketTransport: Websocket 메시지 전송 관련 설정 커스텀
1. setMessageSizeLimit() = 한번에 전송할 수 있는 최대 메시지 크기 제한
2. setSendTimeLimit() = 메시지 전송 제한 시간을 설정하여, 넘어가면 Timeout
3. setSendBufferSizeLimit() = 메시지 버퍼의 크기 제한
위 부분과 같은 경우는 핵심내용만 설명할 예정으로 자세한 건 위에 적어놓은 GitHub repo를 참고하여라!
간단하게 구조를 설명하자면, 일단 채팅방과 채팅메시지를 전부 MongoDB로 관리하도록 설정하였고, member는 postgresql에서 관리한다.
채팅방과 채팅메시지는 Controller - dto - service - serviceImpl - repository - entity를 가지도록 따로따로 구성하였고, 각각에 필요한 메소드를 따로 구현하였다.
// Websocket 으로 부터 넘어오는 메시지 처리
@MessageMapping("{roomId}")
// @DestonationVariable은 MessageMapping에서 전송되는 URL에서 roomId를 뺴오는 역할을 한다. (@GetMapping - @Pathvariable과 동일)
public void sendMessage(@DestinationVariable String roomId, ChatMessageDto chatMessageDto
// Websocket 세션 정보를 관리하는 객체 ( 주로 사용자 인증 정보 or 세션 데이터)
// 서버 측에서 Websocket 세션을 통해 자동으로 관리하는 객체로 request시 특정 값을 넣어줄 필요 X
,SimpMessageHeaderAccessor simpMessageHeaderAccessor){
if (ChatMessageDto.MessageType.ENTER.equals(chatMessageDto.getType())) {
// 새로 들어온 클라이언트이기 때문에, Websocket의 세션에 클라이언드의 이름과 채팅방 번호를 저장한다.
simpMessageHeaderAccessor.getSessionAttributes().put("username", chatMessageDto.getSender());
simpMessageHeaderAccessor.getSessionAttributes().put("roomId",chatMessageDto.getRoomId());
chatMessageDto.setMessage(chatMessageDto.getSender() + "님이 입장하셨습니다.");
}
chatMessageService.sendMessage(roomId, chatMessageDto);
}
@Override
public void sendMessage(String roomId, ChatMessageDto chatMessageDto) {
// MongoDB에 메시지 정보 저장
ChatMessage chatMessage = new ChatMessage();
chatMessage.setRoomId(roomId);
chatMessage.setSender(chatMessageDto.getSender());
chatMessage.setMessage(chatMessageDto.getMessage());
// Type은 enum 타입임으로, 넘어오는 타입의 이름을 넣어준다.
chatMessage.setType(ChatMessage.MessageType.valueOf(chatMessageDto.getType().name()));
chatMessage.setSendDate(LocalDateTime.now());
// insert() => 중복되는 key값에 대한 예외처리를 터트림
// save() => 중복되는 key값을 update하여 덮어씌움
chatMessageRepository.insert(chatMessage);
// Websocket을 통해 메시지 직접 전송 - Client(front)에서는 /topic/message/방번호 를 구독(sub)하고 있는 client만 채팅을 받음
simpMessagingTemplate.convertAndSend("/topic/message/" + roomId, chatMessageDto);
}
@Repository
public interface ChatMessageRepository extends MongoRepository<ChatMessage, String> {
// List<ChatMessage> findAllMsgByRoomId(String roomId);
Optional<ChatMessage> findTopByRoomIdAndSenderAndTypeOrderBySendDateDesc(String roomId, String memberId,
ChatMessage.MessageType messageType);
List<ChatMessage> findByRoomIdAndSendDateAfterOrderBySendDateAsc(String roomId, LocalDateTime leaveTime);
List<ChatMessage> findByRoomIdOrderBySendDateAsc(String roomId);
}
@Getter
@Setter
@Document(collection = "message")
public class ChatMessage {
@Id
private String id;
private String roomId;
private String sender;
private String message;
private LocalDateTime sendDate;
private MessageType type;
public enum MessageType {
ENTER, CHAT, LEAVE
}
}
spring:
# MongoDB 설정
data:
mongodb:
uri: ${MONG_DATABASE_URI}
그 외에도 채팅방 입장, 채팅방 떠나기, 채팅방 처음 들어왔을 경우, 채팅방 메시지 로딩 과 같이 여러가지 처리를 하였지만, 이는 Repo에서 확인하길 바란다.
채팅방, 채팅메시지등 전부 사용자가 있어야지 결국은 채팅 서비스를 구현할 수 있기 때문에 아주 간단한 사용자 정보만 조회해기 위해서 member를 구성하였다.
채팅방과 채팅메시지와는 다르게 사용자 정보는 현재 진행중인 프로젝트에 맞춰서 postgresq에 저장하여 사용하였다.
@RestController("memberController")
@RequestMapping("/member")
public class MemberController {
private final MemberService memberService;
@Autowired
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@GetMapping("/{memberId}")
public String getMemberName(@PathVariable String memberId){
return memberService.getMemberNameById(memberId);
}
@GetMapping("")
public List<MemberDto> getAllMembers(){
return memberService.getAllMembers();
}
}
package com.song.chatpractice.member.service;
import com.song.chatpractice.member.dto.MemberDto;
import com.song.chatpractice.member.entity.Member;
import com.song.chatpractice.member.repository.MemberRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
@Autowired
public MemberServiceImpl(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Override
public String getMemberNameById(String memberId) {
Member member = memberRepository.findById(memberId).orElseThrow();
return member.getName();
}
@Override
public List<MemberDto> getAllMembers() {
return memberRepository.findAll().stream()
.map(this::convertEntityToDto)
.collect(Collectors.toList());
}
// ModelMapper 대신 Entity를 Dto로 변환해주는 메소드
private MemberDto convertEntityToDto(Member member){
MemberDto memberDto = new MemberDto();
memberDto.setId(member.getId());
memberDto.setName(member.getName());
return memberDto;
}
}
@Repository
public interface MemberRepository extends JpaRepository<Member, String> {
}
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@Table(name="MEMBER")
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "member_seq")
@SequenceGenerator(name = "member_seq", sequenceName = "member_id_seq", allocationSize = 1)
@Column(name = "member_id")
private String id;
@Column(name = "member_name")
private String name;
@Column(name = "created_at")
private Timestamp createdAt;
}
Vue.js를 통해서 구성한 화면도 사실 코드로 따지면 길고 복잡하다!! 그렇기 때문에 핵심적인 연결 처리 부분과 메시지 전송등과 같은 몇가지 부분만 설명하고 넘어가고자 한다.
const connectWebSocket = () => {
console.log('웹 소켓 연결 시도 중...')
connectionStatus.value = '웹 소켓에 연결 중...'
const socket = new SockJS('http://localhost:8081/stomp-chat', null, {
transports: ['websocket', 'xhr-streaming', 'xhr-polling'], // websocket 지원하지 않을 시, 대체 방식
})
console.log('SockJS 인스턴스 생성됨')
stompClient.value = new Client({
webSocketFactory: () => socket,
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
debug: function (str) {
console.log(str)
},
onConnect: frame => {
console.log('STOMP 연결됨: ', frame)
isConnected.value = true
connectionStatus.value = '연결됨'
// 사용자별 채팅방 목록 업데이트 구독 (async를 통해서 메시지가 도착했을 때, 실행되는 비동기 콜백 함수)
stompClient.value.subscribe(`/topic/user/${memberId.value}/rooms/update`, async response => {
const updatedRooms = JSON.parse(response.body);
// 모든 채팅방 리스트에서 memberId가 포함된 채팅방만 도출
rooms.value = updatedRooms.filter(room =>
room.participants.includes(memberId.value)
);
// 현재 선택된 방이 없고 채팅방이 있다면 첫 번째 방 선택
if (!currentRoom.value && rooms.value.length > 0) {
currentRoom.value = rooms.value[0].id;
await connectToNewRoom();
}
});
if (currentRoom.value) {
subscribeToRoom(currentRoom.value);
}
},
// 연결이 끊어졌을 때의 처리 추가
onDisconnect: () => {
console.log('STOMP 연결 해제됨')
isConnected.value = false
connectionStatus.value = '연결 끊김'
}
})
console.log('STOMP 클라이언트 활성화 중...')
stompClient.value.activate()
}
const subscribeToRoom = (roomId) => {
if (subscriptions.value[roomId]) {
console.log(`Already subscribed to room ${roomId}`);
return;
}
// 채팅 메시지 구독
subscriptions.value[roomId] = stompClient.value.subscribe(`/topic/message/${roomId}`, message => {
console.log('메시지 수신:', message);
if (!messagesPerRoom.value[roomId]) {
messagesPerRoom.value[roomId] = [];
}
const messageData = JSON.parse(message.body);
messagesPerRoom.value[roomId].push(messageData);
scrollToBottom();
});
// 채팅방 정보 업데이트 구독
subscriptions.value[`${roomId}-update`] = stompClient.value.subscribe(`/topic/room/${roomId}/update`, response => {
const updatedRoom = JSON.parse(response.body);
// 현재 채팅방 목록에서 해당 방 정보 업데이트
const index = rooms.value.findIndex(room => room.id === updatedRoom.id);
if (index !== -1) {
rooms.value[index] = updatedRoom;
}
});
}
const sendMessage = () => {
if (newMessage.value && isConnected.value) {
const chatMessage = {
roomId: currentRoom.value,
sender: username.value, // 실제 로그인한 사용자 이름 사용
message: newMessage.value,
type: 'CHAT'
}
console.log('메시지 전송:', chatMessage)
stompClient.value.publish({
destination: `/app/${currentRoom.value}`,
body: JSON.stringify(chatMessage)
})
newMessage.value = ''
} else if (!isConnected.value) {
console.error('웹소켓에 연결되지 않았습니다')
connectionStatus.value = '메시지를 보낼 수 없습니다. 연결 중...'
}
}
const loadRooms = async () => {
try {
const response = await axios.get('http://localhost:8081/stomp/chatRoom')
// 사용자가 참여한 채팅방만 필터링
rooms.value = response.data.filter(room =>
room.participants.includes(memberId.value)
)
// 사용자가 참여한 채팅방이 있다면 첫 번째 방을 현재 방으로 설정
if (rooms.value && rooms.value.length > 0) {
currentRoom.value = rooms.value[0].id
} else {
connectionStatus.value = '참여 중인 채팅방이 없습니다.'
}
} catch (error) {
console.error('채팅방 목록 로딩 실패:', error)
connectionStatus.value = '채팅방 목록을 불러올 수 없습니다.'
}
}
그 외에도 채팅방 생성, 입장, 퇴장등 다른 기능들도 구현하였지만, 글이 너무 길어지는 관계로 여기까지만 설명하겠다. 궁금하다면 역시 위의 repo를 들어가서 확인해보자!
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 모든 Url 패턴에서 적용
.allowedOrigins("http://localhost:5173") // frontend 도메인에서의 접근 허용
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 허용할 HTTP메소드 (+OPTIONS는 프리플라이트 요청에 사용된다.)
.allowedHeaders("*"); // 모든 HTTP 헤더 허용
}
}


