팀 프로젝트 진행 중, 내가 맡은 서비스가 최장 5분까지 시간이 소요되는 일이 있었다.
노션의 API를 호출해서 특정 사용자의 모든 데이터를 읽어오고,
이를 가공하기 위해 Fastapi 서버에 자연어 처리, 이미지 처리 등의 기능을 요청 한 후,
DB에 저장하고
생성된 ID를 반환하는 로직이었다.
전체적인 로직을 그림으로 그려보면 다음과 같다.
이때 통신 빈도는 다음과 같다.
이때, Notion에서 안정적으로 사용자의 모든 페이지를 제공해주는 API가 없기 때문에 ( search라는 API가 있지만, 모든 페이지를 읽어온다는 보장이 없다고 명시되어 있다 )
사용자의 모든 데이터를 불러오기 위해선 API를 재귀적으로 호출해야 하고,
이 작업이 매우 오래 걸린다.
따라서 최소 통신은 5번이지만, 평균 통신은 40번 정도가 된다.
사용자 입장에서 생각했을 때, 시간이 오래 걸리는 서비스는 충분히 참을 수 있다.
( 대부분의 다운로드가 그 예시이다. )
그러나, 현재 어디까지 진행됐는 지 알지 못한다면 기다리기 매우 괴로울 것이다.
따라서, 더 나은 사용자 경험을 위해선 현재 작업이 어느정도 진행되었는 지 실시간으로 공유할 필요가 있다고 생각했다.
일반적으로 HTTP에서는 1개의 요청에 1개의 응답을 제공한 후 연결이 끊어진다.
그러나 프로젝트에 따라서 지속적으로 정보를 갱신해주어야 할 필요가 있고, 일반적인 1회성 통신으로는 이를 구현하기에 어려움이 있다.
HTTP 통신에서 실시간 Web통신을 구현하기 위해선 다음과 같은 방법이 있다.
WebSocket이란 방법도 있지만, 해당 글에선 다루지 않을 예정이다.
Polling은 주기적으로 서버에 요청해 변경사항이 있는 지 확인하는 기법이다.
재요청에 대한 주기를 길게 잡을 수록 클라이언트-서버의 부하가 적어지만, 실시간성이 그만큼 떨어지기 때문에 재요청 주기를 어느 정도로 잡을 지 고민할 필요가 있다.
Short Polling은 비교적 짧은 주기로 서버에 변경사항이 있는 지 확인하는 기법이고, 구현이 간단하다는 장점이 있다.
정보를 갱신하는데 1RTT의 딜레이가 있지만, 완벽한 실시간성을 보장하지 않아도 되는 서비스에서는 고려할만한 방법이라고 생각한다.
나라면 알림같은 기능을 구현할 때 이 방법을 고려할 것 같다.
잦은 변화가 없는 서비스에서 적합한 방식이라고 생각한다.
만약 상태가 자주 변하는 서비스라면 그만큼 요청이 많아지기 때문에 다소 비효율적이다.
구현하기에 앞서 Connection Timeout 등을 고려해볼 필요가 있다.
요청을 보낸 후 일정 시간동안 연결을 유지한 후, 서버에서 message를 emit하는 방식이다.
연결이 끊기기 전까지는 Client에서 추가로 요청할 필요가 없기 때문에 메시지가 쌓일 수록 0.5RTT만큼의 시간이 절약된다고 생각할 수 있다.
이후 서버에서 message를 전송하면 클라이언트는 WebSocket 통신과 유사한 방식으로 메시지를 받을 수 있으나, 클라이언트에서 서버로 메시지를 전송할 순 없다.
HTML5에서 표준화 된 EventSource API로 SSE연결을 요청할 수 있으며, HTTP/1.1부터 사용이 가능하다.
단점은 서버에서 TCP Connection을 오랫동안 유지하고 있어야 한다는 것이다.
아무런 메시지를 주고 받지 않더라도 low-level에서는 TCP Connection을 유지하기 위해 통신을 주고 받기 때문에, 서버의 Network bandwidth는 그만큼 고갈된다.
소수의 SSE만 연결되어 있다면 부담이 크지 않지만, 한 사용자가 여러 개의 SSE를 연결하고 있다면 서버 입장에서는 큰 부담이 될 것이다.
이를 고려한 것인지 HTTP/1.1에서는 브라우저당 SSE연결을 6개로 제한하고, HTTP/2.0에서는 100개로 제한한다고 한다.
우리는 위 방법 중 SSE를 선택하기로 했는데, 이유는 아래와 같다.
우리 프로젝트에서 실시간 통신을 고려한 이유는 클라이언트에게 작업 진행 상황을 로딩 창의 형태로 제공하기 위해서이다.
해당 서비스의 특징은 다음과 같다.
1번의 이유로 WebSocket은 선택하지 않았고,
3번의 이유로 Long Polling은 선택하지 않았다.
만약 연결을 계속 유지해야 한다면 Short Polling을 선택했겠지만 ( 연결을 유지하는 게 서버의 부담이 되므로 )
2번의 이유로 인해 연결을 오래 유지할 필요가 없었고,
그렇다면 요청을 처음 한 번만 해도 되는 SSE가 Short Polling보다 효율적일 것이라는 생각이었다.
구현한 후에 대한 소감을 간단하게 말하자면, 생각했던 것보다 굉장히 쉬웠다.
아직 경험해본 적 없는 기술이기 때문에, 팀원 한 명과 페어 프로그래밍을 하고 예상 소요 시간을 이틀로 계획했다.
그러나 실제 작업은 6시간만에 끝낼 수 있었고 이마저도 자료 탐색 시간이 2시간과 애니메이션 구현이 2시간이 소요되었기 때문에 실제 연결 자체는 2~3시간만에 끝났다고 볼 수 있다.
WebSocket처럼 아예 다른 프로토콜을 사용하는 것이 아닌, HTTP 기반 통신이기 때문에 연결 구축이 더 쉬웠던 것으로 보인다.
// 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를 지정된 경로로 선언해준다.
<추후 작성 예정>
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연결 중 메시지 전송에 대한 기능을 모듈화했다.
연결 시 중요한 것은 총 두 가지가 있다.
설정해야 하는 헤더는 다음과 같다.
...
event: <client에서 처리할 이벤트 명>
data: <메시지 내용>
\n\n
...
위 형식을 지키지 않으면 Client에서 데이터를 인식하지 못한다.해당 기능을 개발하면서 SSE연결 정보를 서버에 저장하는 것에 대해 많은 고민을 했다.
현재로서는 SSE연결을 사용하는 서비스가 Notion 데이터 처리 1개밖에 없기 때문에, SSE연결 정보를 저장해두지 않고 기존 서비스에 결합하는 식으로 구현했다.
response의 길이를 미리 계산
일반적인 HTTP에서 서버는 Content-length헤더의 body의 총 길이를 작성함
버퍼에 패킷들을 저장
보통 http 패킷은 20byte~60byte이므로 이를 초과하면 패킷을 분할 전송함.
계산된 길이만큼 response 패킷이 모이면 pass
이때, SSE는 Transfer-Encoding: chunked를 사용하기 때문에 Content-length가 동적으로 계산됨
⇒ Nginx가 response의 끝이 어디인 지 알 수 없기 때문에 계속 버퍼에 저장함
Response에 X-Accel-Buffering: no를 추가해주면 Nginx는 이를 인식하고 해당 response에 대해 버퍼링을 하지 않음
⇒ 문제 해결!