Next.js

Next.js와 Supabase로 Apple 로그인 구현

해보구 2025. 6. 8. 22:21

 

iOS 앱 출시를 위해 Apple 소셜 로그인은 필수였다.

Supabase와 Next.js를 사용하고 있었기에 비교적 간단할 것이라 생각했지만, 예상보다 훨씬 많은 장애물을 마주했다. .

 

1단계: Supabase 설정과 끝나지 않던 400 오류

처음에는 단순히 Supabase의 Apple Provider를 활성화하면 될 줄 알았다. 하지만 오류가 계속 발생했고, 원인은 여러 설정의 복합적인 문제였다.

 

  • 문제점: 웹에서도 로그인을 테스트해야 해서 Apple Developer 포털에 iOS 앱 ID 외에 웹용 서비스 ID도 만들었다.
    # Supabase Client IDs 필드
    app.univoice.web,com.univoice.app
  • 해결 과정: Supabase의 Client IDs 필드에는 웹 서비스 ID를 먼저, 그 뒤에 iOS 번들 ID를 쉼표로 구분해서 넣어줘야 했다. Supabase가 웹 OAuth 인증 시 목록의 첫 번째 ID를 사용하기 때문이었다.
  • 문제점: Apple에서 받은 .p8 비공개 키를 Secret Key 필드에 그대로 넣었더니 Secret key should be a JWT 오류가 발생했다.
  • 해결 과정: .p8 키는 JWT(Json Web Token)를 생성하기 위한 재료였다. Node.js 스크립트를 사용해 Team ID, Key ID, Client ID, 그리고 .p8 키로 6개월짜리 JWT를 생성해서 Secret Key 필드에 넣어주니 해결되었다.
  • 문제점: 모든 설정을 마쳤음에도 400 오류가 계속 발생했다.
  • 해결 과정: 가장 허무한 실수였다. Supabase의 Apple Provider 설정 창에서 맨 위의 Enable Sign in with Apple 스위치를 켜고 저장하지 않았었다. 이 스위치를 켜고 저장하니 드디어 Supabase의 400 오류가 사라졌다.

 

2단계: Apple 서버와의 만남과 `invalid_request`

Supabase 오류를 해결하니, 이번에는 Apple 로그인 페이지에서 invalid_request: Invalid client id or web redirect url 오류가 발생했다. Supabase가 Apple로 보낸 정보와 Apple에 등록된 정보가 일치하지 않는다는 뜻이었다.

  • 문제점: 모든 설정 값을 몇 번이고 다시 확인했지만, 값은 정확해 보였다.
  • 해결 과정: 브라우저 개발자 도구의 'Network' 탭에서 실제 Apple로 전송되는 Request URL을 캡처해서 분석했다. 이 방법으로 Supabase가 client_id를 잘못 보내고 있음을 확인했고, 위에서처럼 Client IDs 필드의 순서를 바꿔서 문제를 해결할 수 있었다.

 

3단계: 로그인 성공 직후의 혼란 (404와 화면 초기화)

드디어 로그인에 성공했지만, 이상한 곳으로 이동하거나 로그인 상태가 풀리는 문제가 발생했다.

  • 문제점: 로그인 성공 후, 코드에 지정한 .../auth/callback 페이지가 없어 404 오류가 발생했다.
    // LoginForm.tsx
    await supabase.auth.signInWithOAuth({
      provider: 'apple',
      options: {
        redirectTo: window.location.origin 
      }
    });
  • 해결 과정: 현재 인증 방식이 클라이언트에서 처리하는 'Implicit Flow'(URL에 # 사용)로 동작하고 있었다. 따라서 서버용 콜백 페이지가 필요 없었다. signInWithOAuthredirectTo 옵션을 존재하지 않는 페이지가 아닌, 웹사이트의 메인 페이지 주소로 변경했다.

 

  • 문제점: 404 오류는 해결됐지만, 로그인 성공 후 메인 페이지로 돌아오면 다시 로그인 화면이 나타났다. 콘솔에는 Multiple GoTrueClient instances detected 경고가 찍혀 있었다.
  • 해결 과정: 앱의 여러 파일에서 각자 Supabase 클라이언트를 생성하고 있어 충돌이 발생한 것이었다. lib/supabase.ts에서 Next.js용 클라이언트(createClientComponentClient)를 딱 한 번만 생성하고, 다른 모든 파일에서는 이 파일을 import해서 사용하도록 코드를 통합했다.

 

  • 문제점: 클라이언트를 통합한 후에도 로그인 상태가 화면에 바로 반영되지 않았다.
    // contexts/AuthContext.tsx
    useEffect(() => {
      const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
        // ... 상태 업데이트 로직 ...
        if (event === 'SIGNED_IN') {
          router.refresh(); // 로그인 시 화면 갱신!
        }
      });
      return () => subscription.unsubscribe();
    }, [router, supabase.auth]);
  • 해결 과정: Next.js의 서버 렌더링과 클라이언트 상태 업데이트 간의 미세한 차이 때문이었다. AuthProvideronAuthStateChange 리스너 안에서, 로그인(SIGNED_IN) 이벤트가 발생했을 때 router.refresh()를 호출하여 화면을 부드럽게 갱신해주니 모든 문제가 해결되었다.

 

 


회고

간단할 거라 생각했던 소셜 로그인 하나에 이렇게 많은 함정이 있을 줄은 몰랐다. Supabase와 Apple Developer 포털, 그리고 Next.js코드까지 세 개의 다른 시스템 설정을 동시에 맞춰야 하는 작업은 결코 쉽지 않았다. 특히 Supabase UI의 변경으로 인한 혼란과, 눈에 보이지 않는 설정 값의 불일치를 찾아내는 과정은 인내심을 요구했다.

 

이번 경험을 통해, 문제가 발생했을 때 UI에 보이는 것만 믿지 않고 실제 네트워크 요청을 캡처해서 분석하는 것이 얼마나 중요한지 깨달았다. 또한, 라이브러리의 클라이언트 인스턴스는 반드시 한 곳에서 관리해야 한다는 기본 원칙을 다시 한번 되새겼다. 마지막으로 Next.js의 router.refresh()와 같은 핵심 기능을 적재적소에 사용하는 것이 부드러운 사용자 경험을 만드는 데 얼마나 중요한지도 알게 되었다.