Project/Distance

[Distance] 짝퉁 카톡(KakaoTalk)을 만들어보자. - SSE

EJUN 2024. 10. 29. 17:46

 

우리 서비스의 MVP기능을 뽑으라하면 위치기반을 통한 매칭과 채팅이라고 할 수 있다.

우리가 원하는 채팅의 수준은 카카오톡과 비슷했다.

 

1. 1대1 채팅이 가능하여야 한다.
2. 채팅방 내에서 사진을 보낼 수 있어야한다.
3. 채팅방안에서 실시간으로 읽음표시가 사라져야 한다.
4. 채팅 목록에서 실시간으로 메시지의 변화를 감지해서 보여주어야한다.

총 이렇게 3가지였다.

 

다른 건 사실 크게 어렵지 않았고, 1, 2번은 굳이 없어도? 크게 불편하지 않았다.

 

하지만 3, 4번 같은 경우는 없으면 불편하고 사용자에게 버그처럼 느껴질 수도 있을 거 같았다.

 

우선 3번 같은 경우는 사용자가 방에 들어오면 Event를 감지하여서 Session Table에 값이 들어옴으로써 방에 있는지 없는지를 확인할 수 있었고, 덕분에 읽음표시 기능을 처리할 수 있었다.

 

문제는 4번이었다.

채팅 목록

 

지금 문제는 사용자가 해당 화면을 새로고침하지 않으면 새로운 메시지가 와도 표시가 되지 않고,

추가로 채팅 요청이 들어와도 즉각적으로 표시가 되지 않는다는 문제가 있었다...

 

늘 이거를 어떻게 할까에 대한 고민을 했었는데 우선 제일 단순한 방법은 Stomp를 이 화면에도 연결을 하는 것이다.

그렇게한다면 쉽게? 채팅을 하는거처럼 바로 바로 값이 올 거라고 생각했다.

 

하지만 우리 서비스는 재정난(?)으로 인해 AWS EC2 인스턴스 유형이 t2.medium을 사용 중인데 여기서 RDS를 분산화까지 시켜놔서 돈이,, 만만치 않았다..

 

왜 이야기를 했냐?

이미 충분히 채팅에서 Stomp를 통해서 Network Resource를 사용 중인데 여기에 또 채팅목록에도 연결하는 건 바람직하지 않다고 생각했다.

 

WebSocket 동작 방식

왜 바람직않다고 생각한지 위 구조를 보면서 잠깐 이야기를 해보겠다.

 

우리 서비스에서는 STOMP(Simple Text Oriented Messaging Protocol)를 사용했다고 했는데 이는 Websocket기반 프로토콜이다.

 

자 그럼 WebSocket은 뭘까?

간단히 설명하면 서버와 클라이언트 사이에서 양방향으로 통신할 수 있게 해주는 통신 프로토콜을 의미한다.

Polling 방식

위의 사진은 Polling의 동작방식을 간단하게 나타낸 그림이다.

📌Polling이란?
클라이언트가 주기적으로 서버에 요청을 보내 새로운 데이터가 있는지 확인하는 방식

즉, 폴링은 일정 시간 간격으로 서버에 데이터를 요청하는 형식으로 구현도 굉장히 간단하다.

극한적으로 말해서 특정 비즈니스 메소드를 스케줄러를 통해서 5초 간격으로 호출해서 서버로부터 데이터를 받아오는 거랑 비슷하다.

 

위의 2줄의 설명으로도 알겠지만 리소스 낭비가 엄청나다는게 벌써부터 느껴진다..

또한 Polling방식은 정확히 말하면 실시간이 아니므로 더더욱 우리가 사용하는 서비스에는 어울리지 않기 때문에 쉬운 구현난이도에도 불구하고 선택하지 않았다.

 

그럼 다시 본론으로 돌아와서 채팅 목록에는 왜 STOMP를 사용하지 않았을까?

 

맨 위 Stomp동작 방식의 사진을 다시 보면 가장 먼저 HTTP 핸드셰이크를 통해 초기 연결을 설정한 뒤, 이후에는 TCP 연결을 유지하는 것을 볼 수 있다.

 

STOMP는 양방향의 특징을 지니고 있어서 서버 <-> 클라이언트 간 데이터를 실시간으로 주고 받을 수 있다는 장점이 있다.

이는 Polling방식과 달리 지속적으로 연결을 함으로 다시 연결을 설정하는 오버헤드 비용이 적다.

 

즉, 즉각적으로 데이터가 넘어오고 제가 원하는 실시간을 쉽게 구성할 수 있다.

 

근데 단점이 있다..😂(이 이유가 제가 채팅 목록에서 STOMP를 사용하지 않는 이유다)

STOMP는 WebSocket과 함께 지속적인 연결을 유지하기 때문에 네트워크 리소스서버 리소스가 계속 사용된다는 단점이 있다..

바로 제가 처음에 말했던 "재정난이 어쩌구저쩌구~" 이 내용이다. (가뜩이나 EC2 자원이 소중한데 막 쓰기가 너무 아까웠다는 말)

 

아니 어차피 실시간을 할꺼면 네트워크 리소스를 사용해야되는데 뭐가 아깝다는거지? 라고 할 수 있다.

목적에 따라 아까울수도있고 안아까울수도 있지만, 제가 사용하는 목적은 단순히 서버에서 주는 값들을 받아오는데 사용을 하기 때문이다.

즉, 제가 지금 필요한 건 서버 -> 클라이언트 이 방식만 필요한건데 STOMP는 불필요하게 양방향 연결을 하기 때문이다...

 

 

