본문 바로가기

프로젝트/오디

💭프로젝트 '오디' 핵심 기능 리팩터링 상상일지 : 웹 소켓 전환 시나리오

 

안녕하세요 브로콜리입니다.

 

오늘 이야기할 글의 소재는 일전에 포스팅하였던 오디 프로젝트의 실시간 친구 도착예정정보 공유 기능 구현에서 이어집니다.

 

https://hellobrocolli.tistory.com/186

 

프로젝트 `오디` 핵심 기능 구현 일지 : 실시간 친구 도착 예정정보 공유 기능

언제, 어디서, 무엇을 하든 지각하지 않게 도와주는 서비스 `프로젝트 오디`에서는3차 스프린트 목표로 실시간 친구 도착 여부를 알 수 있는 기능을 핵심 기능으로 삼았습니다. 프로젝트 오디

hellobrocolli.tistory.com

 

지난 글에서는 오디 서비스의 핵심 기능인 친구의 도착 예정정보를 실시간 위치를 기반으로 알려주는 기능을 구현했던 과정과 리팩터링까지 소개해드렸는데요. 다만 폴링 방식의 경우 계속 커넥션을 연결하고 서버에 부하가 집중되는 방법인만큼 웹 소켓 통신으로 전환해보면 어떨까 하는 아쉬움이 있었습니다.

 

이에 따라 저희 팀이 마주한 문제와 더 나은 해결책이 나와 직접 적용되지는 못했지만 코드 구현까지 해보며 대안으로 나왔었던 socket 통신 + STOMP를 활용한 리팩터링 시나리오에 대해 이야기해보고자 합니다.


🤯 Situation : 안드로이드의 스케쥴링 오버 문제

4차 데모데이 날 테스트를 위해 약속을 만들던 중에 한 가지 문제를 발견했습니다. 바로, 약속이 많아지면 ETA 상태 api를 안드로이드가 호출하지 않는 문제였는데요. 

 

우리는 폴링을 통해 정보의 실시간 성을 보장하려 했기에 안드로이드 측에서 동적으로 api 호출을 예약하는 과정이 필요했습니다. 따라서 약속을 만들고 참여하는 순간 기기 사용자 휴대폰에서 약속 30분 전부터 10초 간격으로 api 호출을 예약했습니다. 즉 약속 하나당 기기가 180건의 api 호출을 하도록 스케쥴링을 해놓은 것입니다.

 

 

 

그런데 test 용 약속이 점점 많아지기 시작하면서 한 기기에서 예약 가능한 큐가 가득찼고 그 결과 몇가지 API 호출 예약이 씹히기 시작했습니다. 기기에서 예약가능한 건수를 넘어서 예약을 하기 시작하니 요청을 해야 서버에서 응답을 줄 수 있는데 요청 자체를 하지 않아 약속 참여원들이 보는 화면이 달라지거나 업데이트가 느려지는 등, 실시간 도착 예정 정보 공유라고 볼 수 없는 상황들이 발생하기 시작한 것입니다.

 

예를 들어  유저 피드백을 받기 위해 진행한 패널조사에서는 이런 피드백들이 오고가기도 했습니다.

 

그래서 안드로이드 측과 협의해 방법을 찾고자 하였고, 그 중 하나의 대안으로 나온 것이 예약 부담을 서버에서 질 수 있는 웹 소켓으로의 전환입니다.

 


↩️ Task :  예약 부담을 서버측으로 가져오기

 

문제의 원인은 안드로이드가 과도한 스케쥴링 부담을 질 수 없다는 것이었습니다.  그럼 이 원인을 어떻게 해결할 수 있을까요? 팀원들과 함께 논의한 결과 3가지 정도의 해결책이 있었습니다.

- 1. 안드로이드 측에서 폴링 간격을 10초보다 더 늘려서 예약량을 줄인다
- 2. 안드로이드가 한번에 180개가 아니라 10분 간격으로 60개씩 예약하도록 로직을 고친다
- 3. 스케쥴링 부담을 서버로 이전하여 서버가 위치를 달라는 요청을 보낸다.

 

첫번째 대안은 유저 경험 상 10초 이상의 간격이 부자연스러울 것 같다는 데 의견이 맞추어졌습니다. 두번째 대안은 시도해보았으나 어느 정도 임시책에 불가할 뿐, 결국 한 사람이 생성하는 약속이 많아진다면 똑같은 문제가 발생할 위험이 있었습니다.

 

