떠오를 때마다 적어둬요

user-survey 가 뜨다 말다 해요.

비동기 fetch 와 컴포넌트 unmount 사이의 race 이야기예요.

가입 직후 홈에 진입한 사용자가, 정보 입력을 끝내고 다시 돌아왔을 때 user-survey 모달이 뒤늦게 떠버리는 문제가 있었어요. 가끔 발생하고 가끔은 안 발생하고. “왜 가끔” 이 답일 때는 보통 race condition 이에요.

1. 진단하기

홈 진입 시 useHomeInit 훅이 사용자의 knowledge level 을 비동기로 조회하고, 결과에 따라 user-survey 로 redirect 할지 결정해요. 코드 자체는 이런 모양이었어요.

useEffect(() => {
  let mounted = true

  const init = async () => {
    try {
      await fetchAuthQuery(KNOWLEDGE_LEVEL_QUERY_KEY, () =>
        cheongyakRuleSetApi.getKnowledgeLevel(),
      )
      if (!mounted) return
      // 성공 — knowledge level 존재
    } catch (error) {
      if (error instanceof UnauthorizedError) return
      if (!mounted) return
      Router.replace('/user-survey')  // ← 여기가 문제
    }
  }
  init()
  return () => { mounted = false }
}, [])

코드만 보면 흠잡을 데가 없어 보여요. mounted 플래그로 unmount 후 setState 도 막고 있고요.

문제는 timing 이에요. 사용자가 홈에 들어오자마자 다른 CTA — 예를 들어 “정보 입력하러 가기” 같은 버튼 — 을 빠르게 탭해서 홈을 떠나면, Home 컴포넌트가 unmount 되고 mounted = false 가 돼요. 그리고 그 뒤에 getKnowledgeLevel() 의 404 응답이 도착하면 catch 블록에 진입하는데, 여기서 if (!mounted) return 가드에 걸려 Router.replace('/user-survey')조용히 드롭 돼요.

결과적으로: 첫 진입 때 user-survey 가 떠야 했는데 안 뜨고, 사용자가 정보를 입력하고 다른 화면을 거쳐 다시 홈으로 돌아오면 그제서야 useHomeInit 이 다시 실행되며 user-survey 가 뜨는 거예요. 사용자 입장에서는 “방금 정보 입력 다 했는데, 왜 또 설문이 뜨지?” 가 되고요.

2. 재현하기

타이밍에 의존하는 버그라 신뢰성 있게 재현하려면 네트워크를 인위적으로 늦춰야 했어요. Chrome DevTools 의 Network throttling 을 Slow 3G 로 설정하고 다음 시퀀스를 따라했어요.

  1. 가입 직후 홈에 진입.
  2. getKnowledgeLevel() 응답이 도착하기 에 홈에 있는 다른 CTA 를 빠르게 탭. → Home 이 unmount.
  3. CTA 의 흐름을 따라 정보 입력 등을 마침.
  4. 다시 홈으로 복귀. → useHomeInit 의 useEffect 가 다시 실행되며 그제서야 user-survey 가 뜸.

API 응답 시간이 짧으면 잘 안 보이고, 네트워크가 느려지면 자주 보이는 전형적인 timing-dependent 버그였어요. 빠른 환경에서는 mounted 가드가 닿기 전에 응답이 도착해서 정상 흐름을 타거든요. 그래서 사내에서는 잘 안 보이다가 실제 사용자 일부에서만 산발적으로 발생했어요.

근본 문제를 정리해보면, “비동기 결정의 결과로 navigate 가 일어나야 하는데, 그 navigate 를 mounted 가드가 막고 있다” 는 거였어요. mounted 는 setState 를 막기 위한 것이지, navigate 를 막아야 하는 가드가 아니에요. 두 종류의 사이드 이펙트가 같은 가드 뒤에 묶여 있던 게 사고 지점이었어요.

3. 수정하기

