작업이 오래 걸리는 서비스에 대해 어떻게 대응 할 것인가

Limpet·2022년 12월 2일
1

Web

목록 보기
1/1
post-thumbnail

개요

로딩이 긴 서비스

팀 프로젝트 진행 중, 내가 맡은 서비스가 최장 5분까지 시간이 소요되는 일이 있었다.

노션의 API를 호출해서 특정 사용자의 모든 데이터를 읽어오고,
이를 가공하기 위해 Fastapi 서버에 자연어 처리, 이미지 처리 등의 기능을 요청 한 후,
DB에 저장하고
생성된 ID를 반환하는 로직이었다.

왜 오래 걸리는가?

전체적인 로직을 그림으로 그려보면 다음과 같다.


이때 통신 빈도는 다음과 같다.

  • Client - Node.js : 1회
  • Node.js - Notion : 최소 1번 ( 기하급수적으로 늘어남 )
  • Node.js - Fastapi : 2번 ( 이미지 처리, 자연어 처리 )
  • Node.js - MongoDB : 1번~2번 ( 회원가입이 되있는 경우 1번, 아니면 2번 )

이때, Notion에서 안정적으로 사용자의 모든 페이지를 제공해주는 API가 없기 때문에 ( search라는 API가 있지만, 모든 페이지를 읽어온다는 보장이 없다고 명시되어 있다 )
사용자의 모든 데이터를 불러오기 위해선 API를 재귀적으로 호출해야 하고,
이 작업이 매우 오래 걸린다.

따라서 최소 통신은 5번이지만, 평균 통신은 40번 정도가 된다.

해결방안 : 진행도를 사용자에게 공유하자!

사용자 입장에서 생각했을 때, 시간이 오래 걸리는 서비스는 충분히 참을 수 있다.
( 대부분의 다운로드가 그 예시이다. )

그러나, 현재 어디까지 진행됐는 지 알지 못한다면 기다리기 매우 괴로울 것이다.

따라서, 더 나은 사용자 경험을 위해선 현재 작업이 어느정도 진행되었는 지 실시간으로 공유할 필요가 있다고 생각했다.

실시간(Real-time) 통신

일반적으로 HTTP에서는 1개의 요청에 1개의 응답을 제공한 후 연결이 끊어진다.

그러나 프로젝트에 따라서 지속적으로 정보를 갱신해주어야 할 필요가 있고, 일반적인 1회성 통신으로는 이를 구현하기에 어려움이 있다.

HTTP 통신에서 실시간 Web통신을 구현하기 위해선 다음과 같은 방법이 있다.

  • Short Polling
  • Long Polling
  • SSE

WebSocket이란 방법도 있지만, 해당 글에선 다루지 않을 예정이다.

구현 방법

Short Polling

출처: https://levelup.gitconnected.com/understand-and-implement-long-polling-and-short-polling-in-node-js-94334d2233f3

Polling은 주기적으로 서버에 요청해 변경사항이 있는 지 확인하는 기법이다.
재요청에 대한 주기를 길게 잡을 수록 클라이언트-서버의 부하가 적어지만, 실시간성이 그만큼 떨어지기 때문에 재요청 주기를 어느 정도로 잡을 지 고민할 필요가 있다.

Short Polling은 비교적 짧은 주기로 서버에 변경사항이 있는 지 확인하는 기법이고, 구현이 간단하다는 장점이 있다.

정보를 갱신하는데 1RTT의 딜레이가 있지만, 완벽한 실시간성을 보장하지 않아도 되는 서비스에서는 고려할만한 방법이라고 생각한다.

나라면 알림같은 기능을 구현할 때 이 방법을 고려할 것 같다.

Long Polling

출처:https://tecoble.techcourse.co.kr/static/7e3a34a031897051c1c0e96ca2843ef4/11824/longpolling.png Long Polling은 요청을 보내고 서버에서 변화가 있으면 응답을 받는다.

잦은 변화가 없는 서비스에서 적합한 방식이라고 생각한다.
만약 상태가 자주 변하는 서비스라면 그만큼 요청이 많아지기 때문에 다소 비효율적이다.

구현하기에 앞서 Connection Timeout 등을 고려해볼 필요가 있다.

Server Sent Events

출처:https://tecoble.techcourse.co.kr/static/e9061ada0ae476d3fd73f9271d6d585d/58c38/sse.png

요청을 보낸 후 일정 시간동안 연결을 유지한 후, 서버에서 message를 emit하는 방식이다.