따라서 우선 2번 대안을 시도해보고 3번 대안으로 장기적인 리팩터링을 시도하는 방향으로 의견이 모아졌습니다. 즉 API 호출에 대해 클라이언트가 요청을 보내는 것이 아니라 클라이언트는 각 요청별로 이벤트 트리거 형식의 함수를 만들어두고 서버가 요청을 보내 그 트리거를 당기는 식으로 부담을 이전하고자 했습니다.


💪 Action : 웹 소켓 + STOMP를 이용한 리팩터링 시나리오

 

그리고 그 3번 대안을 구체화한 것이 바로 웹 소켓 + STOMP를 이용한 통신이었습니다.

 

그럼 어떤 사고과정을 통해 웹 소켓이라는 해결책을 떠올렸는지 설명해보겠습니다.

 

1. 웹 소켓이 왜 HTTP의 단점을 극복할 수 있는가?

앞선 polling 방식의 통신에서는 요청에 대한 책임을 온전히 client가 지고 있습니다. 따라서 server는 단순히 요청에 응답을 하는 주체로서의 역할만을 담당했는데요. 이는 요청이 오지 않으면 서버가 할 수 있는 일은 아무것도 없다는 걸 의미하기도 합니다. 현재 겪고 잇는 문제는 안드로이드 측의 예약 호출 부담이 커지면서 요청이 오지 않으면 아무런 일도 할 수 없는 HTTP 프로토콜의 일방향적 통신 방식이 큰 걸림돌이 되었습니다.

 

그러나 웹소켓은 양방향 통신이 가능합니다.

 

즉, 클라이언트가 한번 웹소켓 프로토콜로 전환을 요구한 이후, 커넥션을 계속 들고 있으면서 연결이 끊어질 때까지 양방향 통신을 가능하게 하는데요. 바로 이러한 웹소켓의 양방향성이 우리 팀이 안드로이드 측의 요청 부담을 서버로 이전할 수 있다고 생각했던 부분이었습니다.

 


2. STOMP가 우리의 문제를 어떻게 해결해줄 수 있는가?

이렇게 웹 소켓을 도입하기로 결정하고 페어인 조조와 협의하던 중 메시지 전송에 특화되어 있는 STOMP가 웹소켓 + SpringBoot 환경일 때 굉장히 강력하다는 사실을 알게 되었습니다.

 

이번 글에서는 Stomp의 자세한 내용보다 문제를 해결해 나아가는 과정을 이해할 정도의 간단한 지식들만 서술해보려 합니다. 자세한 STOMP에 대한 설명은 Spring의 STOMP docs를 첨부하도록 하겠습니다.

 

1️⃣ Stomp는 Frame 기반으로 소통한다.

 

RFC2616을 기반으로 Http의 통신에서 주고받는 데이터를 살펴보면 대게 다음과 같습니다.

 

출처 : https://kim-mj.tistory.com/240

 

 

Stomp도 Http를 기반으로 모델링되었기 때문에 비슷한 데이터 형식을 주고 받는데요. 그러나, Http에 있는 모든 정보를 주고 받기에는 너무 무거워 Frame이라는 단위로 소통하게 됩니다.

 

Fame은 이렇게 생겼습니다.

COMMAND
header1:value1
header2:value2

Body^@

 

클라이언트는 SEND, SUBSCRIBE 등의 메시지 명령을 COMMAND에 담습니다.

그리고 어디로 보낼 것인가?(destination) 등의 부가 정보를 header에 담습니다.

 

이외의 메시지는 body에 담아 보내주게됩니다.

 

2️⃣ Stomp는 Pub/Sub 구조를 기반으로 한 메시지 소통이 가능하다

Pub/Sub 구조는 우리가 유투브에서 보는 구조 그 자체입니다.

컨텐츠를 발행하는 Publisher가 있고, 그 컨텐츠를 구독하는 Subscriber가 있습니다.

 

이를 기반으로 메시지 전달 과정을 설명하면 다음과 같습니다.

 

각 Subscriber가 다음과 같은 구독을 했다고 가정해봅시다.

 

각 Publisher는 Broadcasting을 할 수 있는데 이는 특정 채널을 구독한 모든 Subscriber에게 메시지를 전달하는 것을 의미합니다. 예를 들어 위의 예시에서 각 Publisher가 메시지를 Broadcasting을 한다고 가정한다면

 

 

Publisher1을 구독한 Subscriber1과 Subscriber2는 BRO라는 메시지를 전달받게 되고,

Publisher2를 구독한 Subscriber2와 Subscriber3는 CCOLI라는 메시지를 전달받게 되는 것입니다.

 

