Springboot + Websocket로 간단한 채팅 구현하기

의혁·2025년 1월 28일
post-thumbnail

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

1. Spring Boot + Websocket으로 실시간 채팅 구현

1) 간단한 채팅 전송 & 수신 테스트

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-websocket'
    compileOnly 'org.projectlombok:lombok'
}
  • websocket을 통한 채팅 구현을 위해 기본적으로, SpringWeb, Websocket, lombok 라이브러리를 적용시켰다.

< WebsocketChatHandler >

/* 설명.
    WebSocket Handler 추가
    Socket은 서버와 클라이언트가 1:N 관계로 연걸되기 때문에, 힌 서버에 여러 클라이언트들 접속하여 전송한 메세지를 처리해줄 Handler 필요
    TextWebSocketHandler를 상속받아서 Handler 생성
 */

@Component
@Slf4j
public class WebSocketChatHandler extends TextWebSocketHandler {

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {

        // Client가 전송한 Message의 본문을 담는다. (json형태)
        String payload = message.getPayload();
        log.info(payload);

        // Client와 Server가 연결되어 있는 객체를 "session"이라고 하고, 
        // 아래는 session을 통해 서버가 클라이언트에게 보낼 메시지를 담는다.
        TextMessage textMesaage = new TextMessage("Hello World!");
        session.sendMessage(textMesaage);
    }
}
  • WebSocket은 양방향 통신으로 클라이언트와 서버가 연결에서 서로 보내는 메시지를 수신하거나 응답하기 위한 로직이 필요하다.
  • 위 코드를 보면 이 Handler는 서버가 클라이언트에게 메시지의 본문을 수신하고, 서버에서 클라이언트로 응답 메시지를 보내주는 handler를 볼 수 있다.

< WebSocketConfig>

/* 설명.
    Websocket Config 설정
    WebsocketChatHandler를 이요하여 websocket을 활성화하기 위한 config 파일 작성
    endpoint 작성 => /ws/chat
    cors 처리 => setAllowedOrigins("*")
 */

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    private final WebSocketChatHandler webSocketChatHandler;

    @Autowired
    public WebSocketConfig(WebSocketChatHandler webSocketChatHandler) {
        this.webSocketChatHandler = webSocketChatHandler;
    }

    // Client와 Server를 연결할 Handler 등록
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {

        // "ws/chat"로 endpoint를 설정한다. (클라이언트가 연결 생성시, ws://localhost:8080/ws/chat으로 요청을 보내야함)
        // setAllowedOrigins("*") => 모든 도메인으로 부터의 접근 허용 ( CORS )
        registry.addHandler(webSocketChatHandler, "ws/chat").setAllowedOrigins("*");
    }
}
  • WebSocketConfig는 websocket을 활성화 하기 위한 config 파일이다.
  • 메시지를 송수신하는 로직을 담고 있는 WebSocketChatHandler를 등록하면서, endpoint와 setAllowedOrigins()를 설정한다.
  • 이 과정을 통해 Websocket을 통한 채팅 송수신을 할 수 있는 경로(endpoint)를 제시하고, 접근 가능 도메인 권한을 지정한다.

  • 실제 Test를 해본결과 전송한 메세지를 잘 수신하고, 서버에서 전송하려는 메시지를 잘 전송하는 것을 확인할 수 있다.

2) 채팅방 생성 후, 채팅 해보기

< WebSocketChatHandler >

@Component
@Slf4j
public class WebSocketChatHandler extends TextWebSocketHandler {

    private final ObjectMapper objectMapper;
    private final ChatService chatService;

    @Autowired
    public WebSocketChatHandler(ObjectMapper objectMapper, ChatService chatService) {
        this.objectMapper = objectMapper;
        this.chatService = chatService;
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {

        // Client가 전송한 Message의 본문을 담는다. (json형태)
        String payload = message.getPayload();
        // 사용자가 입력한 문자열이 찍힘 (Ex. hello )
        log.info(payload);

        // json으로 넘어오는 Payload를 message로 배분해서 넣어준다.
        ChatMessageDTO chatMessage = objectMapper.readValue(payload, ChatMessageDTO.class);

        // 넘어오는 message에 있는 roomId로 채팅방과 연결한다.
        ChatRoomDTO chatRoom = chatService.findRoomById(chatMessage.getRoomId());
        chatRoom.handleActions(session, chatMessage, chatService);
    }
}
  • 위의 코드와는 다르게 채팅방에 입장하여 여러 사용자들이 채팅을 주고 받는 채팅방을 구현하기 때문에 chatMessage와 chatRoom 정보를 추적으로 받아오게 설정하였다.
  • 사용자가 보내는 text 종류, 채팅방 번호, 사용자 이름, 메시지 내용 이렇게 json으로 넘어오기 떄문에, json으로 넘어오는 정보를 "objectMapper"를 사용해서 chatMessageDTO에 매핑 시켰다.
  • ChatRoomDTO에는 chatService로부터 채팅방 번호로 찾은 채팅방 객체를 넣어준다.
  • chatRoomDTO에 선언한 HandleAction 메소드에 session정보, 메시지, 연결할 Service를 넣어줘 handler가 처리하도록 넘겨준다.

< WebsocketConfig >
위의 코드와 동일 
  • WebsocketConfig는 위 코드와 동일하기 때문에 넘어가겠다.

< ChatMessageDTO >

@Getter
@Setter
public class ChatMessageDTO {

