본문으로 건너뛰기

자바스크립트 Task Queue 블러킹 풀기

· 약 10분
arch-spatula

제목에 어그로가 엄청난데 진짜 별거 없습니다. 그리고 이 블러킹 현상은 브라우저에서 발생했고 보완하는 과정입니다. 정량적인 성능 개선은 전혀 아니고 오히려 성능은 떨어집니다. 하지만 사용자는 페이지 이동하는데 대기시간이 짧아질 뿐입니다.

요약하면 Promise.all()을 100개 그대로 보내지말고 현재 브라우저 에이전트의 최대 TCP 연결량 기준으로 분할합시다. Promise.all()에 그냥 반복문 한번 감싸주세요. 페이지 이동도 비동기 요청인데 Task Queue가 100개 뒤에 Enqueue될지 6개 뒤에 Enqueue할지는 시각적으로 보이는 성능 차이가 큽니다. 엄밀하게 자바스크립트에서 블러킹현상이 발생하는 것이 아닙니다. 그져 Task Queue에서 병목이 발생하고 모든 요청을 응답 받기 전까지 다른 페이지 이동에 대한 동작을 처리할 수 없게 됩니다. 반복문으로 분할 요청하면 블러킹 수준이 한참 낮아집니다.

개인적으로 주말에 다니는 스터디에서 배운 것도 약간 가미되었습니다.

현재의 상황

회사에서 클라이언트에게 보여주는 데이터 규모가 엄청 많은 편입니다. B2B SaaS로 생각하면 많이 보여줄 수 있습니다.

비슷하게 흉내만 내보겠습니다. 실제로 보여주면 NDA를 위반하는 것입니다.

Promise.all()의 문제

Promise.all()1는 다양한 문제를 갖고 있지만 여기서 집중할 문제는 바로 Task Queue 블러킹 문제입니다. 하지만 그외 문제도 있지만 여기서는 안 다루겠습니다. 하지만 목록은 두겠습니다.

  • 여러개의 요청 중 어느 것이 실패할지 모름2
    • 대안으로 Promise.allSettled()이 존재3
      • 하지만 올바른 예외처리인지 고민이 필요함
  • 요청과 에러처리를 하나로 합침
    • 요청은 병렬로 예외처리는 예외처리는 독립적으로 해야하는 경우가 있음
      • 참고. 이 겨우 await foo().catch()로 처리는 가능함
  • 위에서 말한 Task Queue를 여러게 쌓아둠

Promise.all()는 요약하면 Promise를 담은 배열의 요청을 병렬로 처리합니다. 가끔 병렬 요청으로 각각 다른 엔드 포인트에 날리는 경우들이 있어서 꽤 유용합니다.

브라우저 TCP 컨넥션 수

async function reqJsonPlaceholder {
const promises = Array.from({ length: 100 }, (_, idx) =>
fetch(`https://jsonplaceholder.typicode.com/posts/${idx + 1}`).then(
(response) => response.json()
)
);

const res = await Promise.all(promises);
console.log(res);
};

우리가 이렇게 작성하면 요청을 병렬로 100개를 보낼 것이라고 생각합니다. 순진한 사람들입니다.

이런 멘탈 모델을 갖고 있을 것입니다. 이런 Naive 생각은 당장 버려야 할 것입니다. 단순히 언어만 생각하고 끝내는 것입니다. 실제 브라우저가 갖고 있는 제약은 전혀 생각하지 않는 것입니다. 프론트엔드의 운영체제는 브라우저다 라고 농담을 은근히 하는데 그 마인드를 갖고 있어야 합니다.

Naive한 사람이 생각하는 것처럼 보입니다. 지금 스크린샷에서는 100개를 병렬로 처리된다라고 생각이 들 수 있지만 아닙니다. 지금 스크린샷은 그냥 jsonplaceholder의 성능이 좋은 것뿐입니다.

또 백엔드가 아무리 개선해도 크롬 브라우저가 사용할 수 있는 TCP 컨넥션 수는 6개인데 저의 개발환경에서는 거의 병렬처럼 보이는 것처럼 빠르게 처리되었습니다.

