웹소켓

팀 프로젝트 중 채팅 기능을 넣자는 이야기가 나왔다.

전에 프로젝트 할때는 웹소켓이 별로 끌리지 않았고 힘들어보여서 다른 팀원에게 넘겼었다.

하지만 이번엔 머리에 든게 꽤 있으니 그리 어렵게 하지 않을거라는 생각이 들었다.

하다가 정 안되면 저번 프로젝트의 코드를 참고하면 되겠지 싶어서 해보았다.

이번 글에서는 메시지를 저장하지는 않고 웹소켓을 통해 채팅하기 까지만 구현한다.


사용 기술

프론트엔드는 Next.js / 백엔드는 스프링을 이용하여 채팅 기능을 구현해볼 것이다.

채팅 기능에만 집중해서 만들것이며 기본적인 CORS같은건 굳이 설명하지 않는다.

어차피 설정해줄 것도 origin, credential 설정 뿐이기도 하다.

이 글에는 백엔드 사이드 내용만 적고 프론트엔드 쪽은 이 글로 분리하려고 한다.

웹 소켓 통신은 프론트/백엔드 모두 알아야 연동하기 쉬우므로 프론트쪽 글과 함께 읽는걸 추천한다.

  • Spring Boot 3.2.4 / gradle-kotlin
  • Java 17
  • STOMP(프론트엔드에서는 구현체를 SockJS로 쓰긴 했음)

1. 웹소켓 관련 설정 해주기

WebSocket 의존성 추가

// build.gradle.kts
implementation("org.springframework.boot:spring-boot-starter-websocket")

WebSocketConfig 설정

채팅 기능을 구현하려면 MessageBroker가 필요하다.

웹소켓을 사용하기로 했으므로 WebSocketMessageBrokerConfigurer 설정을 해준다.

프론트엔드에서 구현체를 SockJS로 써서 아래처럼 설정한다.

+ 추가 ) 만약 STOMP Client를 사용한다면 withSockJS() 부분을 제거해야한다.

@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final AppConfig appConfig;

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic"); // "/topic"을 구독하는 클라이언트에게 메시지 전송
        registry.setApplicationDestinationPrefixes("/app"); // 프론트에서 붙여줄 엔드포인트의 prefix
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws") // 백엔드 URL의 엔드포인트
            .setAllowedOrigins(appConfig.getFrontUrl())  // 허용할 origin
            .withSockJS();  // SockJS로 통신함.STOMP Client를 사용하면 이 부분을 제거한다.
    }
}

2. 웹소켓을 이용한 채팅 구현

2-1. 도메인 객체 구현

연습용으로는 DTO로 만들어도 되지만 아마 저장까지 해야할것 같아서 엔티티로 만들었다.

단순하게 발송자, 내용, 메시지 타입만 설정해두었다.

ChatMessage

package com.pawland.chat.domain;

import com.pawland.user.domain.User;
import jakarta.persistence.*;
import lombok.*;

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatMessage {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String content;

    private String sender;

    private MessageType type;

    @Builder
    public ChatMessage(String content, String sender, MessageType type) {
        this.content = content;
        this.sender = sender;
        this.type = type;
    }
}

MessageType

아래는 프론트와 주고 받을 메시지 타입이다.

public enum MessageType {
    CHAT,
    JOIN,
    LEAVE
}

2-2. 컨트롤러 구현

컨트롤러 설정은 아래와 같다.

@Payload 애노테이션으로 프론트에서 JSON으로 받을 데이터를 DTO로 역직렬화 할 수 있다.

@DestinationVariable 애노테이션으로 HTTP요청의 @PathVariable 처럼 사용할 수 있다.

@Slf4j
@RestController
@RequiredArgsConstructor
public class ChatController {

    @MessageMapping("/chat.addUser/{roomId}") // 프론트가 publish 할 때 사용할 백엔드 엔드포인트
    @SendTo("/topic/{roomId}") // 백엔드에서 웹소켓으로 프론트에 메시지를 보내주는 구독 url
    public ChatMessage addUser(@Payload ChatMessage chatMessage, @DestinationVariable String roomId, SimpMessageHeaderAccessor headerAccessor) {
        // Add username in websocket session
        log.info("[유저 입장] chatMessage = {}, roomId = {}", chatMessage, roomId);
        headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
        headerAccessor.getSessionAttributes().put("roomId", roomId);
        return chatMessage;
    }

    @MessageMapping("/chat.sendMessage/{roomId}") // 프론트가 publish 할 때 사용할 백엔드 엔드포인트
    @SendTo("/topic/{roomId}")   // 백엔드에서 웹소켓으로 프론트에 메시지를 보내주는 구독 url
    public ChatMessage sendMessageToOne(@Payload ChatMessage chatMessage, @DestinationVariable String roomId) {
        log.info("[메시지 전송 to {}번 방] chatMessage = {}", roomId, chatMessage);
        log.info("id = {}", roomId);
        return chatMessage;
    }
}

 

본인이 작성한 엔드포인트에 대한 설명은 아래와 같다.

/app/chat.addUser/{roomId}

프론트에서는 위 엔드포인트로 입장 알림을 보낸다.

이 때 headerAccessor에 username과 roomId 값을 넣는다.

이는 이후 한쪽이 연결을 종료했을 때 상대방에게 퇴장 메시지를 보내기 위한 EventListener에서 사용한다.

