avatar
Published on

리액트의 신규 훅, "use"

Author
  • avatar
    Name
    yceffort

Table of Contents

서론

리액트에는 새로운 기능을 제안할 수 있는 공식적인 창구인 https://github.com/reactjs/rfcs 저장소가 존재한다. 이 저장소는 리액트에 필요한 새로운 기능 내지는 변경을 원하는 내용들을 제안하여 리액트 코어 팀의 피드백을 받을 수 있는데, 이렇게 제안된 이슈 중에는 리액트 코어 팀이 직접 제안하여 리액트 커뮤니티 개발자들의 의견을 들어보는 이슈도 존재한다.

이 중에 아직 머지되지는 않았지만 한가지 흥미로운 내용이 존재하는데, 바로 use라고 하는 새로운 훅이다. 이 훅은 이후에 설명하겠지만 이전의 훅과는 여러가지 차이점이 있는데, 그중에 하나는 조건부로 호출될 수 있다는 것이다. 이 훅도 예전부터 PR로 올라와 있어서 언제쯤 머지되는지 눈여겨 보고 있었는데 👀 도대체가 머지될 기미가 보이지 않아서 굉장히 의아한 차였다. 알고 보니 해당 proposal 을 만든 사람이 meta에서 vercel로 이적하였고 (🤪) 이 과정에서 뭔가 이 작업이 붕뜬게 아닌가 하는 추측아닌 추측을 혼자 해봤다. 그러던 차에 리액트 카나리아 버전에서 use훅의 존재를 확인하게 되었다.

react-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이라고 비유했다. awaitasync함수에서만 쓰일 수 있는 것 처럼, 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>
  )
}

그리고 이 usepromise뿐만 아니라 Context와 같은 다른 데이터 타입도 지원할 예정이다.

그렇다면 이 use는 왜 만들어졌는지 좀더 자세히 살펴보자.

use에 대한 의문

async가 아닐까?

많은 커뮤니티에서 요구헀던 것은, 서버 컴포넌트, 클라이언트 컴포넌트, share 컴포넌트에 관계 없이 비동기 컴포넌트 렌더링 시에 일관적인 방식을 제공하는 것이었다. 그러나 서버 컴포넌트와 다르게, 클라이언트의 경우 async를 사용하는데 있어 기술적인 제한사항이 존재했다.

이런 기술적 한계사항 외에도, 서버와 클라이언트에서 데이터에 접근하는 방식이 다르면 어떤 환경에서 작업하는지 조금 더 명확해진다는 장점이 있다. 물론 use client라는 지시자가 있지만, 이 지시자는 파일 맨위에 박혀있기 때문에 직관적으로 클라이언트 컴포넌트인지 알아채기 어렵다. 서버 컴포넌트는 클라이언트 컴포넌트와 비슷하지만, 한편으로는 너무 비슷하지 않았으면 한다고 언급했다. 각 환경에는 명확한 한계가 있으므로, 이를 빠르게 구별하면 개발자의 피로감을 줄이는데 많은 도움을 줄 수 있다. 즉, async로 선언되어 있는 컴포넌트는 서버 컴포넌트라는 명확한 신호를 줄 수 있다.

만약 미래에 클라이언트 컴포넌트에서도 async가 가능해지는 미래가 온다 하더라도 비동기 컴포넌트 (데이터를 가져오는 컴포넌트)와 상태를 가지고 있는 컴포넌트 (훅을 사용하는 컴포넌트)를 분리하여 리팩토링하도록 계속해서 권장할 예정이다. 리액트가 기대하는 것은, 데이터를 불러오는 컴포넌트와 상태를 가져오는 컴포넌트를 여러개로 분리하여 리팩토링하고, 필요하다면 서버로 작업을 옮기는 것이다.

fetchread의 불필요한 연결 방지

awaituse의 장점은 promise로 불러오는 비동기 데이터를 어떤식으로 불러오는지 전혀 관여하지 않는 다는 것이다. awaituse의 유일한 목적과 관심사는 데이터를 어떻게 가져오든지 간에, 단순히 비동기 데이터를 풀어서 가져오는 것 뿐이다.

원래 이전의 제안 내용은 Suspense 기반의 새로운 fetching api를 제공는 것이었는데 이렇게 되면 fetchread간에 강하게 연결되기 때문에, fetch와 렌더링이 불필요하게 연결된다는 문제가 존재했다.

그래서 리액트 팀은 현재 렌더링에 대해 영향을 미치지 않고, 데이터를 최적으로 가져올 수 있도록 단순히 use를 제공하는 방향으로 변경했다. use는 개발자가 직관적으로 사용할 수 있으며, 라이브러리와 상관없이 데이터를 가져오는 것이 훨씬더 자연스러워진다.