STOMP가 WebSocket을 기반으로 함께 운용될 때 강력한 이유는 위의 Pub/Sub 구조와도 연관이 있습니다. BroadCasting 과정에서 구독을 한 Subscriber는 본인들이 요청을 하지 않았음에도 Message를 전달받게 됩니다. 즉 양방향 통신의 이점을 잘 살려 실시간 정보 교환을 보장하는 구조인 것입니다.

 

3️⃣ Stomp는 Spring Boot의 Controller 구조를 유지하며 구조 설계가 가능하다

Stomp를 Spring Boot와 함께 쓰면 좋은 점은 기존 controller의 구조를 거의 그대로 유지하면서 메시지 처리 로직을 수행할 수 있기 때문입니다.

 

그럼 어떻게 그러한 구조가 가능할까요? 이는 STOMP의 기본 통신 방식을 이해하면 가능합니다.

 

여러 메시지 요청이 오면 Requset Channel은 요청이 온 URL에 따라 메시지를 전달할 MessageHandler를 탐색하게 됩니다. 여기서 개발자는 설정에 따라 이 요청을 어떤 메시지 브로커에게 전달할 것인지 라우팅 방향을 나눌 수 있는데요. 여기서 요청을 어디로 라우팅할 것인지 정한다는 것은 우리가 작성한 MessageHandler에게 전달할 api prefix와 Stomp에서 기본적으로 주어지는 MessageHandler에게 처리를 위임할 api prefix를 나눈다는 의미와도 동일합니다. 

 

즉, 위의 예시에서는 /app으로 요청이 오면 우리가 직접 작성한 MessageHandler에게 처리를 라우팅하고, /topic으로 시작하는 api 요청이 오면 바로 stomp에서 제공하는 StompBrokerRelay로 처리를 위임한다는 것을 의미합니다.

 

그러면 Stomp 프로토콜에서 개발자가 개발해주어야 할 부분은 어디로 한정될까요?

맞습니다. /app으로 요청이 왔을 때 endpoint가 되는 controller만 구현해주면 사실 상 기존 spring에서 사용하는 layered architecture와 전혀 다를 바 없는 구조에서 웹 소켓 로직을 처리할 수 있게 되는 것입니다.

 

이를 위한 설정도 굉장히 간단합니다.

 

공식문서를 기반으로 따라가보면 

 

- step1. 기존 messagehandler를 결정할 endpoint를 설정합니다.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/portfolio"); 
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry config) {
		config.setApplicationDestinationPrefixes("/app"); 
		config.enableSimpleBroker("/topic", "/queue"); 
	}
}

 

여기서 enalbeSimpleBroker에 설정한 "/topic", 과 "/queue" 의 경우 구독이나 브로드 캐스팅 처럼 기존 simplebroker에게 바로 위임합니다. 그와 달리 /app으로 시작되는 요청의 경우 @Controller 클래스에 @MessageMapping을 통해 구현한 MessageHandler를 통해 우리가 정의한 로직을 선수적으로 처리하도록 합니다. 자세한 MessageHandler의 구현체는 바로 밑에서 설명하도록 하겠습니다.


 

- step2. message 가공이 필요하다면 해당 endpoint를 매핑할 controller를 만듭니다.

@Controller
public class GreetingController {

	@MessageMapping("/greeting")
	public String handle(String greeting) {
		return "[" + getTimestamp() + ": " + greeting;
	}
}

 

 

예를 들어 위와 같이 GreetingController가 정의되어 있다면 /app/greeting에 해당하는 요청에 대하여 GreetingController의 handle을 endpoint로 message를 매핑할 수 있도록 해줍니다.


- step3. message를 전달할 목적지를 정한다.

 

그럼 메시지를 보내는 것은 어떻게 할까요? @SendToUser를 통해 요청을 준 user session을 대상으로 하거나, @SendTo를 통해 특정 주제를 대상으로 broadcast 하는 것이 가능합니다.

@Controller
public class GreetingController {

    @MessageMapping("/greeting")
    @SendToUser("/queue/greeting", broadcast = false)
    public String handle(String greeting) {
        return "[" + getTimestamp() + ": " + greeting;
    }
    
    @MessageMapping("/greeting2")
    @SendTo("/topic/greeting2")
    public String handle2(String greeting) {
        return "[" + getTimestamp() + ": " + greeting;
    }
}

 

- @SendToUser(destination) : 요청을 보낸 user에게 응답

위의 controller에서 handle은 /app/greeting 이란 요청이 들어오면 타임스탬프에 대한 메시지를 /queue/greeting을 통해 요청을 보낸 user를 특정하여 메시지를 보냅니다. broadcast 옵션을 켜면 /queue/greeting 이란 주제를 구독한 모든 user들에게 같은 메시지를 보낼 수 있습니다.

 

