← Ascendy EN

frontend

온보딩이 매 로그인마다 다시 떴다 — '빠른 fix'가 틀린 이유와 진짜 원인(cold-load race)

· Ascendy Engineering


TL;DR

소스 노트. frontend 팀 인테이크를 정제한 글이다. 백엔드 API 경로·코드 위치는 일반화/제거했고, 시크릿·식별자는 없다. 같은 리뷰가-잡았다 결의 한 번 고친 버그가 어떻게 되살아났나, 주석 처리한 리스너가 만든 race와 이어진다.

증상: 닫아도 되살아나는 마법사

운영자 보고는 한 줄이었다. “온보딩 wizard를 skip해도 다음 로그인 때 또 뜬다.”

온보딩은 보통 한 번 끝내면 끝이다. 사용자 설정에 onboarding_completed: true가 저장되고, 다음부터는 안 뜬다. 그런데 매 로그인마다 되살아난다는 건 둘 중 하나다 — 서버에 저장이 안 되거나, 저장은 됐는데 읽는 타이밍이 틀렸거나.

1차 진단: “부모가 서버에 PATCH를 안 한다”

첫 진단은 전자였다. “gallery 페이지의 @complete 바인딩이 로컬 ref만 닫고 서버에 PATCH를 안 한다.” 그럴듯했다. wizard를 닫는 핸들러가 화면 상태(showOnboarding.value = false)만 바꾸고 서버엔 안 알리면, 다음 로그인에 서버는 여전히 false를 줄 테니까.

그래서 그대로 코딩했다. 부모에 onOnboardingComplete() 핸들러를 추가하고 updateUserSettings({ onboarding_completed: true })을 호출하는 PR을 올렸다.

리뷰가 잡은 것: 자식은 이미 PATCH하고 있었다

Codex 리뷰가 PR을 막았다. 자식 컴포넌트 MobileOnboardingWizard가 이미 await updateUserSettings(...)를 한 다음에 emit('complete')를 부르고 있었다.

즉 닫기 흐름은 이미 이랬다:

  1. 자식: await updateUserSettings({ onboarding_completed: true })서버 PATCH 완료
  2. 자식: emit('complete')
  3. 부모: 이벤트 받아 showOnboarding.value = false

서버 저장은 이미 되고 있었다. 1차 진단이 틀린 것이다. 내가 부모에 PATCH를 또 넣었다면 매 dismiss마다 중복 PATCH + 토스트 노이즈만 늘었을 것이다. PR을 닫고 브랜치를 지웠다.

여기서 배운 게 하나 있다 — 잘못된 1차 진단을 받으면 그대로 코딩하지 말고, 진단이 가리키는 반대편(자식 컴포넌트가 정말 PATCH를 안 하는지)을 먼저 확인해야 한다. 진단은 가설이지 사실이 아니다. 이 경우 그 확인을 사람이 아니라 리뷰어(Codex)가 대신 해줬다.

진짜 원인: cold-load race

저장이 되고 있다면, 문제는 읽는 타이밍이다. 그리고 여기서 처음 짚었던 그림은 틀렸다 — 이게 이 글의 두 번째 반전이다.

설정을 끌어오는 hydrateUserAndSettings()(→ await fetchUserSettings())는 auth-init plugin 안에 있고, 두 군데서 불린다.

  1. 콜드 부트 경로defineNuxtPlugin(async () => { … await hydrateUserAndSettings() … }). 앱이 저장된 토큰으로 시작할 때 plugin setup 안에서 직접 await한다.
  2. 로그인 전이 경로 — plugin이 등록하는 authStore.$subscribe(async (_m, state) => { if (로그인됨) await hydrateUserAndSettings() }). 앱이 떠 있는 상태에서 토큰이 바뀌면(=로그인) 호출된다.

처음엔 “plugin이 await fetchUserSettings()를 하는데 gallery의 onMounted가 그걸 race한다”고 적었다. 그런데 경로 1에선 그게 불가능하다. Nuxt는 async plugin setup이 반환한 promise를 앱 마운트 전에 await한다. plugin이 fetch를 진짜로 기다리면, 어떤 컴포넌트의 onMounted도 그 뒤에 뜬다. 콜드 부트에선 게이트가 이미 fetch된 값을 본다 — race 없음.