참고로 각각 브라우저마다 또 버전마다 연결할 수 있는 TCP 컨넥션 수의 차이가 존재합니다.

지금 화면과 지금 예시만 보면 별로 문제가 없어 보입니다. 100개의 데이터 밖에 없고 처리할 응답도 빠르기 때문에 특별히 의식하지 않습니다.

문제는 10만 ~ 100만 단위의 레코드를 갖는다면 어떻게 할 것인가요?

Task Queue

자바스크립트는 Task Queue로 비동기 작업을 스케쥴링합니다.

비동기 작업을 스케줄링 하는데 그냥 Promise.all()에 대입한 배열이 100개면 100개의 요청을 동시에 시도하게 됩니다. 이렇게 되면 Task Queue를 100개 생성합니다.

async function reqJsonPlaceholder() {
const promises = Array.from({ length: 100 }, (_, idx) =>
fetch(`https://jsonplaceholder.typicode.com/posts/${idx + 1}`).then(
(response) => response.json()
)
);

const res = await Promise.all(promises);
console.log(res);
}

이 코드의 단점은 실행하는 중간에 페이지 이동 이벤트가 있으면 모든 작업을 처리해야 페이지가 바뀔 것입니다. 예를 들어 1개 요청에 1초가 걸리면 약 100초 이후 페이지 이동을 할 수 있게 됩니다.

즉 위 네트워크 탭에서 최하단에 페이지 이동 요청이 추가될 것입니다. 100개의 Task Queue 뒤에 페이지 이동에 대한 Task를 Enqueue를 하게 됩니다.

슬라이딩 윈도우로 Task Queue 분할

export async function reqJsonPlaceholder() {
const windowSize = 6;
for (let i = 0; i < Math.ceil(100 / windowSize); i++) {
const res = await Promise.all([
fetch(`https://jsonplaceholder.typicode.com/posts/${i + 1}`),
fetch(`https://jsonplaceholder.typicode.com/posts/${i + 2}`),
fetch(`https://jsonplaceholder.typicode.com/posts/${i + 3}`),
fetch(`https://jsonplaceholder.typicode.com/posts/${i + 4}`),
fetch(`https://jsonplaceholder.typicode.com/posts/${i + 5}`),
fetch(`https://jsonplaceholder.typicode.com/posts/${i + 6}`),
]);
console.log(res.map(async (elem) => await elem.json()));
}
}

위에서 작성한 방식을 활용하면 6개 단위로 병렬 요청을 보냅니다. 한번에 보내지 않고 6개 단위로 분할하다 보니까 Task Queue도 6개 단위로 쌓입니다.

6개인 이유는 크롬 브라우저가 연할 수 있는 TCP 커넥션 수가 6개라서 그렇습니다. 유저 에이전트 정보에서 접근한 브라우저 정보를 활용해서 브라우저별 최대 TCP 연결 수를 대응해주면 됩니다. 저는 그냥 하드 코딩했습니다.

위 이미지처럼 꼭 단일 서버일 필요는 없지만 하지만 외부 요청은 크롬 브라우저의 경우 최대 6개입니다. 이 이미지가 아까보다 올바른 멘탈 모델입니다. 더 올바른 멘탈 모델은 브라우저별 스펙별로 분류된 모델입니다.

각설하고 슬라이딩 윈도우로 요청을 짤라주면 얻을 수 있는 장점은 다음 윈도우 전에 Task Queue에 넣는다는 것입니다.

위에서 보면 저 계단 사이에 페이지 이동을 Enqueue할 수 있게 됩니다.

프론트엔드가 자주 사용하는 프레임워크에서 페이지 이동 이벤트가 발생하면 반복문을 break하고 이런저런 클린업 하시면 됩니다.

네트워크탭을 확인해볼 링크

참고로 백엔드는 안 붙였습니다. 하지만 여기 레포를 확인하기 바랍니다.

Footnotes

  1. MDN - Promise.all

  2. Theo - The Dangers Of Promise.all()

  3. MDN - Promise.allSettled