naver 로그인 구현 with Supabase 3편
TL; DR: admin 권한 쓰면 끝
작년 7월에 supabase에서 편법(?) naver 로그인 기능을 구현했었습니다.
1년 넘게 지났는데 갑자기 편법 말고 진짜로 구현이 되겠잖아? 생각이 들었어요.
supabase 어드민 권한을 쓰면 auth 스키마에도 입력이 될 것 같았고
해봤더니 역시 아주 잘 되었습니다... 이 생각을 왜 작년엔 못했을까요?
한가지 문제는 네이버 OAuth 를 프로덕션으로 쓰려면
네이버 개발자센터 > 내 애플리케이션 > 네이버 로그인 검수 상태
에서 검수가 완료되어야 합니다. 검수가이드
보면 대단한 걸 요구하는건 아닌데 귀찮습니다.
카카오는 검수 없이도 기본적인 정보들은 그냥 받을 수 있는데
네이버는 아무튼간 안되는 것 같습니다.
결론은,
Supabase Auth 와 함께 사용하기 > 문제 없음
프로덕션에서 사용 > 문제 있음. 네이버 검수 통과 필요
입니다.
1. naver-login-button.tsx
네이버 로그인 버튼 컴포넌트 입니다.
- global.d.ts 등에 naver 설정
- 네이버 스크립트를 그냥 버튼 컴포넌트에 next/script 로 포함시키고 버튼 보일때 로딩
- 스크립트 onLoad 시 네이버 로그인 버튼 초기화(hidden 으로 숨기고 ref 달기)
- 버튼 커스텀 하고, 이 버튼 클릭시 숨겨놓은 네이버 로그인 버튼 클릭시키기
"use client";
import Script from "next/script";
import { useCallback, useRef, useState } from "react";
import { SiNaver } from "react-icons/si";
import { PUBLIC_URL } from "@/constants/common.constants";
function NaverLogInButton() {
// 귀찮으니까 any ㅋㅋ
const [naverObj, setNaverObj] = useState<any>(null);
const naverRef = useRef<HTMLButtonElement>(null);
const handleNaverInit = useCallback(() => {
const naver = window.naver;
setNaverObj(naver);
const naverLogin = new naver.LoginWithNaverId({
clientId: process.env.NEXT_PUBLIC_NAVER_CLIENT_ID, //ClientID
callbackUrl: `${PUBLIC_URL}/loading`, // Callback URL
callbackHandle: true,
isPopup: false, // 팝업 형태로 인증 여부
loginButton: {
color: "green", // 색상
type: 1, // 버튼 크기
height: "60", // 버튼 높이
}, // 로그인 버튼 설정
});
naverLogin.init();
}, []);
const handleNaverLoginClick = () => {
if (!naverRef.current?.children[0].children) return;
(naverRef.current.children[0].children[0] as HTMLImageElement).click();
};
return (
<>
<Script
src="https://static.nid.naver.com/js/naveridlogin_js_sdk_2.0.2.js"
onLoad={handleNaverInit}
/>
<button
type="button"
ref={naverRef}
id="naverIdLogin"
className="hidden"
/>
{!naverObj ? (
<SiNaver className="h-10 w-10 text-green-500" />
) : (
<SiNaver
className="h-10 w-10 cursor-pointer text-green-500"
onClick={handleNaverLoginClick}
/>
)}
</>
);
};
export default NaverLogInButton;
2. /loading/page.tsx (이 방법 별로인듯...)
작년에 이런식으로 해놨길래 귀찮아서 그냥 이어서 했습니다.
근데 별로 좋은 방법은 아니에요.
왜 이렇게 했었는지 모르겠지만 ㅋㅋ
현재 상황은 다음과 같은데요.
- 코드가 해시로 > 네이버 SDK Implicit Flow를 사용(위에서 그렇게 하고 있음.. Script 부분)
- 즉 Route Handler로 직접 처리 불가 > 해시는 서버로 전송되지 않기 때문
안전하게 하고 싶으면 네이버 OAuth의 Authorization Code Flow를 사용하는게 좋습니다. 참고
할게 좀 더 많긴한데 https://nid.naver.com/oauth2.0/token
여기로 요청해서 PKCE 도 적용하는게 좋습니다.. (OAuth2 구현기는 나중에...)
아무튼, 지금 상태로는 해시로 오기 때문에 이렇게 할 수 밖에 없습니다.
네이버 개발자센터에서 callback URL 을 /loading 으로 설정해두면
네이버 로그인 버튼 클릭시 여기로 오게 됩니다.
여기서 하는 일은
- 화면에 로더를 표시하면서
- 해쉬로 오는 네이버 토큰을 API route 로 전달
이게 끝입니다
"use client";
import { useEffect } from "react";
import DefaultLoader from "@/components/atoms/common/DefaultLoader";
import { useAuth } from "@/hooks";
function LoadingPage() {
const { naverLogIn } = useAuth();
useEffect(() => {
if (window.location.hash) {
const hash = window.location.hash.substring(1); // 해시로 오고
const params = new URLSearchParams(hash);
const token = params.get("access_token"); // 정직한 이름...
// 이건 왜 이렇게 했는지...
// 아무튼 naverLogIn 이 하는 일은
// /api/auth/callback/naver 여기로
// 헤더에 토큰 실어서 보내는 겁니다..
if (token) naverLogIn(token);
// token 없을 때 처리도 당연히 해줘야 합니다...
}
}, [naverLogIn]);
return <DefaultLoader />;
};
export default LoadingPage;
3. supabase-admin-client.ts
이게 핵심입니다.
어드민용 클라이언트가 하나 필요해요.
어드민용 클라이언트를 위해선
service_role_key 가 필요한데
이건 수파베이스 대시보드에서 받을 수 있습니다.
(최근 이름이 그냥 SECRET_KEY 로 바뀐듯... 저는 그거 썼습니다)
import { createClient } from "@supabase/supabase-js";
/**
* 서버 사이드에서 어드민 권한으로 Supabase 클라이언트를 생성합니다.
* auth.admin API를 사용하기 위해 service_role 키가 필요합니다.
* ⚠️ 주의: 이 클라이언트는 서버 사이드에서만 사용해야 하며, 절대 클라이언트에 노출되면 안 됩니다.
*/
export function createAdminClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
// 시크릿키 써도 됨...
const supabaseServiceRoleKey = process.env.SUPABASE_SECRET_KEY;
if (!supabaseUrl || !supabaseServiceRoleKey) {
throw new Error(
"Missing Supabase environment variables",
);
}
return createClient(supabaseUrl, supabaseServiceRoleKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}
4. api/callback/naver/route.ts
테이블명이 public.buddies 인데
그냥 users 로 하지 뭐 이렇게 했나 싶지만?
1년 전의 나니까... 그냥 넘어갑니다...
getBuddy 이런건 그냥 public.users 에서
user 가져오는 거라고 봐주시면 됩니다.
일단 필요한 함수들 부터
getBuddy
/**
* buddy_id로 buddies 테이블에서 사용자 정보를 가져옵니다.
*/
async function getBuddy(
supabase: SupabaseClient,
id: string,
): Promise<Buddy | null> {
const { data: buddy, error } = await supabase
.from("buddies")
.select("*")
.eq("buddy_id", id)
.single();
if (error) {
console.error("Error fetching buddy by id:", error);
return null;
}
return buddy;
}
getBuddyByEmail
/**
* 이메일로 buddies 테이블에서 사용자 정보를 가져옵니다.
*/
async function getBuddyByEmail(
supabase: SupabaseClient,
email: string,
): Promise<Buddy | null> {
const { data: buddy, error } = await supabase
.from("buddies")
.select("*")
.eq("buddy_email", email)
.single();
if (error) {
return null;
}
return buddy;
}
findUserByEmail
/**
* 이메일로 auth.users에서 사용자를 찾습니다.
*/
async function findUserByEmail(supabaseAdmin: SupabaseClient, email: string) {
const { data: users } = await supabaseAdmin.auth.admin.listUsers();
return users?.users.find((user) => user.email === email) ?? null;
}
getRedirectUrl
/**
* 리다이렉트 URL을 생성합니다.
*/
function getRedirectUrl(
origin: string,
forwardedHost: string | null,
isLocalEnv: boolean,
path: string,
): string {
if (isLocalEnv) {
return `${origin}${path}`;
}
if (forwardedHost) {
return `https://${forwardedHost}${path}`;
}
return `${origin}${path}`;
}
signInUser
/**
* 사용자 로그인 처리를 수행합니다 (세션 생성).
* 로그인도 직접 시켜줘야 함...
*/
async function signInUser(
email: string,
password: string,
): Promise<{ success: boolean; error?: string }> {
try {
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
console.error("Error signing in user:", error);
return { success: false, error: error.message };
}
return { success: true };
} catch (error) {
console.error("Error during sign in:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
isNewUser
const ONE_HOUR_MS = 60 * 60 * 1000;
/**
* 최초 로그인 여부를 확인합니다 (1시간 이내 생성된 사용자).
* 입맛대로...
*/
function isNewUser(createdAt: string): boolean {
return new Date(createdAt).getTime() > Date.now() - ONE_HOUR_MS;
}
handleExistingUser
/**
* 기존 사용자 처리를 수행합니다.
* 필요한 경우 여기서 auth.users 대신
* public의 커스텀 유저 테이블을 사용할 수도 있어요...
* 파라미터도 입맛대로...
*/
async function handleExistingUser(
buddy: Buddy,
userEmail: string,
password: string,
origin: string,
forwardedHost: string | null,
isLocalEnv: boolean,
next: string,
): Promise<NextResponse> {
// 기존 사용자도 로그인 처리 필요
const signInResult = await signInUser(userEmail, password);
if (!signInResult.success) {
console.error("Failed to sign in existing user:", signInResult.error);
return NextResponse.json(
{ error: "Failed to sign in user" },
{ status: 500 },
);
}
const newUser = isNewUser(buddy.buddy_created_at);
// 최초 로그인이면 온보딩으로 리다이렉트
if (newUser) {
const redirectUrl = `${origin}/onboarding?funnel=0&mode=first`;
return NextResponse.json({ redirectUrl, buddy }, { status: 200 });
}
// 기존 사용자는 x-forwarded-host가 있으면 그것을 사용하고,
// 없으면 origin을 사용하여 리다이렉트인데...
// NextResponse.redirect 안하는 이유는
// 그냥 제 프로젝트가 이상하게 되어 있어서 그렇습니다...
// 리턴은 입맛대로 수정하면 됩니다...
const redirectUrl = getRedirectUrl(origin, forwardedHost, isLocalEnv, next);
return NextResponse.json({ redirectUrl, buddy }, { status: 200 });
}
다 되었으면 마지막 route handler 작성!
POST (GET 이 맞을듯…)
아무튼 이렇게 하면 되는데,
참말로 보기 불편하니 리팩토링 해야 합니다...
그리고 리다이렉트 시킬거면 전부 리다이렉트로 처리해야 하고
아니면 그냥 아래처럼 해도 될지도?
!전부 어드민 클라이언트 사용!
순서는
- 네이버 API를 사용하여 사용자 정보 가져오기
- 기존 사용자 있는지 체크 -> auth.user, public.buddies 두 개라 헷갈리지만 잘.. 처리...
- 없으면 createUser
- 3 까지 잘 되었으면 signInWithPassword, 위에서 만든 signInUser 사용
- 리다이렉트 하든지, 결과 리턴하든지..
끝!
export async function POST(request: NextRequest) {
const { searchParams, origin } = new URL(request.url);
// 배포 환경이 vercel 이든 뭐든...
// 로드밸런서일 확률 높으니 그냥 이걸로 하면 됨...
const forwardedHost = request.headers.get("x-forwarded-host");
const isLocalEnv = process.env.NODE_ENV === "development";
const next = searchParams.get("next") ?? "/";
const headersList = await headers();
const accessToken = headersList.get("Authorization")?.split(" ")[1];
if (!FIXED_PASSWORD) {
return NextResponse.json(
{ error: "NAVER_PROVIDER_LOGIN_SECRET is not set" },
{ status: 400 },
);
}
if (!accessToken) {
return NextResponse.json(
{ error: "Access token not found" },
{ status: 400 },
);
}
try {
// 네이버 API를 사용하여 사용자 정보 가져오기
const response = await fetch("https://openapi.naver.com/v1/nid/me", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
return NextResponse.json(
{ error: "Failed to fetch user info from Naver" },
{ status: 400 },
);
}
const userData = await response.json();
const userEmail: string = userData.response?.email;
if (!userEmail) {
return NextResponse.json(
{ error: "Email not found in Naver user data" },
{ status: 400 },
);
}
// ⭐️ 여기서 위에서 만든 어드민클라이언트 사용! ⭐️
const supabaseAdmin = createAdminClient();
// 먼저 buddies 테이블에서 이메일로 기존 사용자 확인
// buddies 테이블의 buddy_email unique constraint 위반을 방지하기 위함
const existingBuddy = await getBuddyByEmail(supabaseAdmin, userEmail);
// 기존 사용자가 있는 경우
if (existingBuddy) {
const existingUser = await findUserByEmail(supabaseAdmin, userEmail);
// 커스텀 테이블에는 있는데, auth.users에서 찾을 수 없는 경우 - 그러니까 망한 상황임
// 이런 상황이 발생하면 커스텀 테이블과 auth.users 간의 데이터 일관성이 깨진 것임
// 그냥 이럴땐 커스텀 테이블에 있는 유저 지우고 다시 하던지 하면 될 듯
if (!existingUser) {
console.error("Buddy exists but auth user not found");
return NextResponse.json(
{ error: "Auth user not found for existing buddy" },
{ status: 500 },
);
}
// 기존 사용자 처리
return handleExistingUser(
existingBuddy,
userEmail,
FIXED_PASSWORD,
origin,
forwardedHost,
isLocalEnv,
next,
);
}
// 새 사용자 생성
const { data: user, error } = await supabaseAdmin.auth.admin.createUser({
email: userEmail,
password: FIXED_PASSWORD, // 지금 그냥 고정값 쓰는데, 안전하게 처리해야겠죠...
email_confirm: true, // 네이버 OAuth 사용자는 이메일이 이미 확인된 상태
app_metadata: {
provider: "naver",
providers: ["naver"],
},
user_metadata: { // 입맛대로 하면 됩니다...
iss: "https://nid.naver.com",
sub: userData.response.id,
name: userData.response.name,
email: userEmail,
picture: userData.response.profile_image,
full_name: userData.response.name,
avatar_url: userData.response.profile_image,
provider_id: userData.response.id,
email_verified: true,
phone_verified: false,
},
});
// auth.users에 이미 존재하는 경우 (buddies는 없지만 auth.users는 있는 경우)
if (error?.message.includes("A user with this email already exists")) {
const existingUser = await findUserByEmail(supabaseAdmin, userEmail);
// 이미 auth.users 에 존재해서 에러가 발생한 상황인데
// email 로는 못찾는 상황임 - 사실 발생하지 않을듯?
// 엄청 이상한 상황이므로 에러 메시지 수정.. 해서 쓰든지 추가 보완 필요
if (!existingUser) {
console.error("odd situation: user exists but could not be found");
return NextResponse.json(
{ error: "odd situation: user exists but could not be found" },
{ status: 500 },
);
}
// 이미 auth.user에는 존재하는 상황이고
// public.buddies 에서 찾아보기
const buddy = await getBuddy(supabaseAdmin, existingUser.id);
// auth.user에는 있고, public.buddies 에는 없는.. 망한 상황
if (!buddy) {
console.error("Buddy not found for existing user");
return NextResponse.json(
{ error: "Buddy profile not found" },
{ status: 404 },
);
}
// 안 망했으면 유저 있는 경우니까
return handleExistingUser(
buddy,
userEmail,
FIXED_PASSWORD,
origin,
forwardedHost,
isLocalEnv,
next,
);
}
// 사용자 생성 에러 처리
if (error) {
console.error("Error creating user:", error);
return NextResponse.json({ error: error?.message }, { status: 400 });
}
// 사용자 생성 실패 시
if (!user) {
console.error("Creating user(Admin) failed?:", error);
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// 새로 생성된 사용자 로그인 처리 (세션 생성)
// 이거 꼭 필요합니다. 안그러면 생성만되고 로그인은 안된 상태임...
const signInResult = await signInUser(userEmail, FIXED_PASSWORD);
if (!signInResult.success) {
console.error("Failed to sign in new user:", signInResult.error);
return NextResponse.json(
{ error: "User created but failed to sign in" },
{ status: 500 },
);
}
const buddy = await getBuddy(supabaseAdmin, user.user.id);
if (!buddy) {
console.error("Buddy not found for new user");
return NextResponse.json(
{ error: "Buddy profile not found" },
{ status: 404 },
);
}
// 최초 로그인 여부 확인 (생성된 사용자는 항상 새 사용자)
const redirectUrl = getRedirectUrl(origin, forwardedHost, isLocalEnv, next);
// 역시 여기도 입맛대로... 리턴
return NextResponse.json({ redirectUrl, buddy }, { status: 200 });
} catch (error) {
console.error("Error during Naver login callback ====>", error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
}'TIL' 카테고리의 다른 글
| [251102 TIL] PostgREST vs GraphQL(Hasura)기술 스택 비교 (0) | 2025.11.02 |
|---|---|
| [251029 TIL] Cookie, CORS, Site, Origin 총정리 (0) | 2025.10.29 |
| [251026 TIL] 실전적 Apollo Client 구현기 (0) | 2025.10.26 |
| [251024 TIL] Hasura(GQL) 사용시 3rd-Party 쿠키 정책 문제 (0) | 2025.10.24 |
| [251024 TIL] Server 용 Apollo Client 생성시 주의점! (0) | 2025.10.24 |