[TIL/React] 2024/12/12

원민관·2024년 12월 12일

[TIL]

목록 보기
161/201
post-thumbnail

비동기와 리액트...(1)

1. Overview ✍️

지금까지 TIL 역사상 가장 긴 분량의, 동시에 가장 완성도 높은 글을 작성할 예정이다. 개요를 구성하는 데에만 정말 많은 시간이 소요되었다. 브라우저를 배회하다가 이 글을 발견할 chobo자를 위해 최대한 이해하기 쉽게 작성했다. 발단은 다음과 같았다.

프로젝트 특성상 에디터를 적용해야 했는데, 에디터 UI가 등장하기 전에 fallback UI를 렌더링 하면 더 좋은 사용자 경험을 제공할 수 있을 것이라 생각해서 Suspense를 도입했다. 그런데 실제로 사용할수록, 그리고 공식 문서를 읽을수록 풀리지 않는 의문이 있었고, 그 내막을 톺아보다 보니 React 전반에 걸쳐 있는 핵심적인 통찰이 담겨 있었다.

정리해야 할 내용이 상당히 길어서 목차 간의 flow를 다듬는 데 상당히 애를 썼다. 딥 다이브를 시작해 보자.

2. Asynchronous Operation ✍️

하나의 작업이 끝날 때까지 대기 상태를 유지하는 동기(synchronous) 방식의 비효율, 그로 인한 사용자 경험 저하를 개선하기 위해 비동기(asynchronous) 방식이 도입되었다. 문제와 해결은 늘 연결되어 있다. 비동기 방식의 핵심은 끝날 때까지 기다리지 않겠다는 본질을 내포한다.

2-1. Parallel Processing 🎯

하나의 작업이 끝날 때까지 다른 작업이 대기하지 않겠다는 것은 곧 병렬 처리를 의미한다. 동기 방식이 직렬 처리라면, 비동기 방식은 병렬 처리다. 다음 이미지를 참고하면 동기와 비동기, 직렬과 병렬에 대한 이해를 쉽게 할 수 있다.

2-2. Thread(Single/Multi) 🎯

동기 방식의 비효율을 개선하기 위해 비동기 방식이 도입되었고, 비동기 방식의 핵심은 병렬 처리이다. 그런데 병렬 처리를 어떻게 하는지 얕게나마 살펴볼 필요는 있다.

Thread는 하나의 작업 단위이다. 작업을 담는 버킷이라고 이해하면 편하다.

이라는 버킷에는 변수나 데이터가 저장된다. 콜 스택은 작성한 코드를 실행해주는 또다른 종류의 버킷이다. 콜 스택은 단일 스레드이기 때문에 한 번에 한 줄의 코드만 실행할 수 있다는 특징을 갖는다.

웹 API는 자바스크립트에서 비동기 작업을 처리하기 위해 브라우저와 같은 환경에서 제공하는 API이다. setTimeout, fetch 같은 API들은 비동기 작업을 처리하게 된다. 일반적인 코드와 달리 비동기적으로 처리되어야 하는 특징을 갖고 있는 코드들의 대기실이라고 볼 수 있겠다.

웹 API에 1초가 소요되는 fetch 함수가 대기하고 있다고 가정하자. 1초가 지난 후에는, 곧바로 실행해야 할 코드로 지위가 바뀐다. 다만 해당 코드가 바로 콜 스택으로 이동하진 않고, 콜백 큐라는 또 다른 대기실로 넘어가게 된다. 그러니까 큐는, 대기가 완료된 기존 비동기 작업들이 적재되는 장소인 것이다. 어쨌든 콜백 큐에서 다시 콜 스택으로 해당 작업을 보내야 하는데, 이때 콜 스택이 비어있는 경우에만 콜백 큐에서 콜 스택으로 작업이 넘어가게 된다. 이렇게 큐에서 스택으로 할당하는 매커니즘을 멋진 용어로 이벤트 루프라고 한다.

자바스크립트는 기본적으로 single thread, 즉 작업을 처리하기 위한 버킷이 하나다. 따라서 하나의 작업이 끝날 때까지 다른 작업들이 대기하는 동기 방식을 취하게 된다. 반면 브라우저 내부는 multi thread로 구성되어 있다. 작업을 처리하는 버킷이 여러 개고, 각각의 버킷에서 작업을 독립적으로 수행하기에 병렬적인 처리가 가능한 것이다.