- @SendTo(topic) : 구독자들에게 broadcast

위와 같은 controller에서 handle2은 /app/greeting2 란 요청이 들어오면 타임스탬프에 대한 메시지를 topic/greeting2를 구독한 모든 user들에게 발송합니다.

 


🤔 3. 웹 소켓 통신 흐름 생각하기

 

그럼 10초마다 서로의 위치정보를 갱신했던 폴링을 어떤 로직을 통해 웹소켓으로 전환할 수 있을까요? 저희가 함께 생각해낸 통신 흐름은 다음과 같은 단계를 따르고 있습니다.

 

 

 

step1. 주제 구독

웹소켓 전환 후 클라이언트 측에서 3가지 토픽에 주제를 구독합니다

 

실시간 약속 구성원들의 위치정보를 받을 채널 : /topic/etas/{meetingId}

서버가 위치 정보를 요구할 트리거 채널 : /topic/coordinates/{meetingId}

서버가 연결 종료를 요구할 트리거 채널 : /topic/disconnect/{meetingId}

 

이 토픽들은 약속 모임원 모두가 하나의 채널을 구독하게 됩니다.

 

step2. 30분간 양방향 통신

- 약속 30분 전이 되면 서버가 클라이언트에게 위치정보를 달라고 요구합니다

- 클라이언트는 서버의 요청을 받아 각 디바이스의 좌표를 보내주게 됩니다.

- 서버는 위치정보를 기반으로 ETA목록을 갱신하여 반환하고 10초 뒤 같은 작업을 서버가 스케쥴링 합니다. 바로 이 포인트가 지금 겪고 있는 안드로이드 측의 스케쥴링 부담을 서버측으로 이전하는 과정이 됩니다.

자세히 보면 요청을 서버가 먼저 하여 클라이언트는 이벤트 리스터 형식의 트리거만 만들어두면 되는 형태로 로직이 바뀌었습니다. 스케쥴링에 대한 부담도 서버가 가져오게되어 안드로이드는 더이상 스케쥴링의 부담을 떠안지 않아도 되는 구조로 변경되었습니다.

 

step3. 연결 종료

 

- 약속 시간이 지나면 서버가 소켓 통신의 종료를 약속 모임원들에게 /topic/disconnect/{meetingId} 채널을 통해 알립니다.

- 클라이언트는 전달받은 disconnect 요청에 따라 disconnect로 소켓 통신을 종료합니다.


👨‍💻 4. 웹 소켓 로직 구현하기

 

위의 로직을 옮기면 다음과 같은 EtaSocketController가 완성됩니다.

@Slf4j
@RestController
@RequiredArgsConstructor
public class EtaSocketController {

    private final EtaSocketService etaSocketService;

    @MessageMapping("/open/{meetingId}")
    public void open(@DestinationVariable Long meetingId) {
        log.info("--- websocket open ! - {}", meetingId);
        etaSocketService.open(meetingId);
    }

    @MessageMapping("/etas/{meetingId}") // /publish/etas/{meetingId}로 오는 요청은 여기로 매핑해줘!
    @SendTo("/topic/etas/{meetingId}") // /topic/etas/{meetingId} 구독자들에게 eta 정보를 발송해줘
    public MateEtaResponsesV2 etaUpdate(
            @DestinationVariable Long meetingId,
            @WebSocketAuthMember Member member,
            @Payload MateEtaRequest etaRequest
    ) {
        log.info("--- etaUpdate 호출 ! - {}, {}, {}", meetingId, member, etaRequest);
        return etaSocketService.etaUpdate(meetingId, member, etaRequest);
    }
}

 

그럼 10초 뒤에 같은 etaUpdate를 반복하는 스케쥴링 작업은 어떤 방식으로 서버가 가져오게 되었을까요?

EtaSocketService의 etaUpdate 메서드를 보면 알 수 있습니다.

 

public class EtaSocketService {

    .... 중략 ...
    
    public synchronized MateEtaResponsesV2 etaUpdate(Long meetingId, Member member, MateEtaRequest etaRequest) {
        
        // 약속이 지났는지 확인 -> 지났다면 disconnect 트리거 당기기
        if (isOverMeetingTime(meetingId)) {
            socketMessageSender.sendMessage(WebSocketTrigger.DISCONNECT.trigger(meetingId));
        } 
        
        // 스케쥴링 해야 하는가? -> 10초 뒤 coordinates 채널로 위치달라는 요청 예약 하기
        if (isTimeToSchedule(meetingId)) {
            reserveLocationTrigger(meetingId, LOCATION_TRIGGER_CALL_MINUTE_GAP);
        }
        
        // 위치정보 업데이트 하여 반환
        return mateService.findAllMateEtas(etaRequest, meetingId, member);
    }
}

 

 

