[Socket, Spring Boot]클라이언트 요청 없이도 서버에서 원할 때 응답(response) 보내기

Yunny.Log ·2022년 10월 10일
1

나의 외주일지

목록 보기
13/16
post-thumbnail

0. 기획 Process

  • 클라이언트 - 외부프로그램 - 서버 로 연결을 진행해야 하는 프로세스가 존재합니다.
  • 서버가 외부프로그램으로부터 적절 응답을 받으면 해당 응답을 클라이언트에게 넘겨줘야 합니다.
    • 이 과정에서 "서버가 클라이언트의 요청이 없어도 응답을 줘야하는 작업" 이 필요했습니다.

  • 서버가 클라이언트의 요청이 없어도 응답을 줘야하는 작업 구현을 위해선 양방향 통신을 사용해야 한다고 판단하였습니다.
  • 이와 같은 판단을 기반으로 아래와 같은 설계를 진행하였고, 이를 코드로 구현하며 기능 구현에 성공했습니다.

1. 설정

  • 양방향 통신을 위한 수단으로 WebSocket을 선택했습니다.
  • 이후 WebSocket과 Spring Boot 연동 과정을 통해 소켓을 사용할 수 있는 환경을 구축했습니다.

2. Code

2-1) Spring Boot

2-1-1) controller 에서 cad upload service 종료된 후에 socket 켜서 cad upload service에서 생성된 반환값 socket에 전달값으로 지정합니다.

Controller

    @PostMapping("/cad")
    @ResponseStatus(HttpStatus.CREATED)
    public Response cad(
            @Valid @ModelAttribute
                    CadRequest req,
            HttpServletRequest request

    ) throws Exception {
    
    ..................................................................
    
    // 1) cad upload service 를 통해서 socket 반환값 제작 
        DesignSocketDto designSocketDto = designService.cadUpload(req);
        
    // 2) socket 에 담아줄 메시지 형태 제작 (소켓 방 아이디, 메시지 , 타입 필요)
        ChatMessage chatMessage = new ChatMessage(
                req.getRoomId(),
                Long.parseLong(req.getItemId())
        );

    // 3) SOCKET 에 담을 MESSAGE 형태로 제작
    // SOCKET 에 MSG 로 담아줄 아이는 JSON 형식이라 JSON 변환
        Gson gson = new Gson();
        String chatMsgJson = gson.toJson(chatMessage);
        TextMessage roomIdMessage = new TextMessage(chatMsgJson);
        
    // 4) handle message => socket 에게 메시지 보내는 것
        webSockChatHandler.handleMessage(
                null,
                roomIdMessage // 보낼 메시지 
        );

        return Response.success(
                designSocketDto
        );
    }

ChatMessage

  • 여기 안에는 소켓이 방을 식별할 방 번호, 만들어진 방 안에서 받을 메시지 정보가 담기게 됩니다.
  • 좀 더 고도화된 소통을 해야한다면 방 type 도 커스텀할 수 있지만 우리팀의 경우엔 단순히 응답만 보내주면 되기에 type은 추후 생략했습니다.

@Getter
@Setter
public class ChatMessage {

    // 고도화된 소통을 해야한다면 방 type 도 커스텀할 수 있지만 제 코드에선 삭제했습니다. 
    // (ex)
    // public enum MessageType {
    //     ENTER, ENTER2
    // }
    // private MessageType type; // 메시지 타입
    private String roomId; // 방 식별 번호
    private String message; // 보낼 메시지
    private Long itemId;

    public ChatMessage(String roomId, Long itemId) {
        this.roomId = roomId;
        this.itemId = itemId;
    }
}

WebSocketChatHandler - 상속받아서 커스텀을 진행합니다.

  1. 웹소켓 클라이언트로부터 채팅 메시지를 전달받아 채팅 메시지 객체로 변환합니다.
  2. 전달받은 메시지에 담긴 채팅방 Id로 발송 대상 채팅방 정보를 조회합니다.
  3. 해당 채팅방에 입장해있는 클라이언트(Websocket session)에게 타입에 따른 메시지를 발송합니다.

/**

 * 1. 웹소켓 클라이언트로부터 채팅 메시지를 전달받아 채팅 메시지 객체로 변환합니다.

 * 2. 전달받은 메시지에 담긴 채팅방 Id로 발송 대상 채팅방 정보를 조회합니다.

 * 3. 해당 채팅방에 입장해있는 
  클라이언트(Websocket session)에게 
  타입에 따른 메시지 발송 
  
**/
    @Slf4j
    @RequiredArgsConstructor
    @Component
    public class WebSockChatHandler extends TextWebSocketHandler {
        private final ObjectMapper objectMapper;
        private final ChatService chatService;
        private final NewItemRepository newItemRepository;
        private final DesignRepository designRepository;

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

            // 1) chat message 읽어옵니다. (ChatMessage 객체로 변환된 상태입니다.) 
            String payload = message.getPayload();
            ChatMessage chatMessage = 
                objectMapper.readValue(payload, ChatMessage.class);
            
            // 2) chat message 에 넘어온 room id 로 지정받은 room 을 찾습니다.
            // 즉  서버가 메시지를 보낼 채팅방을 찾는 것입니다.
            ChatRoomDto room = chatService.findRoomById
            		(chatMessage.getRoomId())
                    .orElseThrow(SocketRoomRefreshException::new);
            
            // 3) 서버는 handle temp action으로 메시지를 클라이언트에게 보냅니다.
            room.handleTempActions
            (
              session, 
              chatMessage, 
              chatService, 
              newItemRepository , 
              designRepository
            );

        }

    }

