1 point by adroot1 1 month ago | flag | hide | 0 comments
현대 웹 애플리케이션에서 소셜 로그인은 사용자 경험을 향상시키는 핵심 기능입니다. 이 보고서는 Next.js, 특히 App Router 환경에서 네이버 로그인을 구현하는 전체 과정을 심층적으로 다룹니다. 단순히 코드를 나열하는 것을 넘어, OAuth 2.0 인증 흐름의 원리를 이해하고, 이를 Next.js의 서버 중심 아키텍처에 최적화하여 적용하며, 안전하고 확장 가능한 세션 관리 전략을 구축하는 방법을 상세히 설명합니다.
네이버 로그인은 업계 표준인 OAuth 2.0 프로토콜을 기반으로 동작합니다. 여러 OAuth 2.0 흐름 중에서도 '인증 코드 승인 그랜트(Authorization Code Grant Flow)' 방식은 웹 애플리케이션에서 가장 보편적이고 안전한 표준으로 자리 잡았습니다. 이 흐름은 네 명의 주요 행위자 간의 상호작용으로 구성됩니다.
이 흐름은 다음과 같은 단계적 서사로 진행됩니다.
이 전체 흐름에서 가장 민감한 정보인 Client Secret과 액세스 토큰은 사용자의 브라우저에 전혀 노출되지 않고 오직 서버 간의 통신에서만 사용됩니다. 이것이 인증 코드 승인 흐름의 핵심적인 보안 이점입니다.
Next.js의 App Router는 서버 컴포넌트(Server Components)와 라우트 핸들러(Route Handlers)를 중심으로 설계되었습니다. 이 아키텍처는 OAuth 2.0 인증 코드 승인 흐름을 구현하는 데 이상적인 환경을 제공합니다.
과거에 사용되던 암시적 승인 흐름(Implicit Grant Flow)과 같은 클라이언트 중심 방식은 액세스 토큰을 브라우저에 직접 노출시켜 보안에 취약했습니다. 하지만 Next.js App Router를 사용하면, 인증 과정의 핵심 로직을 서버 측의 라우트 핸들러에 배치할 수 있습니다.
결론적으로, OAuth 2.0의 보안 모델은 우리 애플리케이션의 아키텍처를 규정합니다. 안전한 인증을 위해서는 반드시 서버 측 로직이 필요하며, 이는 Next.js App Router가 지향하는 서버 중심 패러다임과 완벽하게 일치합니다. 이 구조는 단순히 선택이 아닌, 보안을 위한 필수적인 설계 결정입니다.
성공적인 구현의 첫걸음은 정확한 설정입니다. 이 섹션에서는 네이버 개발자 센터에서의 애플리케이션 등록부터 Next.js 프로젝트의 환경 변수 설정까지, 오류 없이 프로젝트를 준비하는 과정을 상세히 안내합니다.
먼저 네이버 개발자 센터에서 우리 애플리케이션을 등록하고 필요한 API 권한과 자격 증명을 확보해야 합니다.
여기서 콜백 URL 설정은 단순한 주소 입력이 아니라, 네이버와 우리 애플리케이션 간의 '디지털 악수' 약속입니다. 네이버는 오직 이 사전 등록된 URL로만 인증 코드를 전달함으로써, 공격자가 악의적인 URL로 인증 코드를 가로채는 것을 원천적으로 차단합니다. 따라서 콜백 URL은 그 자체로 중요한 보안 장치입니다.
이제 Next.js 프로젝트를 설정하고 네이버로부터 받은 자격 증명을 안전하게 관리할 차례입니다.
프로젝트 생성: 터미널에서 다음 명령어를 실행하여 TypeScript와 Tailwind CSS가 포함된 새로운 Next.js 프로젝트를 생성합니다.
Bash
npx create-next-app@latest naver-login-tutorial --typescript --tailwind --eslint
환경 변수 관리: 보안의 핵심은 민감한 정보를 코드에서 분리하는 것입니다. 프로젝트 루트에 .env.local 파일을 생성하고 다음 내용을 추가합니다. 이 파일은 기본적으로 .gitignore에 포함되어 있어 Git 저장소에 올라가지 않습니다.
コード スニペット
#.env.local
# 네이버 개발자 센터에서 발급받은 Client ID
NAVER_CLIENT_ID="YOUR_NAVER_CLIENT_ID"
# 네이버 개발자 센터에서 발급받은 Client Secret
NAVER_CLIENT_SECRET="YOUR_NAVER_CLIENT_SECRET"
# 애플리케이션의 기본 URL (개발 환경)
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# 세션 암호화를 위한 비밀 키 (최소 32자 이상의 복잡한 문자열)
SESSION_SECRET="a_very_long_and_secure_secret_password_of_at_least_32_characters"
여기서 중요한 점은 NAVER_CLIENT_SECRET과 SESSION_SECRET처럼 서버에서만 사용되어야 하는 민감한 정보는 절대 NEXT_PUBLIC_ 접두사를 붙이면 안 된다는 것입니다. NEXT_PUBLIC_ 접두사가 붙은 변수는 브라우저에 노출될 수 있기 때문입니다.
이러한 환경 변수 관리 방식은 '환경 패리티(environment parity)'의 개념을 도입합니다. 개발자는 로컬, 스테이징, 프로덕션 등 모든 환경에서 이 변수들이 일관되고 안전하게 관리되도록 보장해야 합니다. 이는 Vercel, AWS 등 호스팅 플랫폼에서 제공하는 비밀 관리 도구를 사용해야 함을 의미하며, 이 과정에서의 실수는 안전한 로컬 앱이 프로덕션에서는 취약해지는 결과를 초래할 수 있습니다.
이 섹션에서는 Next.js 라우트 핸들러를 사용하여 인증 흐름의 백엔드 로직을 구축합니다. 각 코드는 상세한 설명과 함께 제공됩니다.
사용자가 "네이버 로그인" 버튼을 클릭했을 때, 네이버 인증 페이지로 안전하게 보내는 역할을 하는 API 엔드포인트입니다.
app/api/auth/naver/login/route.ts 파일을 생성하고 다음 코드를 작성합니다.
TypeScript
// app/api/auth/naver/login/route.ts
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import crypto from 'crypto';
export async function GET(req: NextRequest) {
const clientId = process.env.NAVER_CLIENT_ID;
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/naver/callback`;
// CSRF 방지를 위한 state 토큰 생성
const state = crypto.randomBytes(16).toString('hex');
// state 값을 안전한 HttpOnly 쿠키에 저장
cookies().set('naver_auth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV!== 'development',
maxAge: 60 * 10, // 10분
path: '/',
});
const naverAuthUrl = `https://nid.naver.com/oauth2.0/authorize?response\_type=code\&client\_id=${clientId}\&redirect\_uri=${encodeURIComponent(redirectUri)}\&state=${state}\`;
redirect(naverAuthUrl);
}
이 코드는 두 가지 중요한 보안 작업을 수행합니다.
사용자가 네이버에서 인증을 완료하면, 네이버는 이곳으로 사용자를 리디렉션시킵니다. 이 핸들러는 인증 코드와 state를 받아 액세스 토큰을 요청하고, 사용자 정보를 가져와 세션을 생성하는 핵심적인 역할을 합니다.
app/api/auth/naver/callback/route.ts 파일을 생성하고 다음 코드를 작성합니다.
TypeScript
// app/api/auth/naver/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getIronSession } from 'iron-session';
import { SessionData, sessionOptions } from '@/lib/session';
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
const cookieStore = cookies();
const savedState = cookieStore.get('naver_auth_state')?.value;
// CSRF 공격 방어: state 값 검증
if (!state ||!savedState |
| state!== savedState) {
return NextResponse.json(
{ message: 'Invalid state parameter.' },
{ status: 401 }
);
}
cookieStore.delete('naver_auth_state'); // 검증 후 즉시 삭제
if (!code) {
return NextResponse.json(
{ message: 'Authorization code not found.' },
{ status: 400 }
);
}
const clientId = process.env.NAVER_CLIENT_ID;
const clientSecret = process.env.NAVER_CLIENT_SECRET;
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/naver/callback`;
try {
// 1. 액세스 토큰 요청
const tokenResponse = await fetch('https://nid.naver.com/oauth2.0/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId!,
client_secret: clientSecret!,
code,
state,
redirect_uri: redirectUri,
}),
});
const tokenData \= await tokenResponse.json();
if (\!tokenResponse.ok) {
throw new Error(tokenData.error\_description |
| 'Failed to fetch access token');
}
const accessToken = tokenData.access_token;
// 2\. 사용자 프로필 정보 요청
const profileResponse \= await fetch('https://openapi.naver.com/v1/nid/me', {
headers: {
Authorization: \`Bearer ${accessToken}\`,
},
});
const profileData \= await profileResponse.json();
if (profileData.resultcode\!== '00') {
throw new Error(profileData.message |
| 'Failed to fetch user profile');
}
const userProfile = profileData.response;
// 3\. 세션 생성
const session \= await getIronSession\<SessionData\>(cookies(), sessionOptions);
session.isLoggedIn \= true;
session.id \= userProfile.id;
session.email \= userProfile.email;
session.name \= userProfile.name;
session.avatar \= userProfile.profile\_image;
await session.save();
// 4\. 로그인 후 대시보드 페이지로 리디렉션
return NextResponse.redirect(new URL('/dashboard', req.url));
} catch (error) {
console.error('Naver auth error:', error);
return NextResponse.json(
{ message: 'Authentication failed.', error: (error as Error).message },
{ status: 500 }
);
}
}
이 콜백 핸들러는 애플리케이션 보안의 가장 중요한 지점입니다. state 값을 비교함으로써 사용자의 브라우저 세션의 연속성을 검증하고, Client Secret을 사용하여 우리 애플리케이션 자체를 네이버에 인증하며, 최종적으로 액세스 토큰을 통해 사용자를 대신하여 행동할 권한을 위임받습니다. 이 계층적인 보안 구조는 이 두 라우트 핸들러가 Next.js 내에서 "프론트엔드를 위한 백엔드(Backend-for-Frontend, BFF)" 패턴을 어떻게 구현하는지 보여줍니다. 이들은 범용 API가 아니라, 복잡한 OAuth 과정을 프론트엔드를 대신하여 오케스트레이션하는 특수 목적의 서버 로직입니다.
사용자 인증 후, 그 상태를 안전하고 효율적으로 유지하는 것이 세션 관리의 목표입니다. 이 섹션에서는 다양한 세션 전략을 비교하고, iron-session을 사용한 구체적인 구현 방법을 다룹니다.
서버리스 환경을 지향하는 Next.js에서는 세션 관리 전략 선택이 중요합니다. 주요 옵션들을 비교하여 최적의 선택을 돕습니다.
표 1: 세션 관리 전략 비교
특성 | iron-session (권장) | 수동 JWT (JSON Web Token) | next-auth (Auth.js) |
---|---|---|---|
메커니즘 | 암호화된 데이터를 HttpOnly 쿠키에 저장 | 서명된 JWT를 HttpOnly 쿠키에 저장 | 라이브러리가 쿠키/DB 세션 관리 |
장점 | 서버리스 친화적, 별도 DB 불필요, 간단한 API | 상태 비저장(Stateless), 자체 검증 가능 | 올인원 솔루션, 다중 프로바이더, CSRF 내장 |
단점 | 쿠키 크기 제한(약 4KB) | 안전한 관리 복잡 (폐기, 알고리즘 선택) | 추상화 계층 추가, 커스터마이징 복잡성 |
보안 | 강력한 암호화, CSRF는 수동 구현 필요 | 서명 키 유출 시 취약, 토큰 폐기 어려움 | 커뮤니티가 유지보수하는 높은 수준의 보안 |
적합한 사례 | 단일 프로바이더 로그인, 서버리스 앱 | 마이크로서비스, 자체 인증이 필요한 API | 다수의 소셜 로그인, 복잡한 역할 기반 인증 |
이 분석에 따르면, 단일 소셜 로그인(네이버)을 구현하는 현재 시나리오에서는 iron-session이 보안, 성능, 단순성 측면에서 가장 균형 잡힌 선택입니다. 세션 상태를 암호화하여 클라이언트의 쿠키에 저장하므로, 각 API 요청이 상태를 자체적으로 포함하게 됩니다. 이는 서버리스 함수가 세션 검증을 위해 Redis나 데이터베이스 같은 외부 서비스에 네트워크 호출을 할 필요가 없게 만들어, 지연 시간을 줄이고 별도의 세션용 데이터베이스 운영 비용을 제거하는 상당한 성능 및 비용 이점을 제공합니다.
라이브러리 설치:
Bash
npm install iron-session
세션 유틸리티 생성 (lib/session.ts): 세션 설정을 중앙에서 관리하고 타입 정의를 위한 파일을 생성합니다.
lib/session.ts 파일을 생성하고 다음 코드를 작성합니다.
TypeScript
// lib/session.ts
import { IronSessionOptions } from 'iron-session';
import { cookies } from 'next/headers';
import { getIronSession as getIron } from 'iron-session';
export interface SessionData {
isLoggedIn: boolean;
id?: string;
email?: string;
name?: string;
avatar?: string;
}
export const sessionOptions: IronSessionOptions = {
cookieName: 'naver-app-session',
password: process.env.SESSION_SECRET as string,
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 1주일
},
};
// 서버 컴포넌트나 라우트 핸들러에서 사용하기 위한 래퍼
export function getIronSession() {
return getIron<SessionData>(cookies(), sessionOptions);
}
SESSION_SECRET은 .env.local에 정의한 32자 이상의 비밀 키를 사용해야 합니다.
콜백 핸들러에 통합: app/api/auth/naver/callback/route.ts의 "세션 생성" 부분에서 이 유틸리티를 사용합니다. 사용자 프로필을 성공적으로 가져온 후, getIronSession을 호출하여 세션 객체를 얻고, 사용자 정보를 채운 뒤 session.save()를 호출합니다. 이 save 함수가 데이터를 암호화하고 응답 헤더에 Set-Cookie를 설정하는 역할을 합니다. (위 3.2절 코드에 이미 통합되어 있습니다.)
이 방식의 유일한 제약은 약 4KB의 쿠키 크기 제한입니다. 이는 설계상 중요한 제약을 의미합니다. 세션에는 인증과 식별에 필수적인 최소한의 데이터(사용자 ID, 로그인 상태 등)만 저장해야 합니다. 일반적인 데이터 캐시로 사용해서는 안 됩니다. 이 제약은 개발자가 좋은 아키텍처 패턴을 채택하도록 유도합니다. 즉, 세션을 통해 누가 사용자인지 식별하고, 그 식별자(예: 사용자 ID)를 사용하여 필요한 추가 데이터는 서버 컴포넌트나 API 라우트 내에서 데이터베이스로부터 동적으로 가져오는 방식입니다.
로그아웃은 세션을 파기하는 간단한 과정입니다.
app/api/auth/logout/route.ts 파일을 생성하고 다음 코드를 작성합니다.
TypeScript
// app/api/auth/logout/route.ts
import { getIronSession } from '@/lib/session';
import { NextResponse } from 'next/server';
export async function POST() {
const session = await getIronSession();
session.destroy();
// 클라이언트 측에서 리디렉션을 처리하도록 성공 응답을 보냄
return NextResponse.json({ ok: true });
}
이 핸들러는 세션을 가져와 session.destroy()를 호출하여 쿠키를 삭제하고, 클라이언트에게 성공적으로 처리되었음을 알립니다.
이제 백엔드 로직을 프론트엔드와 연결하여 사용자에게 매끄러운 경험을 제공할 차례입니다.
애플리케이션 전반에서 사용자의 로그인 상태를 쉽게 공유하기 위해 React Context를 사용합니다.
context/AuthContext.tsx 파일을 생성하고 다음 코드를 작성합니다.
TypeScript
// context/AuthContext.tsx
'use client';
import { SessionData } from '@/lib/session';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface AuthContextType {
user: SessionData | null;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType>({ user: null, isLoading: true });
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<SessionData | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function fetchUser() {
try {
const res = await fetch('/api/auth/me');
const data = await res.json();
if (data.isLoggedIn) {
setUser(data);
} else {
setUser(null);
}
} catch (error) {
console.error('Failed to fetch user status', error);
setUser(null);
} finally {
setIsLoading(false);
}
}
fetchUser();
},);
return (
<AuthContext.Provider value={{ user, isLoading }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
이 AuthProvider를 app/layout.tsx에서 애플리케이션 전체를 감싸도록 설정해야 합니다.
클라이언트 측 JavaScript는 보안상의 이유로 HttpOnly 쿠키에 직접 접근할 수 없습니다. 따라서 서버가 쿠키를 읽어 세션 정보를 JSON으로 반환해주는 API 엔드포인트가 필요합니다. 이 엔드포인트는 안전한 서버 전용 쿠키와 클라이언트 측 React 애플리케이션 사이의 중요한 다리 역할을 합니다.
app/api/auth/me/route.ts 파일을 생성하고 다음 코드를 작성합니다.
TypeScript
// app/api/auth/me/route.ts
import { getIronSession, SessionData } from '@/lib/session';
import { NextResponse } from 'next/server';
export async function GET() {
const session = await getIronSession();
if (session.isLoggedIn) {
return NextResponse.json(session);
}
return NextResponse.json({ isLoggedIn: false });
}
사용자의 로그인 상태에 따라 다른 UI를 보여주는 클라이언트 컴포넌트를 만듭니다.
components/AuthButtons.tsx 파일을 생성하고 다음 코드를 작성합니다.
TypeScript
// components/AuthButtons.tsx
'use client';
import { useAuth } from '@/context/AuthContext';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
export default function AuthButtons() {
const { user, isLoading } = useAuth();
const router = useRouter();
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
router.refresh(); // 서버 상태를 새로고침하여 UI를 업데이트
};
if (isLoading) {
return <div className="h-10 w-24 bg-gray-200 animate-pulse rounded-md"></div>;
}
if (user?.isLoggedIn) {
return (
<div className="flex items-center gap-4">
{user.avatar && (
<Image
src={user.avatar}
alt={user.name |
| 'User Avatar'}
width={40}
height={40}
className="rounded-full"
/>
)}
<span className="text-sm font-medium">{user.name}</span>
<button
onClick={handleLogout}
className="px-4 py-2 text-sm font-semibold text-white bg-red-500 rounded-md hover:bg-red-600"
>
Logout
</button>
</div>
);
}
return (
<a
href="/api/auth/naver/login"
className="inline-block px-4 py-2 font-semibold text-white bg-green-500 rounded-md hover:bg-green-600"
>
Login with Naver
</a>
);
}
인증된 사용자만 접근할 수 있는 페이지를 보호하는 것은 필수적입니다.
서버 측 보호 (권장): 서버 컴포넌트에서 직접 세션을 확인하는 가장 안전하고 효율적인 방법입니다.
app/dashboard/page.tsx 파일을 예시로 작성합니다.
TypeScript
// app/dashboard/page.tsx
import { getIronSession } from '@/lib/session';
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
export default async function DashboardPage() {
const session = await getIronSession();
if (!session.isLoggedIn) {
redirect('/'); // 로그인하지 않은 경우 홈으로 리디렉션
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold">Welcome to your Dashboard, {session.name}!</h1>
<p>This is a protected page.</p>
<p>Your email: {session.email}</p>
</div>
);
}
이 접근 방식은 서버에서 렌더링되기 전에 접근 제어가 이루어지므로, 보호된 콘텐츠가 클라이언트에 아예 전달되지 않아 보안성이 매우 높습니다. 이처럼 서버가 인증 상태의 유일한 진실 공급원(single source of truth)이 되고 클라이언트는 그 상태를 표시하는 역할만 담당하게 함으로써, 명확한 관심사 분리가 이루어지고 애플리케이션의 보안과 유지보수성이 향상됩니다.
이 섹션에서는 프로젝트를 쉽게 재구성할 수 있도록 위에서 설명한 모든 파일의 전체 코드를 파일 경로별로 정리하여 제공합니다.
コード スニペット
NAVER_CLIENT_ID="YOUR_NAVER_CLIENT_ID"
NAVER_CLIENT_SECRET="YOUR_NAVER_CLIENT_SECRET"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
SESSION_SECRET="a_very_long_and_secure_secret_password_of_at_least_32_characters"
TypeScript
import { IronSessionOptions } from 'iron-session';
import { cookies } from 'next/headers';
import { getIronSession as getIron } from 'iron-session';
export interface SessionData {
isLoggedIn: boolean;
id?: string;
email?: string;
name?: string;
avatar?: string;
}
export const sessionOptions: IronSessionOptions = {
cookieName: 'naver-app-session',
password: process.env.SESSION_SECRET as string,
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 1주일
},
};
export function getIronSession() {
return getIron<SessionData>(cookies(), sessionOptions);
}
TypeScript
'use client';
import { SessionData } from '@/lib/session';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface AuthContextType {
user: SessionData | null;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType>({ user: null, isLoading: true });
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<SessionData | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function fetchUser() {
try {
const res = await fetch('/api/auth/me');
const data = await res.json();
if (data.isLoggedIn) {
setUser(data);
} else {
setUser(null);
}
} catch (error) {
console.error('Failed to fetch user status', error);
setUser(null);
} finally {
setIsLoading(false);
}
}
fetchUser();
},);
return (
<AuthContext.Provider value={{ user, isLoading }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
TypeScript
'use client';
import { useAuth } from '@/context/AuthContext';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
export default function AuthButtons() {
const { user, isLoading } = useAuth();
const router = useRouter();
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
router.refresh();
};
if (isLoading) {
return <div className="h-10 w-24 bg-gray-200 animate-pulse rounded-md"></div>;
}
if (user?.isLoggedIn) {
return (
<div className="flex items-center gap-4">
{user.avatar && (
<Image
src={user.avatar}
alt={user.name |
| 'User Avatar'}
width={40}
height={40}
className="rounded-full"
/>
)}
<span className="text-sm font-medium">{user.name}</span>
<button
onClick={handleLogout}
className="px-4 py-2 text-sm font-semibold text-white bg-red-500 rounded-md hover:bg-red-600"
>
Logout
</button>
</div>
);
}
return (
<a
href="/api/auth/naver/login"
className="inline-block px-4 py-2 font-semibold text-white bg-green-500 rounded-md hover:bg-green-600"
>
Login with Naver
</a>
);
}
TypeScript
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { AuthProvider } from '@/context/AuthContext';
import AuthButtons from '@/components/AuthButtons';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Next.js Naver Login',
description: 'Example of Naver Login with Next.js App Router',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<AuthProvider>
<header className="p-4 border-b">
<nav className="container mx-auto flex justify-between items-center">
<h1 className="text-xl font-bold">My App</h1>
<AuthButtons />
</nav>
</header>
<main className="container mx-auto p-4">{children}</main>
</AuthProvider>
</body>
</html>
);
}
TypeScript
export default function HomePage() {
return (
<div>
<h1 className="text-3xl font-bold">Welcome to the Home Page</h1>
<p className="mt-4">Please log in to access the dashboard.</p>
</div>
);
}
TypeScript
import { getIronSession } from '@/lib/session';
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
export default async function DashboardPage() {
const session = await getIronSession();
if (!session.isLoggedIn) {
redirect('/');
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold">Welcome to your Dashboard, {session.name}!</h1>
<p>This is a protected page.</p>
<p>Your email: {session.email}</p>
</div>
);
}
TypeScript
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import crypto from 'crypto';
export async function GET(req: NextRequest) {
const clientId = process.env.NAVER_CLIENT_ID;
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/naver/callback`;
const state = crypto.randomBytes(16).toString('hex');
cookies().set('naver_auth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV!== 'development',
maxAge: 60 * 10,
path: '/',
});
const naverAuthUrl = `https://nid.naver.com/oauth2.0/authorize?response\_type=code\&client\_id=${clientId}\&redirect\_uri=${encodeURIComponent(redirectUri)}\&state=${state}\`;
redirect(naverAuthUrl);
}
TypeScript
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { SessionData, sessionOptions, getIronSession } from '@/lib/session';
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
const cookieStore = cookies();
const savedState = cookieStore.get('naver_auth_state')?.value;
if (!state ||!savedState |
| state!== savedState) {
return NextResponse.json({ message: 'Invalid state parameter.' }, { status: 401 });
}
cookieStore.delete('naver_auth_state');
if (!code) {
return NextResponse.json({ message: 'Authorization code not found.' }, { status: 400 });
}
const clientId = process.env.NAVER_CLIENT_ID;
const clientSecret = process.env.NAVER_CLIENT_SECRET;
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/naver/callback`;
try {
const tokenResponse = await fetch('https://nid.naver.com/oauth2.0/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId!,
client_secret: clientSecret!,
code,
state,
redirect_uri: redirectUri,
}),
});
const tokenData \= await tokenResponse.json();
if (\!tokenResponse.ok) throw new Error(tokenData.error\_description |
| 'Failed to fetch access token');
const accessToken = tokenData.access_token;
const profileResponse \= await fetch('https://openapi.naver.com/v1/nid/me', {
headers: { Authorization: \`Bearer ${accessToken}\` },
});
const profileData \= await profileResponse.json();
if (profileData.resultcode\!== '00') throw new Error(profileData.message |
| 'Failed to fetch user profile');
const userProfile = profileData.response;
const session \= await getIronSession();
session.isLoggedIn \= true;
session.id \= userProfile.id;
session.email \= userProfile.email;
session.name \= userProfile.name;
session.avatar \= userProfile.profile\_image;
await session.save();
return NextResponse.redirect(new URL('/dashboard', req.url));
} catch (error) {
console.error('Naver auth error:', error);
return NextResponse.json({ message: 'Authentication failed.', error: (error as Error).message }, { status: 500 });
}
}
TypeScript
import { getIronSession } from '@/lib/session';
import { NextResponse } from 'next/server';
export async function GET() {
const session = await getIronSession();
if (session.isLoggedIn) {
return NextResponse.json(session);
}
return NextResponse.json({ isLoggedIn: false });
}
TypeScript
import { getIronSession } from '@/lib/session';
import { NextResponse } from 'next/server';
export async function POST() {
const session = await getIronSession();
session.destroy();
return NextResponse.json({ ok: true });
}
지금까지 구축한 기능은 견고하지만, 실제 프로덕션 환경에 배포하기 전에는 몇 가지 추가적인 고려사항이 필요합니다.
네이버는 액세스 토큰과 함께 리프레시 토큰(refresh_token)을 발급합니다. 액세스 토큰은 수명이 짧지만(보통 1시간), 리프레시 토큰은 수명이 훨씬 깁니다. 현재 구현에서는 프로필 조회 시에만 액세스 토큰을 한 번 사용하므로 큰 문제가 없지만, 만약 애플리케이션이 사용자를 대신하여 주기적으로 네이버 API를 호출해야 한다면 토큰 갱신 로직이 필수적입니다.
이 경우, 콜백 핸들러에서 access_token, refresh_token, 그리고 토큰의 만료 시간(expires_in)을 모두 세션에 저장해야 합니다. 그리고 네이버 API를 호출하기 전에 액세스 토큰이 만료되었는지 확인하고, 만료되었다면 저장된 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받는 로직을 구현해야 합니다. 이는 사용자가 다시 로그인하는 불편함 없이 지속적인 서비스 이용을 가능하게 합니다.
프로덕션 환경에서는 예상치 못한 오류에 대비해야 합니다. 모든 라우트 핸들러의 fetch 호출을 try...catch 블록으로 감싸고, 구체적인 오류 상황에 대응해야 합니다.
이러한 경우, 단순히 500 에러를 반환하기보다는, 로그에 상세한 오류를 기록하고 사용자에게는 "인증에 실패했습니다. 잠시 후 다시 시도해주세요."와 같은 명확한 메시지를 보여주는 것이 좋습니다. 예를 들어, 콜백 실패 시 /login?error=auth_failed와 같이 쿼리 파라미터를 붙여 리디렉션하고, 로그인 페이지에서 이 파라미터를 감지하여 사용자에게 알림을 표시할 수 있습니다.
애플리케이션을 프로덕션 환경에 배포할 때는 다음 사항을 반드시 확인해야 합니다.
결론적으로, 애플리케이션 보안은 단순히 코드 작성에 그치지 않고, 배포 프로세스와 인프라 관리(DevSecOps)까지 확장되는 개념입니다. 완벽하게 안전한 코드라도 단 하나의 비밀 키 유출이나 잘못된 환경 구성으로 인해 심각한 보안 위협에 노출될 수 있습니다. 따라서 개발자의 책임은 코드 에디터를 넘어 전체 배포 생명주기에 걸쳐 있음을 인지하는 것이 중요합니다.