요컨대, 리액트에서 비동기 작업은 병렬 처리로 구현되고, 병렬 처리는 브라우저 내부의 멀티 스레딩 방식에 의해 가능하게 된 것이다. 중요한 것은 이벤트 루프 방식에 의해 멀티 스레딩으로 동작하는 '척'을 한 것이지, 자바스크립트 자체가 멀티 스레딩이라고 오해하면 안 된다. 어떤 글을 읽어보니 자바스크립트가 직접 병렬처리를 한다고 설명되고 있었다. 자바스크립트는 기본적으로 싱글 스레딩 방식을 취한다는 것을 염두에 두자.

특히 이 파트는 어둠의 생활 코딩, 코딩 애플님의 10분짜리 강의만 보면 명확하게 이해가 된다. 참고하시면 많은 도움이 될 것이다.

https://www.youtube.com/watch?v=v67LloZ1ieI

2-3. Order Guarantee 🎯

비동기 방식은 동기 방식에 비해 효율성이 압도적으로 높다는 것은, 지금까지 작성한 글을 대충이라도 읽었다면 이해하는 데 어려움이 없다. 다만, 비동기 방식은 작업 순서를 보장하기 어렵다는 치명적인 단점이 있다.

예를 들어 dataFetch() 함수와 dataProcess() 함수가 있다고 가정해 보자. 병렬 처리를 하는 것 까지는 좋은데, 1)데이터를 가져온 뒤 2)프로세싱을 거치는 것이 순서상 맞다. 병렬 처리를 한다고 해도 작업 간의 순서를 보장해야 하는 케이스가 다수 존재하는 것이다. 게다가 작업 순서가 보장되지 않는 경우에는 에러 처리가 상당히 어렵다. 아니, 사실 불가능에 가까워진다.

2-3-1. Callback Function 🔍

콜백 함수는 이러한 비동기 작업에서의 순서 보장 이슈를 해결하기 위해 도입된 방식이다. 콜백 함수란 다른 함수에 인수로 전달되어, 특정 작업이 완료된 후 호출되는 함수다. 다음에 실행될 함수를 명시적으로 표현했다는 점에서, 비동기 작업에서의 실행 흐름을 제어할 수 있는 방식이라고 볼 수 있다. 다음 이미지를 통해 콜백 함수가 무엇인지, 동시에 콜백 함수의 치명적인 단점이 무엇인지 파악할 수 있다.

1번 비동기 작업이 처리된 후에는 2번 비동기 작업이 수행되어야 한다는 식의 코드이다. 그런데 콜백 함수가 지나치게 중첩되어 있다. 이른바 Callback Hell 상황이 발생하는 것이다. 가독성이 저하될뿐더러 디버깅과 유지 보수에 큰 어려움이 발생하게 된다.

비동기 방식의 치명적인 단점인 순서 보장을 해결하기 위해 콜백 함수를 도입했는데, 지나친 중첩 구조로 코드의 파악이 어려워진 'Callback Hell' 상황이 발생했다.

2-3-2. Promise 🔍

Promise의 임무는 간단했다. 비동기 처리를 하되 작업 순서를 보장하며 중첩 구조에서 탈피하는 것이 핵심 목적이었다. Promise는 작업에 대한 결과(성공 또는 실패)에 대한 처리를 하기 위한 정보를 담고 있는 객체이다.

비동기 작업인 doSomething()의 결과(성공 또는 실패)에 따라 후속 작업의 순서를 보장한다. 해당 결과가 성공일 경우, 이어질 후속 작업을 then 메서드로 체이닝 한다. 결과가 실패라면 catch 블록에서 에러를 처리하도록 한다.

여전히 해결되지 않은 점이 있다. 성공에 대한 처리, 즉 then을 사용할수록 체이닝이 길어지게 되고, callback function에서 지적된 가독성 저하 이슈에서 벗어날 수 없게 된다.

2-3-3. Async / Await 🔍

