thumbnail thumbnail
  • 개발
  • frontend

UI 블로킹을 막는 최적화 전략에 대해 알아보자

Moon

서론: 동기식 코드의 한계 인식

자바스크립트는 그 특성상 비동기적인 I/O 작업을 처리하는 데 있어서 매우 효율적인 언어입니다. 이는 자바스크립트가 non blocking 전략을 채택하여 I/O 요청을 수행하는 동안 CPU를 점유하지 않기 때문입니다. 이는 네트워크 요청, 파일 시스템 작업과 같은 I/O 바운드 작업을 비동기적으로 처리할 수 있게 하여, 프로그램이 다른 작업을 계속 진행할 수 있도록 합니다. 그러나, for문과 같은 non-I/O 작업들은 이러한 비동기 처리의 혜택을 받지 못하고 동기적으로 수행되기 때문에, 긴 작업(long task)은 CPU를 오랫동안 점유하는 한계를 가지고 있습니다.

이러한 한계를 극복하기 위한 하나의 전략은 동기식 코드를 여러 이터레이터로 분할하여 각각을 비동기적으로 처리하는 것입니다. 이 접근법을 통해, 복잡하고 시간이 많이 소요되는 작업들을 작은 단위로 나누어 처리함으로써, 전체 작업의 효율성을 높일 수 있습니다. 이러한 방식은 UI 블로킹을 방지하고 사용자 경험을 향상시키는 데에 중요한 역할을 하며, 자바스크립트 애플리케이션의 성능을 크게 향상시킬 수 있습니다.

본 포스팅에서는 이러한 동기식 코드를 비동기식으로 전환하는 과정을 탐구하고, 이를 통해 얻을 수 있는 이점과 함께 구현 방법에 대해 자세히 살펴볼 것입니다. 이는 개발자들이 복잡한 작업을 더 효율적으로 관리하고, 최종적으로 사용자에게 더 나은 경험을 제공할 수 있도록 돕는 중요한 기술입니다.

예시: 1000만개의 숫자 정렬로 발생하는 UI 블로킹

이해의 편의를 위해, 동기식 코드가 애플리케이션 성능에 미칠 수 있는 한계를 명확하게 보여주는 실제 예시를 소개하겠습니다. 사용자가 웹 페이지 상의 '정렬' 버튼을 클릭했을 때, 클라이언트에서 1000만 개의 숫자가 오름차순으로 정렬되는 모습을 상상해 보세요. 숫자의 양이 방대하기 때문에, 이 정렬 과정은 상당한 시간이 소요됩니다. 정렬 알고리즘의 시간 복잡도에 따라, 처리 시간은 더욱 길어질 수 있습니다. 예를 들어, 평균적으로 O(nlogn)O(nlogn) 의 시간 복잡도를 가지는 퀵 정렬 알고리즘을 사용한다 하더라도, 1000만 개의 숫자를 정렬하는 데에는 상당한 시간이 소요됩니다.

동시에, 사용자가 페이지 상의 또 다른 버튼, 예를 들어 '숫자 증가' 버튼을 클릭하여 숫자의 카운트를 증가시키려고 시도한다고 가정해 봅시다. 이 버튼은 각 클릭시마다 숫자를 1씩 증가시키고 화면에 그 결과를 바로 반영해야 합니다. 그러나, 정렬 작업이 동기적으로 처리되고 있기 때문에, 정렬 작업이 완전히 끝나기 전까지는 UI 스레드가 그 어떤 다른 작업도 수행할 수 없게 됩니다. 결과적으로, 사용자는 '숫자 증가' 버튼을 클릭해도 화면에 즉각적인 반응이 없는 것을 경험하게 됩니다. 이는 정렬 작업이 진행되는 동안 UI가 완전히 블로킹 되어있음을 의미합니다.

ui blocking 예시

[md] sync - 정렬작업중엔 ui blocking

동기식 코드를 비동기화 코드로 전환하는 방법

동기식 코드를 비동기로 바꾸는 전략은 애플리케이션의 반응성과 성능을 크게 향상시킬 수 있는 핵심적인 접근 방법입니다. 이러한 전환은 복잡하고 시간이 많이 소요되는 작업을 여러 이터레이터로 나누어 각각을 비동기적으로 처리함으로써 구현됩니다. 자바스크립트에서는 Promise, async/await와 같은 비동기 프로그래밍 패턴을 사용하여, 각 이터레이터가 독립적으로 실행되도록 함으로써 동기 작업의 블로킹 문제를 해결할 수 있습니다. 이 방법을 통해, 하나의 긴 작업을 수행하는 동안에도 사용자 인터페이스가 계속 반응적으로 유지될 수 있으며, 다른 작업들이 동시에 진행될 수 있습니다. 이 전략은 특히 데이터 처리, 파일 읽기/쓰기와 같은 작업에 있어서 UI 블로킹을 방지하고, 전반적인 사용자 경험을 개선하는 데 매우 효과적입니다.

비동기 코드로 전환하기 전, 주의할점 - 동시성 관리