약속이 지났다면 소켓 종료 트리거를 당깁니다.

약속이 종료되지 않고 10초 뒤에 예약을 해야하는 상황이라면 10초 뒤에 좌표를 달라는 요청을 예약합니다.

현재 받은 정보를 바탕으로 위치정보를 업데이트하여 반환합니다.

 

즉, 위에 이야기했던 안드로이드 측의 예약 부담을 서버측에서 가져옴으로써 문제를 해결할 수 있게 된 것입니다.

 


👨‍💻  5. 테스트 작성하기

그러나, 모든 기능 구현의 끝은 테스트를 통한 확신까지 이어져야 하는 것이겠죠. STOMP 테스트를 작성해보기로 했습니다. 그러나, 비동기성 테스트를 처음 작성해보기도 하고 정말 막막하더군요. 또 구글링을 통해 얻을 수 있던 reference 들은 모두 웹 소켓 테스트 툴인 apic이나 postman을 통해 직접 요청을 보내보고 답을 확인하는 방식의 테스트라 마음에 들지 않았습니다. 어떤 환경이던 자동화하여 기능을 검증할 수 있는 코드레벨의 테스트를 작성해보고 싶었습니다.

 

그러던 중 yearnlune님 github에서 코드로 작성된 stomp test 레퍼런스를 발견하게되었고 이를 기반으로 테스트를 작성해보고자 하였습니다.

 

그럼 본격적인 테스트 설명에 앞서 우리가 검증해야 할 것들을 생각해봅시다. 기본적으로 웹소켓을 테스트하겠다는 것은 클라이언트가 특정 endpoint로 요청을 했을 때, 1) 의도한 메서드가 호출되는가(행위 검증) 와 2) 의도한 결과가 반환되는가(상태 검증)으로 나뉠 수 있습니다.

 

그렇다면 정확한 테스트를 위해서는 RestAssured 처럼 websocket 요청을 만들고 보내줄 수 있는 client를 만들 필요가 있습니다. 또한, 그 클라이언트가 받은 서버에서 반환하는 결과값을 확인할 수도 있어야 합니다. 이를 위해 우리는 두 가지 객체 세팅을 해주어야 합니다.

 

5-1) 테스트를 위한 객체 만들기

 

첫째, WebSocketStompClient : 웹 소켓 요청을 만들어주고 메시징 처리

WebSocketStompClient는 웹 소켓 서버의 연결과 STOMP 프로토콜을 사용한 메시지 전송 및 구독 등 RestAssured에서 특정 url로 Http request를 만들어주는 것과 비슷하게 STOMP 프로토콜 내에 client 측에서 메시지를 전송하는 시나리오를 테스트할 수 있습니다.

 

그렇기에 우선 다음과 같은 BaseStompTest를 만들어 웹소켓 테스트의 환경을 세팅해주었습니다.

 

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class BaseStompTest {

    private static final String ENDPOINT = "/connect"; //소켓을 연결하는 endpoint

    protected StompSession stompSession;

    @LocalServerPort
    private int port;

    private final String url;

    private final WebSocketStompClient websocketClient;

    public BaseStompTest() {
        this.websocketClient = new WebSocketStompClient(new StandardWebSocketClient());
        this.websocketClient.setMessageConverter(new MappingJackson2MessageConverter());
        this.url = "ws://localhost:";
    }

    // 테스트를 시작하기 전에 여려 있는 소켓에 연결한다.
    @BeforeEach
    public void connect() throws ExecutionException, InterruptedException, TimeoutException {
        this.stompSession = this.websocketClient
                .connect(url + port + ENDPOINT, new StompSessionHandlerAdapter() {}) 
                .get(3, TimeUnit.SECONDS); //3초간 연결 시도 후, 실패 시 에러 발생
    }
    
    // 테스트가 끝난 이후 웹 소켓 연결이 유지중이라면 종료한다.
    @AfterEach
    public void disconnect() {
        if (this.stompSession.isConnected()) {
            this.stompSession.disconnect();
        }
    }
}

 

여기서 webScoketClient는 매 테스트 시작 전에 "ws://localhost:port번호/connect"로 소켓 통신을 연결합니다. 여기서 StompSessionHandlerAdapter는 stomp 메시지를 수신하거나 연결 상태를 관리하는 데 필요한 메서드를 제공하는 디폴트 클래스입니다.

 

연결이 성공하면 stomSession 변수에 STOMP 세션 정보가 저장됩니다. 이후 테스트에서는 stompSession.subscribe(), stompSession.send() 처럼 메시징 관련 처리를 할 수 있습니다.

 