async/await는 순서가 보장되어야 하는 영역(=함수)에 async 키워드를 부여하는 방식으로 코드를 구현한다.

성공에 대한 처리는 try 블록, 에러에 대한 처리는 catch 블록에서 처리한다. try 블록에는 성공 후속작업을 명시하는데, 해당 작업에 await 키워드를 부여한다. 대략적으로, 아래 예시 코드와 같이 구성된다.

비동기 처리, 작업 순서 부여, 체이닝 이슈 해결을 위해 최종적으로 발전된 형태가 바로 async/await라고 할 수 있다.

3. React Life-Cycle ✍️

3-1. Step 🎯

리액트의 생애 주기에는 주요 3단계가 있다.

1. Mount: component가 생성되고 DOM에 추가되는 단계
2. Update: state나 props 변경에 의한 re-rendering 단계
3. Unmount: component가 DOM에서 제거되는 단계

뜬겁새로 리액트의 생애 주기를 언급한 이유는, 후술하게 될 useEffect와 더 나아가 suspense boundary와 연결되기 때문이다.

3-2. useEffect 🎯

useEffect의 정의는 다음과 같다.

useEffect is a React Hook that lets you synchronize a component with an external system.

external이 핵심인데, component에 대한 external을 의미한다. fetch와 같은 API 호출은 UI Component 요소라고 할 수는 없지만 해당 component와 동기화되어야 하는 external system이라고 볼 수 있다.

useEffect는 다음과 같이 리액트 생애 주기 전반에 걸쳐 있다.

4. Suspense Boundary ✍️

4-1. Suspense 🎯

Suspense는 비동기 작업이 끝날 때까지 fallback UI를 rendering하여 UX 경험에 대한 만족을 제고하고자 사용하는 React에서 제공하는 API다.

이번 글을 작성하게 된 핵심적인 원인이 이 부분에서 발생했다.

앞서 살펴본 useEffect는 Rendering 후에 동기화를 진행한다. 반면 Suspense는 Rendering 중에 로딩을 기다린다.

Suspense는 비동기 작업이 끝날 때까지 fallback UI를 보여주기 위해 사용하는 것인데, 정작 비동기 작업은 useEffect에서 진행되는 경우가 태반이다.

즉 비동기 작업이 렌더링 과정에 반영되지 않기에 Suspense가 무용지물 상태가 되는 것이다. 네트워크 속도를 의도적으로 느리게 만들고 테스트를 진행한 결과 useEffect에서 진행한 fetch 비동기 작업에 따른 fallback UI가 나타나지 않는다는 점을 확인했다.

늘상 문제 정의가 가장 중요하고, 이 과정에 노력의 80% 이상이 투입되어야 한다고 주장한 나로써는 문제 정의를 다시 해야 할 필요성을 느꼈다. 새로운 문제 정의는 다음과 같다.

Rendering 단계에서 비동기적 데이터를 어떻게 로드할 수 있는가

5. React 19 'use' Hook ✍️

5-1. use 🎯

use의 정의는 다음과 같다.

use is a React API that lets you read the value of resource like a Provider or Context.

use Hook은 리액트 공식문서에서 정의한 바와 같이, Promise나 Context와 같은 리소스 값을 읽기 위해 사용한다. component 내에서 사용한다는 점이 핵심이다.

component level에서 사용한다는 것은 rendering 단계에서 사용할 것임을 의미하고, rendering 단계에서 비동기적 데이터를 로드하는 것이 문제 정의인데, Promise의 value라는 것이 비동기적 데이터를 뜻하기 때문이다.

한마디로, use라는 것은 비동기 데이터를 컴포넌트와 결합하여 로딩 상태를 관리하는 React의 새로운 패러다임이라고 정리할 수 있겠다.

공식문서 예제의 messagePromise 부분이 fetch 함수라고 가정하면, 외부 파일에 fetch에 대한 로직이 작성되어 있을 것이다. fetch 로직은 비동기 통신이기에 Promise를 반환할 것이고, 앞서 use가 Promise value를 컴포넌트 내에서 읽을 때 사용한다고 확인했다.

위 코드에서는 messageContent, 즉 value에 대한 비동기 통신이 완료되기 전까지 fallback UI가 나올 수 있게 된다.

