- Published on
리액트의 신규 훅, "use"
- Author
- Name
- yceffort
Table of Contents
서론
리액트에는 새로운 기능을 제안할 수 있는 공식적인 창구인 https://github.com/reactjs/rfcs 저장소가 존재한다. 이 저장소는 리액트에 필요한 새로운 기능 내지는 변경을 원하는 내용들을 제안하여 리액트 코어 팀의 피드백을 받을 수 있는데, 이렇게 제안된 이슈 중에는 리액트 코어 팀이 직접 제안하여 리액트 커뮤니티 개발자들의 의견을 들어보는 이슈도 존재한다.
- 서버 컴포넌트: https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md
- 서버 컴포넌트 모듈 컨벤션: https://github.com/reactjs/rfcs/blob/main/text/0227-server-module-conventions.md
이 중에 아직 머지되지는 않았지만 한가지 흥미로운 내용이 존재하는데, 바로 use
라고 하는 새로운 훅이다. 이 훅은 이후에 설명하겠지만 이전의 훅과는 여러가지 차이점이 있는데, 그중에 하나는 조건부로 호출될 수 있다는 것이다. 이 훅도 예전부터 PR로 올라와 있어서 언제쯤 머지되는지 눈여겨 보고 있었는데 👀 도대체가 머지될 기미가 보이지 않아서 굉장히 의아한 차였다. 알고 보니 해당 proposal 을 만든 사람이 meta에서 vercel로 이적하였고 (🤪) 이 과정에서 뭔가 이 작업이 붕뜬게 아닌가 하는 추측아닌 추측을 혼자 해봤다. 그러던 차에 리액트 카나리아 버전에서 use
훅의 존재를 확인하게 되었다.
https://www.npmjs.com/package/react/v/18.3.0-next-1308e49a6-20230330?activeTab=code
왠지 조만간 use
훅이 정식으로 등장할 날이 머지 않은 것 같아 이 참에 한번 다뤄보려고 한다. react rfc에 있는 First class support for promises and async/await
을 읽어보고 use
훅의 실체는 무엇인지 알아보자.
서버 컴포넌트의 등장
서버 컴포넌트의 등장으로 인해, 이제 다음과 같이 async
한 컴포넌트를 만드는 것이 가능해졌다.
export async function Note({ id, isEditing }) {
const note = await db.posts.get(id)
return (
<div>
<h1>{note.title}</h1>
<section>{note.body}</section>
{isEditing ? <NoteEditor note={note} /> : null}
</div>
)
}
이와 같이 서버 자원에 직접 접근하여 서버의 데이터를 불러오는 서버 컴포넌트는 리액트 팀에서도 권장하는 방법이지만, 한가지 치명적인 사실은 대부분의 훅을 사용할 수 없다는 것이다. 물론, 서버 컴포넌트는 대부분 상태를 저장할 수 없는 기능에만 제한적으로 쓰이기 때문에 useState
등은 필요하지 않을 것이며, useId
와 같은 훅은 서버에서도 여전히 사용 가능하다.
그렇다면 클라이언트 컴포넌트는?
이제 서버에서 쓰이는 함수형 컴포넌트가 async
가 가능해진다... 라는 사실은 한가지 의문점을 갖게 한다. 그렇다면 클라이언트 컴포넌트가 비동기 함수가 되는 것은 불가능한 것인가? 지금까지 우리는 클라이언트 컴포넌트에서 비동기 처리를 하기 위해서는 useEffect
내에 비동기 함수를 선언하여 실행하는 것이 고작이었다. 그나마도, useEffect
의 콜백 함수는 이러저러한 이유로 비동기가 되면 안되어 이상한 형태로(?) 만들어져서 사용되었다.
function ClientComponent() {
useEffect(() => {
async function doAsync() {
await doSomething('...')
}
doAsync()
}, [])
return <>...</>
}
클라이언트 컴포넌트가 async
하지 못한 것은 뒤이어 설명할 기술적 한계 때문이다. 그 대신, 리액트에서는 use
라는 특별한 훅을 제공할 계획을 세운다.
use
훅은 무엇인가?
use
훅의 정의에 대해서, rfc에서는 리액트에서만 사용되는 await
이라고 비유했다. await
이 async
함수에서만 쓰일 수 있는 것 처럼, use
는 리액트 컴포넌트와 훅 내부에서만 사용될 수 있다.
import { use } from 'react'
function Component() {
const data = use(promise)
}
function useHook() {
const data = use(promise)
}
use
훅은 정말 파격적이게도, 다른 훅이 할수 없는 일을 할 수 있다. 예를 들어 조건부 내에서 호출될 수도 있고, 블록 구문내에 존재할 수도 있으며, 심지어 루프 구문에서도 존재할 수 있다. 이는 use
가 여타 다른 훅과는 다르게 관리되고 있음을 의미함과 동시에, 다른 훅과 마찬가지로 컴포넌트 내에서만 쓸 수 있다는 제한이 있다는 것을 의미한다.
function Note({ id, shouldIncludeAuthor }) {
const note = use(fetchNote(id))
let byline = null
// 조건부로 호출하기
if (shouldIncludeAuthor) {
const author = use(fetchNoteAuthor(note.authorId))
byline = <h2>{author.displayName}</h2>
}
return (
<div>
<h1>{note.title}</h1>
{byline}
<section>{note.body}</section>
</div>
)
}
그리고 이 use
는 promise
뿐만 아니라 Context
와 같은 다른 데이터 타입도 지원할 예정이다.
그렇다면 이 use
는 왜 만들어졌는지 좀더 자세히 살펴보자.
use
에 대한 의문
async
가 아닐까?
왜 많은 커뮤니티에서 요구헀던 것은, 서버 컴포넌트, 클라이언트 컴포넌트, share 컴포넌트에 관계 없이 비동기 컴포넌트 렌더링 시에 일관적인 방식을 제공하는 것이었다. 그러나 서버 컴포넌트와 다르게, 클라이언트의 경우 async
를 사용하는데 있어 기술적인 제한사항이 존재했다.
이런 기술적 한계사항 외에도, 서버와 클라이언트에서 데이터에 접근하는 방식이 다르면 어떤 환경에서 작업하는지 조금 더 명확해진다는 장점이 있다. 물론 use client
라는 지시자가 있지만, 이 지시자는 파일 맨위에 박혀있기 때문에 직관적으로 클라이언트 컴포넌트인지 알아채기 어렵다. 서버 컴포넌트는 클라이언트 컴포넌트와 비슷하지만, 한편으로는 너무 비슷하지 않았으면 한다고 언급했다. 각 환경에는 명확한 한계가 있으므로, 이를 빠르게 구별하면 개발자의 피로감을 줄이는데 많은 도움을 줄 수 있다. 즉, async
로 선언되어 있는 컴포넌트는 서버 컴포넌트라는 명확한 신호를 줄 수 있다.
만약 미래에 클라이언트 컴포넌트에서도 async
가 가능해지는 미래가 온다 하더라도 비동기 컴포넌트 (데이터를 가져오는 컴포넌트)와 상태를 가지고 있는 컴포넌트 (훅을 사용하는 컴포넌트)를 분리하여 리팩토링하도록 계속해서 권장할 예정이다. 리액트가 기대하는 것은, 데이터를 불러오는 컴포넌트와 상태를 가져오는 컴포넌트를 여러개로 분리하여 리팩토링하고, 필요하다면 서버로 작업을 옮기는 것이다.
fetch
와 read
의 불필요한 연결 방지
await
과 use
의 장점은 promise
로 불러오는 비동기 데이터를 어떤식으로 불러오는지 전혀 관여하지 않는 다는 것이다. await
과 use
의 유일한 목적과 관심사는 데이터를 어떻게 가져오든지 간에, 단순히 비동기 데이터를 풀어서 가져오는 것 뿐이다.
원래 이전의 제안 내용은 Suspense
기반의 새로운 fetching api
를 제공는 것이었는데 이렇게 되면 fetch
와 read
간에 강하게 연결되기 때문에, fetch
와 렌더링이 불필요하게 연결된다는 문제가 존재했다.
그래서 리액트 팀은 현재 렌더링에 대해 영향을 미치지 않고, 데이터를 최적으로 가져올 수 있도록 단순히 use
를 제공하는 방향으로 변경했다. use
는 개발자가 직관적으로 사용할 수 있으며, 라이브러리와 상관없이 데이터를 가져오는 것이 훨씬더 자연스러워진다.
function TooltipContainer({ showTooltip }) {
// 이 요청은 데이터를 블로킹하지 않는다.
const promise = fetchInfo()
if (!showTooltip) {
// 여기로 올경우, `promise`가 끝나던 말던 상관없이 `null`을 반환한다.
return null
} else {
// 여기로 오는 경우, `use`로 거친 `promise`가 끝날 때 까지 기다렸다가 렌더링이 시작된다.
return <Tooltip content={use(promise)} />
}
}
리액트로의 유연한 전환
리액트 아키텍쳐가 많은 사랑을 받았던 이유중 하나는, 리액트 아키텍쳐는 단 하나만 존재하는 것이 아니며, 여러가지 서드파티 라이브러리와 프레임워크의 혁신과 혜택을 동시에 누릴 수 있다는 점이다. 만약 리액트가 여기에서 데이터를 불러오는 공식 api를 추가하게 되면, 많은 리액트 생태계에 혼란이 빚어질 것이다.
상세 설계
use
는 async/await
과 거의 동일한 프로그래밍 모델을 제공하도록 설계되어 있지만, async/await
과 다르게 일반 함수형 컴포넌트나 훅에서도 여전히 작동한다. 자바스크립트 비동기 함수와 유사하게, 런타임은 일시 중단 및 재개를 위해서 내부 상태를 관리하겠지만, 컴포넌트 작성자의 관점에서 보면 순차적으로 실행되는 함수 처럼 보인다.
function Note({ id }) {
// fetch 요청은 비동기이지만, 컴포넌트 작성자는 동기 동작처럼 작성할 수 있다.
const note = use(fetchNote(id))
return (
<div>
<h1>{note.title}</h1>
<section>{note.body}</section>
</div>
)
}
자바스크립트 스펙에 따르면, promise
의 resolve
값은 fulfill 또는 rejected 여부를 항상 비동기로만 확인할 수 있다. 데이터가 이미 로딩이 완료된 시점이라 할지라도, 그 값을 동기적으로 검사해서 확인할 방법이 없다. 이는 애매모호한 순서로 인한 데이터 경합을 피하기 위해, 자바스크립트 설계에서 의도적으로 마련한 장치다.
물론 이 설계의 동기 자체는 충분히 이해가 되지만, 리액트와 같이 props
와 state
를 기반으로 UI를 모델링하는 프레임워크에는 문제가 된다. 상황에 따라 리액트가 선택할 수 있는 방법은 다음과 같다.
-
promise
가 완료되기 전까지 잠시 일시정지 했다가 다시 컴포넌트를 렌더링하기: 만약use
로 넘겨받은promise
의 로딩이 끝나지 않았다면 예외를 던지고, 컴포넌트의 렌더링을 일시 중단한다. 그리고use
의 호출이 완료되면, 이 값을 반환한다.async/await
과 다른 차이점은 일시정지된 함수 컴포넌트는 마지막 중단된 시점에서 다시 시작되는 것이 아니라는 사실이다. 즉, 런타임은 컴포넌트의 시작과use
로 인해 중단된 사이의 모든 코드를 다시실행 해야 한다. 이는 리액트 컴포넌트의 멱등성에 의존한다. 즉, 렌더링 중에 외부 부수 작용이 없으며, 주어진 state, props, context 등에 대해 동일한 결과를 반환한다. 성능 최적화를 위해 리액트는 일부 계산을 따로 메모이제이션 할수도 있다. 물론 이러한 방식은async/await
대비 추가적인 오버헤드가 존재한다. 그러나 컴포넌트에 대한 데이터가 이미 확인된 경우 (데이터가 미리 로드 되었거나, 이와 관련없이 부모 컴포넌트 등으로 인해 리렌더링 되는 경우)use
로 인한 마이크로 태스크 대기열을 기다리지 않고도 값을 가져올 수 있기 때문에 오버헤드가 적다. -
이전
promise
결과 그대로 읽기: 만약props
나state
가 변경된 경우,use
의 값이 이전과 같다고 보장할 수 없다. 이 경우 다른 전략을 취해야 한다. 가장 먼저해볼 수 있는 것은, 이전에 다른use
또는 다른 렌더링 시도로 인해 해당promise
를 읽어왔었는지 확인하는 것이다. 만약 한번이라도 읽은 적이 있다면, 굳이 일시 중단하지 않더라도 동기적으로 지난번 결과를 재사용할 수도 있다. 이를 위해 리액트는promise
객체에 몇가지 값을 더 추가했다.status
필드에pending
fulfilled
rejected
promise
가 이행 (fulfilled) 되었다면,value
필드에 이행된 값을 채워둔다.promise
가 거절 (fulfilled) 되었다면,reason
필드에 거절된 이유, 에러 객체를 추가한다.
한가지 명심해야 하는 것은, 모든
promise
에 이 값을 추가하는 것은 아니라는 것이다. 단지use
를 사용하는promise
에 대해서만 이러한 값을 추가한다. 이 덕분에Promise.prototype
을 오염시키지 않아도 되며, 리액트가 아닌 코드에 영향을 미치지 않게 된다. 이는 물론 자바스크립트 표준은 아니지만, 리액트가promise
의 결과를 추 적하는데 도움을 준다. 만약 미래에Promise.inpect
와 같이 동기적으로Promise
의 현재 상태를 알 수 있는 api가 제공된다면, 이를 사용할 의향도 있다. -
관련 없는 업데이트 중에
promise
결과 읽기:promise
객체에서 결과를 추적하는 것은,promise
객체가 렌더링 중에 변경되지 않았을 때에만 유효한 전략이다. 만약 새로운promise
객체가 반환된다면, 이 전략은 통하지 않는다. 그러나 대부분의 경우에는, 새로운promise
객체라 할지라도, 이미 데이터를 가져온 경우가 많을 것이다. 아래 코드를 살펴보자.async function fetchTodo(id) { const data = await fetchDataFromCache(`/api/todos/${id}`) return { contents: data.contents } } function Todo({ id, isSelected }) { const todo = use(fetchTodo(id)) return ( <div className={isSelected ? 'selected-todo' : 'normal-todo'}> {todo.contents} </div> ) }
id
값이 변경되었다면,fetchTodo
가 새로운 데이터를 반환하는 것이 맞다. 그러나isSelected
값만 변경된 경우는 어떤가?use
에게 넘겨진promise
객체는 다르지만, 이미 과거에 불러온 데이터일 것이다. 만약 리액트가 이러한 경우를 제대로 처리하지 못한다면, 새로운 데이터를 요청한 적이 없음에도 불구하고 이로 인해 UI가 일시 중단될 수 있다. 따라서 이를 처리할 방법이 필요하다. 이 경우 리액트는 일시 중단 하는대신, 마이크로태스크 대기열이 완전히 빌 때 까지 기다린다. 그 동안promise
가resolved
되면, 리액트는Suspense
fallback
을 트리거 하지 않고 즉시 컴포넌트 렌더링을 재가한다. 만약 그 기간 동안resolve
되지 않으면 새로운 데이터가 요청되었다고 가정하고 평소와 같이 일시 중지한다.이부분이 조금 어려울 수도 있어 부연 설명을 추가한다.
Promise
는 마이크로 태스크에서 해결된 다는점, 그리고Promise
는 이미 과거에 resolve된 적이 있는 데이터라면 (비록 동기적으로 상태를 알 수 없지만) 바로 값을 resolve 한다는 특성을 이용한 것이다.그러나 이것이 모든 문제의 해결책은 아니다. 이는 어디까지나 데이터 요청이 캐시된 경우에만 작동한다. 더 정확히 말하면, 새로운 입력이 없이 다시 리렌더링되는 비동기 함수는 반드시 마이크로태스크 시점 내에서만 해결되어야 한다는 제약 조건이 있다. 따라서 이
use
는cache
API 와 함께 출시될 예정이다.cache
가 없이 이use
가 출시될일은 거의 없다. 대략cache
는 아래와 같은 모습이 될 것이다.// cache 함수로 래핑 되어 있다면, `input`이 동일하다면 이 함수는 항상 같은 결과를 반환한다. // cache는 아마도 `invalidate`하는 기능도 추가되어야 할 것이다. const fetchNote = cache(async (id) => { const response = await fetch(`/api/notes/${id}`) return await response.json() }) function Note({ id }) { // id가 변경되거나 캐시가 날아가지 않는한, 항상 같은 결과를 반환한다. const note = use(fetchNote(id)) return ( <div> <h1>{note.title}</h1> <section>{note.body}</section> </div> ) }
요즘 대부분의 fetch 라이브러리는 이미 이러한 이슈를 피하기 위한 캐싱 매커니즘을 구비하고 있으므로, 이
cache
없이도use
를 사용할 수 있을 것이다. 다만 이러한 내용은 컴포넌트에서 비동기 함수를 직접 호출할 경우에 유용할 것이다.
조건부 호출
다른 훅들과 다르게, use
훅은 앞서 소개한 훅들과 다르게 조건부로 호출할 수 있다. 이는 데이터를 별도 컴포넌트로 분리해서 추출하는 수고로움을 덜고, 조건부로 일시 중단 할 수 있도록 하기 위함이다.
이렇게 조건부로 use
를 호출할 수 있는 이유는 대부분의 다른 훅과 달리 컴포넌트 업데이트에 따라서 상태를 추적할 필요가 없기 때문이다. useState
와 같은 훅은 리액트 이전 상태와 연관지을 수 있도록 동일한 위치에서 조건부로 실행되는 일 없이 실행되어야 하지만, use
는 컴포넌트를 일단 렌더링 한뒤에는 데이터를 저장할 필요가 없다. 저장하지 않는 대신, 데이터는 promise
와 연관된다.
서버 컴포넌트에서 클라이언트 컴포넌트로 promise 넘겨주기
미래애 서버 컴포넌트에서 props 형태로 클라이언트 컴포넌트에 promise를 넘기는 기능을 추가하고자 한다. props로 넘겨주는 promise를 조건에 따라 호출하거나 제어할 수 있어 유용할 것이다.
use
로 할 수 있는 다른 것들
다른 훅과는 다르게, use
를 조건부로 호출할 수 있다는 사실이 개발자들에게 혼선을 빚을 수 있지만, 리액트 팀은 충분히 이 기능이 유용하기 때문에 이러한 혼란을 감수할 가치가 있다고 믿는 것 같다. 혼란을 줄이기 위해 리액트 팀은 이 훅이 유일하게 조건부 실행을 지원하는 훅이 될 것이라 약속했고, 개발자는 이 use
의 특징 하나만 기억하면 될 것이다.
미래에 use
는 promise
외에도 다른 유형도 지원하게 될 것이다. 가장 먼저 promise
외에 지원할 타입은 바로 Context
다. 조건부로 호출할 수 있다는 점을 제외하면, 기존의 useContext(Context)
와 동일하다.
FAQ
use
인가여? 좀더 구체적으로 해줄 순 없나요?
왜 이름이 이유는 크게 두가지다.
use
는promise
뿐만 아니라,Context
,store
observable
등 다양한 타입이 될 수 있기 때문이다.use
는 조건부로 쓰일 수 있는 매우 특별한 훅이다. 위 종류에 따라usePromise
useConditionalContext(?)
등으로 도 할 수 있긴 하지만, 이 경우 조건부로 쓸 수 있는 훅을 외워야 하기 때문에use
하나로 묶었다.
왜 컴포넌트에서만 호출 가능한가요?
use
가 조건부로 호출은 되지만, 여전히 훅인 이유는 리액트가 렌더링 될 때만 동작할 수 있기 때문이다. 따라서 use
는 컴포넌트 또는 훅일 수 밖에 없다. 이론적으로는 리액트 컴포넌트 내지는 훅에서만 호출되는 함수 내부에서 use
를 사용하면 동작 자체는 하지만, 컴파일러에서 에러로 처리된다.
만약 일반 함수에서 사용할 수 있도록 허용된다면, 현재의 타입시스템으로는 이를 강제할 방법이 없기 때문에 이것이 올바른 문맥안에서 실행되고 있는지 추적하는 것은 온전히 개발자의 몫으로 남을 것이다. 이는 애초에 리액트 함수와 비 리액트 함수를 구별하기 위해 use
라는 접두사를 만든 이유기도 하다. 즉, use
라는 접두사를 강제 함으로써 개발자가 이러한 훅이 올바른 문맥에서 실행되는지 확인하는 수고로움을 더는 것이다.
왜 클라이언트 컴포넌트는 async가 안되나요
원래는 비동기 클라이언트 컴포넌트를 만드는 것 또한 고려했었다. 기술적으로 가능하긴 하지만, 여기에는 많은 함정과 주의사항이 뒤딸아오므로, 이패턴을 일반적인 권장사항으로 하기엔 무리가 있었다. 런타임에서는 이러한 비동기 클라이언트 컴포넌트를 지원하기는 할 것이지만, 개발중에는 경고를 기록할 것이다. 혼란을 방지하기 위해 문서에도 이러한 비동기 클라이언트 컴포넌트에 대한 내용도 기록하지 않을 것이다.
비동기 클라이언트 컴포넌트를 권장하지 않는 가장 큰 이유 중 하나는 prop
이 컴포넌트를 메모이제이션을 무효화하여, 마이크로태스크 최적화가 꼬이기 너무 쉽기 때문이다.
그러나 비동기 클라이언트 컴포넌트가 유효한 시나리오가 있는데, 바로 네비게이션 중에서만 데이터를 업데이트 하는 경우다. 그러나 이러한 케이스를 보장하기 위해서는 라우터의 동작과 통합되어야 하는데, 이 경우 어느정도까지 문서화되어 관리되어야할지 불분명하다. 따라서 비동기 클라이언트 컴포넌트를 완전히 금지하지는 않았다. 이는 react-router 나 nextjs 등지에서 실험을 할 것으로 보인다.
요약
- 리액트의 렌더링을 일시 중지하고 재개할 수 있는 최적화가 추가되면서 클라이언트에서는 이를 어떻게 처리할지 많은 고민이 있었던 것으로 보인다.
- 클라이언트 컴포넌트와 서버 컴포넌트 간의 구별을 위해 (그리고 기술적인 이유, 개발자들의 편이성 등..)
await
과use
라는 또다른 구분점을 둔것으로 보인다. 얼핏 생각했을 때 이는 합리적인 선택으로 보인다. - rfc에서도 언급했듯,
cache
가 등장하기 전까지use
가 등장하지는 않을 것으로 보인다. 그러나cache
는 아직까지 rfc에 모습조차 들어내지 않았기 때문에 당분간 모습을 보이긴 어려울 것으로 보인다. - Promise 객체에 status를 추가하는 것은 조금,, 도발적으로 보이기도한다. 심지어
Symbol
을 사용하는 것도 아니다. 이러한 작업으로 인해 향후에 표준과 얽히는 일이 없기만을 바랄 뿐이다. - https://github.com/reactjs/rfcs/pull/229 에서 오가는 이야기를 보니 서버 컴포넌트와 마찬가지로 리액트 커뮤니티가 많은 혼란에 빠질 것 같다. 이번 18 버전의 많은 변화가 리액트에 있어 큰 변곡점이 될 지도 모른다. 더 좋은 웹을 만들기 위한 변화로 받아드릴지, 혹은 더 많은 리액트 반대 진영을 양산하는 결과를 만들어버릴지?