SpringBoot + Stomp 로 실시간 채팅 구현하기

의혁·2025년 2월 11일
post-thumbnail

💡 Spring Boot와 Stomp를 사용하여 실시간 채팅을 구현하여 보자

1. 🙋🏻 개요

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


2. 개발에 필요한 고려사항

  • Stomp 설정 파일 생성
  • 실시간 채팅에 필요한 채팅 메시지(ChatMessage), 채팅방(ChatRoom), 사용자(member) 기능 구현
  • 채팅 메시지와 채팅방을 저장할 MongoDB
  • 사용자 정보를 저장하여 mapping할 Postgresql
  • 채팅 화면을 위한 Vue
  • Backend와 Frontend를 연결하기 위한 CORS 설정

3. 개발 코드 & 과정

0. .env 파일 적용하기

  • 보안상의 이유로 .env파일을 사용하기 위해선, Edit-configurations.. 를 클릭해서 위와 같이 .env파일 설정을 추가해주면 된다.

1. StompWebSocketConfig

@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() = 메시지 버퍼의 크기 제한


2. 실시간 채팅에 필요한 채팅 메시지,채팅방 구현

위 부분과 같은 경우는 핵심내용만 설명할 예정으로 자세한 건 위에 적어놓은 GitHub repo를 참고하여라!
간단하게 구조를 설명하자면, 일단 채팅방과 채팅메시지를 전부 MongoDB로 관리하도록 설정하였고, member는 postgresql에서 관리한다.
채팅방과 채팅메시지는 Controller - dto - service - serviceImpl - repository - entity를 가지도록 따로따로 구성하였고, 각각에 필요한 메소드를 따로 구현하였다.

1) ChatMessageController.java - SendMessage

// 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);
    }
  • frontend로 부터 Websocket 연결 요청을 받아 Websocket을 연결하고 나서, Websocket을 통해서 상대방이 보내는 STOMP 메시지를 처리하는 메소드이다.
  • Websocket으로 부터 넘어오는 상대방의 메시지를 받아 필요한 정보를 꺼내어 Service 계층으로 넘겨주는 역할을 한다.
  • @MessageMapping(): STOMP 메시지를 처리하는 핸들러를 정의하는 어노테이션
  • @DestinationVariable : 전송되는 URL에서 MessageMapping()안에 있는 roomId를 빼와서 넣어주는 역할
  • simpleMessageHeaderAccesor: 서버에서 Client의 Websocket 세션에 대한 정도를 자동으로 관리하여, 사용자 정보나 인증정보와 같은 것들을 담고 있다.

2) ChatMessageServiceImpl.java - sendMessage

@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);
    }
  • Controller에서 넘어온 보내는 사용자의 정보, 받는 사람의 정보, 보낸 채팅방 번호, 메시지 타입, 메시지 내용, 메세지 전송 시간등을 통해서 날라온 메시지 정보를 MongoDB의 Message파일에 저장하고 ,정보에 해당하는 Client에게 메시지를 보낸다.
  • Insert(): MongoRepository에서 제공하는 메소드로 JPA의 save()와 동일하게 저장하지만, 중복된 값이 들어오면 예외처리를 일으킨다.
  • convertAndSend("/topic/message/" + roomId, chatMessageDto);: /topic/message/{roomId}로 구독 중인 Client에게 Message를 전송한다.

3) ChatMessageRepository.java

@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);
}
  • MongoDB에 연동하기 위해서 Repository에 MongoRepository<Entity명, PK타입>을 extends하여서, 해당 repository를 거쳐 MongoDB에 데이터가 들어가도록 설정하였다.

4) ChatMessage.java

@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
    }
}
  • MongoDB는 기존 MariaDB와 Postgresql과 다르게 @Document로 Entity를 선언한다.
  • 메시지 타입도 들어왔을 때, 채팅할 때, 나갈때를 따로 구분하여 넣어주었다.

5) application.yml

spring:
  # MongoDB 설정
  data:
    mongodb:
      uri: ${MONG_DATABASE_URI}
  • MongoDB를 사용하기 위해서 위와 동일하게 설정해주었고, 보안을 위해 .env파일로 uri를 지정하였다.
  • uri는 mongodb://localhost:(본인포트번호)/(MongoDB파일)로 설정하면 된다.

그 외에도 채팅방 입장, 채팅방 떠나기, 채팅방 처음 들어왔을 경우, 채팅방 메시지 로딩 과 같이 여러가지 처리를 하였지만, 이는 Repo에서 확인하길 바란다.


3. 사용자(member) 정보를 저장할 Postgresql & 조회 기능

채팅방, 채팅메시지등 전부 사용자가 있어야지 결국은 채팅 서비스를 구현할 수 있기 때문에 아주 간단한 사용자 정보만 조회해기 위해서 member를 구성하였다.
채팅방과 채팅메시지와는 다르게 사용자 정보는 현재 진행중인 프로젝트에 맞춰서 postgresq에 저장하여 사용하였다.

