현재 서비스의 특정 화면에서 래플 이벤트의 당첨 확률의 정보를 표기해줘야하는 부분이 존재한다.
회원들의 응모가 이뤄질때마다 당첨 확률의 변동이 발생되어야 한다.
해당 요구사항을 해결하기 위한 방법은 여러가지가 있을 것이다.
API를 한번 더 호출하거나, polling 방식을 이용하는 등 다양한 방식이 존재할 것이다.
기존에는 변경된 확률을 즉각 반영해서 회원들에게 노출하지 않고 새로고침(API 호출)을 통해 변경된 확률이 노출되고 있었다.
이를 개선하기 위해 SSE를 활용하였고, SSE 활용을 앞서 현재 비즈니스를 간단한 프로토타입으로 개발하여 테스트를 진행하였다.
먼저, HTTP 기반으로 문제를 해결하기 위한 방법을 알아본 후 간단한 예제 코드로 확인하고자 한다. (단, API 재호출 방식은 제외)
클라이언트가 서버에 주기적으로 요청을 보내는 방식이다.
일정 시간마다 서버에 요청을 보내 데이터 변경이 발생했는지 확인하고 변경이 이뤄졌다면 응답을 받는다.
요청을 보낸 후 서버에서 데이터 변경이 일어날 때까지 대기하는 방법이다.
실시간 메시지 전달이 중요하지만, 상태 변경이 빈번하지 않는 경우에 적합하다.
서버로 부터 응답을 받은 후 다시 연결 요청을 하기 때문에 상태가 빈번하게 발생하게 된다면 Short Polling과 같이 불필요한 오버헤드가 발생한다.
클라이언트와 서버가 HTTP 기반으로 HandShaking 후 ws 프로토콜을 통해 상호간 응답을 주고 받는 방식.
단, websocket 프로토콜을 처리하기 위해 전이중 연결과 새로운 웹소켓 서버가 필요하다.
클라이언트가 SSE 연결을 설정하면 서버는 연결을 열린 상태로 유지하여 지속적으로 업데이트 내역을 보냅니다.
서버가 정기적으로 클라이언트에 데이터를 푸시해야 하는 상황에 적합하며, 클라이언트는 서버에 정보를 다시 보낼 필요 없이 데이터만 수신한다.
SSE란, Server-Sent Events이며 서버가 클라이언트로 데이터를 실시간으로 보내는 단방향 통신 방식이다.
(Header정보에 text/event-stream)
서버(text/event-stream + keep-slive
)는 해당 요청을 수락하여 이벤트가 발생할 때마다 클라이언트로 메시지를 전달하면서 커넥션이 유지되게 된다.Spring에서 SSE를 활용해 래플에 응모 후 당첨 확률을 제공하는 프로토타입 기능을 구현하였습니다.
전체 코드는 깃헙에서 확인하시면 됩니다.
SSE를 사용할 때 단일서버에서는 큰 문제가 생기지는 않는다. SseEmitter가 서버 메모리에 저장
되기 때문이다.
그래서 분산서버에서의 문제를 해결하기 위해 Redis의 Pub/Sub 을 이용하기로 하였다.
/**
* 래플 참여 시 래플 참여 정보를 저장하고, 래플 정보를 Redis로 Publish 한다.
*
* @param raffleId
* @param memberId
*/
public void participateInRaffle(Long raffleId, Long memberId) {
Raffle raffle = raffleRepository.findById(raffleId)
.orElseThrow(() -> new IllegalArgumentException("해당하는 래플이 존재하지 않습니다."));
participationRepository.save(Participation.builder()
.raffleId(raffle.getId())
.memberId(memberId)
.build());
redisOperations.convertAndSend(
RaffleChannelGenerator.getChannelName(String.valueOf(raffle.getId())),
RaffleParticipationRequest.of(raffle, memberId)
);
}
public SseEmitter subscribe(final Long raffleId) throws IOException {
final String id = String.valueOf(raffleId);
final SseEmitter emitter = createSseEmitter(id);
// Redis에 새로운 이벤트가 발생하면 자동으로 onMessage 호출
final MessageListener messageListener = createMessageListenerAndSendToClient(emitter, id);
// redisMeesageListenerContainer에 새로운 MessageListener를 추가함
// MessageListener들을 redisMessageListenerContainer에 추가하여 관리한다.
addMessageListenerToContainer(messageListener, id);
// emitter 완료되었는지 타임아웃이 났는지
handleEmitterCompletionAndTimeout(emitter, messageListener);
return emitter;
}
http://localhost:9998/api/v1/raffle/1/subscribe
깃헙에는 유니크한 id를 생성하도록 예제를 수정했으니 필요한 사람은 참고하면 좋을 것 같다.
)서버에서의 커넥션 연결이 만료되어도 브라우저 레벨에서 자동으로 재연결 요청을 보내기 때문이다.
IOException의 경우 클라이언트에서 브라우저 새로고침 or 브라우저 종료 등이 발생하면 에러가 발생하게 된다.
IllegalStateException의 경우 Timeout이 발생해서 그런데 아래 사진 처럼 후처리에서 해당 객체들을 제거해주면 된다.
특별한 이유가 있지 않는 이상 애플리케이션을 단일 서버로 구성하는 경우는 별로 없을 것이라고 생각합니다.
현재 저희 서비스 또한 여러 AZ에 걸쳐 분산 서버로 운영되고 있습니다. 그래서 분산 서버 환경에서 동기화 처리가 필요했고 레퍼런스를 조사하다보니 Redis의 Pub/Sub 구조를 선택하게 되었습니다.
Redis를 회사에서 사용중에 있었고 SQS와 같은 메세징 서비스와는 다르게 Producer가 생성한 이벤트를 가장 먼저 consume한 consumer만 해당 데이터를 처리할 수 있기 때문에 더욱 적절하다고 생각하였습니다.
현재 저희 서비스에서 특정 래플의 당첨 확률을 보여주고 있는데 확률을 보는 사이에 여러 사용자들이 래플에 응모하게 되면 그 당첨 확률을 변동하게 될 것입니다.
이러한 요구사항에 가장 적절한 방식이라고 생각했기에 SSE를 활용하게 되었습니다.
이번 SSE를 적용한 덕분에 현재는 SSE가 필요한 영역에서 적절하게 사용되고 있습니다.
참고
https://blog.bytebytego.com/p/network-protocols-behind-server-push
https://medium.com/techieahead/http-short-vs-long-polling-vs-websockets-vs-sse-8d9e962b2ba8
https://medium.com/@saurabh.singh0829/redis-pub-sub-implementation-f3208e4625c7