회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
AWS 이용 중이라면 최대 700만 원 지원받으세요
*FEConf2023에서 발표한 <use 훅이 바꿀 리액트 비동기 처리의 미래 맛보기>를 정리한 글입니다. 발표 내용을 2회로 나누어 발행합니다. 1회에서는 use훅의 등장과 특징에 관해 살펴보고 실제 실무에서 사용한 코드를 바탕으로 기존 훅의 제약을 살펴봅니다. 이번 글 use훅이 바꿀 리액트 비동기 처리의 미래 맛보기 2회에서는 기존 훅의 제약을 use를 통해 어떻게 해결했는지를 알아보고, use훅에도 존재하는 제약을 살펴보며 앞으로의 리액트를 생각해봅니다. 1회를 먼저 읽고 오시길 권합니다. 본문에 삽입된 이미지의 출처는 모두 동명의 발표자료로, 따로 출처를 표기하지 않았습니다. 발표자료는 FEConf2023 홈페이지에서 다운받을 수 있습니다.
회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
회원가입을 하면
성장에 도움이 되는 콘텐츠를
스크랩할 수 있어요!
확인
*FEConf2023에서 발표한 <use 훅이 바꿀 리액트 비동기 처리의 미래 맛보기>를 정리한 글입니다. 발표 내용을 2회로 나누어 발행합니다. 1회에서는 use훅의 등장과 특징에 관해 살펴보고 실제 실무에서 사용한 코드를 바탕으로 기존 훅의 제약을 살펴봅니다. 이번 글 use훅이 바꿀 리액트 비동기 처리의 미래 맛보기 2회에서는 기존 훅의 제약을 use를 통해 어떻게 해결했는지를 알아보고, use훅에도 존재하는 제약을 살펴보며 앞으로의 리액트를 생각해봅니다. 1회를 먼저 읽고 오시길 권합니다. 본문에 삽입된 이미지의 출처는 모두 동명의 발표자료로, 따로 출처를 표기하지 않았습니다. 발표자료는 FEConf2023 홈페이지에서 다운받을 수 있습니다.
FEConf2023에서 발표된 ‘use훅이 바꿀 리액트 비동기 처리의 미래 맛보기’/문태근 데브시스터즈 프론트엔드 엔지니어
이제 저희 팀에서 use 훅이 바꿀 리액트 비동기 처리의 미래 맛보기 1편 맨 마지막 부분에서 말한 문제점들을 use를 사용해 어떻게 해결했는지 알아보겠습니다. 다들 잘 알고 계시는 것처럼 훅은 사용할 수 없는 곳이 굉장히 많습니다.
1. 조건문, 반복문
2. return문 다음
3. 이벤트 핸들러
4. 클래스 컴포넌트
5. useMemo, useReducer, useEffect에 전달한 클로저
반면 use는 조건부로 호출이 될 수 있기 때문에 조건문, 반복문 그리고 return문 다음에 사용이 가능합니다. 따라서 기존에 작성한 코드는 아래와 같이 use를 사용해서 리소스 데이터를 사용하는 곳에서 조회하도록 바꿀 수 있습니다.
위 방법을 통해 코드 상단의 불필요한 훅을 제거하여 DX를 향상시켰고, 필요한 순간에만 리소스를 로딩하여 UX를 더욱 좋게 향상시킬 수 있었습니다.
그렇다면 리액트 쿼리와 같은 fetch 라이브러리들을 전혀 사용할 필요 없이 use만 사용하면 될까요? 사실 그렇지는 않습니다. use는 로우 레벨 API이기 때문에 더 많은 기능과 편의성을 제공하는 라이브러리들에 비해 고려할 점이 더 많습니다. 실제로 저희 팀은 use를 사용하면서 몇 가지 문제점을 해결하기 위해 필요한 기능 일부를 직접 구현했습니다. 첫 번째는 중복 fetch 문제 해결을 위한 캐시 기능이고 두 번째는 리퀘스트 워터폴 문제 해결을 위한 prefetch 기능입니다.
아래 코드는 앞서 작성한 use를 사용한 useInventory 코드를 단순화시킨 코드입니다. 사실 이 코드에는 한 가지 버그가 있습니다. 어떤 버그인지 아시겠나요?
노멀 아이템을 fetch 하는 promise가 resolve 되면 리렌더링이 발생하고, 리렌더링이 완료되면 다시 fetch을 시작하게 되고 이 과정이 무한히 반복되는 버그입니다. 이 버그는 간단한 방법으로 해결할 수 있습니다. 바로 fetch를 리턴하던 fetchNormalItems라는 훅에 캐시 기능만 추가하면 됩니다.
일종의 메모이제이션(memoization)이라고 생각할 수 있습니다. 이렇게 하면 fetchNormalItems는 항상 동일한 promise 인스턴스를 리턴하게 됩니다. 추가적으로 필요한 경우 promise의 refetch 기능도 있으면 더욱 좋을 것 같습니다. 이 캐시 API는 리액트 공식 api로 추가될 예정입니다. 현재는 실험적 기능으로만 제공되고 있고 서버 컴포넌트에서만 사용 가능합니다. 저희 팀에서는 lodash 라이브러리의 memoize 함수를 사용하여 처리했습니다.
두 번째는 리퀘스트 워터폴 문제입니다. 이번에는 검색 키워드가 있는 경우를 가정하겠습니다. 아래 코드는 인벤토리 아이템들을 순회하면서 아이템이 노말 아이템인 경우 ‘use(fetchNormalItems())’가 호출되고 로딩이 시작됩니다. 이때 use를 사용했기 때문에 블로킹이 발생합니다. 노말 아이템에 대한 로딩이 끝난 다음 이벤트 아이템에 대한 로딩이 진행되고 또 블로킹이 발생합니다. 모든 아이템에 대한 순회가 끝나면 훅이 종료됩니다. 이 두 가지 아이템들은 아래 그림처럼 순차적으로 로딩이 됩니다.
사실 이 두 아이템 들은 순차적으로 로딩될 필요가 없습니다. 동시에 로딩되는 것이 더 바람직합니다. 이와 같이 불필요한 순차적 로딩 현상을 리퀘스트 워터폴이라고 합니다.
일반적으로 데이터 fetch 라이브러리들은 이러한 문제를 해결하기 위해 prefetch나 패러렐 쿼리(parallel query)를 사용합니다. 우리는 prefetch를 사용해서 이 문제를 해결해 보겠습니다. prefetch를 구현하는 것은 아주 간단합니다. fetch를 사전에 한 번 더 하면 됩니다.
이렇게 하면 코드 상단에서 모든 아이템에 대한 fetch가 시작됩니다. 이 부분에서는 use를 사용하지 않았기 때문에 블로킹이 발생하지 않습니다. 실제로 리소스 데이터를 사용하는 코드에 도달하기 전까지 아이템에 대한 로딩이 백그라운드에서 진행되고 있습니다. 그리고 인벤토리 아이템을 순회하는 코드에서 노말 아이템을 fetch 하여 prefetch의 로딩을 이어가고 블로킹이 발생합니다. 노말 아이템에 대한 로딩이 끝나면 이벤트 아이템을 fetch 하여 블로킹이 발생하고 로딩이 끝나면 훅이 종료됩니다. 이렇게 fetch를 미리 해주는 것으로 리퀘스트 워터폴을 해결할 수 있습니다.
위 방법으로 리퀘스트 워터폴을 해결했지만, use를 사용하면서 해결한 코드 응집성 문제가 다시 발생하게 됩니다. 저희 팀은 이 문제를 다이내믹 prefetch라는 아이디어로 해결했습니다.
다이내믹 prefetch란 prefetch 할 대상을 소스 코드에 기록하는 것이 아니라 런타임에 동적으로 결정하는 방법입니다. 아래와 같이 ‘현재 페이지에서 노말 아이템을 사용했다’는 정보를 노말 아이템 fetch 함수에서 localstorage에 기록하는 것입니다. 그리고 실제로 페이지에 접속했을 때 발생하는 이벤트 핸들러에서 prefetch를 발생시키는 코드를 작성하면 됩니다.
이렇게 하면 기존에 useInventory에서 사용한 prefetch를 바깥 어딘가로 코드를 옮길 수 있습니다. 일종의 관심사 분리라고도 할 수 있습니다. 이때 옮겨간 코드는 다이내믹 prefetch이기 때문에 어떤 리소스 데이터를 fetch 할지는 런타임에 결정됩니다. 그렇기 때문에 실제 노멀 아이템과 이벤트 아이템을 fetch 하는 코드는 사용하는 곳에서 단 한 번만 작성하면 됩니다.
기존에 use를 사용하기 전에는, promise를 컴포넌트나 훅 안에서 사용하기 위해서 데이터 fetch 라이브러리의 훅을 통한 간접적인 사용만 가능했습니다. use를 사용하면 불필요한 훅을 사용할 필요 없이 promise를 직접 사용할 수 있게 됩니다. 이는 앞서 RFC 문서에서 언급한 promise에 대한 1급 지원을 의미합니다. 그리고 데이터 fetch 라이브러리에서 지원하는 캐시나 prefetch를 추가적으로 구현하는 방법을 간단하게 알아봤습니다.
이 내용을 준비하면서 놀라운 사실을 알게 됐습니다. 바로 use에도 제약이 있다는 점입니다. 앞서 use의 안정화되지 않은 사용을 언급할 때, 임의의 함수에서 promise를 throw 할 수 있다고 설명했습니다. 그러나 실제 정식 출시한 use는 모든 함수가 아닌, 리액트 컴포넌트나 훅 안에서만 use를 사용할 수 있다고 합니다.
이 제약에 따르면 지금까지 제가 작성했던 코드는 잘못된 코드였기 때문에 굉장히 당황스럽고 놀랐습니다. 혹시 어떤 게 잘못됐는지 짐작 가는 게 있으신가요? 아래의 코드를 보면 filter에서 전달한 closure 함수에서 use를 사용하고 있습니다. 바로 이 부분이 잘못된 코드입니다.
이 제약을 알게 된 다음 저는 혼란스러웠습니다. 기존의 useState나 useEffect는 이러한 제약 조건을 지키지 않으면 에러가 발생합니다. 그러나 지금까지 저희는 제약 없이 use를 사용했지만 아무런 문제가 발생하지 않았습니다. 이 제약을 지키면 map, filter, reduce 같은 함수에서 use를 사용할 수 없고, 중복 로직 또한 함수로 묶어서 사용하는 것이 불가능합니다. 즉, 컴포넌트나 훅 안에서 직접적인 호출만 가능합니다. 저는 이 제약이 치명적이라고 생각했습니다. 그래서 RFC 문서에서 제약 조건이 왜 있는지 찾아봤습니다.
‘use를 호출하는 함수가 리액트 컴포넌트나 훅에서 호출되면 런타임에서는 작동하지만, 이 코드는 컴파일러 에러를 발생시킬 것이다.’라고 합니다. 여기서 재밌는 키워드가 있습니다. 바로 컴파일러라는 단어입니다. 이 컴파일러가 무엇을 말하는 걸까요? 지금까지 사용한 바벨이나 웹팩, 타입스크립트 컴파일러일까요? 놀랍게도 앞서 말한 컴파일러가 아니라 2021년 리액트 컨퍼런스에서 소개한 React Forget이라는 최적화 컴파일러입니다.
async와 await는 자바스크립트의 문법적인 요소이기 때문에 컴파일러에 기반한 최적화가 가능합니다. 예를 들어 바벨은 구 버전의 브라우저를 대상으로, 비동기 함수를 제네레이터를 사용하는 코드로 트랜스파일링합니다. 제네레이터는 동기 함수이기 때문에 성능적인 이점이 더 있을 수 있다고 합니다. 그리고 최근에 서버 컴포넌트가 출시되면서 비동기 컴포넌트를 작성할 수 있게 되었습니다. 이 서버 컴포넌트에 대한 성능 최적화도 개발 중이라고 합니다.
반면에 use는 함수이지만 리액트 내에서 문법 요소처럼 역할하도록 강제할 수 있습니다. 예를 들어 await는 async 함수에서만 사용할 수 있고, yield는 제네레이터 안에서만 사용할 수 있다는 제약처럼 use는 컴포넌트나 훅 안에서만 사용할 수 있다는 제약을 통해 강제하는 것입니다. use의 제약은 문법적인 역할을 하도록 하는 제약이고, 이 제약은 앞으로 다가올 리액트 컴파일러에서의 최적화를 가능하게 하기 위한 대비라고 할 수 있습니다.
이 내용들을 찾아보면서 놀라운 점을 많이 알게 됐습니다. 처음 React Forget 컴파일러를 소개할 때는, 단순히 useMemo를 자동으로 해주는 정도로 소개했는데, 이 컴파일러를 통해 비동기 최적화를 준비한다고 하니 신기하고 기대가 됐습니다. 반면 컴파일러 최적화를 위한 use의 문법적인 제약이 DX를 저하시키지 않을까라는 걱정도 들었습니다. 자연스럽게 이런 제약을 우회할 방법이 있는지 생각하게 됐습니다. 예를 들어 @ts-ignore와 같은 방법을 이용해 use의 제약을 우회하여 사용할 수 있지 않을까 생각도 됩니다. 다만, 아직 컴파일러가 어떤 형태인지 알 수 없기 때문에 정식 출시를 하고 나서야 정확하게 알 수 있을 것 같습니다.
React Forget은 아직 비공개 상태이고, 알파 단계에도 들어가지 못한 상태입니다. RFC 문서에 의하면 use 훅은 cache API와 함께 출시할 예정인데, cache는 아직 RFC 문서도 없는 상태입니다. 따라서 앞서 설명한 안정화되지 않은 방법으로 use를 좀 더 사용하게 되지 않을까 하는 생각이 듭니다.
오늘 설명드린 use 훅이나 React Forget이 해결하려는 문제는 비슷합니다. 바로 오늘날의 리액트는 DX와 UX를 동시에 잡기 어렵다는 것입니다. 다들 리액트로 개발하다 보면 비슷한 경험을 했을 거라 생각되는데, 리액트의 리렌더링 문제를 최적화하기 위해 즉, UX를 개선하기 위해서는 useMemo를 빈번하게 사용해야 하고 이는 DX를 저하시키는 경향이 있습니다. 반대로 useMemo를 사용하지 않는다면 UX가 저하되는 경향이 있어 트레이드오프가 발생합니다.
React Forget도 이 문제를 해결하기 위한 방법입니다. 즉, UX에 대한 개선을 컴파일러가 담당하게 되고, 개발자에게는 최고의 DX를 경험을 제공하겠다는 것입니다. use도 비슷한 맥락이라고 생각합니다.
오늘 예시를 통해 소개드린 문제들은 또 다른 다양한 방법으로 해결이 가능합니다. 다만 이러한 과정을 위한 코드 작성이 굉장히 번거롭습니다. 리액트 팀도 이 점을 잘 알고 있기 때문에 DX와 UX를 함께 개선하기 위해 점점 발전해 나갈 것이라 생각합니다. 이러한 노력을 바탕으로 발전할 앞으로의 리액트가 기대됩니다.
이번 글에서는 use라는 새로운 훅을 통해 클라이언트 컴포넌트에서 promise를 1급 지원한다는 내용을 소개했고, 실제 코드를 통해 use를 사용해 DX와 UX를 어떻게 개선했는지 알아봤습니다. 또, use 훅의 제약 조건을 통해 앞으로의 리액트는 어떤 모습일지 생각해 봤습니다. 이 글에서는 다루지 못했지만 아래와 같은 재밌는 주제들도 있습니다.
1. 동기 함수 promise 블로킹 원리 - 컴포넌트 멱등성
2. promise 객체에서 결과 꺼내기
3. use를 context에서 사용하기
4. 서버 컴포넌트에서 use와 await를 함께 쓰기
이런 내용들은 앞서 말씀드린 RFC 문서에 잘 정리가 되어 있으니 시간 되실 때 한 번씩 읽어보시면 좋을 것 같습니다. 이 글을 통해 use 훅에 대해 알고 어떻게 사용할지 고민해 보면서 다가올 리액트의 미래에 대해 생각해 볼 수 있는 계기가 되었으면 좋겠습니다. 감사합니다.
글 FEConf
편집 오신엽 객원 에디터
요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.