(번역) 훅을 사용할 때 주의할 점

강엽이·2022년 6월 4일
16
post-thumbnail

원문 : https://labs.factorialhr.com/posts/hooks-considered-harmful

오, 훅!.. 2018년에 Sophie Alpert와 Dan Abramov가 React Conf에서 발표했을 때 프레젠테이션을 본 기억이 아직도 생생합니다. 저는 매우 놀랐습니다. 음, 아마 모두가 놀랐을 것입니다. 훅은 매우 혁신적인 API였기에 프런트엔드 분야에서 빠르게 유명해졌습니다. 함수형 컴포넌트에서는 상태를 저장하는 로직과 렌더링하는 로직을 쉽게 분리할 수 있습니다 🤯. 우리는 함수형 프로그래밍의 최고의 경지에 도달한 걸까요?

몇 년 동안 훅을 사용한 후, 훅을 사용하면서 겪었던 위험한 경우를 공유하고 싶습니다. 코드를 리뷰하는 동안 매주 수십 개의 훅 관련 문제를 발견했다고 해도 과언이 아닙니다. 이러한 문제는 대부분 사용자에게 잘 보이지 않지만 잘못된 코드는 나중에 결국 버그가 될 것입니다.

고장난 시계는 하루에 두 번 정확한 시간을 알려준다. - 불명

클로저

일반적인 오해는 객체 지향 패러다임은 상태 저장(stateful)이고 함수형 패러다임은 상태 비 저장(stateless)이라는 것입니다. 이러한 논쟁에서 state는 몹시 나쁘므로 객체지향은 이를 피해야한다는 주장이 따라옵니다. 약간은 맞지만, 대부분의 뻔한 주장과 마찬가지로 미묘한 차이가 있습니다.

state란 무엇일까요? 컴퓨터에서 state는 "다른 것을 계산하는 동안 주변에 값을 저장하는 것"과 같은 의미로 주로 메모리에 저장됩니다. 변수에 무언가를 저장할 때마다 주어진 수명 동안 state를 유지합니다. 프로그래밍 패러다임 간의 유일한 차이점은 얼마나 오랫동안 값을 유지하는지와 이러한 결정들이 수반하는 시공간적 절충이라고 말해도 무방합니다.

다음은 기능 면에서 같은 두 가지 코드입니다.

class Hello {
  i = 0
  inc () { return this.i++ }
  toString () {return String(this.i) }
}
const h = new Hello()
console.log(h.inc()) // 1
console.log(h.inc()) // 2
console.log(h.toString()) // "2"
function Hello () {
  let i = 0
  return {
    inc: () => i++,
    toString: () => String(i)
  }
}
const h = Hello()
console.log(h.inc()) // 1
console.log(h.inc()) // 2
console.log(h.toString()) // "2"

메모리를 유지하는 메커니즘에는 공통점이 많습니다. 클래스는 객체의 인스턴스를 참조하기 위해 this를 사용하는 반면, 함수는 범위 내의 모든 변수를 기억하는 기능인 클로저를 구현합니다.

클로저는 함수가 상태를 저장할 수 있도록 하므로 중요합니다. 클로저를 이용하면 객체나 클래스가 필요하지 않습니다.

클로저를 사용할 때 한 가지 중요한 것은 메모리 누수를 쉽게 유발할 수 있다는 점입니다. 함수가 범위 밖에서 오래 지속되므로 가비지 콜렉터가 해당 값을 수집할 수 없습니다. 위의 예에서는 inc를 유지하는 동안 i는 정리되지 않습니다.

클로저에 대한 또 다른 중요한 점은 명시적 의존성을 암시적 의존성으로 바꾸는 것입니다. 함수에 인수를 전달할 때 해당 의존성은 명시적이지만 프로그램이 클로저의 의존성을 알 방법은 없습니다. 즉, 클로저는 결정론적이지 않다는 것입니다. 따라서 클로저가 메모리에 유지하는 값이 호출 간에 변화하여 다른 결과를 산출할 수 있습니다.

클로저 - 훅의 실패의 원인?

클로저는 어떻게 마법을 부려서 리엑트로 바뀔까요? 글쎄요, 리엑트 팀이 가능한 모든 API를 조사하고 가능한 최선의 결정을 내렸다고 확신하지만, 클로저에 기반한 훅은 놀라운 결과를 낳았습니다.

function User ({ user }) {
  useEffect(() => {
    console.log(user.name)
  }, []) // eslint가 철저하게 경고할 것입니다.

  return <span>{user.name}</span>
}

훅은 의존성이 변경될 때마다 사이드 이펙트를 생성합니다. 예를 들어, useEffect는 엑셀 시트처럼 사이드 이펙트를 발생시키는데 필요한 입력이 다를 때만 실행되어야 합니다. 이는 useMemo, useCallback에도 동일하게 적용됩니다.

