치춘짱베리굿나이스

클라이언트에서 Server Sent Event 수신받기 본문

ClientSide/기타

클라이언트에서 Server Sent Event 수신받기

치춘 2022. 12. 26. 19:42

Server Sent Event

개요

이번에 만들게 된 서비스의 목적은 나와 맞는 팀원 구하기 였는데, 생각해보면 팀에 합류할 지 말지 결정하기도 전에 개인 연락처 (메일 등) 를 노출시켜 연락을 취하도록 하는 것은 그다지 좋지 못한 것 같았다

이 사람이 나랑 함께 할 지 말 지도 모르는데 다짜고짜 개인 메일로 연락을 보내는 것도 이상하지만, 로그인이 되지 않은 상태에서도 프로필이 노출되는 상황에서 악의적인 사용자가 스팸 메일을 보내게 된다면…?

결국 쪽지 기능을 도입하기로 하였는데, 쪽지 송신은 HTTP로 구현하되 수신은 실시간으로 알림을 띄워주고 싶어 실시간 통신 기술을 고려하게 되었고, 보편적으로 활용되는 기술로 웹소켓, 롱 폴링, SSE를 찾았다

왜 SSE를 사용하게 되었는가?

웹소켓, 롱 폴링, SSE 중 고민을 꽤 많이 하였으나 결론적으로 SSE를 선택하게 되었다

간단하게 장단점을 비교해보도록 하자

웹소켓

  • 대다수의 브라우저 (IE 포함!) 를 지원하긴 한다
  • HTTP 기반의 통신이 아니다
  • 클라이언트 ↔ 서버 간 양방향 통신을 지원하고, 연결을 한번 맺으면 유지하는 방식이지만 그만큼 리소스 소모가 많이 된다
  • 오랜 시간 연속적으로 이어지는 채팅이 아닌, 데이터가 오가는 주기가 굉장히 긴 (길 수 있는) 쪽지이기 때문에 굳이 3-way Handshaking 까지 사용해가면서 소켓을 열고 유지할 필요가 없다고 느껴졌다
  • 또한, 클라이언트에서 서버로의 새로운 쪽지 요청은 HTTP로 보내도 무방하다고 느껴졌다 (서버에서 새로운 쪽지 알림이나 내용 정도만 SSE로 보내주어도 괜찮은 상황)

롱 폴링

  • 폴링 (Polling) 은 주기적으로 서버에 HTTP 요청을 보내는 방법으로, 서버에 요청을 대량으로 보내야 하는 만큼 부담이 굉장히 커진다
  • 롱 폴링은 이러한 단점을 보완하기 위해 데이터에 변화가 없다면 요청을 대기시키고, 데이터가 업데이트되었을 때만 응답을 보내는 방법이다
  • 데이터 업데이트가 잦다면 폴링과 별반 차이가 없다 (서버에 부담이 매우 크다)
  • 서버의 성능이 좋은 편이 아니기 때문에, 매번 요청을 보내는 것은 유저의 수에 따라 감당하기 힘들어 보였다

Server Sent Event

  • 비교적 구현이 간단하다
  • 이벤트 수신 창구를 한번 열어두면 주기적인 핸드쉐이킹이 필요 없다
  • HTTP 기반의 통신으로, 요청-응답을 보낼 때와 같이 헤더와 바디로 구성되어 있다
  • 다만 인터넷 익스플로러는 지원하지 않는다는 문제가 있다

실시간 채팅이 아닌 쪽지 알림 기능을 구현하고 싶었기 때문에, 연결을 유지하는 소켓 통신이나 주기적으로 요청을 보내야 하는 롱 폴링보다 SSE가 가장 적합하다고 생각하여 SSE로 구현을 시작했다

사실 쪽지 알림을 보내야 하는 텀이 생각보다 길 수 있기 때문에, 롱 폴링까지는 아니더라도 사용자가 임의의 API로 요청을 보낼 때마다 응답에 새로운 쪽지 정보도 같이 실어 보내는 방식으로도 구현이 가능할 법하나, 쪽지와 전혀 관계 없는 API에서까지 쪽지 내용을 전송해야 하는 것이 별로라고 생각했다

