Promise 및 (Micro)Task 등의 비동기는 만능이 아니다

Composite·2023년 2월 6일
9

오늘은 프론트엔드 초보들이 주로 실수하는 패턴을 통해 왜 비동기에서 이렇게 하면 되는지 설명하는 시간을 갖도록 하겠다.
길게 설명하기 귀찮으니 자세히 알고싶으면 용어를 검색해서 찾아서 배우도록.

흔한 예시

가장 많이 하는 실수로, 비동기로 대량 반복문 처리 시 비동기에 맡기는 게 아닌, 그냥 이런 식으로 단순한 반복문을 짜기도 한다.

fetch('/path/to/large.csv')
  .then(response => response.text())
  .then(csv => {
    const lines = csv.split(/\n/)
    for (let i = 0; i < lines.length; i++) {
      const tr = document.createElement('tr')
      const row = lines[i].split(',')
      if (row.length) {
        row.forEach(cell => {
          tr.appendChild(document.createElement('td')).textContent = cell
        })
      }
      tbody.appendChild(tr)
    }
  })

자, 예시에서는 대량의 CSV 파일을 단순히 테이블에다 넣는데, 만약 CSV 라인 수가 십만줄을 넘는다 치자. 과연 이게 부드럽게 작동할까?

결론부터 말하겠다. 브라우저 뻗는다. 즉, 아무리 비동기라 해도 지금 반복해서 CSV 나누는 것부터가 이미 비동기는 아니게 되어 멈칫하는 브라우저를 볼 수 있다.

초보들은 Promise 나 그 떨거지들 안에다가 for loop을 많이 걸어도 비동기가 돌아갈 것이라는 실수를 저지르는데, 절대 그러지 말도록 하자. 이는 MicroTask 를 공부했다면 충분히 이해갈 설명인데,

MicroTask는 작업 단위, 즉, 대체적으로 네가 정의한 함수 단위의 내용 자체가 작업으로 들어간다.
따라서 그 안에 네가 직접 MicroTask를 걸지 않으면, 그건 즉, 동기적인 코드가 되는 것이다.
위 예제를 극단적이고 단순하게 예시를 걸겠다.

async function parseCsv(csv) {
  const lines = csv.split(/\n/)
  for (let i = 0; i < lines.length; i++) {
    const tr = document.createElement('tr')
    const row = lines[i].split(',')
    if (row.length) {
      row.forEach(cell => {
        tr.appendChild(document.createElement('td')).textContent = cell
      })
    }
    tbody.appendChild(tr)
  }
}

함수 빼고 돌려보자. 기적이 일어나지 않는 이상 달라질 거 없이 브라우저가 여전히 뻗을 것이다.
그럼 어떻게 해야 하나?

아무리 작은 작업도 비동기 작업으로

가장 간편하고 쉬운 방법이다. 즉, loop 구문 안에 작은 작업을 따로 작업으로 빼놓는 일이다.
자바스크립트가 싱글 스레드이고, 이벤트루프 시스템을 통해 돌아가는 걸 안다면, 이렇게 하면 많은 비동기 작업을 할 때에도 뻗지 않고 사용자 경험을 유지할 수 있다는 것 쯤은 알고 있을 것이다.
물론 성능까지 잡으면 금상첨화지만, 지금은 단순하게 해결 가능한 방법으로 설명하도록 하겠다.

fetch('/path/to/large.csv')
  .then(response => response.text())
  .then(csv => {
    const lines = csv.split(/\n/)
    for (let i = 0; i < lines.length; i++) {
      // 작은 작업으로 분리
      queueMicrotask(() => {
        const tr = document.createElement('tr')
        const row = lines[i].split(',')
        if (row.length) {
          row.forEach(cell => {
            tr.appendChild(document.createElement('td')).textContent = cell
          })
        }
        tbody.appendChild(tr)
      })
    }
  })