function TooltipContainer({ showTooltip }) {
  // 이 요청은 데이터를 블로킹하지 않는다.
  const promise = fetchInfo()

  if (!showTooltip) {
    // 여기로 올경우, `promise`가 끝나던 말던 상관없이 `null`을 반환한다.
    return null
  } else {
    // 여기로 오는 경우, `use`로 거친 `promise`가 끝날 때 까지 기다렸다가 렌더링이 시작된다.
    return <Tooltip content={use(promise)} />
  }
}

리액트로의 유연한 전환

리액트 아키텍쳐가 많은 사랑을 받았던 이유중 하나는, 리액트 아키텍쳐는 단 하나만 존재하는 것이 아니며, 여러가지 서드파티 라이브러리와 프레임워크의 혁신과 혜택을 동시에 누릴 수 있다는 점이다. 만약 리액트가 여기에서 데이터를 불러오는 공식 api를 추가하게 되면, 많은 리액트 생태계에 혼란이 빚어질 것이다.

상세 설계

useasync/await과 거의 동일한 프로그래밍 모델을 제공하도록 설계되어 있지만, async/await과 다르게 일반 함수형 컴포넌트나 훅에서도 여전히 작동한다. 자바스크립트 비동기 함수와 유사하게, 런타임은 일시 중단 및 재개를 위해서 내부 상태를 관리하겠지만, 컴포넌트 작성자의 관점에서 보면 순차적으로 실행되는 함수 처럼 보인다.

function Note({ id }) {
  // fetch 요청은 비동기이지만, 컴포넌트 작성자는 동기 동작처럼 작성할 수 있다.
  const note = use(fetchNote(id))
  return (
    <div>
      <h1>{note.title}</h1>
      <section>{note.body}</section>
    </div>
  )
}

자바스크립트 스펙에 따르면, promiseresolve 값은 fulfill 또는 rejected 여부를 항상 비동기로만 확인할 수 있다. 데이터가 이미 로딩이 완료된 시점이라 할지라도, 그 값을 동기적으로 검사해서 확인할 방법이 없다. 이는 애매모호한 순서로 인한 데이터 경합을 피하기 위해, 자바스크립트 설계에서 의도적으로 마련한 장치다.

물론 이 설계의 동기 자체는 충분히 이해가 되지만, 리액트와 같이 propsstate를 기반으로 UI를 모델링하는 프레임워크에는 문제가 된다. 상황에 따라 리액트가 선택할 수 있는 방법은 다음과 같다.

  • promise가 완료되기 전까지 잠시 일시정지 했다가 다시 컴포넌트를 렌더링하기: 만약 use로 넘겨받은 promise의 로딩이 끝나지 않았다면 예외를 던지고, 컴포넌트의 렌더링을 일시 중단한다. 그리고 use의 호출이 완료되면, 이 값을 반환한다. async/await과 다른 차이점은 일시정지된 함수 컴포넌트는 마지막 중단된 시점에서 다시 시작되는 것이 아니라는 사실이다. 즉, 런타임은 컴포넌트의 시작과 use로 인해 중단된 사이의 모든 코드를 다시실행 해야 한다. 이는 리액트 컴포넌트의 멱등성에 의존한다. 즉, 렌더링 중에 외부 부수 작용이 없으며, 주어진 state, props, context 등에 대해 동일한 결과를 반환한다. 성능 최적화를 위해 리액트는 일부 계산을 따로 메모이제이션 할수도 있다. 물론 이러한 방식은 async/await 대비 추가적인 오버헤드가 존재한다. 그러나 컴포넌트에 대한 데이터가 이미 확인된 경우 (데이터가 미리 로드 되었거나, 이와 관련없이 부모 컴포넌트 등으로 인해 리렌더링 되는 경우) use로 인한 마이크로 태스크 대기열을 기다리지 않고도 값을 가져올 수 있기 때문에 오버헤드가 적다.

  • 이전 promise 결과 그대로 읽기: 만약 propsstate가 변경된 경우, 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가 일시 중단될 수 있다. 따라서 이를 처리할 방법이 필요하다. 이 경우 리액트는 일시 중단 하는대신, 마이크로태스크 대기열이 완전히 빌 때 까지 기다린다. 그 동안 promiseresolved되면, 리액트는 Suspense fallback을 트리거 하지 않고 즉시 컴포넌트 렌더링을 재가한다. 만약 그 기간 동안 resolve되지 않으면 새로운 데이터가 요청되었다고 가정하고 평소와 같이 일시 중지한다.

    이부분이 조금 어려울 수도 있어 부연 설명을 추가한다. Promise는 마이크로 태스크에서 해결된 다는점, 그리고 Promise는 이미 과거에 resolve된 적이 있는 데이터라면 (비록 동기적으로 상태를 알 수 없지만) 바로 값을 resolve 한다는 특성을 이용한 것이다.

    그러나 이것이 모든 문제의 해결책은 아니다. 이는 어디까지나 데이터 요청이 캐시된 경우에만 작동한다. 더 정확히 말하면, 새로운 입력이 없이 다시 리렌더링되는 비동기 함수는 반드시 마이크로태스크 시점 내에서만 해결되어야 한다는 제약 조건이 있다. 따라서 이 usecache 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의 특징 하나만 기억하면 될 것이다.

