useEffect보다 더 좋은 게 있다고?

YOKITOMI·2023년 9월 3일
42
post-thumbnail
post-custom-banner

처음 들어보는 얘기일겁니다. 방금 제가 만들었으니까요.
바로 react-eff-hook이라는 이름의 라이브러리입니다.

먼저 useEffect에 대한 불만을 늘어놓는 것부터 시작해보죠.

As-is

count를 1초마다 1씩 증가시키는 동작을 생각해봅시다.

useEffect(() => {
  let cancelled = false
  void (async () => {
    while (!cancelled) {
      await delay(1000)
      
      setCount((x) => x + 1)
    }
  })()
  
  return () => { cancelled = true }
}, [])

보통 위와 같은 방식으로 구현할텐데요. 별거아닌 동작인 것치고 너무 난리부르스를 떠는것 같지 않나요? 물론 그 난리에 다 이유는 있습니다.

  • (async () => { ... })()
    IIFE(Immediately Invoked Function Expression) 라고 불리는 기법입니다. 우리말로 하면 함수 선언 후 즉시 실행 쯤 될까요. useEffect에 인자로 비동기 함수를 바로 쓸수 없어 이렇게 한번 감싸줘야합니다.
    그렇다면 왜 비동기 함수를 못 쓰는 걸까요?
  • return () => { cancelled = true; }
    바로 이녀석 때문입니다. cleanup 함수라고 하죠. 컴포넌트가 unmount되거나, deps의 값이 바뀔때 불리는 함수인데요. 이게 없다면 저 while 루프를 멈출 방법이 없습니다.
    이걸 반환하는건 비동기 함수여선 안됩니다. unmount로 인해 cleanup을 해야할 시점에 왔을때 아직도 cleanup 함수를 기다리고 있으면 안되겠죠?
    cancelled = true도 별로 마음에 안듭니다. 직관적이지 못하죠. 루프가 비동기 함수안에 있어서 이렇게 해야만 합니다. 실수로 빼먹기도 쉽습니다.

이 문제들을 어떻게 해결하면 좋을까요?

To-be

import { useEff } from 'react-eff-hook'

useEff(function*() {
  while (true) {
    yield delay(1000)
    
    setCount((x) => x + 1)
  }
}, [])

react-eff-hook의 새로운 훅인 useEff를 쓴 코드입니다.
function* yield 구문이 생소한 분도 있을텐데요. Generator라고 해서 JavaScript의 코루틴 기능입니다. 이 단어들마저 생소하다면, 일단 그냥 써보는걸로 시작해도 좋습니다.

규칙은 간단합니다.

yield promise = await promise

useEff 내에서 좌변은 비동기 함수 내에서의 우변과 의미가 같습니다. 평소에 우변으로 쓰시던걸 좌변으로 바꿔쓰시기만 하면 됩니다.
반환 값의 타입이 필요한 경우에는

import { wait } from 'react-eff-hook'
// ...
const data = yield* wait(fetch())

로 쓰면 data의 타입이 추론됩니다.

이제 어떤 점들이 개선되었는지 살펴봅시다.
일단 cancelled가 없습니다. 루프는 알아서 멈춥니다. 무한 루프처럼 보이지만, 컴포넌트가 unmount할때 함께 죽습니다. 끝내주죠? 이게 코루틴입니다.
그래서 cleanup 함수도 없습니다. 애초에 cancelledtrue로 변경하는 용도였는데 더 이상 그럴 필요가 없으니까요.

어때요?

Further-more

using 은 TypeScript 5.2에서 새로 도입된 문법입니다. 자세한 소개는 이쪽을 보시죠.
Python의 with 구문랑 비슷하다고 보시면 되고요. 그동안 TypeScript에선 이게 필요한 곳에 finally 구문을 툴툴 거리며 쓰고 있었습니다.

문제는, useEffect와 이 친구가 서로 잘 안 맞는다는 겁니다. 한번 시도해보세요.
얼핏 생각하면 cleanup 함수를 Symbol.dispose에 구현해놓으면 될것 같지만, 곧 안된다는걸 깨달을거에요. 호출되는 시점이 다르거든요.

하지만 우리에겐 useEff가 있죠.

class Interval {
  constructor(fn, interval) {
    this.interval = setInterval(fn, interval); 
  }
  
  [Symbol.dispose]() {
    clearInterval(this.interval)
  }
}

const forever = new Promise(() => {})

useEff(function*() {
  using interval = new Interval(
	() => { setCount((x) => x + 1) }, 
    1000)
  yield forever
}, [])

Interval 클래스는 선언된 스코프를 벗어날때 알아서 clearInterval를 호출해 줍니다. 유령처럼 남아있는 타이머는 이제 안녕입니다.

forever는 절대 resolve되지 않는 promise로써, yield foreverinterval이 해제되는것을 막아줍니다. 이 부분이 좀 요사스럽다고 생각할 수 있는데요. Interval같은 걸 쓸때만 필요하고, 평소엔 거의 볼일이 없을겁니다.
잠시 생각해보면 저게 의미를 잘 나타낸다는 코드란 걸 금방 알수 있습니다.

한가지 주의할 점은, 에러를 throw 했을때의 동작이 useEffect와 약간 다르단 겁니다. useEff는 에러가 발생한 즉시 cleanup 함수를 호출합니다.
useEffect는 그 안에서 호출된 비동기 함수의 에러는 무시하고, deps가 변경되거나 컴포넌트가 unmount될때만 cleanup하죠.
저는 useEff의 동작이 더 낫다고 생각하는데요. 예상하지 못한 시나리오가 있을 수 있어, 아직 100% 확신은 못합니다. 이 동작이 불편한 사례를 발견하면 알려주세요.

To-do

react-eff-hook은 작고 귀여운 라이브러리로, 기존 코드에 통합하는데 전혀 무리가 없습니다. 아직은 좀 미심쩍다면, 일부 useEffect만 먼저 useEff로 옮겨보세요.
슬슬 이게 더 낫다는 생각이 들기 시작하면, 별 하나만 부탁드릴게요.

저는 useEff의 기능이 언젠가 React의 useEffect에 포함되기를 바랍니다. breaking change없이 가능할 것입니다. 그렇게 될때까지 여러분들과 함께 연구해보고 싶습니다. 불편하거나 개선하고 싶은 부분이 있다면 언제든지 이슈트래커에 남겨주세요.

...였는데 검색해보니 이미 같은 내용의 RFC가 올라와 있더라고요. 2년이나 지났는데 진척이 없는걸로 보아, 실제로 도입될 가능성이 낮아보이는건 사실입니다. using 키워드의 활용이 어필되기를 기대해봅니다.

profile
어쩌다 쓴 글들이 아까운 사람
post-custom-banner

4개의 댓글

comment-user-thumbnail
2023년 9월 5일

감사합니다 사용해보겠습니다!

답글 달기
comment-user-thumbnail
2023년 9월 5일

헐 대박

답글 달기
comment-user-thumbnail
2023년 9월 11일

감사합니다!

답글 달기
comment-user-thumbnail
2024년 4월 29일

저는 useAsyncEffect 만들어서 쓰고있습니다

useAsyncEffect(async () => {
  await ...
}, [...])

비동기 함수인지 검증 하고 내부에서 처리해주는식으로..

답글 달기