클라이언트에서 SSE 수신받기

EventSource 인스턴스 생성

eventSource = new EventSource(
    `https://whatever.service.api.baseurl/api/sse`, // 서버의 API 주소를 넣으면 된다
    {withCredentials: true,} // 세션 쿠키를 실어보내기 위함 (선택사항)
);

소켓에서는 Socket 인스턴스를 생성하여 통신에 사용하는 것처럼, SSE에서는 EventSource 인스턴스를 생성한다

EventSource 생성자는 axiosfetch와 유사하게 ‘서버상에서 연결할 SSE API 주소’ 와 헤더 옵션을 인자로 받는다

예시에서는 아무 주소나 지어냈지만, 실제 API 주소는 env와 상수로 관리하자

withCredentials 옵션은 SSE 연결 시 서버에서 쿠키를 읽어들여 사용자 정보를 파악해야 했기 때문에, 크로스 도메인에 쿠키를 실어보내기 위한 옵션이다 (굳이 쿠키나 헤더의 Authorization 옵션을 사용하지 않는다면 넣을 필요가 없다)

 

EventSource 객체가 생성되면 네트워크 탭에서 이벤트 스트림을 볼 수 있다

이 스트림이 열려있다는 것은? 서버로부터 언제든 이벤트를 받을 준비가 되어있다는 것이다

헤더의 다른 부분은 HTTP 요청-응답과 똑같은데 Content-Typetext/event-stream인 것을 볼 수 있다

연속적인 이벤트 (이벤트 스트림) 를 수신받는 방식의 컨텐츠라는 뜻이다

한번 열린 EventStream은 1분마다 닫히고 다시 연결된다

// service layer
@Injectable()
export class MessageService {
    private readonly emitter: EventEmitter;

    constructor (
        private readonly messageRepository: MessageRepository, // 메시지 관련 repository
        private readonly userRepository: UserRepository,
  ) {
    this.emitter = new EventEmitter();
  }
    ...
}

// controller layer

참고로 서버에서는 EventSource 객체 대신 EventEmitter 객체를 생성해주어야 하며, EventEmitter 객체에서 이벤트를 클라이언트로 emit 하면 클라이언트의 EventSource 객체에서 이벤트를 수신받는 방식이다

이벤트 창구가 처음 열렸을 때의 동작 정의

eventSource.onopen = (event: Event) => {
    // 수행할 동작
};

EventSource 인스턴스를 생성하고 이벤트를 받을 준비가 되었을 때 (창구가 열렸을 때) 수행할 동작을 onopen 프로퍼티에 지정할 수 있다

상태값에 변화를 준다던지, 콘솔을 띄우는 등의 동작이 가능하다

인자로는 Event 타입 객체 하나를 받는다

eventSource.addEventListener('open', (event: Event) => {
    // 수행할 동작
});

onopen 프로퍼티에 함수 객체를 넘겨주는 방법 말고도, addEventListener를 통해 open 이벤트에 콜백을 넘겨주는 방법도 있다

eventSource.onopen = () => {
    setIsSSESet(true);
};

이번 프로젝트에서는 EventSource 인스턴스가 두 번 이상 생성되는 것을 막기 위해, 인스턴스가 생성되었음을 나타내는 상태값을 true로 설정해 주었다

서버로부터 이벤트를 수신받았을 때 동작 정의

eventSource.onmessage = (event: MessageEvent) => {
    // 수행할 동작
};

서버에서 이벤트를 수신받았을 때 실행할 함수를 onmessage 프로퍼티에 지정하면 된다

다른 API들은 대개 콜백 함수로 후속 동작을 지정하는데, SSE는 프로퍼티에 함수를 직접 넘겨주는 것도 눈여겨볼 점이다