둘째, StompFrameHandler : client에서 받은 결과값을 확인하기 위해

 

다음으로 client가 받은 결과값을 확인하기 위해서 STOMP 메시지 프레임을 처리하는 StompFrameHandler를 커스터마이즈해주어야 합니다. StompFrameHandler 인터페이스를 구현하면 되는데요. 직접 클래스를 보면서 설명해보겠습니다.

 

@Slf4j
public class MessageFrameHandler<T> implements StompFrameHandler {

    // 비동기 작업의 결과를 저장하는 객체
    private final CompletableFuture<T> completableFuture = new CompletableFuture<>();

    private final Class<T> tClass;

    public MessageFrameHandler(Class<T> tClass) {
        this.tClass = tClass;
    }
    
    @Override
    public void handleFrame(StompHeaders headers, Object payload) {
        if (completableFuture.complete((T) payload)) {
        }
    }

    @Override
    public Type getPayloadType(StompHeaders headers) {
        return this.tClass;
    }

    public CompletableFuture<T> getCompletableFuture() {
        return completableFuture;
    }
}

 

여기서 중요한 점은 CompletableFuture의 개념과 오버라이드한 handleFrame 메서드입니다. 여기에서 CompletableFuture는 비동기 작업의 결과를 저장하기 위한 목적으로 선언되어 있습니다. 그럼 어떤 비동기 작업의 결과를 저장할까요? 

 

handleFrame을 보면 StompHeaders와 payload를 파라미터로 받습니다. 즉 상대로부터 수신한 STOMP 메시지를 수신하여 그 결과값을 CompletableFuture에 complete로 저장할 때 비동기 작업의 완료를 알리게 되며 성공적으로 완료되면 true를 반환합니다.

 

즉, 비동기 통신인 웹소켓의 특성에 알맞게 서버로부터 결과가 전해질 때까지 메시지를 수신하는 CompletableFuture는 대기하게 되고 메시지가 수신되어 결과를 저장하는 순간 메시지를 반환받을 수 있게 됩니다. 서버에서 전해주는 응답값을 받을 때까지 대기하게 되므로 클라이언트가 메시지를 받는 시점(서버 측에서 확실히 작업이 끝난 시점)을 알 수 있게 된 것입니다.

 


5-2) 테스트 작성하기

이제 테스트에 대한 준비는 모두 끝났습니다. 본격적인 테스트 작성으로 들어가봅시다.

 

1️⃣  최초 open 요청 후 10초 뒤 좌표 요청이 client 측에 오는가?

 

처음으로 테스트할 부분은 클라이언트가 open 요청을 주었을 때 서버가 10초 뒤에 위치 호출 함수를 예약하여 요청까지 오는지를 테스트하는 것입니다. 

 

우선 웹 소켓의 요청-응답이 잘 작동하는지를 위해 하위 service는 모킹하여 응답을 stubbing하여 주었습니다.

그럼 다음과 같은 테스트 코드를 작성할 수 있습니다.

@DisplayName("오픈 동시 요청에 대해 위치 호출 함수가 예약된다")
@Test
void callEtaMethodWhenStartConnection() throws InterruptedException {
    Mockito.when(timeCache.exists(anyLong())).thenReturn(false);
    Mockito.when(meetingService.findById(anyLong())).thenReturn(Fixture.ODY_MEETING);

    handler = new MessageFrameHandler<>(Object.class);
    stompSession.subscribe("/topic/coordinates/1", handler); //coordinates/1 주제 구독
    Thread.sleep(1000); //비동기 작업이라 구독될 때까지 기다리는 시간

    stompSession.send("/publish/open/1", ""); //open 요청과 동시에 서버는 1초 뒤 coordinates/1 구독자들에게 위치 요청 함수를 호출함

    CompletableFuture<Object> completableFuture = handler.getCompletableFuture();
    assertThatCode(() -> completableFuture.get(10, TimeUnit.SECONDS)) //10초 안에 메시지를 전달받았는지 검증, 못받을시 TimeoutException
            .doesNotThrowAnyException();
}

 

위의 모킹된 세팅은 서비스 로직이라 제하고 핵심적인 웹 소켓 테스트 로직에만 집중하면 이렇습니다.

1) 클라이언트 측 stomSession이 /coordinates/1 주제를 구독하고 이때 커스터마이즈한 messageHandler를 지정

2) 클라이언트에서 /open/1로 요청을 보냄

3) completableFuture에서 10초 이내에 메시지를 전달받을 경우 complete가 호출되면서 테스트 성공, 받지 못할 경우 TimeoutException이 나면서 테스트 실패

 