    // 메시지 타입: 입장(ENTER), 채팅(TALK)
    private MessageType type;

    // 채팅방 번호
    private String roomId;

    // 전송자
    private String sender;

    // 메시지
    private String message;

    public enum MessageType {
        ENTER, TALK
    }
}
  • json 형태로 넘어오는 데이터를 Message로 Mapping할 수 있게 ChatMessageDTO를 선언하였고, 채팅방 번호, 수신자, 메시지 내용, 메시지 타입 (입장 or 채팅)으로 넣어준다.

< ChatRoomDTO >

@Getter
public class ChatRoomDTO {

    // 채팅방 번호
    private String roomId;

    // 채팅방 이름
    private String name;

    // 입장한 클라이언트의 정보 ( WebsocketSession 정보 list ) => HashSet을 이용하여 중복 세션 추가를 방지한다.
    private Set<WebSocketSession> sessions = new HashSet<>();

    // @Setter대신 Builder 패턴 적용하여 불변성 유지
    @Builder
    public ChatRoomDTO(String roomId, String name) {
        this.roomId = roomId;
        this.name = name;
    }

    // 클라이언트의 action(ENTER, TALK) 처리 => roomId를 서비스로 부터 조회하여 json에 담긴 메시지를 전달해주는 메소드
    public void handleActions(WebSocketSession session, ChatMessageDTO chatMessageDTO, ChatService chatService){
        if (chatMessageDTO.getType().equals(ChatMessageDTO.MessageType.ENTER)){
            // 세션에 연결
            sessions.add(session);
            chatMessageDTO.setMessage(chatMessageDTO.getSender() + "님이 입장했습니다.");
        }
        sendMessage(chatMessageDTO, chatService);
    }

    // 채팅방에 존재하는 모든 클라이언트들에게 해당 메시지 전송
    public <T> void sendMessage(T message, ChatService chatService) {
        // parallelStream()은 여러 스레드를 사용해 데이터를 병렬로 처리한다.( stream보다 빠름 )
        sessions.parallelStream().forEach(session -> chatService.sendMessage(session, message));
    }
}
  • chatRoom에 대한 정보를 담는 chatRoomDTO도 생성하였다.
  • 채팅방 번호와 채팅방 이름을 저장할 수 있고, 해당 채팅방에 입장한 클라이언트의 세션 정보를 HashSet을 사용해서 중복되지 않게 저장한다.
  • @Builder 패턴을 이용해서 Service 계층으로부터 대입받는 값의 불변성을 유지하고, 원하는 필드만 넣을 수 있게 설정하였다.
  • handleActions()를 선언하여, 클라이언트의 메시지 형태 (ENTER or TALK)에 따라 다르게 행동하도록 설정하였다.
    => ENTER: 입장한 클라이언트의 session 정보를 저장하고, 입장하였다는 메시지를 전달한다.
    => TALK: 클라이언트가 보낸 chatMessageDTO를 Service 계층으로 전달한다.
  • sendMessage를 통해서 한명의 사용자가 보낸 메시지를 parallelStream()를 사용해서, 채팅방에 접속중인 모든 클라이언트에게 모두 보내준다.

💡 @PostConstruct 기능

  1. 빈(Bean) 생성과 의존성 주입이 완료 된 후 실행되도록 하는 설정
  2. Bean의 라이프 사이클 내에서 1번만 실행되는 것을 보장 ( 채팅방 정보를 저장하는 Map은 여러번 선언하면 리소스 낭비 증가)
  3. 동시성 문제 예방 ( 애플리케이션 시작 지점에 초기화하여, 여러 사용자가 동시에 접속하여 발생하는 동시성 문제 예방)
  4. 명시적인 초기화 시점 제공 ( 초기화 코드의 의도가 더 명확해짐)
< ChatService >

public interface ChatService {
    <T> void sendMessage(WebSocketSession session, T message);