6. React.lazy() ✍️

Suspense는 어쨌든 컴포넌트를 비동기적으로 처리하겠다는 의도를 담은 API이다. 컴포넌트의 비동기적 처리에서 조금 더 확장하면 코드 스플리팅 이야기를 할 수밖에 없다. Suspense와 lazy는 컴포넌트의 비동기적 처리라는 관점에서 연결된다.

6-1. lazy 🎯

React.lazy는 컴포넌트가 실제로 화면에 표시되기 전까지 관련 코드를 브라우저가 다운로드하지 않도록 한다. 필요한 시점에 동적으로 컴포넌트를 불러오겠다는 것이고, 초기 로딩 속도 향상을 목적으로 한다. 사실 lazy는 빙산의 일각이고, code splitting에 대해 살펴볼 필요가 있다.

6-2. Code-Splitting 🎯

오늘만 대충 수습하며 사는, 군만두를 굉장히 싫어하는 '오대수'. 코드 스플리팅은, 일단 필요한거 먼저 수습하자는 느낌이 강하다.

그림이 잘 설명해준다. 코드 스플리팅이란, 웹 애플리케이션에서 자바스크립트 번들을 분할하여, 필요한 부분만 로드되도록 하는 기법이다. 이를 통해 초기 로딩 시간을 줄이고, 사용자가 애플리케이션을 사용하는 중에 필요한 코드만 동적으로 로드할 수 있어 성능을 최적화할 수 있다.

질문에 필요한 답을 하고 쓸데없이 주절대지 말자는 것이다.

6-2-1. Static Splitting 🔍

개발에서 빌드는, <컴파일, 압축, 번들링, 최적화> 등을 포함하여 배포 가능한 형태로 만드는 과정을 뜻한다. 이러한 빌드 단계에서 미리 코드 스플리팅을 설정하여 여러 개의 번들로 나누는 방법을 정적 스플리팅이라고 부르기로 했다.

6-2-2. Dynamic Splitting 🔍

동적 스플리팅이란, 사용자가 특정 액션을 취했을 때만 코드가 로드되도록 설정하는 방법이다. 예를 들어, 특정 페이지나 컴포넌트가 렌더링될 때 필요한 코드만 불러오게 된다. React.lazy()는 동적 스플리팅에 해당한다.

7. Conclusion ✍️

  1. 하나의 작업이 완전히 끝날 때까지 대기 상태를 유지하는 동기 방식의 비효율을 개선하기 위해 비동기 방식이 도입됨

  2. 비동기 방식은 병렬 처리를 의미하고 병렬 처리는 곧 멀티 스레딩 방식을 의미함

  3. 효율성은 확보했으나 순서 보장 문제가 생겼고, 콜백 함수 -> Promise -> async/await 순으로 문제를 해결해 나감

  4. 리액트의 생애 주기를 살펴보고, 생애 주기에서 발생하는 useEffect와 Suspense 간의 동기화 이슈에 대해 살펴봄

  5. 해당 이슈를 해결하기 위한 노력의 일환으로 리액트 19 버전에서 제시한 use Hook을 학습함

  6. 비동기적 컴포넌트 렌더링이라는 관점에 기초하여 그 외연을 확장하면 lazy에 대한 논의를 피할 수 없고, 이는 코드 스플리팅과 밀접하게 관련됨

  7. 비동기와 리액트 후속편에서는 번들링이라는 키워드를 통해 이야기를 이어나갈 예정

회고 🌿

문제를 정확히 진단하는 순간 해결책은 자연스럽게 따라온다. ‘무엇을 해야 할지 모르겠다’는 말은, 어쩌면 '지금까지 누적된 나'에 대해 깊이 고민하지 않았다는 증거일지도 모른다. 지금을 명확히 이해하면 다음 스텝이 저절로 보인다. 점과 점을 연결하는 과정은 고단하지만, 내가 가진 희망만이 그 길을 이을 수 있는 동력이다. 믿든 믿지 않든, 결국 내게 주어진 희망에 기대어 나아가는 수밖에 없다. 이번에는 반박 시 내 말이 다 맞음.

profile
Write a little every day, without hope, without despair ✍️

0개의 댓글