All Articles

(번역)Hook을 사용할때 주의해야할 점

원문: Pau Ramon Revilla

2018년에 Hook이 처음 소개되었을때는 굉장히 혁신적이었다.
State 로직과 렌더링 로직을 분리할 수 있었고, functional components의 조합은 기존 클래스형에서 this로 접근했던 방법과 비교했을때 매우 효율적이고 직관적이었다. 하지만 hook이 도입되었다고해서 완벽하게 잘 쓰고 있는지는 되짚어봐야 한다.

근 몇 년 동안 Hook을 사용해봤고, Hook을 사용할때 겪었던 위험한 상황에 대해 공유해보려고 한다. 코드 리뷰를 하면서 매 주 수십개의 Hook 관련 문제를 발견했다. 대부분은 사실 겉으로 보았을때는 잘 작동하기 때문에 end-user단에는 관련이 없긴 하다. 하지만 잘못된 코드들은 오늘은 문제가 없더라도 결국에는 드러나게 되고 만다.

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


클로저

일반적인 오해는 객체지향 패러다임은 Stateful하고 함수형은 Stateless하다는 것이다. 이러한 논쟁에서 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"

메모리를 유지하는 메커니즘에는 공통점이 많다. Class는 this를 사용하여 객체의 인스턴스를 참조한다. 반면에 함수형은 실행 컨텍스트안에서 사용된 값을 기억하는 클로저로 구현하게 된다.

클로저는 함수형이 State를 저장할 수 있도록 하기 때문에 중요하다. 이렇게 하면 객체나 클래스가 필요하지 않다.

모두가 알지만 클로저는 메모리 누수를 쉽게 유발할 수 있다. 가비지 컬렉터가 이게 쓰이지 않는지 판단하기 어렵다. 위 코드 예에서는 inc를 임의로 cleanup하지 않는 한 i를 쓰레기로 수집하지 않는다.

클로저에 대해서 또 다른 중요한 점은 명시적 종속성을 암시적 종속성으로 바꾼다는 것이다.(기존에는 문맥상 어떤 값이 바뀔때 state 업데이트가 되는지 눈에 잘 보였다는 의미) 함수에 argument를 보낼때 종속성은 명시적이지만, 프로그램이 클로저의 종속성까지 알 수 있는 방법은 없다. 이 결과는 클로저가 결정적이지는 않다는 것이다. 클로저가 메모리에 유지하는 값은 호출때마다 변경되고 다른 결과를 낳게 된다.

클로저 - Hook을 해제하기 (cleanup)

클로저는 어떻게 마법을 부려서 React로 바뀔까? React팀이 API를 연구하고 고민해서 최선으로 만들었을 것이라고 생각하지만, 클로저에서 Hook은 다음과 같이 중요한 결과를 낳았다.

function User ({ user }) {
  useEffect(() => {
    console.log(user.name)
  }, []) // exhaustive-deps eslint will bark

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

이걸 클로저에서 해결한 방법은 종속성 (위 코드에서 []안에 있는 것들) 이 바뀔때마다 업데이트(side-effect)를 해주는 것이다. useEffect는 업데이트가 다를 경우때나 실행된다. (Reactivity)의 개념 useMemouseCallback 역시 마찬가지이다.

Hooks는 위 코드에서 user를 보면 알 수 있듯이, 스코프내에서 정보를 “보고” 유지할 수 있기 때문에 클로저로 사용했을때 장점이 있다. 그런데 클로저 종속성이 암시적이기 때문에 언제 업데이트를 하여 렌더링을 할지 알 수 없다.

이게 클로저에서 hooks API에 종속성 배열[]이 필요한 이유이다. 잘 알다시피 사람이 집어넣기 때문에 실수를 하기 쉽고 이는 메모리 관리의 어려움으로 이어진다.

Hooks를 써본적이 있다면 알겠지만, over-subscription, under-subscription이 발생하기가 쉽다. 너무 많이 업데이트가 되거나 적게 업데이트가 될 수 있다. 이건 성능 문제를 일으키거나 버그를 일으킬 수 있다.

React에선 이를 lint로 해결방법을 제시하는데, 이게 답이 될 수 없는게 우선 사용자 정의 hook이라면 문제가 되며, lint에서 못잡는게 많다는 건 말할 필요도 없다.

다른 방법으로는 hook을 컴포넌트 바깥으로 빼내는 것이다. 이렇게 사용하려면 arguments들을 전달해야 한다.

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>
}