    // 채팅방 생성 메소드
    ChatRoomDTO createRoom(String name);

    // 모든 채팅방을 조회하는 메소드
    List<ChatRoomDTO> findAllRooms();

    ChatRoomDTO findRoomById(String roomId);
}

< ChatServiceImpl >

@Service
@Slf4j
public class ChatServiceImpl implements ChatService {

    private final ObjectMapper objectMapper;      // java -> json or json -> java 해주는 역할

    // key값인 String은 roomId, value값으로 chatRoomDTO가 들어간다. - 여러 채팅방들의 정보를 저장하는 Map이다.
    private Map<String, ChatRoomDTO> chatRooms;

    @Autowired
    public ChatServiceImpl(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    // Bean 생성과 의존성 주입을 모두 완료한 뒤에 실행되는 메소드 (안전한 초기화 보장, 1번만 실행됨을 보장, 동시성 문제 예방)
    @PostConstruct      // @PostConstruct는 의존성 주입이 모두 완료된 후 초기화를 수행하는 메소드
    private void init(){
        chatRooms = new LinkedHashMap<>();
    }

    // 채팅방 생성 메소드
    @Override
    public ChatRoomDTO createRoom(String name) {
        // 채팅방 번호는 랜덤으로 배정
        String randomRoomId = UUID.randomUUID().toString();

        ChatRoomDTO chatRoom = ChatRoomDTO.builder()
                .roomId(randomRoomId)
                .name(name)
                .build();

        // 새로운 채팅방이 개설되면 chatRooms(모든 채팅방 정보를 가진 Map)에 추가해줌
        chatRooms.put(randomRoomId, chatRoom);
        return chatRoom;
    }


    // 모든 채팅방을 조회하는 메소드
    @Override
    public List<ChatRoomDTO> findAllRooms(){
        return new ArrayList<>(chatRooms.values());
    }

    // roomId로 채팅방을 찾는 메소드
    @Override
    public ChatRoomDTO findRoomById(String roomId) {
        // key 값으로 방을 찾아 value를 받아간다.
        return chatRooms.get(roomId);
    }

    // 메세지의 type이 TALK일 경우 메세지를 보내는 메소드
    @Override
    public <T> void sendMessage(WebSocketSession session, T message) {
        try{
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message)));
        } catch(IOException e){
            log.error(e.getMessage(), e);
        }
    }
}
  • 채팅방도 여러 개가 만들어질 수 있기 때문에, Map형태로 Key값은 roomId를 value값은 ChatRoomDTO를 저장할 수 있도록 선언하였다.
  • @PostConstruct를 통해 chatRooms를 초기화 하였다. ( 위 정리 확인 )
  • 채팅방 생성 메소드(채팅방 번호는 UUID로 랜덤생성), 모든 채팅방 조회 메소드, 채팅방Id로 조회 메소드를 구현하여, 채팅에 필요한 메소드들을 구현하였다.
  • 위의 chatRoomDTO에서 채팅방에 존재하는 모든 클라이언트에게 메세지를 전달해주는 메소드로 sendMessage()를 사용하였고, writeValueAsString()를 사용해서 JSON 형식의 문자열로 변환(직렬화)하여 메시지로 전송한다.

< ChatController >

@RestController("chatWebsocketController")
@RequestMapping("/chat/websocket")
public class ChatController {

    private final ChatService chatService;

    @Autowired
    public ChatController(ChatService chatService) {
        this.chatService = chatService;
    }

    @PostMapping("")
    public ChatRoomDTO createRoom(@RequestBody String name){

        return chatService.createRoom(name);
    }

    @GetMapping("")
    public List<ChatRoomDTO> findAllRoom(){
        return chatService.findAllRooms();
    }
}
  • controller를 통해 사용자가 채팅방의 이름을 지정하여 채팅방을 생성하고, 모든 채팅방을 조회할 수 있는 메소드를 구현하였다.

  • 채팅방의 이름을 지정하여 채팅방을 만들면, 채팅방 번호가 UUID를 통해서 랜덤으로 생성되어 채팅방이 생성된다.

  • 창을 2개를 띄워서 채팅방 접속 후, 메시지 전송을 해보면 채팅방에 존재하는 2명의 유저 모두에게 채팅이 업데이트 되는 것을 확인할 수 있다.

2. 추가정리 사항 - 2025.02.14

