Supabase Auth에 네이버 OAuth 연동하기

김지훈·2026년 1월 13일
post-thumbnail

예전에 진행했던 쇼핑몰 프로젝트를 개인적으로 Next.js로 마이그레이션 하면서 별도의 백엔드 서버 없이 Supabase를 사용하고 있다. 카카오와 구글은 Supabase Auth가 기본적으로 지원하지만, 네이버는 지원하지 않는다.

Supabase가 지원하지 않는 Provider를 붙이기 위해 별도의 서버를 띄우지 않고, Next.js Route Handler와 Supabase Admin API를 조합하여 비교적 간단하게 네이버 OAuth를 연동하는 과정을 정리해 보았다.


1. OAuth 흐름 선택

OAuth 로그인은 크게 두 가지 방식으로 구현할 수 있다.

1) Implicit Flow
클라이언트가 직접 토큰을 받는다. URL에 Access Token이 노출되므로 탈취 위험이 비교적 높다.

[사용자] → [네이버 로그인] → [콜백 URL#access_token=xxx] 
                                  ↑ URL에 토큰 노출

2) Authorization Code Flow
클라이언트는 인가 코드만 받고, 실제 토큰 교환은 서버(Next.js Route Handler)에서 수행한다. Client Secret이 서버에만 저장되므로 안전하다.

[사용자] → [네이버 로그인] → [콜백 URL?code=xxx]
                                  ↓
                        [서버: code + Client Secret → Token 교환]
                                  ↓ 
                        [안전하게 토큰 및 사용자 정보 획득]

2. 핵심 로직

Supabase Auth에는 네이버 Provider가 없으므로, 네이버에서 받은 정보로 Supabase 유저를 강제로 생성하고 로그인 세션을 맺어줘야 한다.

이 과정에서 비밀번호가 없는 OAuth 유저를 처리하기 위해 Magic Link를 활용했다. 토큰만 추출해서 즉시 세션을 생성한다.

구현 시나리오

  1. Admin API: 네이버 사용자 정보로 Supabase 유저 생성 (또는 조회)
  2. Magic Link 생성: 해당 유저의 로그인용 Magic Link 생성
  3. 세션 교환: 링크에서 토큰만 추출해 verifyOtp로 세션 획득

3. 구현

3-1. Supabase Admin 클라이언트

네이버 사용자를 강제로 생성/수정하려면 SERVICE_ROLE_KEY가 필요하다. 이 키는 절대 브라우저에 노출되면 안 되므로 서버 사이드에서만 사용한다.

// src/shared/lib/supabase/admin.ts
import { createClient } from '@supabase/supabase-js';

export function createAdminClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    {
      auth: {
        autoRefreshToken: false, // 일회성 작업이므로 갱신 불필요
        persistSession: false,   // 서버 메모리에 세션 저장 안 함
      },
    }
  );
}

3-2. 네이버 OAuth 유틸리티

네이버 로그인 URL을 생성하고, Client Secret을 사용해 안전하게 토큰을 교환하기 위한 유틸 함수들이다.

// src/features/auth/utils/naverOAuth.ts

// 1. 로그인 URL 생성 (진입점)
export function generateNaverAuthUrl({
  clientId,
  redirectUri,
  state, 
}: {
  clientId: string;
  redirectUri: string;
  state: string;
}) {
  const params = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    response_type: 'code',
    state, // CSRF 방지용 토큰
  });
  return `https://nid.naver.com/oauth2.0/authorize?${params}`;
}

// 2. 토큰 교환 (Server Side Only)
export async function exchangeNaverToken({
  clientId,
  clientSecret,
  code,
  state,
}: {
  clientId: string;
  clientSecret: string;
  code: string;
  state: string;
}) {
  const params = new URLSearchParams({
    grant_type: 'authorization_code',
    client_id: clientId,
    client_secret: clientSecret,
    code,
    state,
  });

  const res = await fetch(
    `https://nid.naver.com/oauth2.0/token?${params}`,
    { method: 'POST' }
  );
  
  if (!res.ok) throw new Error('Failed to exchange token');
  return res.json();
}

// 3. 사용자 정보 조회 (Server Side Only)
export async function getNaverUserInfo(accessToken: string) {
  const res = await fetch('https://openapi.naver.com/v1/nid/me', {
    headers: { Authorization: `Bearer ${accessToken}` },
  });

  if (!res.ok) throw new Error('Failed to fetch user info');
  
  const data = await res.json();
  if (data.resultcode !== '00') {
    throw new Error(`Naver API error: ${data.message}`);
  }
  
  return data.response;
}

3-3. 로그인 요청 (진입점)

클라이언트에서 네이버 로그인을 시도한다.

// src/features/auth/services/authService.ts
import { generateNaverAuthUrl } from '@/features/auth/utils/naverOAuth';

