BackEnd/spring

Spring Webocket을 이용한 실시간 채팅 구현(+ JWT 인증)

연향동큰손 2025. 5. 14. 14:44

 

 

GitHub - yangwoohyeon/OAuth2_9oormthonUNIV

Contribute to yangwoohyeon/OAuth2_9oormthonUNIV development by creating an account on GitHub.

github.com

 

WebSocket이란?

서버와 클라이언트 간에 Socket Connection을 유지해서 언제든 양방향 통신 또는 데이터 전송이 가능하도록 하는 기술로써, sns, 화상 채팅, 증권 거래 등에서 널리 사용되고 있다.

 

 

왜 WebSocket을 사용하는가?

 

HTTP를 사용하여 실시간 양방향 통신을 구현하기 위해서는 다음과 같은 방법을 사용할 수 있다.

  • 백엔드에서 변경 사항이 생길 때마다 프론트엔드에게 응답을 보내주는 방식 : Server-Sent-Event

하지만 HTTP를 사용하여 클라이언트간 실시간 통신을 할때는 단방향 통신(서버 -> 클라이언트)이 이루어지게 된다.

따라서 클라이언트와 서버간의 양방향 통신을 지원해야 하는 real-time service(ex 채팅)에는 적합하지 않을 수 있다.

 

또한 SSE는 HTTP 기반으로 동작하므로, 클라이언트와 서버 간의 연결을 계속 유지하기 위해 HTTP 요청과 응답을 지속적으로 처리해야 한다. 이는 대규모 사용자 수에 대해 효율적이지 않게 될 수 있다.

 

 

 

  Socket Server-Sent-Event
브라우저 지원 대부분 브라우저에서 지원  대부분 모던 브라우저 지원(polyfills 가능)
통신 방향 양방향 단방향(서버에서 클라이언트로)
리얼타임 Yes Yes
데이터 형태 Binary, UTF-8 UTF-8
자동 재접속 No Yes(3초마다 제시도)
최대 동시 접속 수 브라우저 연결 한도는 없지만 서버 셋업에 따라 다름  HTTP를 통해서 할 때는 브라우저당 6개 까지 가능 / HTTP2로는 100개가 기본
프로토콜 websocket HTTP
베터리 소모량 작음
Firewall 친화적 Nope Yes

 

따라서 SSE는 실시간 알림, 주식 시세, 날씨 정보 등 단방향 실시간 데이터 전송에는 적합하지만, 채팅과 같은 양방향 실시간 통신이 필요한 서비스에서는 적합하지 않을 수 있다.

 

하지만 WebSocket은 클라이언트와 서버 간의 양방향 실시간 통신을 지원하며, 대규모 사용자 수를 처리할 때도 효율적인 연결 관리를 제공한다.

 

 

 

Spring WebSocket으로 실시간 채팅 기능 구현하기

Spring framework는 WebSocket을 구현하기 위한 라이브러리를 제공한다.

implementation 'org.springframework.boot:spring-boot-starter-websocket'

 

 

관련 어노테이션

@EnableWebSocket WebSocket을 활성화하고 WebSocket 관련 설정을 수행하는 클래스에 추가한다. 이 어노테이션은 Spring의 WebSocket 기능을 사용하려는 설정 클래스에 필요하다.
@Configuration WebSocket 설정을 위한 클래스를 구성하는 어노테이션. WebSocket 핸들러와 엔드포인트를 설정하는 데 사용된다.
@MessageMapping STOMP 메시지를 처리할 메서드에 추가된다. 클라이언트가 보낸 메시지를 특정 메서드로 라우팅하고, 해당 메서드에서 메시지 처리 로직을 수행한다.
@SendTo @MessageMapping 메서드에서 처리된 메시지를 클라이언트에게 보낼 때 사용된다. 지정된 destination으로 메시지를 전송.
@SubscribeMapping 클라이언트가 특정 주제에 구독을 시작할 때 실행되는 메서드에 추가한다. 이 메서드는 구독을 시작하는 클라이언트에게 초기 메시지를 보낼 때 유용하다.
@ClientEndpoint WebSocket 클라이언트에서 사용할 수 있는 어노테이션으로, 클라이언트가 WebSocket 서버에 연결하는 엔드포인트를 설정한다.
@OnMessage WebSocket 클라이언트에서 수신한 메시지를 처리하는 메서드에 추가된다. 서버로부터 받은 메시지를 처리하는 메서드에 사용된다.
@OnOpen WebSocket 연결이 열릴 때 호출되는 메서드에 추가한다. 클라이언트가 서버에 연결되었을 때 실행됨.
@OnClose WebSocket 연결이 종료될 때 호출되는 메서드에 추가한다. 연결이 종료되었을 때 클린업 작업 등을 처리할 수 있다.
@OnError WebSocket 연결에서 에러가 발생할 때 호출되는 메서드에 추가한다. 연결에서 발생한 오류를 처리하는 데 사용된다.

 

 

WebSocketConfig