비동기화 코드로 전환하기 전에, 비동기식 코드가 non-blocking 이점을 제공함에도 불구하고, 주의 깊게 관리하지 않으면 예상치 못한 문제에 직면할 수 있다는 점을 인식하는 것이 중요합니다. 특히, 동시에 수십만 개의 비동기 작업이 호출되는 상황이 발생하면, 시스템의 자원을 과도하게 사용하게 되어, 최악의 경우 Out Of Memory Problem (OOP)이 발생할 수 있습니다. 이러한 문제는 시스템이 가용 메모리를 모두 소진하게 되어, 애플리케이션의 성능 저하나 심지어는 중단까지 이어질 수 있습니다.

이러한 문제를 방지하기 위해, 비동기 작업의 동시성(concurrency)을 적절히 관리하는 전략이 필수적입니다. 동시성 관리란, 한 번에 처리될 수 있는 비동기 작업의 수를 제한하여, 시스템 자원의 과부하를 방지하는 방법을 말합니다. 이는 특정 시점에 실행되는 비동기 작업의 최대 개수를 설정해서 시스템이 안정적으로 운영될 수 있도록 보장합니다.

예시

Typescript
function doAll(callbacks: () => Promise<any>, concurrency: number) { return new Promise((resolve) => { let index = 0 let running = 0 function next() { if(index === callbacks.length) return resolve("done") // concurrency를 제한한다. while(runnging < concurrency) { const callback = callbacks[index] running++ index++ callback.then(() => { running-- next() }) } } next() }) }
callback을 micro task에 queueing 하는 개수를 제한한다.

코드를 여러 개의 테스크로 분할하는 방법

동기식 코드를 비동기식으로 효과적으로 전환하기 위한 한 가지 접근 방법은, 코드를 여러 Iterator로 분리하여 각 단계를 명확히 구분하는 것입니다. 이러한 방식은 Spring Batch의 인터페이스에서 영감을 받아, read, write, process의 세 가지 기본 단계로 코드를 조직하는 데 중점을 둡니다. 이 접근 방식을 구현하기 위해, 우리는 Scheduler라는 추상 클래스를 정의하여, 이 세 단계를 사용자가 커스텀 할 수 있도록 합니다.

Typescript
/* * 이 메소드는 데이터 소스로부터 데이터를 읽어오는 역할을 합니다. * 이 데이터는 다음 단계인 process 단계에서 처리될 원시 데이터(SOURCE) 배열을 반환합니다. */ protected abstract read(): SOURCE[]; /* * read에서 읽어온 데이터를 실제로 처리하는 역할을 합니다. * 이 메소드는 원시 데이터(SOURCE) 배열을 받아, 필요한 비즈니스 로직을 적용하여 새로운 데이터(TARGET) 배열로 변환합니다. */ protected abstract process(items: SOURCE[]): TARGET[]; /* * 처리된 데이터(TARGET)를 최종적으로 쓰기(write) 작업을 담당합니다. * 이 메소드는 누적된 결과(acc)와 현재 처리된 결과(current)를 받아, 최종 데이터를 조합하고 반환합니다. */ protected abstract write(acc: TARGET[], current: TARGET[]): TARGET[]

이러한 구조는 개발자가 복잡한 비동기 작업을 보다 체계적으로 관리할 수 있도록 돕습니다. 각 단계는 명확하게 분리되어 있으며, 필요에 따라 쉽게 커스터마이징할 수 있습니다. 예를 들어, 데이터를 비동기적으로 처리하는 데 필요한 다양한 작업을 Scheduler 클래스를 상속받는 하위 클래스에서 구현할 수 있습니다. 이는 코드의 재사용성을 높이고, 비동기 처리 과정을 명확하게 이해하며 개발할 수 있는 기반을 마련합니다.

이 인터페이스를 사용함으로써, 개발자는 비동기 처리를 필요로 하는 다양한 시나리오에서 보다 유연하고 효율적인 코드 작성이 가능해집니다. 특히, 대규모 데이터 처리나 복잡한 비즈니스 로직을 갖는 애플리케이션 개발에 있어서, 이러한 구조적 접근 방법은 코드의 가독성과 유지보수성을 크게 향상시킬 수 있습니다.

결과물 - Scheduler class

위의 내용을 바탕으로 Scheduler 추상클래스를 구현해보겠습니다. run 메서드는 read, process, write 동작을 비동기로 바꿉니다. 이때 setTimeout을 통해 macro task에 queueing 해야하는 점을 눈여겨 보세요. 브라우더 랜더링은 macro task와 주기가 관련되어 있기 때문에, 만약 micro task queueing(process.nextTick)에 머무른다면 여전히 UI 블로킹이 발생합니다. do 메서드에서는 run을 반복적으로 호출함과 동시에 concurrecny 제한을 지키는 모습을 확인할 수 있습니다.

Typescript
abstract class Scheduler<SOURCE, TARGET> { private limit: number; constructor({ concurrency = 1, }: { concurrency?: number } = {}) { this.limit = concurrency; } protected abstract read(): SOURCE[]; protected abstract write(acc: TARGET[], current: TARGET[]): TARGET[] protected abstract process(items: SOURCE[]): TARGET[]; private run(acc: TARGET[], done: () => void): Promise<TARGET[]> { return new Promise((resolve) => { setTimeout(() => { const sources = this.read(); if (sources.length === 0) { done(); } const targets = this.process(sources); const result = this.write(acc, targets); resolve(result); }, 0); }); } do(): Promise<TARGET[]> { return new Promise((resolve) => { let isDone = false; let running = 0; let targets: TARGET[] = []; const next = () => { if (isDone) { resolve(targets); return; } while (running < this.limit) { running++; this.run(targets, () => { isDone = true; }).then((result) => { running--; targets = result; next(); }); } }; next(); }); } } export default Scheduler;
Scheduler - concurrency의 개수를 설정할 수 있다.

Scheduler를 상속해 정렬을 수행하자

위에서 제공하는 Scheduler 인터페이스에 맞게 SortScheduler 코드를 작성해보겠습니다. process에서 전체 배열의 일부분을 정렬하고, write를 통해 정렬된 배열을 병합합니다. concurrency 개수는 20개로 제한해보겠습니다. 아래는 코드 예시입니다.

Typescript
class SortScheduler extends Scheduler<number, number> { constructor(private numbers: number) { super({ concurrency: 20 }); } index = 0; size = 10000; protected override read(): number[] { const chunk = this.numbers.slice(this.index, this.index + this.size); this.index += this.size; return chunk; } protected override process(chunk: number[]): number[] { return chunk.sort((a, b) => a - b); } protected override write(acc: number[], chunk: number[]): number[] { const [arr1, arr2] = [acc, chunk]; const mergedArray: number[] = []; let i = 0; // arr1의 인덱스 let j = 0; // arr2의 인덱스 // 두 배열을 비교하면서 더 작은 값을 mergedArray에 추가 while (i < arr1.length && j < arr2.length) { if (arr1[i] < arr2[j]) { mergedArray.push(arr1[i]); i++; } else { mergedArray.push(arr2[j]); j++; } } // arr1에 남은 요소가 있으면 추가 while (i < arr1.length) { mergedArray.push(arr1[i]); i++; } // arr2에 남은 요소가 있으면 추가 while (j < arr2.length) { mergedArray.push(arr2[j]); j++; } return mergedArray; } }

결과물 - UI Blocking이 발생하지 않는다.

Scheduler 추상 클래스를 활용한 구조를 통해, 우리는 이제 UI 블로킹 없이 복잡한 작업을 수행할 수 있는 효과적인 예시를 제공할 준비가 되었습니다. 예를 들어, 사용자가 '정렬' 버튼을 클릭하면, Scheduler 클래스는 백그라운드에서 수백만 개의 숫자를 오름차순으로 정렬하는 작업을 비동기적으로 처리합니다. 이 과정에서 핵심은, 정렬 작업이 진행되는 동안에도 사용자가 '숫자 변경' 버튼을 클릭하여 UI 상의 숫자를 자유롭게 조정할 수 있다는 점입니다.

이는 Scheduler 클래스가 각 작업을 독립적인 이터레이터로 분리하여 비동기적으로 처리하기 때문에 가능해집니다. 정렬 과정이 별도의 비동기 작업으로 수행되면서, 메인 스레드는 사용자의 다른 인터랙션을 계속해서 수용할 수 있게 됩니다. 결과적으로, 사용자는 정렬 작업의 진행 상황과 상관없이, 애플리케이션을 원활하게 사용할 수 있으며, UI 블로킹에 의한 불편을 겪지 않게 됩니다.

[md] async - 숫자가 24로 변할 때 정렬 완료

마치며

이번 포스팅에서는 UI 블로킹을 막기 위해 동기식 코드를 여러 작업으로 분할하여 비동기화 하는 방법에 대해 서술했습니다. 갑자기 이런 주제로 포스팅을 작성한 이유는 오늘 Node.js 디자인 패턴 바이블 이란 책을 읽으면서 promise의 동시성을 통제하는 코드를 보고 영감을 받았기 때문입니다. 전직장에 근무할때 대용량 테이블 라이브러를 만들면서 이런 기능도 추가하고 마무리하려고 했는데 아쉬운 마음을 이렇게 달래 봅니다.

Scheduler 클래스를 제대로 구현하기 위해서는 CPU와 메모리 현황도 체크해야합니다. 여러개의 Scheduler가 동시에 호출되어도 서로 조율될 수 있어야 하기 때문입니다. 지금은 단순히 setTimeout의 시간을 0으로 한정 했지만, cpu 사용량에 따라 적절하게 조율 할 수도 있을 것입니다. 또한 concurrency의 숫자도 자동으로 설정할수도 있을거구요. 다만 시간과 능력이 부족해 여기까지는 알아보지 못했습니다.

포스팅에서 소개한 코드의 전문을 보고 싶으면 github 링크를 확인해주세요

추신: 위의 요구사항과 관련해서 라이브러리를 찾아봤는데 async 란 라이브러가 있더라구요. 비동기를 순차적으로 순회할때 유용해보입니다.

Reference