연결이 끊기기 전까지는 Client에서 추가로 요청할 필요가 없기 때문에 메시지가 쌓일 수록 0.5RTT만큼의 시간이 절약된다고 생각할 수 있다.

이후 서버에서 message를 전송하면 클라이언트는 WebSocket 통신과 유사한 방식으로 메시지를 받을 수 있으나, 클라이언트에서 서버로 메시지를 전송할 순 없다.

HTML5에서 표준화 된 EventSource APISSE연결을 요청할 수 있으며, HTTP/1.1부터 사용이 가능하다.

단점은 서버에서 TCP Connection을 오랫동안 유지하고 있어야 한다는 것이다.
아무런 메시지를 주고 받지 않더라도 low-level에서는 TCP Connection을 유지하기 위해 통신을 주고 받기 때문에, 서버의 Network bandwidth는 그만큼 고갈된다.

소수의 SSE만 연결되어 있다면 부담이 크지 않지만, 한 사용자가 여러 개의 SSE를 연결하고 있다면 서버 입장에서는 큰 부담이 될 것이다.

이를 고려한 것인지 HTTP/1.1에서는 브라우저당 SSE연결을 6개로 제한하고, HTTP/2.0에서는 100개로 제한한다고 한다.

결론

우리는 위 방법 중 SSE를 선택하기로 했는데, 이유는 아래와 같다.

우리 프로젝트에서 실시간 통신을 고려한 이유는 클라이언트에게 작업 진행 상황을 로딩 창의 형태로 제공하기 위해서이다.

해당 서비스의 특징은 다음과 같다.

  1. 클라이언트에서 메시지를 보낼 필요 없는 단방향 서비스이다.
  2. 특정 시간동안 연결이 유지되고 작업이 완료되면 바로 연결을 해제할 수 있음.
  3. 연결이 유지된 동안엔 지속적으로 메시지를 전달 (작업이 완수될 때마다)

1번의 이유로 WebSocket은 선택하지 않았고,
3번의 이유로 Long Polling은 선택하지 않았다.

만약 연결을 계속 유지해야 한다면 Short Polling을 선택했겠지만 ( 연결을 유지하는 게 서버의 부담이 되므로 )

2번의 이유로 인해 연결을 오래 유지할 필요가 없었고,
그렇다면 요청을 처음 한 번만 해도 되는 SSEShort Polling보다 효율적일 것이라는 생각이었다.

SSE를 활용한 로딩 프로그레스바 구현

구현한 후에 대한 소감을 간단하게 말하자면, 생각했던 것보다 굉장히 쉬웠다.

아직 경험해본 적 없는 기술이기 때문에, 팀원 한 명과 페어 프로그래밍을 하고 예상 소요 시간을 이틀로 계획했다.
그러나 실제 작업은 6시간만에 끝낼 수 있었고 이마저도 자료 탐색 시간이 2시간애니메이션 구현이 2시간이 소요되었기 때문에 실제 연결 자체는 2~3시간만에 끝났다고 볼 수 있다.

WebSocket처럼 아예 다른 프로토콜을 사용하는 것이 아닌, HTTP 기반 통신이기 때문에 연결 구축이 더 쉬웠던 것으로 보인다.

Client

전체 코드

// src/components/ProgressBar/index.tsx
useEffect(() => {
    if (!listening) {
      eventSource = new EventSource(eventSourceUrl, {
        withCredentials: true,
      });
      eventSource.onmessage = (event) => {
        const res: IResponse = JSON.parse(event.data);
        gsap.to(barRef.current, {
          x: `${res.progress}%`,
          duration: 2,
          onStart: () => {
            textRef.current.innerText = res.kind;
          },
          onComplete: () => {
            if (res.progress === 100) {
              onLoad(res.data);
            }
          },
        });
      };
      eventSource.onerror = (e) => {
        let timer = 3;
        const interval = setInterval(() => {
          textRef.current.innerText = `에러가 발생했습니다. ${timer}초 뒤 다시 시도합니다.`;
          timer--;
          if (timer === 0) {
            setListeing(false);
            clearInterval(interval);
          }
        }, 1000);
      };

      setListeing(true);
      return () => eventSource?.close();
    }
  }, []);

생성하기 버튼을 누르면 useEffect가 실행되면서 프로그레스바 컴포넌트가 렌더링 되고, 해당 코드가 실행된다.

초기 연결

if(!listening){
  eventSource = new EventSource(eventSourceUrl, {
    withCredentials: true,
  });
  
  ...
  
  setListening(true);
  return ()=> eventSource?.close();
}

SSE 연결 구축 여부를 listening이라는 상태에 저장하고,