가장 안전한 해결은 knowledge level 조회가 끝나기 전엔 Home 자체를 렌더하지 않는 것 이었어요. ready state 를 만들어서 조회 완료 전엔 Home 의 자리에 null 을 두도록 바꿨어요.

// pages/index.tsx
export default function HomePage() {
  const ready = useHomeInit()
  return ready ? <Home /> : null
}
// useHomeInit
export function useHomeInit() {
  const [ready, setReady] = useState(false)

  useEffect(() => {
    const cached = getAuthQueryData<{ level?: string | null }>(
      KNOWLEDGE_LEVEL_QUERY_KEY,
    )
    if (cached?.level) {
      setReady(true)
      return
    }

    let mounted = true
    const init = async () => {
      try {
        await fetchAuthQuery(KNOWLEDGE_LEVEL_QUERY_KEY, () =>
          cheongyakRuleSetApi.getKnowledgeLevel(),
        )
        if (!mounted) return
        amplitudeLogger.event('set_once_knowledge_level')
        setReady(true)
      } catch (error) {
        if (error instanceof UnauthorizedError) {
          if (mounted) setReady(true)
          return
        }
        if (!mounted) return
        Router.replace('/user-survey')
      }
    }
    init()
    return () => { mounted = false }
  }, [])

  return ready
}

이렇게 바꾸면 knowledge level 이 결정되기 전엔 Home 이 마운트조차 되지 않아요. 사용자가 홈에서 다른 화면으로 이탈할 수 있는 CTA 자체가 화면에 없으니까, race condition 이 발생할 수 있는 창구 자체가 닫혀요. 조회가 끝나야만 Home 이 마운트되고, 그 시점에 redirect 가 필요한 사용자는 곧장 user-survey 로, 그렇지 않은 사용자는 Home 으로 가요.

캐시가 있으면 즉시 ready=true 로 처리해서, 두 번째 진입부턴 깜빡임 없이 바로 Home 이 떠요. UnauthorizedError 케이스도 ready=true 로 처리해서, 비로그인 사용자는 막히지 않고 그대로 Home 을 보게 했고요.

4. 재발 방지하기

이번 사건이 가르쳐준 건 “비동기 결정 + redirect” 패턴을 다룰 때의 기본 자세였어요.

첫째, 비동기 결정의 결과로 redirect 가 일어나야 하는 흐름에선 결정 전엔 렌더 보류 를 기본 패턴으로 삼아요. mounted 가드는 setState 차단엔 잘 동작하지만, “navigate / redirect 같은 사이드 이펙트가 조용히 사라지는” 케이스를 못 막아줘요. 사용자에게 영향을 주는 사이드 이펙트는 mounted 와 분리해서 다뤄야 해요.

둘째, race condition 이 의심될 땐 흐름을 상태 다이어그램으로 그려봐요. (loading → ready | redirect-pending | unmounted) 식으로 그려보면, “ready 도 아니고 redirect 도 아닌” 모호한 상태가 코드에 있는지 한눈에 보여요. 위 케이스가 정확히 그 모호한 상태였어요. 다이어그램을 그리는 일이 거창해 보이지만, 종이 한 장에 그리는 것만으로도 사고 범위가 줄어들어요.

셋째, 사용자가 화면을 이탈할 수 있는 모든 시점을 시뮬레이션해요. 네트워크가 느린 환경에서 빠르게 화면을 옮겨다니는 시나리오는 대부분의 race 를 끌어내요. Chrome DevTools 의 throttling 은 가장 저렴하게 timing 버그를 노출시키는 도구예요. 개발 중에 한 번씩만 켜봐도 큰 사고를 미리 잡을 수 있어요.

타이밍이 만든 버그는 코드만 봐서는 이상한 게 안 보여요. 그래서 더 방지 하는 쪽에 무게를 둬야 해요. 비동기 + 이탈 가능성이 같이 있는 코드를 만날 때마다 “이 결정이 끝나기 전에 사용자가 떠나면 어떻게 되지?” 를 한 번씩 묻는 습관이 생겼어요.