Skip to content
··3분 읽기

3분에서 500ms로: 말이 안 되는 가입 버그

3분의 가입 지연을 추적했더니 쓰기와 읽기 간의 데이터베이스 복제 지연으로 인해 존재하면서 동시에 존재하지 않는 슈뢰딩거의 사용자였습니다.

사용자가 전혀 말이 안 되는 버그를 보고할 때의 그 느낌 아시죠? "가입 후 앱이 로드되는 데 3분이 걸려요." 3분? 그건 로딩 시간이 아니라 커피 한 잔 마실 시간입니다. 이것은 DIALØGUE 역사상 가장 이상한 버그 중 하나를 추적한 이야기입니다.

미스터리의 시작

순진하게 시작되었습니다. 새 사용자가 Google SSO로 가입하고 DIALØGUE를 시도해보려고 흥분해있었습니다. 그런데... 아무것도. 정확히 아무것도가 아니라 - 아름답게 디자인된 로딩 스켈레톤을 받았습니다. 3분이나요.

이상한 점? 사용자에게만 발생했습니다. 기존 사용자는 즉시 로그인할 수 있었습니다. 그리고 일관적이지도 않았습니다 - 때로는 1분, 때로는 3분, 가끔은 즉시 작동했습니다.

첫 번째 생각: "콜드 스타트 문제겠지." (내레이터: 콜드 스타트 문제가 아니었습니다.)

조사

라운드 1: 프론트엔드 탓

```typescript
// 첫 번째 용의자: 프로필 로딩 훅
useEffect(() => {
  if (user) {
    fetchUserProfile(); // 이것이 영원히 걸리고 있었음
  }
}, [user]);
```

여기저기에 타이머를 추가했습니다. API 호출이 실제로 3분이 걸리고 있었습니다. 하지만 왜? 백엔드는 데이터를 반환하거나 에러를 내야지, 그냥... 기다려서는 안 됩니다.

라운드 2: 백엔드 탓

Supabase Edge Functions를 파고들었습니다:

```typescript
// 사용자 프로필 가져오기 Edge Function
const { data: profile } = await supabase
  .from('users')
  .select('*')
  .eq('id', userId)
  .single();

if (!profile) {
  // 새 사용자 - 프로필 생성
  await createUserProfile(userId);
}
```

이것은 괜찮아 보였습니다. 빠를 것이 맞죠? 로깅을 더 추가할 시간입니다.

라운드 3: 미스터리가 깊어지다

모든 곳에 로그를 추가한 후(정말 모든 곳에), 기이한 것을 발견했습니다:

[00:00] 사용자가 Google로 로그인
[00:01] Auth 트리거 실행 - 사용자 레코드 생성
[00:01] 프론트엔드가 프로필 요청
[00:01] Edge Function이 사용자 조회... 결과 없음
[00:02] Edge Function이 사용자 생성 시도...
[00:02] 데이터베이스 제약 조건 에러: 사용자가 이미 존재함
[00:03] Function 재시도...
[03:00] Function이 최종 타임아웃

잠깐, 뭐? 사용자가 존재하지 않는데, 이미 존재한다고? 슈뢰딩거의 사용자? T.T

깨달음

눈이 아플 때까지 데이터베이스 로그를 응시한 후, 마침내 보았습니다. 데이터베이스에 경쟁하는 프로세스가 있었습니다:

  1. Supabase Auth 트리거: 가입 시 사용자 레코드 생성
  2. Edge Function: 찾을 수 없으면 사용자 생성 시도
  3. 데이터베이스 복제 지연: 트리거의 INSERT가 읽기 복제본에 아직 복제되지 않음

일어나고 있었던 일:

-- Auth 트리거 (기본 데이터베이스에서)
INSERT INTO users (id, email) VALUES ($1, $2);

-- Edge Function (복제본에서 읽기)
SELECT * FROM users WHERE id = $1; -- 아무것도 반환하지 않음!

-- Edge Function (도우려고 시도)
INSERT INTO users (id, email) VALUES ($1, $2); -- 충돌!

함수는 지수 백오프로 재시도하며, 각 시도가 같은 경합 조건에 부딪혔습니다:

  • 복제가 마침내 따라잡거나 (1-3분)
  • 함수가 타임아웃되거나 (3분)