이렇게 하면 최소한 브라우저가 뻗지는 않을 것이다. 그래. 최소한이다.
다행인 점은 queueMicrotask 라는 함수에서 접두어에 queue 를 보라.
C나 알고리즘 등의 이론에서 queue 는 선입선출 배웠지?
특성 상어느정도 순서 보장이 가능하다. 어느 정도는.
다른 MicroTask가 방해하지 않는 선에서 말이지.
하지만 만약 순서 보장이 안되는 결과가 도출된다면, 어쩔 수 없지. 브라우저가 뻗지 않으면서도 동기 자체를 비동기처럼 작업하는 방법이 없지 않다.

근데 왜 queueMicrotask 썼냐고? 사실 비동기니 작업이니 뭐니 거창한데, 간단히 말하면 작업 분리 함수다. 즉, 이벤트 루프에서 지금 구문을 종료한 뒤 다음에 큐에다 박고 바로 쓰겠다는 의미다. 어떤가? 눈에 팍팍 들어오고 귀에 팍팍 들려오지 않는가? 그리고 이녀석의 return 가능한 버전이 바로 너도알고 나도알고 우리모두 익숙한 Promise 다.

setTimeout 이나 setImmediate 있다고? 써봐. 뭐 결과는 동일하긴 한데... 꽤 느릴 거다.

Web worker 및 Worker thread 사용

브라우저에서는 Web Worker, node.js 에서는 Worker thread 를 사용하는 방법이다. 이 방법을 사용하면, 기존 로직을 그대로 브라우저 뻗지 않고 작업할 수 있다.
하지만, 기존 로직이라 했지 기존 코드라고 안했다. Web Worker 의 경우, 몇몇 제약사항이 존재하는데, 가장 흔한 제약으로 문서 DOM 접근이 안된다. 전달도 안 된다. 일단 주고받는 건 원시 객체와 몇몇 API에만 한정되어 있다. 따라서, 이를 유의하며 사용하면, 네가 쓰던 로직을 그대로 사용할 수 있다.

// Web Worker 특성 상 스크립트 파일을 만들어야 한다.
// 하지만 blob을 이용한 꼼수를 사용하면 인라인으로 가능하다.
const blobURL = URL.createObjectURL( new Blob([ '(',
function(){
  // 이 안에서 바깥에 변수나 API를 참조하지 않도록 주의!
  // 부모가 메시지를 보내면 작업을 시작하도록 메시지 리스너를 만든다.
  self.onmessage = e => {
    // fetch 함수는 Web Worker에서도 지원한다.
    fetch('/path/to/large.csv')
      .then(response => response.text())
      .then(csv => {
        const lines = csv.split(/\n/)
        for (let i = 0; i < lines.length; i++) {
          // DOM 접근은 지원하지 않으므로 부모에 행을 메시지로 보낸다.
          self.postMessage(lines[i])
        }
      })
  }
}.toString(),
')()' ], { type: 'application/javascript' } ) ),
// 그 꼼수를 적용한 Web Worker
const worker = new Worker( blobURL );
// DOM 접근이 가능한 여기서 메시지 받을 때 작업하도록 한다.
worker.onmessage = e => {
  if (e.data) {
    const tr = document.createElement('tr')
    const row = e.data.split(',')
    if (row.length) {
      row.forEach(cell => {
        tr.appendChild(document.createElement('td')).textContent = cell
      })
    }
    tbody.appendChild(tr)
  }
}
// 작업 시작
worker.postMessage('작업시작!')

좀 길어졌다. 하지만 원하던 순서도 보장할 수 있고, 로직이라도 그대로 사용할 수 있다. 비록 DOM 접근 되냐 안되냐에 따른 분리는 불가피하지만, 가장 원하는 시나리오가 완성되었다.

허무하게 마치며

예전에 어떤 커뮤니티 사이트에 질문이 있었는데, 비동기를 쓰면 브라우저가 안뻗는다 하여 위에 예시처럼 했는데 왜 브라우저가 뻗냐는 질문이 생각나며 글을 싸질렀다.
여러분같은 똑똑한 프론트엔드 개발자는 이런 실수 하지 않을 거라 믿는다.
에이 설마 하겠어? ㅋㅋ

사실 나도 옛날에 이런 실수 했었다.
심지어 그땐 MicroTask 나오기도 전이었다.
물론 스스로 발견하고 수정하긴 했지만...

끗.

profile
지옥에서 온 개발자

0개의 댓글