훅은 위의 예에서 user와 같이 해당 범위에서 정보를 "보고" 유지할 수 있으므로 클로저의 이점을 얻습니다. 그러나 클로저 의존성이 암시적이기 때문에 언제 사이드 이펙트를 실행할지 알 수 없습니다.

클로저는 훅 API에 의존성 배열이 필요한 이유입니다. 훅이 클로저를 사용하기 때문에 프로그래머는 암시적 의존성을 명시적으로 지정하여 일종의 "인간 컴파일러"로 행동할 책임이 있습니다. 의존성을 선언하는 것은 수동 상용구 작업이며 C 메모리 관리와 같이 오류가 발생하기 쉽습니다.

수동으로 이벤트 구독 관리를 수행해 본 적이 있다면, 두 가지 주요 문제인 초과 구독과 미달 구독에 익숙할 것입니다. 즉, 너무 많이 반응하거나 너무 적게 반응하는 것입니다. 전자는 성능 문제를 일으키고 후자는 버그를 일으키는 경향이 있습니다.

이 문제에 대한 리엑트의 솔루션은 린터(linter)지만 리엑트 훅이 사용자 정의 훅으로 구성된다면 예측하기가 어려워집니다. 데이터 구조에 관해 이야기할 때 볼 수 있듯이 린터에 정보가 충분하게 제공되지 않으면 종종 초과 구독으로 이어지게 됩니다.

이 문제를 완전히 방지하는 훅 대체 API가 있습니다. 컴포넌트 외부로 훅을 이동하는 것입니다. 이렇게 하면 의존성을 적절하게 사용할 수 있는 인수를 전달해야 합니다.

const createEffect = (fn) => (...args) => useEffect(() => fn(...args), args)
const useDebugUser = createEffect((user) => { console.log(user.name) })

function User ({ user }) {
  useDebugUser(user)

  return <span>{user.name}</span>
}

훅을 클로저 외부로 이동시키면 의존성을 수동으로 추적해야하는 문제와 구독 부족 문제가 해결됩니다. 그러나 리엑트와 자바스크립트가 두 의존성을 동일하게 해석하는 방식과 관련된 초과 구독에 여전히 취약할 것입니다.

정체성(Identity)과 메모리

정체성은 까다로운 개념입니다. 사람들은 시간이 지나서 사물과 사람이 변하더라도 인식할 수 있는 직관이 있어 정체성을 파악하기 쉽습니다. 그런데도 철학적으로 정체성은 복잡한 주제입니다.

사람은 같은 강물에 두 번 발을 담그지 않습니다. 그 강물은 같은 강이 아니며 같은 사람도 아니기 때문입니다. - 기원전 500년, 그리스 최초의 클로저 커뮤니티를 조직한 헤라클레이토스

변하지 않는 것에 대한 정체성은 쉽습니다. 예를 들어, 3은 항상 3일 것입니다. 우리는 3 == 3이라고 자신 있게 주장할 수 있습니다. 하지만 상황이 변한다면 어떨까요? 테세우스 배처럼 모든 판자를 교체한 배는 처음의 배와 같은 배일까요? ship == replacePlanks(ship)가 참일까요?

모든 프로그래밍 언어는 이 철학적 갈림길에 있습니다. 예를 들어, 일부 언어는 변화를 완전히 금지하여 문제가 발생하지 않도록 만듭니다. 즉, "불변의" 배를 개조하려 시도하면 판자를 교체할 수 없기 때문에 항상 새 배를 만들어야 합니다.

불변성은 반복적으로 빌드되기 때문에 성능에 안 좋은 영향을 미칠 수 있지만 결정적 함수(deterministic function)는 정체성 속성을 캐시하기 쉬우므로 안 좋은 성능을 상쇄할 수 있습니다.

자바스크립트 및 기타 많은 언어는 값의 일치를 판단하기 위해 다양한 방법을 사용합니다. 예를 들어, ==, ===Object.is는 완전히 다른 동작이며 다른 답변을 제공합니다. Object.is는 목록에 최근에 추가되었으며 값이 같은지 평가합니다.

  • 둘다 undefined
  • 둘다 null
  • 둘다 true 또는 false
  • 둘다 +0
  • 둘다 -0
  • 둘다 Nan
  • 또는 둘 다 0이 아니고 Nan이 아니며 둘 다 동일한 값
  • 문자열의 경우 크기가 동일하고 문자가 동일한 순서인지 확인
  • 나머지는 원시가 아니며 변경될 수 있으므로 메모리 참조가 동일하지 확인합니다. 이것은 우리의 직관을 무시합니다. 예를 들어, Object.is([],[])false입니다. 왜냐하면, 두 객체가 메모리에 다른 포인터를 가지고 있기 때문입니다. 그러나 let a = b = []; Object.is(a,b)true입니다. 두 변수는 같은 것을 가리키고 있기 때문입니다.

이 마지막 부분은 개발자가 두 객체가 같은지 예측할 수 없기 때문에 필수적입니다. 두 개의 객체가 주어지면 객체가 메모리에 어떻게 저장되는지 이해하지 않는 한 Object.istrue또는 false를 반환할지를 알 수 없습니다.

