WebSocket

PUROMANGA·2025년 5월 30일

기술스택

목록 보기
15/22

개요


처음에 웹소켓을 준비하면서 매칭은 나중에 제작하려 했습니다. 그렇지만 웹소캣의 특성상 어느 시점에 둘 사이를 구독(연결)시켜야하는 필요성을 느꼈고, 이러다보니 그 트리거가 되는 매칭을 먼저 제작할 수 밖에 없었습니다.

WebSocket

WebSocket은 기존의 단방향 http프로로콜과 호환시켜 양방향 통신을 제공하기 위해 개발된 프로토콜입니다.

Polling

예전에는 클라이언트가 일반적인 Http요청을 주기적으로 보내서 변경된 데이터를 확인하는 방식을 취했습니다.

이는 당연히 주기적으로 많은 요청을 보내게 되고, 이를 해결하기 위해 Long-Polling이 등장했습니다.

Long-Polling

이것은 일단 HTTP요청을 보내고, 서버는 상태를 유지하던 도중 클라이언트가 원하는 이벤트가 서버에 발생하면 그 때 응답 메세지를 보냅니다.

그 후 다시 클라이언트는 서버에 요청을 보내게 되고 다음 이벤트를 기다리게 됩니다.

그러나 마찬가지로 서버에 부담을 주는 방식인 건 다름이 없었습니다.

SSE(Server-Sent Event)

SSE(Server-Sent Event)는 클라이언트가 서버로 요청을 보내면 서버는 요청을 끊지 않고 계속 유지합니다. 이렇게 되면 서버는 이벤트 발생에 변경이 필요한 데이터만 계속 응답하면 됩니다.

그러나 실시간 통신이라고 부르기에는 무리가 있고, 이를 타파하기 위해 Websockt이 등장하게 되었습니다.

WebSocket

동작과정

클라이언트 → HTTP프로토콜로 Handshake요청(HTTP1.1)

const socket = new SockJS("/ws");
const stompClient = Stomp.over(socket);

stompClient.connect({}, function(frame) {
  console.log("Connected: " + frame);
});

이에 대해 서버는 Get/ws요청을 받게 되고, WebSocket 프로토콜로 전환하게 됩니다.

이게 바로 Handshake입니다.

STOMP

STOMP는 Simple Text Oriented Message Protocol의 약자로, 메시지 전송을 효율적으로 하기 위한 프로토콜입니다.

STOMP는 PUB/SUB 구조로 동작하고, 메세지를 공급하는 주체와 소비하는 주체를 분리하여 메세징할 수 있습니다.

@MessageMapping

클라이언트가 pub으로 보낸 메세지를 처리합니다.

ex)

	@MessageMapping("/chats/{chatId}")
	public void sendMessage(
		@Validated RequestMessageDto requestMessageDto,
		@DestinationVariable Long chatId,
		@AuthenticationPrincipal Member member) {
		chatService.sendChatMessage(requestMessageDto, chatId, member.getEmail());
	}

클라이언트가 /chats/{chatId}여기로 보내면, 서버에는 @MessageMapping("/chats/{chatId}")가 매칭됩니다.

또한 MessageMapping은 @PathVaribale을 사용할 수 없고 @RequestBody역시 사용할 수 없습니다.

	public void sendChatMessage(RequestMessageDto requestMessageDto, Long chatId, String email) {
		Boolean isRead = false;
		simpMessagingTemplate.convertAndSend("/sub/chats/" + chatId, requestMessageDto);
		ChatRoom chatRoom = chatRoomRepository.findById(chatId).orElseThrow(() -> new RuntimeException("채팅방이 없습니다."));
		Member me = memberRepository.findByEmail(email).orElseThrow(() -> new CustomRuntimeException(ExceptionCode.USER_CANT_FIND));

		Member opponent = chatRoom.checkMember(me);
		Message message = new Message(chatRoom, me, opponent, isRead, requestMessageDto);
		messageRepository.save(message);
	}

따라서 우리는 sub을 붙여서 그 메세지와 해당 채팅방을 찾아서 전송합니다.

이 기술이 바로 convertAndSend입니다.

이후 messageRepository에 저장됩니다.

@SendToUser

특정 유저의 개인 큐에 메세지를 보냅니다.

@MessageMapping("/hello")
@SendToUser("/queue/reply")
public String replyToSender(MessageDto msg) {
    return "Hi, " + msg.getName();
}

클라이언트가 pub/hello로 보내면, 다른 클라이언트 중 queue/reply를 구독하고 있는 클라이언트에게 수신합니다.

convertAndSendToUser

프로그래밍적으로 특정 유저에게만 메세지를 전송합니다.

WebSocketConfig

@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker

public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

	private final StompHandler stompHandler;

	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/ws")
			.setAllowedOriginPatterns("*")
			.withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.enableSimpleBroker("/sub");
		registry.setApplicationDestinationPrefixes("/pub");
	}

	@Override
	public void configureClientInboundChannel(ChannelRegistration channelRegistration) {
		channelRegistration.interceptors(stompHandler);
	}
}

registerStompEndpoints

프론트에서 handshake 연결용 endpoint를 등록하는 역할을 합니다.

ws로 연결을 했을 때 WebSocket handshake 수행합니다. 현재는 모든 도메인을 허용받고 있는데 보안적으로는 제한하는 게 좋습니다.

configureMessageBroker

sub은 서버 → 클라이언트로 보내는 경로입니다.

pub은 클라이언트 → 서버로 보내는 경로입니다.

이를 url의 경로로 설정하는 역할을 하고 있습니다.

configureClientInboundChannel

들어온 정보를 필터에서 처리하기 전 stompHandler에서 처리하게끔 해줍니다.

HttpHandshakeInterceptor