/app/chat.addUser/{roomId}

프론트에서 입력한 채팅 메시지를 /topic/{roomId} 토픽을 구독하고 있는 모든 사용자에게 전송한다.

2-3. WebSocketListener 구현

한쪽이 연결을 종료했을 때 상대방에게 퇴장 메시지를 보내는 EventListener를 작성한다.

SessionDisConnectEvent를 이용하여 구현하였다.

프론트에서는 남은 한 쪽이 이걸 받고 메시지를 확인할 수 있게 출력하면 된다.

@Component
@RequiredArgsConstructor
@Slf4j
public class WebSocketListener {

    private final SimpMessageSendingOperations messageTemplate;

    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
        String username = (String) headerAccessor.getSessionAttributes().get("username");
        String roomId = (String) headerAccessor.getSessionAttributes().get("roomId");
        if (username != null) {
            log.info("User disconnected");
            ChatMessage chatMessage = ChatMessage.builder()
                .type(MessageType.LEAVE)
                .sender(username)
                .build();
            messageTemplate.convertAndSend("/topic/" + roomId, chatMessage);
        }
    }
}

인터셉터

사용자 인증과 같은 웹과 관련된 공통 관심 사항을 효과적으로 처리할 수 있도록 스프링 MVC 가 제공하는 기능이다.

서블릿 필터 서블릿이 제공하는 기능이라면 스프링 인터셉터 스프링 MVC 가 제공하는 기능이다.

 

공통 관심사는 스프링의 AOP로도 해결할 수 있지만 아래와 같은 이유로 웹과 관련된 공통 관심사는

서블릿 필터 또는 스프링 인터셉터를 사용하는 것이 좋다. 

  • 웹과 관련된 공통 관심사를 처리할 때는 쿠키나 세션을 확인해야 하기 때문에 HTTP의 헤더나 URL의 정보들이 필요하다.
  • 서블릿 필터나 스프링 인터셉터는 HttpServletRequest 를 제공하기 때문에 이를 처리하기 용이하다

 

스프링 인터셉터 흐름

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러

 

스프링 인터셉터의 흐름은 위와 같다. 스프링을 사용하기 때문에 위 흐름에서 표시된 서블릿은 DispatcherServlet 이다.

인터셉터는 스프링 MVC 가 제공하는 기능이기 때문에 서블릿을 지난 다음 적용된다.

 

스프링 인터셉터 제한

로그인 사용자
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러       

비 로그인 사용자                                              
HTTP 요청 -> WAS -> 필터  -> 서블릿 -> 스프링 인터셉터 <<< 적절하지 않은 요청, 컨트롤러 호출 X    

 

인터셉터에서 적절하지 않은 요청이라고 판단하면 서블릿을 호출하지 않고 거기에서 끝낼수도 있다. 

 

인터셉터 체인

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 인터셉터3 -> 컨트롤러

 

스프링 인터셉터는 체인으로 구성되는데 중간에 인터셉터를 자유롭게 추가할 수 있다.

예를 들어서 로그를 남기는 인터셉터를 먼저 적용하고 그 다음에 로그인 여부를 체크하는 인터셉터를 만들 수 있다.

 


필터와의 차이점

위까지만 보면 사실 필터와 크게 다른 차이를 못느낄것이다.

필터는 서블릿(DispatcherServlet ) 호출 전에 적용되기 때문에 Response 와 Request 정도만 조정할 수 있지만

인터셉터는 서블릿 호출 이후에 적용되므로 컨트롤러 호출 전, 호출 후, 요청 완료 이후 까지 세분화해서 조정할 수 있다.

 

인터셉터 호출 흐름

1. 정상 흐름

 

  1. preHandle
    - 컨트롤러 전에 호출된다.
    - preHandle 의 응답값이 true 면 다음으로 진행하고, false 면 요청을 여기서 끝낸다.
      false 일 경우 체인된 나머지 인터셉터는 물론이고 핸들러 어댑터도 호출되지 않는다.
  2. postHandle
    - 컨트롤러 호출 후에 호출된다. (정확히는 핸들러 어댑터 호출 후)
  3. afterCompletion
    - 뷰가 렌더링 된 이후에 호출된다.
    - 항상 호출되며, 예외 상황 시 예외 정보를 포함해서 호출된다.

2. 스프링 인터셉터 예외 상황

 

  1. preHandle
    - 컨트롤러 전에 호출된다.
  2. postHandle
    - 컨트롤러에서 예외가 발생하면 postHandle 은 호출되지 않는다.
  3. afterCompletion
    - 항상 호출되며, 예외 상황 시 예외 정보를 포함해서 호출된다.

위처럼 afterCompletion은 예외가 발생해도 호출된다.

예외가 발생하면 postHandle() 은 호출되지 않으므로 예외와 무관하게 공통 처리를 하려면

afterCompletion() 을 사용해야 한다.

 

 

참고자료)

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

www.inflearn.com

 

'백엔드 > Spring' 카테고리의 다른 글

Interceptor 써보기  (0) 2023.09.29
Filter  (0) 2023.09.29
BeanValidation 써보기  (0) 2023.09.22
@ModelAttribute 와 @RequestBody 써보기  (0) 2023.09.21
DispatcherServlet  (0) 2023.08.01

+ Recent posts