가입 직후 홈에 진입한 사용자가, 정보 입력을 끝내고 다시 돌아왔을 때 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 로 설정하고 다음 시퀀스를 따라했어요.
- 가입 직후 홈에 진입.
getKnowledgeLevel()응답이 도착하기 전 에 홈에 있는 다른 CTA 를 빠르게 탭. → Home 이 unmount.- CTA 의 흐름을 따라 정보 입력 등을 마침.
- 다시 홈으로 복귀. → 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 버그를 노출시키는 도구예요. 개발 중에 한 번씩만 켜봐도 큰 사고를 미리 잡을 수 있어요.
타이밍이 만든 버그는 코드만 봐서는 이상한 게 안 보여요. 그래서 더 방지 하는 쪽에 무게를 둬야 해요. 비동기 + 이탈 가능성이 같이 있는 코드를 만날 때마다 “이 결정이 끝나기 전에 사용자가 떠나면 어떻게 되지?” 를 한 번씩 묻는 습관이 생겼어요.