미래에 usepromise외에도 다른 유형도 지원하게 될 것이다. 가장 먼저 promise외에 지원할 타입은 바로 Context다. 조건부로 호출할 수 있다는 점을 제외하면, 기존의 useContext(Context)와 동일하다.

FAQ

왜 이름이 use 인가여? 좀더 구체적으로 해줄 순 없나요?

이유는 크게 두가지다.

  • usepromise 뿐만 아니라, Context, store observable 등 다양한 타입이 될 수 있기 때문이다.
  • use는 조건부로 쓰일 수 있는 매우 특별한 훅이다. 위 종류에 따라 usePromise useConditionalContext(?) 등으로 도 할 수 있긴 하지만, 이 경우 조건부로 쓸 수 있는 훅을 외워야 하기 때문에 use 하나로 묶었다.

왜 컴포넌트에서만 호출 가능한가요?

use가 조건부로 호출은 되지만, 여전히 훅인 이유는 리액트가 렌더링 될 때만 동작할 수 있기 때문이다. 따라서 use는 컴포넌트 또는 훅일 수 밖에 없다. 이론적으로는 리액트 컴포넌트 내지는 훅에서만 호출되는 함수 내부에서 use를 사용하면 동작 자체는 하지만, 컴파일러에서 에러로 처리된다.

만약 일반 함수에서 사용할 수 있도록 허용된다면, 현재의 타입시스템으로는 이를 강제할 방법이 없기 때문에 이것이 올바른 문맥안에서 실행되고 있는지 추적하는 것은 온전히 개발자의 몫으로 남을 것이다. 이는 애초에 리액트 함수와 비 리액트 함수를 구별하기 위해 use라는 접두사를 만든 이유기도 하다. 즉, use라는 접두사를 강제 함으로써 개발자가 이러한 훅이 올바른 문맥에서 실행되는지 확인하는 수고로움을 더는 것이다.

왜 클라이언트 컴포넌트는 async가 안되나요

원래는 비동기 클라이언트 컴포넌트를 만드는 것 또한 고려했었다. 기술적으로 가능하긴 하지만, 여기에는 많은 함정과 주의사항이 뒤딸아오므로, 이패턴을 일반적인 권장사항으로 하기엔 무리가 있었다. 런타임에서는 이러한 비동기 클라이언트 컴포넌트를 지원하기는 할 것이지만, 개발중에는 경고를 기록할 것이다. 혼란을 방지하기 위해 문서에도 이러한 비동기 클라이언트 컴포넌트에 대한 내용도 기록하지 않을 것이다.

비동기 클라이언트 컴포넌트를 권장하지 않는 가장 큰 이유 중 하나는 prop이 컴포넌트를 메모이제이션을 무효화하여, 마이크로태스크 최적화가 꼬이기 너무 쉽기 때문이다.

그러나 비동기 클라이언트 컴포넌트가 유효한 시나리오가 있는데, 바로 네비게이션 중에서만 데이터를 업데이트 하는 경우다. 그러나 이러한 케이스를 보장하기 위해서는 라우터의 동작과 통합되어야 하는데, 이 경우 어느정도까지 문서화되어 관리되어야할지 불분명하다. 따라서 비동기 클라이언트 컴포넌트를 완전히 금지하지는 않았다. 이는 react-router 나 nextjs 등지에서 실험을 할 것으로 보인다.

요약

  • 리액트의 렌더링을 일시 중지하고 재개할 수 있는 최적화가 추가되면서 클라이언트에서는 이를 어떻게 처리할지 많은 고민이 있었던 것으로 보인다.
  • 클라이언트 컴포넌트와 서버 컴포넌트 간의 구별을 위해 (그리고 기술적인 이유, 개발자들의 편이성 등..) awaituse라는 또다른 구분점을 둔것으로 보인다. 얼핏 생각했을 때 이는 합리적인 선택으로 보인다.
  • rfc에서도 언급했듯, cache가 등장하기 전까지 use가 등장하지는 않을 것으로 보인다. 그러나 cache는 아직까지 rfc에 모습조차 들어내지 않았기 때문에 당분간 모습을 보이긴 어려울 것으로 보인다.
  • Promise 객체에 status를 추가하는 것은 조금,, 도발적으로 보이기도한다. 심지어 Symbol을 사용하는 것도 아니다. 이러한 작업으로 인해 향후에 표준과 얽히는 일이 없기만을 바랄 뿐이다.
  • https://github.com/reactjs/rfcs/pull/229 에서 오가는 이야기를 보니 서버 컴포넌트와 마찬가지로 리액트 커뮤니티가 많은 혼란에 빠질 것 같다. 이번 18 버전의 많은 변화가 리액트에 있어 큰 변곡점이 될 지도 모른다. 더 좋은 웹을 만들기 위한 변화로 받아드릴지, 혹은 더 많은 리액트 반대 진영을 양산하는 결과를 만들어버릴지?