연결이 되어있지 않다면 eventSource를 지정된 경로로 선언해준다.

EventSource

<추후 작성 예정>

Server

전체 코드

export function createConnectionSSE(res) {
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Connection": "keep-alive",
    "charset": "UTF-8",
    "Transfer-Encoding": "chunked",
    "X-Accel-Buffering": "no",
  });
  res.write("data: " + JSON.stringify({ kind: "시작", progress: 0, data: {} }) + "\n\n");
}

export function writeMessageSSE(msg, res) {
  console.log(msg);
  res.write("data: " + msg + "\n\n");
}

export function endConnectionSSE(res, data) {
  res.write("data: " + JSON.stringify({ kind: "완료", progress: 100, data: data }) + "\n\n");
}

SSE연결 구축, 해제 및 SSE연결 중 메시지 전송에 대한 기능을 모듈화했다.

SSE 연결 시 고려해야 할 것들

연결 시 중요한 것은 총 두 가지가 있다.

  1. Response 헤더 설정
  2. 메시지 전송 시 규칙

설정해야 하는 헤더는 다음과 같다.

  • Content-Type : text/event-stream
    표준으로 지정되어 있다.
    text형식의 간단한 stream으로 구성되어 있으며, 각 데이터는 \n\n로 구분된다.
    ...
    event: <client에서 처리할 이벤트 >
    data: <메시지 내용>
    \n\n
    ...
    위 형식을 지키지 않으면 Client에서 데이터를 인식하지 못한다.
  • Connection : keep-alive
    SSE의 persistent connection을 위해 keep-alive로 지정해야 한다.
  • Transfer-Encoding : chunked
    SSE는 동적으로 생성된 컨텐츠를 스트리밍하기 때문에 서버에서 미리 본문의 길이를 알 수 없다. 따라서 청크단위로 끊어서 보낸다.
  • charset : UTF-8
    event-stream은 binary로 해석될 수 없으며, utf-8방식으로 인코딩이 되어야 한다.
  • X-Accel-Buffering : no [Optional]
    Nginx상에서의 SSE연결을 위한 헤더이고, 자세한 내용은 밑에서 설명하겠다.

SSE 연결 정보를 저장해 둘 것인가?

해당 기능을 개발하면서 SSE연결 정보를 서버에 저장하는 것에 대해 많은 고민을 했다.

  • SSE 연결 정보를 저장할 경우
    • 장점 : 기존 처리되는 작업과 완전히 분리할 수 있어서 구조가 더 간단해진다.
    • 단점 : 서버 메모리에 연결 정보를 적재해야 한다.
  • SSE 연결 정보를 저장하지 않을 경우
    • 장점 : 서버 메모리에 연결 정보를 적재하지 않아 부담이 덜하다.
    • 단점 : 기존 처리되는 작업과 강결합된다.

현재로서는 SSE연결을 사용하는 서비스가 Notion 데이터 처리 1개밖에 없기 때문에, SSE연결 정보를 저장해두지 않고 기존 서비스에 결합하는 식으로 구현했다.

Nginx를 사용한 SSE

발단

  • 개발 상에서 정상적으로 작동하던 로딩 프로그레스바 애니메이션이 배포 후 동작하지 않음.

원인

  • Nginx가 SSE 응답을 클라이언트에 전송하지 못하고 있었음

Nginx가 response를 pass해주는 방식

  1. response의 길이를 미리 계산

    일반적인 HTTP에서 서버는 Content-length헤더의 body의 총 길이를 작성함

  2. 버퍼에 패킷들을 저장

    보통 http 패킷은 20byte~60byte이므로 이를 초과하면 패킷을 분할 전송함.

  3. 계산된 길이만큼 response 패킷이 모이면 pass

이때, SSE는 Transfer-Encoding: chunked를 사용하기 때문에 Content-length가 동적으로 계산됨

⇒ Nginx가 response의 끝이 어디인 지 알 수 없기 때문에 계속 버퍼에 저장함

해결방안

  • 해당 response에서 버퍼링 옵션을 꺼야 함
    • 이때, 모든 api에 대해 버퍼링 옵션을 끈다면 Nginx의 성능이 크게 저하되는 문제가 있음 ⇒ SSE 연결만 지정해서 꺼주어야 한다.
  • X-Accel헤더
    • response에 X-Accel로 시작하는 헤더가 있으면, Nginx는 이를 인식할 수 있음

Response에 X-Accel-Buffering: no를 추가해주면 Nginx는 이를 인식하고 해당 response에 대해 버퍼링을 하지 않음

⇒ 문제 해결!

0개의 댓글