인자의 타입인 MessageEventEvent를 상속받은 자식 클래스로, 다음과 같은 프로퍼티가 있다

  • data: 메시지 데이터 (본문) 으로, 직렬화되어있기 때문에 사용할 때 역직렬화가 필요하다
  • origin: Message Emitter (서버 쪽의 이벤트 발생기 = Event Emitter) 의 위치
  • lastEventId: 이벤트의 고유 ID
  • source: Message Emitter 를 표현하는 MessageEventSource 객체
  • ports: 메시지가 전송된 채널의 포트

이번 구현에서는 data 만 사용하기 때문에, 구조분해 할당으로 가져왔다

eventSource.addEventListener('message', (event: MessageEvent) => {
    // 수행할 동작
});

마찬가지로 addEventListener로 이벤트 콜백 추가가 가능하다

eventSource.onmessage = ({ data }) => {
    const messageUserID = window.location.pathname.split('/message/')[1];
    if (!messageUserID) {
        setIsNewMessage(true);
        return;
    }
    const message = JSON.parse(data);
    if (message.from === messageUserID) setNewMessage(JSON.parse(data));
    else setIsNewMessage(true);
};

본 프로젝트에서는 현재 페이지가 쪽지 페이지인지 아닌지를 window.location.pathname을 통해 체크한 뒤, 만약 쪽지 페이지에 접속해 있을 경우 누구와 대화하고 있는지를 검사하고 쪽지 목록을 갱신하거나 알림만 띄워줄 수 있도록 설정하였다

isNewMessage는 단순히 새로운 쪽지가 도착했다는 알림을 위해 사용하는 전역 상태값이고, newMessage는 실제 메시지 데이터를 담는 전역 상태값이므로 쪽지 페이지에서 변화를 체크한 뒤 쪽지 목록을 업데이트한다

 

네트워크 탭에서 수신받은 이벤트는 EventStream에서 확인할 수 있다

지금까지 수신받은 메시지들 (직렬화된 문자열) 이 누적되어 있다

이벤트 오류 발생 시의 동작 정의

eventSource.onerror = (event: Event) => {
    // 수행할 동작
}

서버와 EventSource와의 연결 중 오류가 발생했을 때 수행할 동작을 정의할 수 있다

인자로는 message 이벤트와 같이 Event를 받아온다

eventSource.addEventListener('error', (event: Event) => {
    // 수행할 동작
});

마찬가지로 addEventListener를 통해 콜백 추가가 가능하다

본 프로젝트에선 별다른 오류 처리는 하지 않았다

이벤트 수신 닫기

eventSource.close()

컴포넌트가 언마운트되었을 때 등, 이벤트 수신을 중단하고 싶다면 close 메서드를 호출하면 된다

EventSourceEventEmitter 간 연결을 닫고, 더이상 서버로부터 데이터를 받을 수 없게 된다

결론

서버에서도 복잡하게 처리할 것 없이, (nest 기준) return으로 클라이언트에 응답을 보내는 대신 event emitter에서 이벤트를 emit하기만 하면 된다

생각보다 사용하기 간단한 기술이지만 IE 지원이 없다는 것이 좀 고민해볼 점인 것 같다

물론 IE가 죽은 브라우저긴 하지만… 그래도 쓰는 사람이 있으니까…?


참고 자료

https://velog.io/@stella6767/SSE-Protocol-활용해서-Spring-React-단방향-통신하자

https://velog.io/@green9930/실전-프로젝트-React와-SSE
https://surviveasdev.tistory.com/entry/웹소켓-과-SSEServer-Sent-Event-차이점-알아보고-사용해보기

'ClientSide > 기타' 카테고리의 다른 글

로그인 유지  (0) 2023.05.01
Vercel 앱 GoDaddy 도메인과 연결하기  (0) 2023.04.02
클라이언트에서 crypto 모듈 동작 안 할때 해결책  (0) 2022.10.07
XML 파서와 XPath  (0) 2022.10.05
DOM Element replace 함수들  (0) 2022.10.04
Comments