특히, 여기서 subscribe요청과 함께 handler를 넘겨주는 것은 커스터마이즈한 MessageHandler를 사용하여 주제를 구독하고 이 주제로부터 수신된 메시지를 우리가 커스터마이즈한 handleFrame으로 다루겠다는 의미를 지닙니다.

 

 

2️⃣ 도착 예정정보를 주제 구독자들에게 보내주는가?

 

두번째로 테스트하고 싶었던 부분은 약속 시간 내에 반복되는 호출함수입니다. 흐름에 따르면 약속 참여자 전원에게 10초 간격으로 모두 동일한 정보를 보내주게 되는데요. 과연 구독자들이 정말 이 정보를 받게 되는지 궁금해졌습니다.

void subscribe() throws ExecutionException, InterruptedException, TimeoutException {
    
    // given - step1 : 도착 예정 정보 요청이 왔을 대 응답을 stubbing
    MateEtaResponsesV2 stubResponse = new MateEtaResponsesV2(100L, List.of());
    Mockito.when(mateService.findAllMateEtas(any(), any(), any()))
                .thenReturn(stubResponse); 
    
    // given - step2 : 도착 예정정보를 응답으로 받는 messageHandler 세팅
    MessageFrameHandler<MateEtaResponsesV2> handler = new MessageFrameHandler<>(MateEtaResponsesV2.class);
    stompSession.subscribe("/topic/etas/1", handler);
    Thread.sleep(1000);

    // given - step3 : 도착 예정 정보 서비스를 호출하는 조건 stubbing : 약속 시간 안지남 + trigger 당긴지 10초가 안지났을 때
    Mockito.when(timeCache.get(anyLong()))
            .thenReturn(LocalDateTime.now().plusMinutes(10L)) // meeting 시간 (10분 뒤)
            .thenReturn(LocalDateTime.now()); //trigger 당긴지 0초 > 새로 예약 x

    // when : 요청 보내기
    MateEtaRequest request = new MateEtaRequest(false, "37.515298", "127.103113");
    stompSession.send("/publish/etas/1", request); // 위치 정보를 함께 보냄
    Thread.sleep(1000);
    
    //then - stubbing 한 응답이 주제를 구독한 client에게 잘 전달됨
    MateEtaResponsesV2 mateEtaResponsesV2 = handler.getCompletableFuture().get(10, TimeUnit.SECONDS); // 서버로 부터 받은 응답을 확인
    assertThat(mateEtaResponsesV2.requesterMateId()).isEqualTo(stubResponse.requesterMateId()); // 보낸 응답과 같은지 검증
}

 

복잡한 서비스 로직이 많이 섞였지만 그래도 테스트를 성공하긴 했습니다. 물론 리팩터링할 부분들이 많이 보이기도 하면서 테스트가 무거워 단위 테스트 위주로 테스트를 쪼개야겠다는 생각도 들었습니다. 

 

흐름은 다음과 같습니다.

1) 먼저 도착 예정정보를 줘! 라고 요청했을 때 응답할 response를 stubbing합니다

2) 도착예정정보 응답 객체인 MateEtaResponseV2를 수신할 수 있는 MessageHandler를 커스터마이즈하여 /etas/1을 구독합니다.

3) /etas/1로 서버에게 요청을 보냅니다.

4) 서버로부터 받은 응답값이 우리가 stubbing한 Response인지 검증합니다.

 

 

3️⃣ 약속 시간이 지나면 discnonnect 트리거를 서버가 당기는가?

 

마지막으로 약속시간이 지났을 때 서버가 disconnect 주제로 약속 구독자들에게 메시지를 보내는지 검증하고 싶었습니다. 

 

@DisplayName("약속 시간 이후의 상태 목록 조회 호출 시 disconnect 트리거를 당긴다.")
@Test
void triggerDisconnect() throws InterruptedException {
    
    //disconnect trigger를 당기는 조건 stubbing : 약속시간 지났을 시
    Mockito.when(timeCache.get(anyLong()))
            .thenReturn(LocalDateTime.now().minusMinutes(10L)) // meeting 시간 (10분 전)
            .thenReturn(LocalDateTime.now()); //trigger 당긴지 0초 > 새로 예약 x
    
    // disconnect 토픽 구독하기
    handler = new MessageFrameHandler<>(Object.class);
    stompSession.subscribe("/topic/disconnect/1", handler);
    Thread.sleep(1000);
    
    /// 약속시간이 지난 이후 위치 정보를 보냄
    MateEtaRequest request = new MateEtaRequest(false, "37.515298", "127.103113");
    stompSession.send("/publish/etas/1", request);
    Thread.sleep(1000);
    
    //disconnect로 요청이 들어오는지 검증
    CompletableFuture<Object> completableFuture = handler.getCompletableFuture();
    assertThatCode(() -> completableFuture.get(10, TimeUnit.SECONDS))
            .doesNotThrowAnyException();
}