1. Masking

  • 목적
    => 중간자(Proxy)가 Websocket의 트래픽을 악의적으로 조작하는 보안 위협 방지
    (ex. 캐시 포이즈닝 (cache poisoning) = 브라우저에 캐싱된 값에 올바르지 않는 값을 흘려서 악의적인 데이터를 캐싱)
    => Clinet -> Server로 들어가는 모든 Frame은 "Masking 처리를 필수"로 해야한다.
  • 방식
    => payload의 각 byte에 대해 masking-key를 사용해 무작위 난수 생성 (XOR 연산) - 브라우저가 생성
    => masking-key는 서버에 전달되어, XOR 연산을 통해 원본값으로 복원가능

2. Ping/pong

WebSocket은 양방향 통신을 지원하지만, 불안정한 네트워크 환경에서는 연결 상태를 정확히 파악하는 것이 어렵다. 클라이언트나 서버가 연결 해제 요청을 보내지 않거나 네트워크 문제로 인해 정상적인 종료가 되지 않는 경우, 이를 감지하고 적절한 조치를 취해야 한다.

1) 연결 끊김 감지 문제

  • 클라이언트가 네트워크 문제로 인해 연결이 끊어졌을 경우, 서버는 이를 즉시 알 수 없다.
  • 일정 시간 트래픽이 없으면 프록시 또는 NAT 라우터에서 연결을 자동으로 종료할 수도 있다.
  • 클라이언트가 일시적으로 연결이 끊겼다가 재연결할 수도 있기 때문에, 단순히 트래픽이 없다고 바로 오프라인 상태로 처리하는 것은 적절하지 않다.

2) Ping/Pong을 이용한 연결 상태 유지

WebSocket에서는 주기적인 ping/pong 메시지를 활용하여 연결 상태를 확인할 수 있다.

  • 한 쪽에서 ping을 보내면, 상대는 반드시 pong을 응답해야 한다.
  • 일정 시간 동안 ping/pong을 주고받지 못하면 연결을 끊는다.
  • ping/pong비동기적으로 처리하면, 다른 메시지 송수신을 방해하지 않는다.

3) Ping 주기 설정

Ping 주기를 결정하는 것은 중요한 문제이며, 공식적인 IETF(Internet Engineering Task Force) 가이드라인이 명확하지 않다. 몇 가지 사례를 참고하면:

  • Proxy/NAT의 TTL(Time-To-Live) 고려:
    • 일반적으로 최소 60초 이상으로 설정되므로, ping 주기는 이보다 짧아야 한다.
  • WebSocket 라이브러리 사례 참고:
    • WebSocket-Node: 기본 ping 간격 20초
    • IETF 권장: 25초
    • Spring SockJS 기본값: 25초
    • RabbitMQ: 5~20초 권장

이러한 기준을 참고하여 서비스 특성에 맞는 ping 주기를 설정해야 한다.

4) Ping을 이용한 데이터 동기화

WebSocket의 ping(0x9)pong(0xA) 프레임은 애플리케이션 데이터를 포함할 수 있다.
이를 활용하면 단순한 연결 유지 외에도 데이터 동기화 기능을 수행할 수 있다.

📌 예제 시나리오

  • 클라이언트에서 사용자가 입력하여 상태가 변경되었을 때, 즉시 서버로 전송하는 대신 ping에 데이터를 포함시킬 수 있다.
  • 서버는 해당 데이터를 수신하고 pong을 반환하며, 이에 대한 응답을 포함할 수도 있다.
  • Ping의 주기가 약 20초이므로, 사실상 거의 실시간 동기화가 가능하다.

하지만 실시간성이 매우 중요한 애플리케이션(예: 주식 거래, 실시간 게임 등)에서는 초 단위의 차이도 문제될 수 있으므로, 이 방법을 활용하기 어렵다.

5) 결론

  • WebSocket 연결은 네트워크 상태에 따라 끊길 수 있으며, 이를 감지하기 위해 ping/pong을 활용해야 한다.
  • Ping의 주기는 최소 20~25초 정도로 설정하는 것이 일반적이다.
  • ping 프레임에 데이터를 포함하여 동기화 기능을 수행할 수도 있지만, 실시간성이 중요한 서비스에서는 적절하지 않을 수 있다.

즉, WebSocket에서는 단순한 연결 유지뿐만 아니라 효율적인 통신을 위해 ping/pong을 전략적으로 활용할 필요가 있다.


참조 블로그
https://rhgustmfrh.tistory.com/15
https://jaeseo0519.tistory.com/411

profile
매일매일 차근차근 나아가보는 개발일기

0개의 댓글