Spring WebSocket을 설정하는 클래스이다. 

@EnableWebSocket을 통해 WebSocket 기능을 활성화하고, WebSocket 연결에 필요한 설정을 WebSocketConfigurer 인터페이스를 통해 등록한다.

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

@Configuration  // WebSocket 설정을 위한 클래스
@EnableWebSocket  // WebSocket 기능 활성화
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {  // WebSocketConfigurer 인터페이스 구현

    private final WebSocketHandler webSocketHandler;  // WebSocket 핸들러
    private final AuthHandshakeInterceptor authHandshakeInterceptor;  // 인증 인터셉터

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(webSocketHandler, "/ws/conn")  // WebSocket 핸들러를 "/ws/conn" 경로에 등록
                .setAllowedOrigins("*")  // 모든 출처에서 연결 허용
                .addInterceptors(authHandshakeInterceptor);  // 핸드쉐이크 시 인증 인터셉터 추가
    }
}

 

 

addHandler(webSocketHandler, "/ws/conn")를 통해 WebSocket 핸들러를 특정 경로("/ws/conn")에 등록한다.

클라이언트는 이 경로로 WebSocket 연결을 요청하게 됩니다.

 

 

 

 

AuthHandshakeInterceptor

WebSocket 연결을 수립하기 전에 인증을 처리하는 핸드쉐이크 인터셉터이다.

WebSocket 연결에서 클라이언트가 제공하는 인증 정보를 Sec-WebSocket-Protocol 헤더에서 추출하고 이를 서버 측에서 사용할 수 있도록 attributes에 추가

@Component 
public class AuthHandshakeInterceptor implements HandshakeInterceptor {

    // WebSocket 핸드쉐이크가 시작되기 전에 호출되는 메서드
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
                                   WebSocketHandler wsHandler, Map<String, Object> attributes) {
        // ① 쿼리 파라미터에서 token 가져오기 (예: ws://localhost:8080/ws/conn?token=xxx)
        if (request instanceof ServletServerHttpRequest servletRequest) {  // request를 ServletRequest로 캐스팅
            String token = servletRequest.getServletRequest().getParameter("token");  // 쿼리 파라미터에서 token 추출
            if (token != null && !token.isEmpty()) {  // token이 비어 있지 않으면
                attributes.put("token", token);  // token을 attributes에 저장
                return true;  // 핸드쉐이크 허용
            }
        }

        // ② subprotocol에서 token 추출하기 (예: access_token,xxx 형식)
        List<String> protocols = request.getHeaders().get("Sec-WebSocket-Protocol");  // subprotocol 헤더 확인
        if (protocols != null && !protocols.isEmpty()) {  // subprotocol이 존재하는 경우
            String protocolHeader = protocols.get(0);  // 첫 번째 프로토콜 헤더 값 가져오기 (예: "access_token,eyJ...")
            if (protocolHeader.startsWith("access_token,")) {  // "access_token,"으로 시작하는 경우
                String token = protocolHeader.split(",")[1];  // 토큰 값을 분리하여 추출
                attributes.put("token", token);  // token을 attributes에 저장
                // 응답에 subprotocol 설정 (이 설정은 선택 사항이며 클라이언트에게 알림)
                response.getHeaders().set("Sec-WebSocket-Protocol", "access_token");
            }
        }

        return true;  // 핸드쉐이크 허용
    }

    // WebSocket 핸드쉐이크가 완료된 후 호출되는 메서드 (필요시 추가 작업 가능)
    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
                               WebSocketHandler wsHandler, Exception exception) {
        // 생략 가능: 추가적인 후속 작업을 수행하려면 이곳에 작성
    }
}

 

 

WebSocketChatHandler 

WebSocket을 사용하여 실시간 채팅을 처리하는 핸들러이다.