이렇게 외부로 빼게되면 종속성을 수동으로 추적할 수 있고 under-subscripton을 잡아낼 수 있다. 하지만 over-subscription은 여전히 문제가 있다.

Identity와 메모리


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

Javascript 및 기타 많은 언어는 같은지를 비교할 때 다양한 방법을 사용하여 문제를 해결한다. 아무튼 종속성에서 같은지 비교할때 이게 고려해야 할 점이 좀 많다. ==, ===Object.is는 완전히 다르며 다른 결과를 낳는다. Object.is는 최근에 추가되었는데 이것 역시 value가 같은지 확인한다. (IE에서는 지원하지 않는 문법임)

  • 둘 다 undefined
  • 둘 다 null
  • 둘 다 true이거나 둘 다 false
  • 둘 다 +0
  • 둘 다 -0
  • 둘 다 NaN
  • 둘 다 0이 아니고 둘 다 NaN이 아니고 둘 다 동일한 값
  • 문자열은 size가 동일하고 문자가 동일한 순서인지 확인
  • 나머지는 원시값이 아니고 변경될 수 있으므로 메모리 참조가 동일한지 확인한다. 종종 이것 때문에 헷갈리는 일이 많다. 예를 들어 Object.is([],[])는 false이지만 let a = b = []; Object.is(a, b)true이다.

마지막 부분은 특히나 문제인데, 개발자가 봤을 때 두 비교 대상이 동일한지 예측 불가능하다. 두 개의 객체가 주어지면 객체가 메모리에 어떻게 상주하는지 이해하지 않는 한 Object.istrue 또는 false를 반환할지 여부를 알 수 없다.

Hooks와 Identity

Hook도 Object.is를 사용해서 종속성을 확인한다. 두 세트의 종속성이 주어지면 Hooks는 “동일하지” 않은 경우에만 실행된다. 이 경우 “동일한지”는 위에서 설명한 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} />
}

위 이유 때문에 Hooks가 미묘하다는 것이다. App1에서는 매번 새로운 객체를 할당한다. 개발자가 봤을때는 동일해보이지만, Object.is가 봤을때는 다르다. 이 App1을 렌더링할때마다 “Hello pack”라는 업데이트를 하게 된다.

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} />
}

이 코드는 동일 요청을 반복적으로 계속 수행한다. 만들때마다 새 객체를 만들고 확인할 것이기 때문에 Object.is는 매번 다르다고 판단할 것이다. useEffect에 종속성을 아무리 추가해봤자 계속 서버에 요청을 보내게 되는 것이다. 이게 바로 흔하게 나타날 수 있는 over-subscription이다. 우리 코드는 문제 없는데 서버는 고통받게 될 것이다.

결론

Hooks는 쉬워보이지만 그 내부에서 돌아가는 로직은 매우 복잡하기 때문에 부정확성의 위험이 증가한다. 다행히도 대부분의 버그는 컴포넌트에서 훅을 이동하고 원시타입을 유일한 종속성으로 추가하게 되면 해결할 수 있다. Typescript는 항상 유일한 hook을 만들고 엄격하게 관리할 수 있다. 이렇게 하면 같은 팀의 개발자가 만든 코드를 이해하는데도 도움을 준다.

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)
}

Typescript를 사용하지 않는다면 대안을 찾아야 한다. zustand, jotai 그리고 redux와 mobx등이 있다. 이러한 라이브러리들은 쉽고 정확하게 쓸 수 있도록 도와준다.