업데이트: 진짜 원인과 최종 견고한 솔루션

초기 게시 후, 디버깅을 계속했고, 백엔드 변경 사항이 개선이기는 했지만 미스터리의 근본은 여전히 찾기 어려웠습니다. 돌파구는 백엔드에서 클라이언트 측 하이드레이션 및 인증 흐름으로 초점을 옮겼을 때 왔습니다.

진짜 원인: 클라이언트 측 경합 조건

문제는 느린 데이터베이스 트리거나 콜드 Edge Function이 아니었습니다. 진짜 문제는 전형적인 클라이언트 측 경합 조건이었습니다:

  1. OAuth 리디렉트: 새 사용자가 Google로 로그인하고 앱으로 다시 리디렉트됩니다.
  2. 비동기 세션: Supabase 클라이언트 라이브러리(supabase-js)가 세션을 설정하기 위해 URL에서 토큰을 처리하기 시작합니다. 이것은 비동기 프로세스입니다.
  3. 조기 렌더링: 그러나 React 앱은 즉시 렌더링합니다. 2단계의 비동기 프로세스가 완료되기 전에 사용자의 세션을 요청합니다.
  4. 실패: 앱은 null 세션을 받고, 사용자가 로그인하지 않았다고 결론 짓고, 빈 화면이나 에러 상태를 렌더링합니다. 잠시 후 세션이 사용 가능해지지만, 이미 늦었습니다—UI가 이미 결정을 내렸습니다.

클라이언트 측 재시도를 추가하는 것과 같은 초기 해결책은 이 근본적인 경합 조건과 싸우는 증상일 뿐이었습니다.

솔루션: 인증의 단일 진실 소스

올바르고 최종적인 솔루션은 프론트엔드 인증 상태 관리를 진정으로 이벤트 기반이고 견고하게 재설계하는 것이었습니다.

1. SupabaseProvider의 중앙집중화된 로직: SupabaseProvider를 인증의 단일 권위적 진실 소스로 리팩토링했습니다. 다른 훅의 모든 다른 리스너와 검사를 제거했습니다.

2. onAuthStateChange를 올바르게 사용: 수정의 핵심은 Supabase의 onAuthStateChange 리스너에 전적으로 의존하는 것이었습니다.

// SupabaseProvider.tsx의 단순화된 로직

export function SupabaseProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true); // 로딩 상태로 시작

  useEffect(() => {
    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (event, session) => {
        setUser(session?.user ?? null);
        // 이 리스너가 실행되어야만 인증이 "완료"된 것으로 간주
        setLoading(false);
      }
    );

    return () => subscription.unsubscribe();
  }, []);

  // ...
}

이 패턴은 Supabase가 사용자의 세션이 유효한지 null인지 확인할 때까지 전체 애플리케이션이 loading 상태를 유지하도록 보장합니다. 더 이상 경합 조건이 없습니다.

해피 엔딩

DIALØGUE는 이제 1초 이내에 새 사용자를 온보딩합니다. 가입 중 더 이상 커피 브레이크가 없습니다. 뭔가 망가뜨렸나 궁금해하는 혼란스러운 사용자도 없습니다.

이 수정은 3주째 프로덕션에서 운영되고 있습니다. 타임아웃 이슈 제로. 경합 조건 제로. 처음부터 그래야 했던 것처럼 부드럽고 빠른 가입.

이것을 디버깅하는 데 일주일을 보낸 가치가 있었나요? 새 사용자가 가입 후 몇 분 내에 원활하게 첫 팟캐스트를 만드는 것을 볼 때 — 절대적으로요. :D

무언가가 동시에 존재하면서 존재하지 않는 버그를 추적해본 적 있으신가요? 모든 개발자에게 적어도 하나의 슈뢰딩거의 버그 이야기가 있을 것 같습니다. 여러분의 이야기를 듣고 싶습니다!

감사합니다,

Chandler

이제 빨라진 가입을 시도해 보고 싶으시나요? DIALØGUE에서 AI 팟캐스트를 만들어 보세요. 더 이상 3분이 걸리지 않을 것을 약속합니다 :)

계속 읽기

나의 여정
연결
언어
환경설정