@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketChatHandler extends TextWebSocketHandler {

    private final ObjectMapper mapper;  // JSON을 Java 객체로 변환하는 데 사용
    private final ChatMessageRepository chatMessageRepository;  // 채팅 메시지를 DB에 저장하는 레포지토리
    private final ChatRoomRepository chatRoomRepository;  // 채팅방 정보를 DB에서 조회하는 레포지토리
    private final UserRepository userRepository;  // 유저 정보를 DB에서 조회하는 레포지토리
    private final JwtUtil jwtUtil;  // JWT 유효성 검증 및 유저 정보 추출을 위한 유틸리티
    private final Set<WebSocketSession> sessions = new HashSet<>();  // 연결된 모든 WebSocket 세션을 저장
    private final Map<Long, Set<WebSocketSession>> chatRoomSessionMap = new HashMap<>();  // 각 채팅방에 연결된 세션을 관리

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        log.info("{} 연결됨", session.getId());  // WebSocket 연결이 열리면 로그 출력
        sessions.add(session);  // 세션을 연결된 세션 목록에 추가
        session.sendMessage(new TextMessage("WebSocket 연결 완료"));  // 클라이언트에게 연결 완료 메시지 전송
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();  // 받은 메시지 페이로드 추출
        log.info("payload: {}", payload);

        // 받은 메시지를 DTO로 변환
        ChatMessageDto chatMessageDto = mapper.readValue(payload, ChatMessageDto.class);
        log.info("chatMessageDto: {}", chatMessageDto);

        Long chatRoomId = chatMessageDto.getChatRoomId();  // 채팅방 ID 추출
        String receiverId = chatMessageDto.getReceiverId();  // 수신자 ID 추출

        // 클라이언트에서 전달된 JWT 토큰을 이용해 유효성 검사
        String token = (String) session.getAttributes().get("token");
        if (!jwtUtil.validateToken(token)) {  // 토큰이 유효하지 않으면 에러 메시지 전송
            session.sendMessage(new TextMessage("❌ 유효하지 않은 토큰입니다."));
            return;
        }

        String senderId = jwtUtil.extractUserId(token);  // 토큰에서 발신자 ID 추출

        // 발신자 유저 정보 조회
        User sender = userRepository.findByUserId(senderId)
                .orElseThrow(() -> new IllegalArgumentException("유저 없음: " + senderId));

        // 입장 메시지 처리
        if (chatMessageDto.getMessageType().equals(ChatMessageDto.MessageType.JOIN)) {
            chatRoomSessionMap.computeIfAbsent(chatRoomId, key -> new HashSet<>()).add(session);
            chatMessageDto.setMessage(sender.getName() + "님이 입장하셨습니다.");
        }
        // 퇴장 메시지 처리
        else if (chatMessageDto.getMessageType().equals(ChatMessageDto.MessageType.LEAVE)) {
            Set<WebSocketSession> roomSessions = chatRoomSessionMap.get(chatRoomId);
            if (roomSessions != null) {
                roomSessions.remove(session);  // 해당 세션을 채팅방에서 제거
                chatMessageDto.setMessage(sender.getName() + "님이 퇴장하셨습니다.");

                // 퇴장 메시지를 다른 세션들에게 전송
                for (WebSocketSession s : roomSessions) {
                    s.sendMessage(new TextMessage(mapper.writeValueAsString(chatMessageDto)));
                }
            }
            return;
        }

        // 채팅방에 메시지 전송 (브로드캐스트)
        for (WebSocketSession s : chatRoomSessionMap.getOrDefault(chatRoomId, new HashSet<>())) {
            s.sendMessage(new TextMessage(mapper.writeValueAsString(chatMessageDto)));
        }

        // 일반적인 채팅 메시지 처리
        if (chatMessageDto.getMessageType().equals(ChatMessageDto.MessageType.TALK)) {
            // 채팅방 존재 여부 확인 후 없으면 새로 생성
            ChatRoom chatRoom = chatRoomRepository.findByUsers(sender.getUserId(), receiverId)
                    .orElseGet(() -> {
                        User receiver = userRepository.findByUserId(receiverId)
                                .orElseThrow(() -> new IllegalArgumentException("상대방 없음: " + receiverId));

                        ChatRoom newRoom = new ChatRoom();
                        newRoom.setUserA(sender);
                        newRoom.setUserB(receiver);
                        return chatRoomRepository.save(newRoom);
                    });

            // 채팅 메시지를 DB에 저장
            ChatMessage saved = ChatMessage.builder()
                    .chatRoom(chatRoom)
                    .sender(sender)
                    .content(chatMessageDto.getMessage())
                    .timestamp(LocalDateTime.now())
                    .build();

            chatMessageRepository.save(saved);  // 메시지 저장
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        log.info("{} 연결 끊김", session.getId());  // WebSocket 연결이 종료되면 로그 출력
        sessions.remove(session);  // 연결된 세션 목록에서 해당 세션 제거
    }
}

 

메서드별 기능

1. afterConnectionEstablished

  • WebSocket 세션을 세션목록에 추가하여 연결된 모든 세션을 추적
  • 클라이언트에게 연결 완료 메시지를 전송

2. handleTextMessage

  • 클라이언트로부터 WebSocket을 통해 텍스트 메시지를 받았을 때 호출된다.
  • 클라이언트가 전송한 메시지를 chatMessageDto로 파싱
  • 메시지 타입(JOIN, TALK, LEAVE)에 따라 메시지 처리를 다르게 구현
  • 클라이언트 인증을 위해 JWT 토큰 인증
  • 채팅 메시지를 DB에 저장
  • 송 수신자 간의 채팅방이 있는지 확인하고 없으면 새롭게 생성

3. afterConnectionClosed

  • WebSocket 연결이 종료되면 호출되는 메서드
  • 세션을 세션 목록에서 제거

 

 

 

실행 결과

 

 

'BackEnd > spring' 카테고리의 다른 글

@Controller와 @RestController의 차이점  (0) 2025.02.23
SpringBean  (1) 2025.02.11
회원가입 로그인 구현  (0) 2024.08.19
BeanValidation-Form 전송 객체 분리  (0) 2024.08.05
Bean Validation  (0) 2024.08.01