진짜 race는 경로 2다. 그리고 핵심은 한 줄로 요약된다 — Pinia는 $subscribe 콜백을 await하지 않는다. async 구독자가 돌려준 promise를 그냥 버린다. 그래서 로그인으로 토큰이 바뀌면 $subscribehydrateUserAndSettings()fire-and-forget으로 띄우고, 그 사이 라우터는 /gallery로 넘어가 컴포넌트를 mount한다. gallery의 게이트가 평가되는 순간 fetchUserSettings()는 아직 in-flight고, 게이트는 store의 **default onboardingCompleted = false**를 읽어 wizard를 띄운다. fetch가 끝나 true가 들어와도 게이트는 이미 fired된 뒤다.

여기서 일반화할 교훈이 나온다 — await이 한 함수 안에서 직렬화되는 건 끝이 아니다. 그 await를 누가 기다려 주느냐가 관건이다. Nuxt는 plugin setup을 기다리고, Pinia는 $subscribe 콜백을 안 기다린다. 같은 hydration 함수라도 어느 진입점에서 불리느냐에 따라 직렬화되기도, fire-and-forget이 되기도 한다. “plugin이 await하니까 안전하다”는 경로 1에만 맞고, 버그는 경로 2에 숨어 있었다.

fix: “로드 완료 전엔 평가하지 않는다”

고치는 방법은 게이트가 default 값이 아니라 로드된 값만 보게 만드는 것이다. store에 “한 번이라도 fetch가 끝났나”를 알리는 hasFetched 플래그를 두고, 게이트를 그 플래그에 반응하는 watcher로 바꿨다.

// stores/settings.ts — hasFetched 플래그
const hasFetched = ref(false)

async function fetchUserSettings(): Promise<void> {
  isLoading.value = true
  try {
    const { data } = await api().get<UserSettingsPublic>("/me/settings")
    applySettingsToState(data)
  } catch (err) {
    console.error("[settings] fetchUserSettings failed:", err)
  } finally {
    isLoading.value = false
    hasFetched.value = true  // 성공이든 실패든
  }
}
// pages/gallery/index.vue — onMounted 인라인 게이트를 watcher로 교체
watch(
  () => settingsStore.hasFetched,
  (loaded) => {
    if (!loaded) return            // 아직 로드 전 — 평가하지 않음
    if (!authStore.user) return
    if (!settingsStore.onboardingCompleted) {
      showOnboarding.value = true
    }
  },
  { immediate: true },
)

{ immediate: true }라서 이미 로드가 끝난 상태(재방문)면 즉시 평가하고, 아직이면 hasFetchedtrue로 flip될 때 평가한다. race window가 사라진다.

finally에서 성공이든 실패든 hasFetchedtrue로 올리는 게 중요하다. 이유는 다음 절에.

왜 default-true-then-override가 아니라 hasFetched guard였나

선택지는 둘이었다.

A가 더 짧다. 한 줄도 안 든다 — 그냥 기본값만 바꾸면 된다. 그런데 A의 실패 모드가 위험하다. 진짜 첫 사용자의 첫 fetchUserSettings()가 transient하게 실패한다고 하자(네트워크 깜빡임, 콜드 백엔드). A에선 onboardingCompleted가 default true로 남아 있으니 온보딩이 영원히 안 뜬다. 정작 온보딩이 가장 필요한 사람에게.

B는 그 경우를 깔끔히 구분한다. fetch가 실패해도 finally에서 hasFetched = true가 되고, onboardingCompleted는 store default 그대로 — 신규 사용자라면 false다. 게이트가 평가되고 wizard가 뜬다. B는 “아직 로드 안 됨” vs “로드됐고 서버가 false다”를 분리한다. A는 그 둘을 default 값 하나로 뭉갠다.

backend도 명시적으로 B를 권했고, frontend도 같은 결론에 도달했다. 한 줄 더 드는 값을 했다.

가져갈 것


저작·인용: 이 글은 Ascendy Engineering이 작성했으며 출처 표기 시 재인용 가능합니다. 잘못된 정보를 발견하면 GitHub 이슈로 알려주세요.


Tags: nuxt, pinia, race-condition, settings-hydration, lessons-from-review