그럼 무엇을 써야할까? 에 대한 고민을 하던 중 SSE(Server-Sent-Event)라는 것을 알게 되었고 알아보니 제가 원하는 목적과 정말 딱 맞았다.

 

그렇다면 SSE는 뭘까?

📌SSE란?
클라이언트가 서버로부터 일방향 스트림을 통해 실시간 데이터를 받을 수 있도록하는 HTTP 기반 기술을 의미

위처럼 단방향으로 서버가 클라이언트에게 지속적으로 데이터를 전송해야할 때 유용하다.

출처: https://ably.com/blog/websockets-vs-sse

왼쪽이 서버이고 오른쪽이 클라이언트다.

대충 흐름은 

1. 클라이언트가 서버에게 연결 요청
2. 서버에서 response(text/event-stream형식으로 응답)
3. 서버에서 특정 이벤트 발생 시 데이터 전송
4. 클라이언트에서 데이터 수신 및 처리
5. 연결 종료

이런 식의 흐름으로 흘러간다.

 

또한 SSE는 HTTP 기반이기 때문에 방화벽이나 프록시와 호환이 잘 되어 복잡한 설정이 필요하지 않다는 장점이 있다.

그리고 제가 우려했던 오버헤드도 Websocket이나 Polling보다 더 적게 사용할 수 있다는 장점이 있다.

 

그럼 이제 어떤식으로 구현을 했는지에 대해 간략하게 말해보겠다.

@GetMapping(value = "/subscribe/{memberId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public ResponseEntity<SseEmitter> subscribe(
        @PathVariable Long memberId
    ) {
        return ResponseEntity.ok(sseService.subscribe(memberId));
    }

우선 클라이언트가 서버에게 연결을 요청을 할 때 사용해야하는 API이다.

잘 보면 SSE는 스트림형식이기 때문에 ContentType을 "TEXT_EVENT_STREAM_VALUE"로 한 것을 볼 수 있다.

 

public SseEmitter subscribe(Long memberId) {
        SseEmitter emitter = createEmitter(memberId);
        sendToClient(memberId,chatRoomService.findAllRoomTest(memberId),"chatRoom");
        sendToClient(memberId,chatWaitingService.countingWaitingRoom(memberId),"waitingCount");
        return emitter;
    }

그 다음은 비즈니스로직을 살펴보자.

여기서 SseEmitter라는 클래스가 있는데 Spring MVC에서 제공하는 비동기 처리 도구로 HTTP 응답을 스트리밍 형식으로 클라이언트에 전달하기 위한 클래스다.

 

createEmitter를 통해서 emitter객체를 만들고 만든 객체를 Client에 반환을 해준다.

 

그런 다음 sendToClient를 통해서 memberId에게 원하는 데이터를 전달하고 있다.

 private void sendToClient(Long memberId, Object data,String eventName) {
        SseEmitter emitter = sseRepository.get(memberId);
        if (emitter != null) {
            try {
                emitter.send(SseEmitter.event().name(eventName).data(data));
            } catch (IOException exception) {
                sseRepository.deleteById(memberId);
            }
        }
    }

memberId를 통해 아까 생성한 emitter를 찾고 있다면 SSE에서 제공해주는 send()를 통해서 data를 전달해준다.

지금까지 설명한 건 client가 서버에 처음으로 연결을 요청한 경우 해당하는 값을 초기에 전달해준 과정이다.

subscribe 결과

이런식으로 값이 넘어온다.

 

자 그럼 이제 채팅 목록에서 바로바로 메시지 표시를 할 수 있을까?

저는 채팅을 보낼 때 상대방에 목록에 있다면 서버에서 채팅이 왔다는 이벤트를 발송하는 방식이 나을거같아 STOMP환경 안에서 이벤트를 발송한다.

 

aep.publishEvent(new ChatMessageAddedEvent(opponentId));
aep.publishEvent(new ChatMessageAddedEvent(memberId));

이런식으로 나와 상대방에게 메시지 이벤트를 준다.

그럼 이제 어떻게 동작을 할까?

@EventListener
    public void onChatWaitingAdded(ChatWaitingAddedEvent event) {
        Long memberId = event.memberId();
        sseService.notify(memberId, chatWaitingService.countingWaitingRoom(memberId));
    }

이렇게 @EventListener메소드를 만들고 해당 이벤트가 발생하면 notify()라는 메소드에 이 이벤트를 전달한 memberId 그리고 전달해줄 데이터를 함께 넘겨준다.

 

그럼

public void notify(Long memberId, Object event) {
        sendToClient(memberId, event,"waitingCount");
    }

다시 여기서 받아서 아까와 같은 방식으로 서버가 클라이언트에게 데이터를 전달해준다.

 

이렇게 하면 SSE구현은 끝났다.

그럼 SSE의 단점은 뭘까?
 

우선 연결 수 제한이 단점이다.

HTTP/1.1에서 브라우저에서는 한 도메인당 최대 6개까지만 연결을 할 수 있다는 제한이 있다고한다.

처음에 전 어? 그럼 한 도메인에 동시에 6개 접속을 할 수 없다는 소리인가? 라고 생각했는데 알아보니 한 도메인에 SSE구독을 6개 할 수 있다는 말 이었다.

 

예를 들어서

  • 알림 스트림
  • 뉴스 피드
  • 사용자 위치 업데이트

이런식으로 SSE를 사용하는 부분이 한 도메인에 6개인 경우 제한이 있다는 말이다.

 

그래서 결국 

SSE적용 결과

이렇게 짝퉁 카톡을 완성했다.