1) MemberController.java

@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();
    }
}
  • 채팅방과 채팅메시지를 조회, 생성, 송신, 수신등을 위해서는 사용자의 정보가 필요했기 때문에, 모든 사용자의 정보와 선택한 사용자의 id를 통해 이름을 받아올 수 있는 메소드를 만들었다.

2) MemberServiceImpl.java

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;
    }
}
  • 사용자의 id를 통해 사용자의 정보를 받아와서 return 해줄 수 있는 여러 메소드를 구성하였다.

3) MemberRepository.java

@Repository
public interface MemberRepository extends JpaRepository<Member, String> {
}
  • postgresql은 관계형 데이터베이스(RDBMS)이기 때문에, 사용할 JpaRepository<Entit명, PK타입>을 extends 시켜서 내부 메소드를 사용하였다.

4) Member.java

@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;
}
  • postgresql은 mariadb와 같이 @Table 과 @Entity를 통해서 Entity를 정의할 수 있다.
  • 추가적으로 Mariadb와 다르게 Sequence를 제공하여, PK값을 커스텀하지 않아도, String타입의 AUTO INCREMENT를 적용할 수 있다.

4. Vue.js를 통해서 화면 구성

Vue.js를 통해서 구성한 화면도 사실 코드로 따지면 길고 복잡하다!! 그렇기 때문에 핵심적인 연결 처리 부분과 메시지 전송등과 같은 몇가지 부분만 설명하고 넘어가고자 한다.

1) Chat.vue - connectWebSokcer()

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()
}
  • backend에서 설정해두었던 endpoint 경로를 통해서 웹소켓을 연결한다. (이때, 브라우저 지원이 안되는 경우를 대비하여 SockJS 설정을 하였다.)
  • Stomp 클라이언트를 생성하여, 연결을 처리해줄 추가 세팅을 진행한다.
  • 웹소켓 연결이 완료되면, 각 사용자별 채팅방 목록을 구독하여, 접속한 사용자가 들어가있는 채팅방 목록을 받아서 화면에 뿌려준다.
  • 웹소켓 연결이 끊어지면, 연결 상태를 업데이트 하여 끊어짐을 나타낸다.

2) Chat.vue - subscibeToRoom()

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;
        }
    });
}
  • backend의 StompWebsocketConfig.java에서 설정했던 파일에 따라, 구독하여 채팅을 수신하기 위해서 "/topic" 경로로 시작하는 채팅 메시지 구독 경로와 채팅방 구독 경로로 각각 지정하여 메시지와 채팅방 정보를 업데이트 받는다.

3) Chat.vue - sendMessage()

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 = '메시지를 보낼 수 없습니다. 연결 중...'
    }
}
  • 사용자가 채팅방에서 보낸 메시지는 backend에서 메시지를 발행(송신)하기 위해 설정하였던 "/app"으로 시작하는 경로에 보내진다. (/app/{roomId})
  • 해당 경로의 채팅방을 구독하고 있는 모든 Client들에게 해당 메시지가 전송이 되어 채팅방이 실시간으로 업데이트 된다.

4) Chat.vue - loadRooms()

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 = '채팅방 목록을 불러올 수 없습니다.'
    }
}
  • 해당 메소드를 사용하여, 모든 채팅방 정보를 전부 불러와 해당 정보 중 Participants 리스트에 현재 접속한 사용자의 Id가 있는지 없는지 확인한다.
  • 하나라도 있다면 가장 첫번째 방의 채팅방 환경을 화면에 업로드 해준다.
  • 없다면 참여중인 채팅방이 없다라고 화면에 업로드 해준다.

그 외에도 채팅방 생성, 입장, 퇴장등 다른 기능들도 구현하였지만, 글이 너무 길어지는 관계로 여기까지만 설명하겠다. 궁금하다면 역시 위의 repo를 들어가서 확인해보자!


5. Backend와 Frontend의 CORS 연결을 위해서 Backend에서 CORS처리

CorsConfig.java

@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 헤더 허용
    }
}
  • Backend와 Frontend의 CORS 처리를 backend에서 진행하였고, 설정한 Frontend(http://localhost:5173)에서 접근 가능하게 설정하였고, 그외에는 HTTP 메소드, 헤더, 패턴등을 모두 허용하여 CORS를 처리하였다.

4. 결과

  • 위의 사진에서 보는 것과 같이 2명의 유저가 서로 메시지를 보내면 실시간으로 메시지가 보여지는 것을 볼수있다.

  • 또한, MongoDB의 채팅 메시지와 채팅방에 대한 정보도 잘 저장되는 것을 확인할 수 있다.
profile
매일매일 차근차근 나아가보는 개발일기

0개의 댓글