훅 및 정체성

훅은 Object.is를 사용하여 의존성을 확인합니다. 두 세트의 의존성이 주어지면 훅은 "동일하지" 않은 경우에만 실행됩니다. 이 경우 "동일함"은 위에서 설명한 Object.is 결과에 따라 결정됩니다.

다음 예제를 이용하여 문제를 이해했는지 확인해 보겠습니다.

const User({ user }) {
  useEffect(() => {
    console.log(`hello ${user.name}`)
  }, [user]) // eslint의 경고로, 우리는 의존성을 추가했습니다.

  return <span>{user.name}</span>
}

이 컴포넌트를 보고 알 수 있는 것을 이야기해보면, useEffect가 몇 번 실행될까요? 우리는 말할 수 없습니다. 우리가 수신하는 서로 다른 user에 대해 정확히 한 번 실행됩니다. 우리가 정체성에 대해 말한 것을 기억하시나요? 메모리가 어떻게 할당되었는지 모르면 객체의 정체를 알 수 없습니다. 그리고 문제는 이 메모리 할당이 다른 곳에서 발생한다는 것입니다. 즉, 이 코드는 작동할 수 있지만 올바르지 않으며 상위 컴포넌트의 변경으로 인해 완전히 중단될 수 있습니다.

function App1 () {
  const user = { name: 'paco' }

  return <User user={user} />
}

const user = { name: 'paco' }
function App2 () {
  return <User user={user} />
}

위의 예에서 훅의 미묘함을 볼 수 있습니다.
App1에서 매번 새로운 객체를 할당합니다. 사람들에게는 그 객체는 항상 동일하지만 Object.is의 경우에는 그렇지 않습니다. 이것은 우리가 컴포넌트를 렌더링할 때마다 "hello paco"라는 로깅을 실행할 것임을 의미합니다.

하지만 App2에서는 항상 동일한 객체 포인터를 참조합니다. 즉, 렌더링 횟수와 관계없이 한 번만 올바르게 로깅 됩니다.

이 예제는 실제 코드와 유사하지 않습니다만, 가장 간단한 경우로 문제를 보여주고 싶었습니다. 실제 코드는 훨씬 더 복잡하며, 개발자가 객체가 언제 할당되고 얼마만큼 할당되어 있는지 이해하기 어렵습니다.

다음은 프로덕션과 가까운 코드 예시입니다.

function App ({ options, teamId }) {
  const [user, setUser] = useState(null)
  const params = { ...options, teamId }

  useEffect(() => {
    fetch(`/teams/${params.teamId}/user`)
      .then(response => response.json)
      .then(user => { setUser(user) })
  }, [params])

  return <User user={user} params={params} />
}

이 코드는 동일한 요청을 반복적으로 수행하여 서버를 파괴합니다. 렌더 될 때마다 객체를 새로운 객체로 할당하여, useEffect의 의존성을 의미 없게 만듭니다. 이것은 초과 구독 및 버그의 사례로 사용자는 겪지 못할 수 있지만 서버는 겪을 수 있습니다.

결론

훅은 모든 기술과 마찬가지로 신기술 과대광고의 축복을 누렸습니다. 많은 개발자가 상태 관리 솔루션을 버리고 상태 저장 로직을 구현하기 위해 훅을 채택했습니다. 또한, API는 쉬워 보이지만 그 아래에는 믿을 수 없을 정도로 복잡하여 부정확성의 위험이 증가합니다.

우리는 사람들이 API의 부족한 점과 대규모 응용 프로그램에 대한 위험을 깨닫기 시작하는 시점에 있습니다.

대부분의 버그는 컴포넌트에서 훅을 멀리 이동하고 기본 요소를 유일한 의존성으로 사용하여 해결할 수 있습니다. 타입스크립트를 사용하는 경우 항상 고유한 훅을 만들고 엄격하게 입력할 수 있습니다. 이렇게 하면 팀의 개발자가 제한 사항을 크게 이해하는 데 도움이 됩니다.

type Primitive = boolean | number | string | bigint | null | undefined
type Callback = (...args: Primitive[]) => void
type UnsafeCallback = (...args: any[]) => void

const createEffect = (fn: Callback): Callback => (...args) => {
  useEffect(() => fn(...args), args)
}

const createUnsafeEffect = (fn: UnsafeCallback): UnsafeCallback => (...args) => {
  useEffect(() => fn(...args), args)
}

타입스크립트를 사용하지 않으면 대안을 찾아야 할 때일 수 있습니다. zustand, jotai, 그리고 오래된 redux 및 mobx와 같은 라이브러리가 추가되어 선택할 수 있는 옵션이 많이 있습니다. 이러한 라이브러리는 코드가 작동할 뿐만 아니라 정확하다는 것을 보장하여 삶을 더 쉽게 만들어 줄 것입니다.

🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!

profile
FE Engineer

0개의 댓글