export function signInWithNaver() {
  const state = crypto.randomUUID(); // CSRF 방지
  const redirectUri = `${window.location.origin}/auth/callback/naver`;
  
  // 추후 검증을 위해 state 저장
  if (typeof window !== 'undefined') {
    sessionStorage.setItem('naver_oauth_state', state);
  }

  const url = generateNaverAuthUrl({
    clientId: process.env.NEXT_PUBLIC_NAVER_CLIENT_ID!,
    redirectUri,
    state,
  });

  window.location.href = url;
}

3-4. 네이버 인증 서비스 로직

네이버 유저 정보를 Supabase 유저와 동기화하고, 로그인 토큰을 발급한다.

// src/features/auth/services/naverAuthService.ts
import { createAdminClient } from '@/shared/lib/supabase/admin';
import { createClient as createServerClient } from '@/shared/lib/supabase/server';

export async function createOrGetNaverUser({
  email,
  name,
  providerId,
  profileImage,
}: NaverUserParams) {
  const admin = createAdminClient();

  // 1. provider_id로 기존 사용자 탐색
  const { data: users } = await admin.auth.admin.listUsers();
  const existing = users?.users.find(
    (u) =>
      u.app_metadata?.provider === 'naver' &&
      u.user_metadata?.provider_id === providerId
  );

  let user;
  if (existing) {
    // 2. 기존 유저 프로필 업데이트
    const { data } = await admin.auth.admin.updateUserById(existing.id, {
      user_metadata: {
        ...existing.user_metadata,
        full_name: name,
        avatar_url: profileImage,
      },
    });
    user = data.user;
  } else {
    // 3. 신규 유저 생성
    const { data } = await admin.auth.admin.createUser({
      email,
      email_confirm: true,
      user_metadata: {
        full_name: name,
        avatar_url: profileImage,
        provider_id: providerId, // 고유 ID 저장
      },
    });
    user = data.user;
  }

  // 4. 로그인용 Magic Link 토큰 생성
  const { data: link } = await admin.auth.admin.generateLink({
    type: 'magiclink',
    email: user.email!,
  });

  const token = new URL(link.properties.action_link).searchParams.get('token');
  if (!token) throw new Error('Failed to extract token');

  return { user, magicLinkToken: token };
}

export async function createNaverSession(token: string, userId: string) {
  const server = await createServerClient();
  const admin = createAdminClient();

  // 5. 토큰으로 세션 생성 (쿠키 설정됨)
  await server.auth.verifyOtp({
    token_hash: token,
    type: 'magiclink',
  });

  // verifyOtp가 provider를 'email'로 변경하므로 'naver'로 복구
  await admin.auth.admin.updateUserById(userId, {
    app_metadata: {
      provider: 'naver',
      providers: ['naver'],
    },
  });
}

(참고) 네이버 로그인 API를 활용하는 경우, 이메일은 고유 식별자가 될 수 없다. 누군가 카카오로 가입한 이메일과 동일한 이메일로 네이버 로그인을 시도할 수 있기 때문에 provider_id를 기준으로 삼았다.

3-5. Route Handler (Callback 처리)

네이버에서 리다이렉트된 요청을 받아 위 로직들을 수행한다.

// src/app/auth/callback/naver/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { exchangeNaverToken, getNaverUserInfo } from '@/features/auth/utils/naverOAuth';
import { createOrGetNaverUser, createNaverSession } from '@/features/auth/services/naverAuthService';

export async function GET(request: NextRequest) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get('code');
  const state = searchParams.get('state');

  if (!code || !state) {
    return NextResponse.redirect(new URL('/auth/login?error=missing_params', origin));
  }

  try {
    // 1. 네이버 토큰 교환 및 유저 정보 조회
    const { access_token } = await exchangeNaverToken({
      clientId: process.env.NEXT_PUBLIC_NAVER_CLIENT_ID!,
      clientSecret: process.env.NAVER_CLIENT_SECRET!,
      code,
      state,
    });
    
    const naverUser = await getNaverUserInfo(access_token);
    
    // 2. Supabase 유저 동기화 및 세션 토큰 발급
    const { user, magicLinkToken } = await createOrGetNaverUser({
      email: naverUser.email,
      name: naverUser.name,
      providerId: naverUser.id,
      profileImage: naverUser.profile_image,
    });

    // 3. 세션 생성 (쿠키)
    await createNaverSession(magicLinkToken, user.id);

    return NextResponse.redirect(new URL('/', origin));
  } catch (err) {
    console.error('Naver Login Error:', err);
    return NextResponse.redirect(new URL('/auth/login?error=login_failed', origin));
  }
}

마치며

Supabase Admin API와 Next.js 서버 기능을 활용하면 별도의 백엔드 구축 없이도 안전한 인증 흐름을 만들 수 있다. 이 구조는 네이버뿐만 아니라 Supabase가 지원하지 않는 다른 서드 파티 인증을 연동할 때도 동일하게 적용할 수 있을 것이다.


참고 자료

0개의 댓글