ChatRoomDto

  • handleTempActions 으로 이제 본격 내가 WebSocketChatHandler 에서 찾은 방에다가 메시지 보내는 작업을 수행합니다.
  • web socket session 은 WebSocket connection이 맺어진 세션입니다.
  • 이때 해당 session을 통해서 메시지를 보낼( sendMessage() ) 수 있지만 저는 방번호(roomId)로 소켓방 식별할 것이며 , 외부 프로그램에서 요청이 들어오면 수행하고 소켓 연결해서 세션을 어차피 사용할 수 없는 상황이기에 세션이 아닌 방 번호 기준으로 room을 식별해주었습니다.
@Getter
@Transactional
public class ChatRoomDto {

    private String roomId;
    private Set<WebSocketSession> sessions = new HashSet<>();

    @Builder
    public ChatRoomDto(String roomId) {
        this.roomId = roomId;
    }

    @Transactional
    public void handleTempActions(
    
            // web socket session 은 WebSocket connection이 맺어진 세션
            // 해당 session을 통해서 메시지를 보낼( sendMessage() ) 수 있지만
            // 저는 방번호(roomId)로 소켓방 식별할 것이며 ,
            // 외부 프로그램에서 요청이 들어오면 수행하고 소켓 연결해서
            // 세션을 어차피 사용할 수 없는 상황이기에 세션이 아닌 방 번호 기준으로 room을 식별해주었습니다.  
            @Nullable  WebSocketSession session,
            @Nullable ChatMessage chatMessage,
            ChatService chatService,
            NewItemRepository newItemRepository,
            DesignRepository designRepository) throws ParseException {

        NewItem newItem = null;


		// chat message 에 message를 지정해줍니다. 
        chatMessage.setMessage(
    
    .......................................................................
    
        );
		// 현재 서버와 연결된 클라이언트의 ws 세션에다가 메시지 보내줍니다.(push) 
        sendMessage(chatMessage, chatService);
    }
    
    /**
    * 클라이언트의 ws 세션에 메시지를 보내는 메소드입니다. 
    **/
    @Transactional
    public <T> void sendMessage(T message, ChatService chatService) {
        sessions.parallelStream().forEach(session -> chatService.sendMessage(session, message));
    }
}

2-2 ) nginx

  • 또한 nginx 추가 설정을 해주며 socket 연결 시간에 대한 설정과 upgrade 속성을 더해주었습니다.

location / {
         proxy_pass http://도메인:8180;
         # 받는 대상 서버(WAS)  
         proxy_set_header Host $http_host;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
         
         # socket - upgrade 코드 추가 
         proxy_set_header X-Forwarded-Proto $scheme;
         proxy_set_header Upgrade $http_upgrade; 
         proxy_set_header Connection "upgrade"; 
         proxy_http_version 1.1;  
         proxy_read_timeout 86400s;
         proxy_send_timeout 86400s;

     	}
    }

nginx - socket 설정 글을 토대로 코드를 추가했습니다.

  • HTTP에서 WebSocket으로 연결 전환시 HTTP의 Upgrade 및 Connection 헤더를 사용해야 합니다.
# hop-by-hop 헤더 추가 코드 
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; 
  • WebSocket을 지원할 때 리버스 프록시 서버가 직면하는 몇 가지 문제가 있습니다.

  • 하나는 WebSocket이 hop-by-hop 프로토콜이므로 프록시 서버가 클라이언트의 Upgrade 요청을 가로챌 때 적절한 헤더를 포함하여 WAS 서버에 업그레이드 요청을 보내야 한다는 것입니다.

  • 또한 HTTP의 단기 연결과 달리 WebSocket은 오래 지속되기에, 리버스 프록시는 연결을 닫지 않고 열린 상태로 유지하는 것을 허용해야 합니다.

  • 이때 Nginx는 클라이언트와 WAS 간 터널(소켓)을 설정할 수 있도록 WebSocket을 지원합니다.

  • Nginx는 클라이언트에서 WAS로 업그레이드 요청을 보내려면 Upgrade 및 Connection 헤더를 명시적으로 설정해야 하기에 위의 코드를 추가했습니다.


  • 또한 아래 코드를 추가해줌으로써 socket이 빠르게 closed 되는 상황을 방지했습니다.
         proxy_read_timeout 86400s;
         proxy_send_timeout 86400s;

3. 결과

  • 왼쪽에 노란색 화살표가 붙은 것은 클라이언트가 보내는 REQUEST 입니다.
  • 왼쪽에 하늘색 화살표가 붙은 것은 서버가 보내는 RESPONSE 입니다.
  • 하트로 표시해놓은 응답이 서버가 비동기적으로 외부 프로그램이 수행을 마친 뒤, 클라이언트에게 주는 응답입니다.
  • 이는 노란색 요청(클라이언트의 요청)이 없었음에도 외부 프로그램으로부터 RESPONSE 를 받은 이후에 클라이언트에게 비동기적으로 소켓 통신으로 응답을 발사하고 있는 것을 볼 수 있습니다.

4. 최종 Process

  • 위와 같은 방식으로 클라이언트 - exe (외부 프로그램) - 서버 간의 비동기 통신을 구현하는 방식이 완료되었습니다.
  • 우리의 비동기 통신이 이뤄지는 순서, 방법은 아래의 그림으로 최종 프로세스를 그릴 수가 있습니다.

0개의 댓글