흐름은 다음과 같습니다.

 

1) disconnect를 호출하는 조건을 stubbing합니다. : 약속 시간이 지났음을 반환

2) 클라이언트가 disconnect 토픽을 구독합니다

3) 클라이언트가 약속 시간이 지났음에도 위치 정보를 보냅니다.

4) disconnect 채널로 서버로부터 요청이 들어오는지 확인합니다.

 

이로서 open - service - disconnect 까지의 동작이 1차적으로는 검증되었습니다. 잘 작성된 테스트라고 묻는다면 여기저기 아쉬움이 많습니다. Thread.sleep을 이용해 비동기 작업을 기다리기 때문에 테스트에 소요되는 시간이 많고 네트워크 상황에 따라서는 테스트의 일관성이 지켜지지 않을 수도 있을 것 같았습니다.

 

무엇보다 E2E 테스트를 인수 테스트처럼 진행하려다보니 무거운 mocking을 사용할 수 밖에 없었습니다. 하나의 작업이 일어날 때 일어날만한 side-effect를 모두 mocking으로 통제하려다보니 테스트 하나에서 검증하고자 하는 바가 명확하지 않고 given 절이 매우 두꺼워지는 현상을 경험했습니다. 

 


🏆 Result :  우리 팀이 선택한 방향과 느낀 점

매우 의외이겠지만 기능적으로는 어느정도 검증이 되었음에도 불구하고 웹소켓으로의 리팩터링은 적용하지 않기로 결정하였습니다. 안드로이드가 백그라운드가 아닌 포그라운드 작업으로 전환하여 조금 더 나은 방향을 찾고자 했고  프로토콜의 변화 없이 폴링 로직으로도 기능을 만족시키기 충분하다고 판단했기 때문입니다. 

 

코드를 다 구현해놓은 입장에서 직접 서비스에 배포한 모습을 볼 수 없다는 것은 참으로 아쉽습니다. 그러나 웹 소켓에 대해 팀원 모두의 이해도가 높지 않고 안드로이드 측도 STOMP에 대한 추가적인 학습을 해야 하는 상황에서 스프린트 환경에서 가장 간단한 방향으로 기능을 구현하고자 했습니다. 

 

그러나, 얻거나 느낀 점도 많았습니다.

 

첫번째로 웹 소켓 통신에 대한 심리적 부담감이 줄었습니다.

웹소켓 통신이 그저 프로토콜의 변화이지 매우 어려운 괴물같은 존재는 아니라는 점을 알게 되었습니다. 특히 STOMP의 경우 spring controller의 모습을 그대로 활용 가능하다보니 웹 소켓에 대한 심리적 부담감이 많이 줄었습니다.

 

두번째로 메시지 브로커와 pub/sub 구조에 대한 전반적인 이해도를 가지게 되었습니다.

 

세번째로 비동기 프로그래밍에 관심을 가지게 되었습니다.

웹 소켓 통신은 테스트 스레드와 별개의 스레드에서 작업이 되는 비동기 작업이다보니 테스트를 작성하는데 정말 많은 시간이 필요했습니다. 그 과정에서 비동기 프로그래밍에 관심을 가지게 되었고 CompletableFuture를 다루면서 비동기 작업에 관심이 생겼습니다. 모던 자바 인 액션의 CompletableFuture 챕터를 읽으며 reactive 프로그래밍과 비동기 프로그래밍에 대해 공부해보고 싶다는 생각이 들었습니다.

 

덕지덕지 작성한 테스트를 정리하면서 아쉬움이 많이 남습니다. 반대로 어떻게 개선할지 시나리오가 떠오르지 않아 제 지식이 아직 많이 부족함을 느끼기도 합니다. 하지만 구현할 의지라고 했던가요? 기어서 가든 굴러서 가든 우선 구현을 해내긴 했습니다. 그리고 그 사실만으로 30%는 왔다고 생각합니다.

 

프로젝트의 웹 소켓 전환 시나리오는 여기까지입니다.

최대한 시각화하여 풀어 설명해보려 했는데 잘 전달이 되었는지 모르겠네요. 오타나 잘못된 개념은 언제든지 댓글로 지적해주시면 시정하겠습니다!

 

그럼 오늘도 행복하세요 :)