@Slf4j
public class HttpHandshakeInterceptor implements HandshakeInterceptor {

	@Override
	public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
		Map<String, Object> attributes) throws Exception {

		if(request instanceof ServletServerHttpRequest servletServerHttpRequest) {
			HttpServletRequest httpServletRequest = (HttpServletRequest) servletServerHttpRequest;

			String authHeader = httpServletRequest.getHeader("Authorization");

			if(authHeader != null && authHeader.startsWith("Bearer ")) {
				String token = authHeader.substring(7);

				//if절로 JWTPROVIDER의 VALIDATETOKEN을 이용해서 TOKEN이 정상적인지 확인
				// 이후 위에 ATTRIBUTE을 사용해서 WEBSOCKERT세션에 저장

			} else {
				throw new RuntimeException("헤더 정보가 없습니다");
			}
		}
		return false;
	}

	@Override
	public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
		Exception exception) {

		if(request instanceof ServletServerHttpRequest servletServerHttpRequest) {
			String ip = servletServerHttpRequest.getServletRequest().getRemoteAddr();
			String userAgent = servletServerHttpRequest.getServletRequest().getHeader("User-Agent");

			log.info("WebSocket 연결됨 - IP: {}, UA: {}", ip, userAgent);
		}
	}
}

실제 WebSocketHandler로 들어가기 전에 실행되는 인터셉터입니다.

before은 HandShake이전에 시행됩니다.

ServletServerHttpRequest이 파라미터로 들어온 만큼 request를 HttpServcletRequest로 만들면 Header에 접근할 수가 있어 jwt를 받아올 수 있습니다.

따라서 토큰을 빼앗아서 Websocket의 세션에 저장하고 필요할 때 꺼내서 사용할 수 있습니다.

After은 HandSake이후에 시행됩니다.

넣어줄 게 없어서 Ip와 user의 정보를 넣어주었습니다.

StompHandler

@Component
@RequiredArgsConstructor
@Slf4j

public class StompHandler implements ChannelInterceptor {

	// private final JwtTokenProvider jwtTokenProvider;
	private final ChatService chatService;
	private final ChatRoomRepository chatRoomRepository;
	private final RedisTemplate<String, Object> redisTemplate;

	@Override
	public Message<?> preSend(Message<?> message, MessageChannel channel) {
		StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);

		if(StompCommand.CONNECT == accessor.getCommand()) {
			String authToken = accessor.getFirstNativeHeader("Authorization");
			String token = authToken.substring(7);

			// if(!jwtTokenProvider.validateToken(token)) {
			// 	throw new IllegalArgumentException("유효하지 않은 토큰입니다.");
			// }
			//
			// String username = jwtTokenProvider.getUserNameFromToken(token);
			// accessor.setUser(() -> username);

		} else if(StompCommand.SUBSCRIBE == accessor.getCommand()) {

			String destination = accessor.getDestination();

			//세션아이디
			String sessionId = accessor.getSessionId();

			//방 아이디
			Long roomId = chatService.submitRoomId(destination);
			ChatRoom chatRoom = chatRoomRepository.findById(roomId).orElseThrow(() -> new RuntimeException("채팅방이 존재하지 않습니다."));

			//Redis-key
			String key = "entryChatRoom : " + sessionId + ":" + roomId;
			String value = sessionId + ":" + roomId;
			//세션과 방 아이디를 저장
			redisTemplate.opsForSet().add(key, value);

			//입장 메세지 전송
			com.example.luvisluvproject.domain.chat.entity.Message entryMessage = com.example.luvisluvproject.domain.chat.entity.Message.builder()
				.messageType(MessageType.ENTER)
				.content("욕설은 제제 대상이 될 수 있습니다. 매너를 지켜서 대화해주세요.")
				.chatRoom(chatRoom)
				.senderId(0L)
				.build();

			chatService.sendChatEnterMessage(entryMessage);

		} else if (StompCommand.DISCONNECT == accessor.getCommand()) {
			// Websocket 연결 종료

			String destination = accessor.getDestination();

			//세션아이디
			String sessionId = accessor.getSessionId();

			//방 아이디
			Long roomId = chatService.submitRoomId(destination);

			String key = "entryChatRoom : " + sessionId + ":" + roomId;
			redisTemplate.delete(key);
		}
		return message;
	}
}

🔁 실제 흐름 순서

  1. 클라이언트가 WebSocket 연결 시도
const socket = new SockJS("/ws");
const stompClient = Stomp.over(socket);
stompClient.connect(headers, onConnected);
  1. 서버는 CONNECT 요청 수락 후 WebSocket 세션 생성

*CONNECT*은 WebSocket 연결 직후 STOMP 프로토콜의 CONNECT 프레임이 도착했을 때 어떤 행동을 할지 알려주는 겁니다.

  1. 프론트가 특정 채팅방 구독 시작
stompClient.subscribe("/sub/chats/123", callback);

SUBSCRIBE은 두 사람이 구독을 시작했을 때 알려주는 공통된 로직입니다. 여기서 구독이 시작됐을 때, 라는 건 저희로 따지면 채팅방이 만들어졌을 때 프론트엔드에서 두 사람을 연결하게 하고 이때 구독이 시작됨으로 채팅방 메세지가 발송되게 됩니다. 이후 캐시에 또 연결을 하지 못하게끔 SET구조로 넣어주고 관리하게 됩니다.

  1. 이후 두 사람의 연결이 끊어지게 되면

DISCONNECT은 두 사람이 구독을 해지했을 때 알려주는 공통된 로직입니다. 캐시에서 삭제합니다.

따라서 채팅방을 먼저 만들어줘야할 필요가 있었고, 그를 위해서 매칭을 먼저 완성시킬 필요가 있었습니다.

0개의 댓글