현대 웹 애플리케이션에서 사용자들은 복잡한 회원가입 과정을 거치기보다는 이미 사용 중인 Google, GitHub, Facebook 계정으로 간편하게 로그인하기를 선호합니다. 이런 소셜 로그인을 가능하게 하는 것이 바로 OAuth(Open Authorization) 프로토콜입니다.
이 글에서는 OAuth가 무엇인지부터 시작해서, 실제 애플리케이션에 Google, GitHub, Facebook 등의 OAuth 제공자를 통합하는 방법을 단계별로 자세히 설명하겠습니다.
OAuth 2.0 기본 개념 이해
OAuth란 무엇인가?
OAuth 2.0은 사용자가 비밀번호를 공유하지 않고도 다른 웹서비스의 자원에 접근할 수 있게 해주는 개방형 표준 프로토콜입니다. 쉽게 말해, 사용자가 Google 계정 정보를 직접 우리 앱에 입력하지 않아도, Google이 “이 사용자가 맞습니다”라고 인증해주는 시스템입니다.
OAuth의 핵심 구성요소
OAuth 2.0에는 네 가지 주요 역할이 있습니다:
1. 리소스 소유자(Resource Owner) 실제 사용자를 말합니다. Google 계정을 가지고 있고, 그 계정으로 다른 서비스에 로그인하려는 사람입니다.
2. 클라이언트(Client) OAuth를 사용하여 사용자 정보에 접근하려는 애플리케이션입니다. 우리가 개발하는 웹사이트나 앱이 바로 클라이언트입니다.
3. 리소스 서버(Resource Server) 사용자의 보호된 리소스(프로필 정보, 이메일 등)를 호스팅하는 서버입니다. Google의 경우 Google API 서버가 이에 해당합니다.
4. 인증 서버(Authorization Server) 클라이언트에게 액세스 토큰을 발급하는 서버입니다. Google OAuth의 경우 https://accounts.google.com이 인증 서버 역할을 합니다.
OAuth 인증 플로우 이해하기
OAuth 2.0의 가장 일반적인 인증 플로우인 Authorization Code Grant를 단계별로 살펴보겠습니다:
1단계: 사용자가 소셜 로그인 버튼 클릭 사용자가 우리 웹사이트에서 “Google로 로그인” 버튼을 클릭합니다.
2단계: 인증 서버로 리다이렉트 우리 애플리케이션이 사용자를 Google의 인증 페이지로 보냅니다. 이때 필요한 정보들(클라이언트 ID, 리다이렉트 URI, 권한 범위 등)을 함께 전달합니다.
3단계: 사용자가 권한 승인 Google 로그인 페이지에서 사용자가 자신의 Google 계정으로 로그인하고, 우리 애플리케이션이 요청한 권한(이메일 주소 접근 등)을 승인합니다.
4단계: 인증 코드 발급 Google이 사용자를 우리 애플리케이션으로 다시 보내면서, URL에 일회성 인증 코드를 포함시킵니다.
5단계: 액세스 토큰 교환 우리 애플리케이션이 받은 인증 코드를 Google의 토큰 엔드포인트로 보내서 액세스 토큰과 교환합니다.
6단계: 사용자 정보 접근 발급받은 액세스 토큰을 사용해서 Google API로부터 사용자의 기본 정보(이름, 이메일 등)를 가져옵니다.
이 전체 과정을 통해 사용자의 비밀번호는 우리 애플리케이션에 노출되지 않으면서도 안전하게 사용자 인증을 완료할 수 있습니다.
Google OAuth 통합 구현
Google Cloud Console에서 OAuth 앱 설정
Google OAuth를 사용하기 위해서는 먼저 Google Cloud Console에서 OAuth 애플리케이션을 등록해야 합니다.
1단계: Google Cloud Console 접속 Google Cloud Console에 접속해서 새 프로젝트를 생성하거나 기존 프로젝트를 선택합니다.
2단계: OAuth 동의 화면 설정
- 왼쪽 메뉴에서 “APIs & Services” > “OAuth consent screen” 클릭
- User Type을 “External” 선택 (내부 조직용이 아닌 경우)
- 앱 이름, 사용자 지원 이메일, 개발자 연락처 정보 입력
- 앱 도메인 정보 입력 (홈페이지 URL, 개인정보처리방침 URL 등)
3단계: OAuth 클라이언트 ID 생성
- “APIs & Services” > “Credentials” 클릭
- “CREATE CREDENTIALS” > “OAuth client ID” 선택
- Application type을 “Web application” 선택
- Authorized redirect URIs에 다음과 같이 입력:
- 개발환경:
http://localhost:3000/api/auth/callback/google - 프로덕션:
https://yourdomain.com/api/auth/callback/google
- 개발환경:
설정이 완료되면 Client ID와 Client Secret을 얻을 수 있습니다. 이 정보는 애플리케이션에서 Google OAuth를 설정할 때 필요합니다.
NextAuth.js를 사용한 Google OAuth 구현
NextAuth.js를 사용하면 Google OAuth를 매우 쉽게 구현할 수 있습니다.
// pages/api/auth/[...nextauth].ts (Pages Router)
// 또는 app/api/auth/[...nextauth]/route.ts (App Router)
import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
export const authOptions = {
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
authorization: {
params: {
// 추가 권한이 필요한 경우 scope를 확장할 수 있습니다
scope: 'openid email profile',
// 사용자가 항상 계정을 선택하도록 강제
prompt: 'select_account',
},
},
})
],
callbacks: {
async jwt({ token, account, profile }) {
// 초기 로그인 시에만 실행됩니다
if (account) {
token.accessToken = account.access_token
token.refreshToken = account.refresh_token
}
return token
},
async session({ session, token }) {
// 클라이언트에서 접근할 수 있는 세션 정보를 설정합니다
session.accessToken = token.accessToken
return session
},
async signIn({ user, account, profile }) {
// 로그인 시 추가 검증 로직을 구현할 수 있습니다
if (account?.provider === 'google') {
// Google 계정의 이메일이 인증되었는지 확인
return profile?.email_verified === true
}
return true
},
},
pages: {
signIn: '/auth/signin', // 커스텀 로그인 페이지 (선택사항)
error: '/auth/error', // 에러 페이지
},
}
export default NextAuth(authOptions)
환경 변수 설정은 다음과 같습니다:
# .env.local
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-super-secret-key-here
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
프론트엔드에서 Google OAuth 사용하기
이제 React 컴포넌트에서 Google OAuth를 사용할 수 있습니다:
// components/GoogleLoginButton.tsx
import { signIn, signOut, useSession } from 'next-auth/react'
import { useState } from 'react'
export const GoogleLoginButton = () => {
const { data: session, status } = useSession()
const [isLoading, setIsLoading] = useState(false)
const handleGoogleLogin = async () => {
try {
setIsLoading(true)
// Google OAuth 로그인 시작
await signIn('google', {
callbackUrl: '/dashboard' // 로그인 성공 후 이동할 페이지
})
} catch (error) {
console.error('Login error:', error)
} finally {
setIsLoading(false)
}
}
const handleLogout = async () => {
try {
await signOut({ callbackUrl: '/' })
} catch (error) {
console.error('Logout error:', error)
}
}
// 로딩 중일 때
if (status === 'loading') {
return (
<div className="flex items-center justify-center p-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
<span className="ml-2">로딩 중...</span>
</div>
)
}
// 로그인된 상태
if (session) {
return (
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
{session.user?.image && (
<img
src={session.user.image}
alt="프로필"
className="w-8 h-8 rounded-full"
/>
)}
<span className="text-sm font-medium">
안녕하세요, {session.user?.name}님!
</span>
</div>
<button
onClick={handleLogout}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
>
로그아웃
</button>
</div>
)
}
// 로그인되지 않은 상태
return (
<button
onClick={handleGoogleLogin}
disabled={isLoading}
className="flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
) : (
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24">
{/* Google 아이콘 SVG */}
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
)}
Google로 로그인
</button>
)
}
App Router에서의 설정
Next.js 13+ App Router를 사용하는 경우, SessionProvider로 앱을 감싸야 합니다:
// app/providers.tsx
'use client'
import { SessionProvider } from 'next-auth/react'
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}
// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ko">
<body>
<Providers>
{children}
</Providers>
</body>
</html>
)
}
GitHub OAuth 통합 구현
GitHub OAuth는 개발자 도구나 코드 관련 서비스에서 많이 사용됩니다. GitHub의 OAuth 설정 과정을 살펴보겠습니다.
GitHub에서 OAuth 앱 등록
1단계: GitHub Settings 접속 GitHub에 로그인한 후, Settings > Developer settings > OAuth Apps로 이동합니다.
2단계: 새 OAuth App 등록
- “New OAuth App” 버튼 클릭
- 다음 정보 입력:
- Application name: 앱 이름
- Homepage URL:
http://localhost:3000(개발 시) 또는 실제 도메인 - Authorization callback URL:
http://localhost:3000/api/auth/callback/github
3단계: Client ID와 Client Secret 획득 등록이 완료되면 Client ID를 볼 수 있고, “Generate a new client secret” 버튼으로 Client Secret를 생성할 수 있습니다.
NextAuth.js에 GitHub 제공자 추가
// pages/api/auth/[...nextauth].ts
import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import GitHubProvider from 'next-auth/providers/github'
export const authOptions = {
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHubProvider({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
authorization: {
params: {
// GitHub에서 사용자 이메일 정보를 가져오기 위한 scope
scope: 'read:user user:email',
},
},
})
],
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
token.accessToken = account.access_token
// GitHub의 경우 추가 사용자 정보 저장
if (account.provider === 'github') {
token.githubUsername = profile?.login
token.githubId = profile?.id
}
}
return token
},
async session({ session, token }) {
session.accessToken = token.accessToken
session.githubUsername = token.githubUsername
return session
},
async signIn({ user, account, profile }) {
if (account?.provider === 'github') {
// GitHub 계정 검증 로직
// 예: 특정 조직의 멤버만 허용
// const isOrgMember = await checkGitHubOrgMembership(profile.login)
// return isOrgMember
return true
}
return true
},
}
}
export default NextAuth(authOptions)
환경 변수에 GitHub 설정 추가:
# .env.local
GITHUB_ID=your-github-client-id
GITHUB_SECRET=your-github-client-secret
GitHub OAuth 버튼 컴포넌트
// components/GitHubLoginButton.tsx
import { signIn } from 'next-auth/react'
import { useState } from 'react'
export const GitHubLoginButton = () => {
const [isLoading, setIsLoading] = useState(false)
const handleGitHubLogin = async () => {
try {
setIsLoading(true)
await signIn('github', { callbackUrl: '/dashboard' })
} catch (error) {
console.error('GitHub login error:', error)
} finally {
setIsLoading(false)
}
}
return (
<button
onClick={handleGitHubLogin}
disabled={isLoading}
className="flex items-center justify-center w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-900 text-white text-sm font-medium hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 disabled:opacity-50"
>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
) : (
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
)}
GitHub로 로그인
</button>
)
}
Facebook OAuth 통합 구현
Facebook(Meta) OAuth는 일반 사용자를 대상으로 하는 소셜 네트워크 서비스에서 널리 사용됩니다.
Facebook 개발자 앱 설정
1단계: Facebook for Developers 접속 Facebook for Developers에 접속해서 개발자 계정을 만들거나 로그인합니다.
2단계: 새 앱 생성
- “내 앱” > “앱 만들기” 클릭
- 앱 유형을 “소비자” 선택 (일반 웹사이트의 경우)
- 앱 이름과 연락처 이메일 입력
3단계: Facebook 로그인 제품 추가
- 앱 대시보드에서 “제품 추가” 클릭
- “Facebook 로그인” 선택 후 “설정” 클릭
4단계: OAuth 리다이렉트 URI 설정
- Facebook 로그인 설정에서 “유효한 OAuth 리디렉션 URI”에 추가:
- 개발:
http://localhost:3000/api/auth/callback/facebook - 프로덕션:
https://yourdomain.com/api/auth/callback/facebook
- 개발:
5단계: 앱 ID와 앱 시크릿 확인 앱 대시보드의 “설정 > 기본”에서 앱 ID와 앱 시크릿을 확인할 수 있습니다.
NextAuth.js에 Facebook 제공자 추가
// pages/api/auth/[...nextauth].ts
import FacebookProvider from 'next-auth/providers/facebook'
export const authOptions = {
providers: [
// ... 다른 제공자들
FacebookProvider({
clientId: process.env.FACEBOOK_CLIENT_ID!,
clientSecret: process.env.FACEBOOK_CLIENT_SECRET!,
authorization: {
params: {
scope: 'email', // Facebook에서 이메일 정보 요청
},
},
})
],
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
token.accessToken = account.access_token
if (account.provider === 'facebook') {
token.facebookId = profile?.id
}
}
return token
},
async session({ session, token }) {
session.accessToken = token.accessToken
session.facebookId = token.facebookId
return session
},
async signIn({ user, account, profile }) {
if (account?.provider === 'facebook') {
// Facebook 계정 검증 로직
// 예: 연령 제한 확인
return true
}
return true
},
}
}
환경 변수 추가:
# .env.local
FACEBOOK_CLIENT_ID=your-facebook-app-id
FACEBOOK_CLIENT_SECRET=your-facebook-app-secret
Facebook OAuth 버튼 컴포넌트
// components/FacebookLoginButton.tsx
import { signIn } from 'next-auth/react'
import { useState } from 'react'
export const FacebookLoginButton = () => {
const [isLoading, setIsLoading] = useState(false)
const handleFacebookLogin = async () => {
try {
setIsLoading(true)
await signIn('facebook', { callbackUrl: '/dashboard' })
} catch (error) {
console.error('Facebook login error:', error)
} finally {
setIsLoading(false)
}
}
return (
<button
onClick={handleFacebookLogin}
disabled={isLoading}
className="flex items-center justify-center w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
) : (
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
)}
Facebook으로 로그인
</button>
)
}
통합 소셜 로그인 페이지 구현
여러 OAuth 제공자를 하나의 페이지에서 선택할 수 있는 통합 로그인 페이지를 만들어보겠습니다.
// pages/auth/signin.tsx 또는 app/auth/signin/page.tsx
import { GetServerSideProps } from 'next'
import { getServerSession } from 'next-auth/next'
import { authOptions } from '../api/auth/[...nextauth]'
import { GoogleLoginButton } from '@/components/GoogleLoginButton'
import { GitHubLoginButton } from '@/components/GitHubLoginButton'
import { FacebookLoginButton } from '@/components/FacebookLoginButton'
import { signIn, getProviders } from 'next-auth/react'
import { useState } from 'react'
import Link from 'next/link'
interface Provider {
id: string
name: string
type: string
signinUrl: string
callbackUrl: string
}
interface SignInPageProps {
providers: Record<string, Provider>
}
export default function SignInPage({ providers }: SignInPageProps) {
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false)
const handleEmailSignIn = async (e: React.FormEvent) => {
e.preventDefault()
if (!email) return
try {
setIsLoading(true)
await signIn('email', { email, callbackUrl: '/dashboard' })
} catch (error) {
console.error('Email sign in error:', error)
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
계정에 로그인
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
또는{' '}
<Link href="/auth/signup" className="font-medium text-indigo-600 hover:text-indigo-500">
새 계정 만들기
</Link>
</p>
</div>
<div className="mt-8 space-y-4">
{/* 소셜 로그인 버튼들 */}
<div className="space-y-3">
<GoogleLoginButton />
<GitHubLoginButton />
<FacebookLoginButton />
</div>
{/* 구분선 */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-gray-50 text-gray-500">또는 이메일로 계속</span>
</div>
</div>
{/* 이메일 로그인 폼 */}
<form onSubmit={handleEmailSignIn} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
이메일 주소
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="your@email.com"
/>
</div>
<button
type="submit"
disabled={isLoading || !email}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
) : null}
매직 링크 보내기
</button>
</form>
{/* 이용약관 및 개인정보처리방침 */}
<p className="text-xs text-center text-gray-500">
로그인하면{' '}
<Link href="/terms" className="underline">이용약관</Link>과{' '}
<Link href="/privacy" className="underline">개인정보처리방침</Link>에 동의하는 것으로 간주됩니다.
</p>
</div>
</div>
</div>
)
}
// 서버사이드에서 이미 로그인된 사용자는 리다이렉트
export const getServerSideProps: GetServerSideProps = async (context) => {
const session = await getServerSession(context.req, context.res, authOptions)
if (session) {
return {
redirect: {
destination: '/dashboard',
permanent: false,
},
}
}
const providers = await getProviders()
return {
props: {
providers: providers ?? {},
},
}
}
고급 OAuth 기능 구현
사용자 정보 동기화
OAuth 로그인 시 받아온 사용자 정보를 데이터베이스에 저장하고 동기화하는 방법:
// lib/user-sync.ts
import { prisma } from '@/lib/prisma'
export async function syncUserWithOAuth(oauthProfile: any, provider: string) {
try {
// 기존 사용자 확인
let user = await prisma.user.findUnique({
where: { email: oauthProfile.email },
include: { accounts: true }
})
if (!user) {
// 새 사용자 생성
user = await prisma.user.create({
data: {
email: oauthProfile.email,
name: oauthProfile.name,
image: oauthProfile.picture || oauthProfile.avatar_url,
emailVerified: new Date(),
accounts: {
create: {
type: 'oauth',
provider: provider,
providerAccountId: oauthProfile.sub || oauthProfile.id,
access_token: oauthProfile.access_token,
refresh_token: oauthProfile.refresh_token,
expires_at: oauthProfile.expires_at,
token_type: oauthProfile.token_type,
scope: oauthProfile.scope,
}
}
},
include: { accounts: true }
})
} else {
// 기존 사용자 정보 업데이트
const existingAccount = user.accounts.find(account =>
account.provider === provider
)
if (!existingAccount) {
// 새 OAuth 계정 연결
await prisma.account.create({
data: {
userId: user.id,
type: 'oauth',
provider: provider,
providerAccountId: oauthProfile.sub || oauthProfile.id,
access_token: oauthProfile.access_token,
refresh_token: oauthProfile.refresh_token,
expires_at: oauthProfile.expires_at,
token_type: oauthProfile.token_type,
scope: oauthProfile.scope,
}
})
} else {
// 기존 OAuth 계정 토큰 업데이트
await prisma.account.update({
where: { id: existingAccount.id },
data: {
access_token: oauthProfile.access_token,
refresh_token: oauthProfile.refresh_token,
expires_at: oauthProfile.expires_at,
}
})
}
// 사용자 기본 정보 업데이트 (선택적)
if (!user.name && oauthProfile.name) {
await prisma.user.update({
where: { id: user.id },
data: {
name: oauthProfile.name,
image: oauthProfile.picture || oauthProfile.avatar_url || user.image,
}
})
}
}
return user
} catch (error) {
console.error('User sync error:', error)
throw error
}
}
액세스 토큰 갱신
OAuth 액세스 토큰은 만료 시간이 있으므로, 리프레시 토큰을 사용해서 자동으로 갱신하는 기능을 구현할 수 있습니다:
// lib/token-refresh.ts
export async function refreshAccessToken(token: any) {
try {
const url = token.provider === 'google'
? 'https://oauth2.googleapis.com/token'
: `https://github.com/login/oauth/access_token`
const response = await fetch(url, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: 'POST',
body: new URLSearchParams({
client_id: token.provider === 'google'
? process.env.GOOGLE_CLIENT_ID!
: process.env.GITHUB_ID!,
client_secret: token.provider === 'google'
? process.env.GOOGLE_CLIENT_SECRET!
: process.env.GITHUB_SECRET!,
grant_type: 'refresh_token',
refresh_token: token.refreshToken,
}),
})
const tokens = await response.json()
if (!response.ok) {
throw tokens
}
return {
...token,
accessToken: tokens.access_token,
accessTokenExpires: Date.now() + tokens.expires_in * 1000,
refreshToken: tokens.refresh_token ?? token.refreshToken,
}
} catch (error) {
console.error('Token refresh error:', error)
return {
...token,
error: 'RefreshAccessTokenError',
}
}
}
// NextAuth 설정에서 사용
export const authOptions = {
// ... 기존 설정
callbacks: {
async jwt({ token, account }) {
// 초기 로그인
if (account) {
return {
accessToken: account.access_token,
accessTokenExpires: Date.now() + account.expires_in * 1000,
refreshToken: account.refresh_token,
provider: account.provider,
user: token.user,
}
}
// 토큰이 아직 유효한 경우
if (Date.now() < token.accessTokenExpires) {
return token
}
// 액세스 토큰이 만료된 경우, 갱신 시도
return refreshAccessToken(token)
},
}
}
OAuth 스코프 관리
각 OAuth 제공자별로 필요한 권한(스코프)을 관리하는 방법:
// lib/oauth-scopes.ts
export const OAUTH_SCOPES = {
google: {
basic: ['openid', 'email', 'profile'],
calendar: ['https://www.googleapis.com/auth/calendar.readonly'],
gmail: ['https://www.googleapis.com/auth/gmail.readonly'],
drive: ['https://www.googleapis.com/auth/drive.readonly'],
},
github: {
basic: ['read:user', 'user:email'],
repo: ['repo'],
org: ['read:org'],
gist: ['gist'],
},
facebook: {
basic: ['email'],
friends: ['user_friends'],
posts: ['user_posts'],
pages: ['manage_pages'],
}
}
// 동적 스코프 요청
export function buildOAuthProvider(provider: string, scopes: string[] = []) {
const baseScopes = OAUTH_SCOPES[provider]?.basic || []
const allScopes = [...baseScopes, ...scopes]
switch (provider) {
case 'google':
return GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
authorization: {
params: {
scope: allScopes.join(' '),
prompt: 'select_account',
},
},
})
case 'github':
return GitHubProvider({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
authorization: {
params: {
scope: allScopes.join(' '),
},
},
})
default:
throw new Error(`Unsupported provider: ${provider}`)
}
}
보안 고려사항
PKCE (Proof Key for Code Exchange) 구현
PKCE는 OAuth 2.0의 보안을 강화하는 확장 기능으로, 특히 모바일 앱이나 SPA에서 중요합니다:
// lib/pkce-utils.ts
import crypto from 'crypto'
export function generateCodeVerifier(): string {
// 43-128자의 랜덤 문자열 생성
return crypto.randomBytes(32).toString('base64url')
}
export function generateCodeChallenge(verifier: string): string {
// SHA256 해시 후 base64url 인코딩
return crypto
.createHash('sha256')
.update(verifier)
.digest('base64url')
}
// NextAuth에서 PKCE 사용 (자동으로 처리됨)
export const authOptions = {
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
checks: ['pkce'], // PKCE 활성화
})
]
}
State 매개변수 검증
CSRF 공격을 방지하기 위한 state 매개변수 검증:
// lib/csrf-protection.ts
export function generateState(): string {
return crypto.randomBytes(32).toString('hex')
}
export function verifyState(receivedState: string, storedState: string): boolean {
return crypto.timingSafeEqual(
Buffer.from(receivedState),
Buffer.from(storedState)
)
}
// NextAuth는 자동으로 state 검증을 수행하지만,
// 커스텀 OAuth 구현 시 다음과 같이 사용:
const authorizationUrl = `${provider.authorizationURL}?` + new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: scope,
response_type: 'code',
state: generateState(), // CSRF 방지를 위한 state
code_challenge: generateCodeChallenge(codeVerifier),
code_challenge_method: 'S256'
})
사용자 계정 연결/해제 관리
하나의 사용자가 여러 OAuth 제공자를 연결할 수 있는 기능:
// components/AccountConnection.tsx
import { useState, useEffect } from 'react'
import { useSession } from 'next-auth/react'
interface ConnectedAccount {
id: string
provider: string
providerAccountId: string
createdAt: string
}
export const AccountConnectionManager = () => {
const { data: session } = useSession()
const [connectedAccounts, setConnectedAccounts] = useState<ConnectedAccount[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchConnectedAccounts()
}, [])
const fetchConnectedAccounts = async () => {
try {
const response = await fetch('/api/user/connected-accounts')
const accounts = await response.json()
setConnectedAccounts(accounts)
} catch (error) {
console.error('Failed to fetch connected accounts:', error)
} finally {
setLoading(false)
}
}
const handleConnect = async (provider: string) => {
// OAuth 연결을 위한 새 창 열기
const popup = window.open(
`/api/auth/signin/${provider}?callbackUrl=${encodeURIComponent('/auth/connect-callback')}`,
'oauth-popup',
'width=500,height=600'
)
// 팝업 닫힘 감지 및 계정 목록 갱신
const checkClosed = setInterval(() => {
if (popup?.closed) {
clearInterval(checkClosed)
fetchConnectedAccounts()
}
}, 1000)
}
const handleDisconnect = async (accountId: string, provider: string) => {
if (!confirm(`${provider} 계정 연결을 해제하시겠습니까?`)) {
return
}
try {
const response = await fetch(`/api/user/connected-accounts/${accountId}`, {
method: 'DELETE',
})
if (response.ok) {
fetchConnectedAccounts()
}
} catch (error) {
console.error('Failed to disconnect account:', error)
}
}
const providers = [
{ id: 'google', name: 'Google', icon: '🔍' },
{ id: 'github', name: 'GitHub', icon: '🐙' },
{ id: 'facebook', name: 'Facebook', icon: '📘' },
]
if (loading) {
return <div>연결된 계정을 불러오는 중...</div>
}
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">연결된 계정</h3>
{providers.map((provider) => {
const connectedAccount = connectedAccounts.find(
account => account.provider === provider.id
)
return (
<div key={provider.id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center space-x-3">
<span className="text-2xl">{provider.icon}</span>
<div>
<div className="font-medium">{provider.name}</div>
{connectedAccount ? (
<div className="text-sm text-gray-500">
연결됨 • {new Date(connectedAccount.createdAt).toLocaleDateString()}
</div>
) : (
<div className="text-sm text-gray-500">연결되지 않음</div>
)}
</div>
</div>
<div>
{connectedAccount ? (
<button
onClick={() => handleDisconnect(connectedAccount.id, provider.id)}
className="px-3 py-1 text-sm text-red-600 border border-red-300 rounded hover:bg-red-50"
>
연결 해제
</button>
) : (
<button
onClick={() => handleConnect(provider.id)}
className="px-3 py-1 text-sm text-blue-600 border border-blue-300 rounded hover:bg-blue-50"
>
연결
</button>
)}
</div>
</div>
)
})}
</div>
)
}
에러 처리 및 디버깅
OAuth 에러 처리
OAuth 과정에서 발생할 수 있는 다양한 에러를 처리하는 방법:
// pages/auth/error.tsx
import { GetServerSideProps } from 'next'
import { useRouter } from 'next/router'
import Link from 'next/link'
interface AuthErrorPageProps {
error: string
}
const ERROR_MESSAGES = {
Configuration: '서버 설정에 문제가 있습니다. 관리자에게 문의하세요.',
AccessDenied: '접근이 거부되었습니다. 권한을 확인해주세요.',
Verification: '인증 링크가 만료되었거나 이미 사용되었습니다.',
Default: '로그인 중 오류가 발생했습니다. 다시 시도해주세요.',
OAuthSignin: 'OAuth 로그인 시작 중 오류가 발생했습니다.',
OAuthCallback: 'OAuth 콜백 처리 중 오류가 발생했습니다.',
OAuthCreateAccount: '계정 생성 중 오류가 발생했습니다.',
EmailCreateAccount: '이메일 계정 생성 중 오류가 발생했습니다.',
Callback: '콜백 처리 중 오류가 발생했습니다.',
OAuthAccountNotLinked: '이 이메일 주소는 다른 방법으로 가입되어 있습니다. 기존 로그인 방법을 사용해주세요.',
EmailSignin: '이메일 전송 중 오류가 발생했습니다.',
CredentialsSignin: '잘못된 로그인 정보입니다.',
SessionRequired: '이 페이지에 접근하려면 로그인이 필요합니다.',
}
export default function AuthErrorPage({ error }: AuthErrorPageProps) {
const router = useRouter()
const errorMessage = ERROR_MESSAGES[error as keyof typeof ERROR_MESSAGES]
|| ERROR_MESSAGES.Default
const handleRetry = () => {
router.push('/auth/signin')
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<div className="mx-auto h-12 w-12 text-red-600">
<svg fill="none" stroke="currentColor" viewBox="0 0 48 48">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
로그인 오류
</h2>
<p className="mt-2 text-sm text-gray-600">
{errorMessage}
</p>
{error === 'OAuthAccountNotLinked' && (
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<p className="text-sm text-yellow-800">
이 이메일은 다른 로그인 방법(예: 비밀번호)으로 가입되어 있습니다.
기존 방법으로 로그인한 후 계정 설정에서 소셜 계정을 연결할 수 있습니다.
</p>
</div>
)}
</div>
<div className="space-y-4">
<button
onClick={handleRetry}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
다시 시도
</button>
<Link
href="/"
className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
홈으로 돌아가기
</Link>
</div>
</div>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const error = context.query.error as string
return {
props: {
error: error || 'Default'
}
}
}
OAuth 디버깅 도구
개발 중 OAuth 관련 문제를 디버깅하기 위한 도구:
// lib/oauth-debug.ts
export const oauthDebug = {
logRequest: (provider: string, params: any) => {
if (process.env.NODE_ENV === 'development') {
console.log(`[OAuth Debug] ${provider} request:`, {
timestamp: new Date().toISOString(),
provider,
params: {
...params,
// 민감한 정보는 마스킹
client_secret: params.client_secret ? '***MASKED***' : undefined,
}
})
}
},
logResponse: (provider: string, response: any) => {
if (process.env.NODE_ENV === 'development') {
console.log(`[OAuth Debug] ${provider} response:`, {
timestamp: new Date().toISOString(),
provider,
status: response.status,
data: {
...response.data,
// 민감한 정보는 마스킹
access_token: response.data?.access_token ? '***MASKED***' : undefined,
refresh_token: response.data?.refresh_token ? '***MASKED***' : undefined,
}
})
}
},
logError: (provider: string, error: any) => {
console.error(`[OAuth Error] ${provider}:`, {
timestamp: new Date().toISOString(),
provider,
error: error.message || error,
stack: error.stack,
})
},
validateEnvironment: () => {
const required = {
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
}
const missing = Object.entries(required)
.filter(([_, value]) => !value)
.map(([key]) => key)
if (missing.length > 0) {
console.error('[OAuth Config Error] Missing environment variables:', missing)
return false
}
return true
}
}
// NextAuth 콜백에서 사용
export const authOptions = {
// ... 기존 설정
callbacks: {
async signIn({ user, account, profile }) {
oauthDebug.logRequest(account?.provider || 'unknown', {
user: user.email,
account: account?.provider,
})
// 검증 로직...
return true
},
},
events: {
async signIn({ user, account, profile }) {
oauthDebug.logResponse(account?.provider || 'unknown', {
success: true,
user: user.email,
})
},
async signInError({ error, user, account }) {
oauthDebug.logError(account?.provider || 'unknown', error)
},
}
}
성능 최적화
OAuth 응답 속도 개선
OAuth 로그인 과정의 성능을 개선하는 방법들:
// lib/oauth-optimization.ts
import { LRUCache } from 'lru-cache'
// 사용자 프로필 정보 캐시
const profileCache = new LRUCache<string, any>({
max: 1000,
ttl: 5 * 60 * 1000, // 5분
})
export async function getCachedProfile(provider: string, userId: string, accessToken: string) {
const cacheKey = `${provider}:${userId}`
// 캐시된 프로필 확인
const cached = profileCache.get(cacheKey)
if (cached) {
return cached
}
// API 호출하여 프로필 가져오기
const profile = await fetchUserProfile(provider, accessToken)
// 캐시에 저장
profileCache.set(cacheKey, profile)
return profile
}
async function fetchUserProfile(provider: string, accessToken: string) {
const endpoints = {
google: 'https://www.googleapis.com/oauth2/v2/userinfo',
github: 'https://api.github.com/user',
facebook: 'https://graph.facebook.com/me?fields=id,name,email,picture'
}
const response = await fetch(endpoints[provider], {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
// 타임아웃 설정
signal: AbortSignal.timeout(5000),
})
return response.json()
}
// 병렬 처리를 통한 성능 개선
export async function syncUserDataInBackground(userId: string, providers: string[]) {
// 여러 OAuth 제공자의 데이터를 병렬로 처리
const promises = providers.map(async (provider) => {
try {
const account = await getAccountByProvider(userId, provider)
if (account?.access_token) {
const profile = await getCachedProfile(provider, userId, account.access_token)
await updateUserFromOAuthProfile(userId, provider, profile)
}
} catch (error) {
console.error(`Failed to sync ${provider} data for user ${userId}:`, error)
// 개별 제공자 실패가 전체를 막지 않도록 함
}
})
// 모든 작업이 완료될 때까지 대기 (실패한 것들은 무시)
await Promise.allSettled(promises)
}
클라이언트 측 최적화
OAuth 로그인 버튼과 UI의 성능을 개선하는 방법:
// components/OptimizedOAuthButtons.tsx
import { memo, useCallback, useMemo } from 'react'
import { signIn } from 'next-auth/react'
interface OAuthButtonProps {
provider: string
children: React.ReactNode
disabled?: boolean
callbackUrl?: string
}
// 메모이제이션으로 불필요한 리렌더링 방지
export const OAuthButton = memo<OAuthButtonProps>(({
provider,
children,
disabled = false,
callbackUrl = '/dashboard'
}) => {
const handleClick = useCallback(async () => {
try {
await signIn(provider, { callbackUrl })
} catch (error) {
console.error(`${provider} login error:`, error)
}
}, [provider, callbackUrl])
return (
<button
onClick={handleClick}
disabled={disabled}
className="oauth-button"
>
{children}
</button>
)
})
// 여러 OAuth 버튼을 효율적으로 렌더링
export const OAuthButtonGroup = () => {
const providers = useMemo(() => [
{ id: 'google', name: 'Google', icon: GoogleIcon },
{ id: 'github', name: 'GitHub', icon: GitHubIcon },
{ id: 'facebook', name: 'Facebook', icon: FacebookIcon },
], [])
return (
<div className="space-y-3">
{providers.map(({ id, name, icon: Icon }) => (
<OAuthButton key={id} provider={id}>
<Icon className="w-4 h-4 mr-2" />
{name}으로 로그인
</OAuthButton>
))}
</div>
)
}
// 컴포넌트 이름 설정 (개발 도구에서 디버깅 시 유용)
OAuthButton.displayName = 'OAuthButton'
OAuthButtonGroup.displayName = 'OAuthButtonGroup'
실제 운영 환경 고려사항
프로덕션 배포 체크리스트
OAuth 기능을 프로덕션에 배포하기 전 확인해야 할 사항들:
1. 환경 변수 보안
# 프로덕션 환경 변수 예시
NEXTAUTH_URL=https://yourdomain.com
NEXTAUTH_SECRET=very-long-random-string-for-production
GOOGLE_CLIENT_ID=production-google-client-id
GOOGLE_CLIENT_SECRET=production-google-client-secret
GITHUB_ID=production-github-id
GITHUB_SECRET=production-github-secret
2. OAuth 앱 설정 업데이트
- Google Cloud Console에서 프로덕션 도메인을 Authorized redirect URIs에 추가
- GitHub OAuth App에서 Authorization callback URL을 프로덕션 URL로 업데이트
- Facebook 앱에서 유효한 OAuth 리디렉션 URI에 프로덕션 URL 추가
3. HTTPS 필수 설정 OAuth는 보안상 HTTPS가 필수입니다. 프로덕션에서는 반드시 SSL 인증서를 설정해야 합니다.
4. 도메인 검증 각 OAuth 제공자에서 요구하는 도메인 소유권 검증을 완료해야 합니다.
모니터링 및 로깅
OAuth 관련 메트릭과 로그를 수집하여 시스템 상태를 모니터링:
// lib/oauth-monitoring.ts
interface OAuthMetrics {
provider: string
event: 'login_attempt' | 'login_success' | 'login_failure' | 'token_refresh'
userId?: string
timestamp: string
duration?: number
error?: string
userAgent?: string
ip?: string
}
export class OAuthMonitoring {
private metrics: OAuthMetrics[] = []
logEvent(metric: OAuthMetrics) {
this.metrics.push({
...metric,
timestamp: new Date().toISOString(),
})
// 실제 운영에서는 외부 모니터링 서비스로 전송
// 예: DataDog, New Relic, CloudWatch 등
this.sendToMonitoringService(metric)
}
private async sendToMonitoringService(metric: OAuthMetrics) {
try {
// 예시: DataDog로 메트릭 전송
await fetch('https://api.datadoghq.com/api/v1/series', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'DD-API-KEY': process.env.DATADOG_API_KEY!,
},
body: JSON.stringify({
series: [{
metric: `oauth.${metric.event}`,
points: [[Date.now() / 1000, 1]],
tags: [
`provider:${metric.provider}`,
`event:${metric.event}`,
],
}],
}),
})
} catch (error) {
console.error('Failed to send monitoring data:', error)
}
}
getMetrics(timeRange: number = 24 * 60 * 60 * 1000): OAuthMetrics[] {
const cutoff = new Date(Date.now() - timeRange)
return this.metrics.filter(m => new Date(m.timestamp) > cutoff)
}
getSuccessRate(provider?: string, timeRange?: number): number {
const metrics = this.getMetrics(timeRange)
const filtered = provider ? metrics.filter(m => m.provider === provider) : metrics
const attempts = filtered.filter(m => m.event === 'login_attempt').length
const successes = filtered.filter(m => m.event === 'login_success').length
return attempts > 0 ? (successes / attempts) * 100 : 0
}
}
// 전역 모니터링 인스턴스
export const oauthMonitoring = new OAuthMonitoring()
// NextAuth 콜백에서 사용
export const authOptions = {
// ... 기존 설정
events: {
async signIn({ user, account, profile, isNewUser }) {
const startTime = Date.now()
oauthMonitoring.logEvent({
provider: account?.provider || 'unknown',
event: 'login_success',
userId: user.id,
duration: startTime - (profile?.login_start_time || startTime),
userAgent: profile?.user_agent,
ip: profile?.ip_address,
})
// 새 사용자인 경우 추가 분석
if (isNewUser) {
await trackNewUserRegistration(user, account?.provider)
}
},
async signInError({ error, user, account }) {
oauthMonitoring.logEvent({
provider: account?.provider || 'unknown',
event: 'login_failure',
userId: user?.id,
error: error.message || 'Unknown error',
})
},
},
}
async function trackNewUserRegistration(user: any, provider: string) {
// 사용자 가입 추적 (예: Google Analytics, Mixpanel 등)
try {
await fetch('/api/analytics/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'user_registration',
properties: {
provider,
user_id: user.id,
registration_method: 'oauth',
timestamp: new Date().toISOString(),
},
}),
})
} catch (error) {
console.error('Failed to track new user registration:', error)
}
}
A/B 테스트 및 사용자 경험 최적화
OAuth 버튼의 배치, 디자인, 문구 등을 A/B 테스트로 최적화:
// components/ABTestOAuthButtons.tsx
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
interface ABTestConfig {
variant: 'A' | 'B'
buttonStyle: 'default' | 'prominent'
buttonOrder: string[]
showEmailFirst: boolean
}
export const ABTestOAuthButtons = () => {
const [config, setConfig] = useState<ABTestConfig>()
const { data: session } = useSession()
useEffect(() => {
// A/B 테스트 변형 결정 (사용자 ID 기반으로 일관성 유지)
const getABTestConfig = (): ABTestConfig => {
const userId = session?.user?.id || 'anonymous'
const hash = simpleHash(userId)
const isVariantB = hash % 2 === 0
return {
variant: isVariantB ? 'B' : 'A',
buttonStyle: isVariantB ? 'prominent' : 'default',
buttonOrder: isVariantB ? ['google', 'github', 'facebook'] : ['github', 'google', 'facebook'],
showEmailFirst: isVariantB,
}
}
const testConfig = getABTestConfig()
setConfig(testConfig)
// A/B 테스트 참여 로깅
trackABTestParticipation(testConfig.variant)
}, [session])
const trackButtonClick = (provider: string) => {
// 버튼 클릭 이벤트 추적
if (config) {
trackABTestEvent('oauth_button_click', {
variant: config.variant,
provider,
button_style: config.buttonStyle,
button_position: config.buttonOrder.indexOf(provider),
})
}
}
if (!config) {
return <div>로딩 중...</div>
}
const buttonClassName = config.buttonStyle === 'prominent'
? 'oauth-button-prominent'
: 'oauth-button-default'
return (
<div className="space-y-4">
{config.showEmailFirst && (
<div className="email-login-section">
{/* 이메일 로그인 폼 */}
</div>
)}
<div className="oauth-buttons space-y-3">
{config.buttonOrder.map((provider) => (
<button
key={provider}
onClick={() => {
trackButtonClick(provider)
handleOAuthLogin(provider)
}}
className={buttonClassName}
>
{getProviderButton(provider)}
</button>
))}
</div>
{!config.showEmailFirst && (
<div className="email-login-section">
{/* 이메일 로그인 폼 */}
</div>
)}
</div>
)
}
// 간단한 해시 함수 (일관된 A/B 테스트 분할을 위해)
function simpleHash(str: string): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash // 32비트 정수로 변환
}
return Math.abs(hash)
}
async function trackABTestParticipation(variant: string) {
try {
await fetch('/api/analytics/ab-test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'ab_test_participation',
variant,
test_name: 'oauth_button_layout',
}),
})
} catch (error) {
console.error('Failed to track A/B test participation:', error)
}
}
async function trackABTestEvent(event: string, properties: any) {
try {
await fetch('/api/analytics/ab-test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event,
properties: {
...properties,
test_name: 'oauth_button_layout',
timestamp: new Date().toISOString(),
},
}),
})
} catch (error) {
console.error('Failed to track A/B test event:', error)
}
}
고급 OAuth 사용 사례
기업용 SSO (Single Sign-On) 통합
기업 환경에서 OAuth를 사용한 SSO 시스템 구축:
// lib/enterprise-sso.ts
interface EnterpriseConfig {
domain: string
ssoProvider: 'google-workspace' | 'azure-ad' | 'okta'
autoRedirect: boolean
requiredGroups?: string[]
allowedDomains?: string[]
}
export async function handleEnterpriseSSO(email: string): Promise<{
shouldRedirect: boolean
provider?: string
config?: EnterpriseConfig
}> {
// 이메일 도메인으로 기업 설정 조회
const domain = email.split('@')[1]
const enterpriseConfig = await getEnterpriseConfig(domain)
if (!enterpriseConfig) {
return { shouldRedirect: false }
}
// 허용된 도메인 확인
if (enterpriseConfig.allowedDomains &&
!enterpriseConfig.allowedDomains.includes(domain)) {
throw new Error('Domain not allowed for SSO')
}
return {
shouldRedirect: enterpriseConfig.autoRedirect,
provider: enterpriseConfig.ssoProvider,
config: enterpriseConfig,
}
}
async function getEnterpriseConfig(domain: string): Promise<EnterpriseConfig | null> {
// 실제로는 데이터베이스에서 조회
const enterpriseConfigs: Record<string, EnterpriseConfig> = {
'example.com': {
domain: 'example.com',
ssoProvider: 'google-workspace',
autoRedirect: true,
requiredGroups: ['employees'],
allowedDomains: ['example.com'],
},
'bigcorp.com': {
domain: 'bigcorp.com',
ssoProvider: 'azure-ad',
autoRedirect: true,
requiredGroups: ['staff', 'contractors'],
},
}
return enterpriseConfigs[domain] || null
}
// NextAuth 설정에서 기업 SSO 처리
export const authOptions = {
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
authorization: {
params: {
hd: '*', // Google Workspace 도메인 힌트
},
},
}),
],
callbacks: {
async signIn({ user, account, profile }) {
if (account?.provider === 'google' && user.email) {
try {
const ssoResult = await handleEnterpriseSSO(user.email)
if (ssoResult.config) {
// Google Workspace 도메인 검증
if (profile?.hd && ssoResult.config.allowedDomains) {
const isAllowedDomain = ssoResult.config.allowedDomains.includes(profile.hd)
if (!isAllowedDomain) {
return false
}
}
// 그룹 멤버십 확인 (선택적)
if (ssoResult.config.requiredGroups) {
const hasRequiredGroup = await checkUserGroupMembership(
user.email,
ssoResult.config.requiredGroups
)
if (!hasRequiredGroup) {
return false
}
}
}
return true
} catch (error) {
console.error('Enterprise SSO validation failed:', error)
return false
}
}
return true
},
},
}
async function checkUserGroupMembership(email: string, requiredGroups: string[]): Promise<boolean> {
// Google Admin SDK 또는 Azure Graph API를 사용하여 그룹 멤버십 확인
// 실제 구현은 사용하는 SSO 제공자에 따라 달라집니다
return true
}
OAuth 토큰을 활용한 API 호출
사용자 대신 외부 API를 호출하는 기능 구현:
// lib/oauth-api-client.ts
export class OAuthAPIClient {
constructor(private provider: string, private accessToken: string) {}
async makeAuthenticatedRequest(url: string, options: RequestInit = {}) {
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
}
return response.json()
}
// Google API 호출 예시들
async getGoogleCalendarEvents() {
if (this.provider !== 'google') {
throw new Error('This method requires Google OAuth')
}
return this.makeAuthenticatedRequest(
'https://www.googleapis.com/calendar/v3/calendars/primary/events'
)
}
async getGoogleDriveFiles() {
if (this.provider !== 'google') {
throw new Error('This method requires Google OAuth')
}
return this.makeAuthenticatedRequest(
'https://www.googleapis.com/drive/v3/files?pageSize=10'
)
}
// GitHub API 호출 예시들
async getGitHubRepos() {
if (this.provider !== 'github') {
throw new Error('This method requires GitHub OAuth')
}
return this.makeAuthenticatedRequest('https://api.github.com/user/repos')
}
async getGitHubProfile() {
if (this.provider !== 'github') {
throw new Error('This method requires GitHub OAuth')
}
return this.makeAuthenticatedRequest('https://api.github.com/user')
}
// Facebook API 호출 예시
async getFacebookProfile() {
if (this.provider !== 'facebook') {
throw new Error('This method requires Facebook OAuth')
}
return this.makeAuthenticatedRequest(
'https://graph.facebook.com/me?fields=id,name,email,picture'
)
}
}
// API 라우트에서 사용 예시
// pages/api/user/external-data.ts
export default async function handler(req: NextRequest, res: NextResponse) {
const session = await getServerSession(req, res, authOptions)
if (!session?.accessToken) {
return res.status(401).json({ error: 'No access token available' })
}
try {
const client = new OAuthAPIClient('google', session.accessToken)
const [calendarEvents, driveFiles] = await Promise.all([
client.getGoogleCalendarEvents(),
client.getGoogleDriveFiles(),
])
res.json({
calendar: calendarEvents,
drive: driveFiles,
})
} catch (error) {
console.error('External API error:', error)
res.status(500).json({ error: 'Failed to fetch external data' })
}
}
다중 OAuth 계정 통합 대시보드
사용자가 연결한 여러 OAuth 계정의 데이터를 통합하여 보여주는 대시보드:
// components/IntegratedDashboard.tsx
import { useState, useEffect } from 'react'
import { useSession } from 'next-auth/react'
interface IntegratedData {
google?: {
calendar: any[]
drive: any[]
gmail: any[]
}
github?: {
repos: any[]
activity: any[]
}
facebook?: {
posts: any[]
friends: any[]
}
}
export const IntegratedDashboard = () => {
const { data: session } = useSession()
const [data, setData] = useState<IntegratedData>({})
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (session) {
fetchIntegratedData()
}
}, [session])
const fetchIntegratedData = async () => {
setLoading(true)
setError(null)
try {
// 연결된 모든 계정의 데이터를 병렬로 가져오기
const responses = await Promise.allSettled([
fetch('/api/integrations/google').then(r => r.json()),
fetch('/api/integrations/github').then(r => r.json()),
fetch('/api/integrations/facebook').then(r => r.json()),
])
const [googleResult, githubResult, facebookResult] = responses
const integratedData: IntegratedData = {}
if (googleResult.status === 'fulfilled') {
integratedData.google = googleResult.value
}
if (githubResult.status === 'fulfilled') {
integratedData.github = githubResult.value
}
if (facebookResult.status === 'fulfilled') {
integratedData.facebook = facebookResult.value
}
setData(integratedData)
} catch (err) {
setError('데이터를 불러오는 중 오류가 발생했습니다.')
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-2">데이터 로딩 중...</span>
</div>
)
}
if (error) {
return (
<div className="p-4 border border-red-200 rounded-lg bg-red-50">
<p className="text-red-800">{error}</p>
<button
onClick={fetchIntegratedData}
className="mt-2 px-3 py-1 text-sm text-red-600 border border-red-300 rounded hover:bg-red-100"
>
다시 시도
</button>
</div>
)
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">통합 대시보드</h1>
{/* Google 섹션 */}
{data.google && (
<div className="border rounded-lg p-4">
<h2 className="text-lg font-semibold mb-3">Google</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<h3 className="font-medium mb-2">캘린더 일정</h3>
<div className="space-y-2">
{data.google.calendar?.slice(0, 3).map((event, index) => (
<div key={index} className="text-sm p-2 bg-gray-50 rounded">
{event.summary}
</div>
))}
</div>
</div>
<div>
<h3 className="font-medium mb-2">드라이브 파일</h3>
<div className="space-y-2">
{data.google.drive?.slice(0, 3).map((file, index) => (
<div key={index} className="text-sm p-2 bg-gray-50 rounded">
{file.name}
</div>
))}
</div>
</div>
<div>
<h3 className="font-medium mb-2">최근 이메일</h3>
<div className="space-y-2">
{data.google.gmail?.slice(0, 3).map((email, index) => (
<div key={index} className="text-sm p-2 bg-gray-50 rounded">
{email.subject}
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* GitHub 섹션 */}
{data.github && (
<div className="border rounded-lg p-4">
<h2 className="text-lg font-semibold mb-3">GitHub</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h3 className="font-medium mb-2">최근 저장소</h3>
<div className="space-y-2">
{data.github.repos?.slice(0, 5).map((repo, index) => (
<div key={index} className="text-sm p-2 bg-gray-50 rounded flex justify-between">
<span>{repo.name}</span>
<span className="text-gray-500">{repo.language}</span>
</div>
))}
</div>
</div>
<div>
<h3 className="font-medium mb-2">최근 활동</h3>
<div className="space-y-2">
{data.github.activity?.slice(0, 5).map((activity, index) => (
<div key={index} className="text-sm p-2 bg-gray-50 rounded">
{activity.type}: {activity.repo?.name}
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* 연결되지 않은 서비스 안내 */}
<div className="border border-dashed border-gray-300 rounded-lg p-4">
<h3 className="font-medium mb-2">더 많은 서비스 연결</h3>
<p className="text-sm text-gray-600 mb-3">
추가 서비스를 연결하여 더 많은 정보를 한눈에 볼 수 있습니다.
</p>
<div className="space-x-2">
{!data.google && (
<button className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700">
Google 연결
</button>
)}
{!data.github && (
<button className="px-3 py-1 text-sm bg-gray-800 text-white rounded hover:bg-gray-900">
GitHub 연결
</button>
)}
{!data.facebook && (
<button className="px-3 py-1 text-sm bg-blue-500 text-white rounded hover:bg-blue-600">
Facebook 연결
</button>
)}
</div>
</div>
</div>
)
}
결론
OAuth 프로바이더 통합은 현대 웹 애플리케이션에서 필수적인 기능이 되었습니다. 이 가이드에서 다룬 내용을 요약하면:
핵심 요점
OAuth 2.0의 이해: OAuth는 단순한 소셜 로그인을 넘어서 사용자의 동의 하에 안전하게 외부 서비스와 데이터를 주고받을 수 있는 표준 프로토콜입니다.
보안 우선 설계: PKCE, state 매개변수, 토큰 갱신 등의 보안 기능을 적절히 구현하여 안전한 인증 시스템을 구축해야 합니다.
사용자 경험 최적화: 직관적인 UI/UX와 빠른 응답 속도를 통해 사용자가 쉽고 편리하게 로그인할 수 있도록 해야 합니다.
운영 고려사항: 모니터링, 로깅, A/B 테스트 등을 통해 지속적으로 시스템을 개선하고 사용자 경험을 향상시켜야 합니다.
구현 시 주의사항
OAuth 구현 시 가장 중요한 것은 보안입니다. 사용자의 민감한 정보를 다루는 만큼, 모든 보안 모범 사례를 준수하고 정기적으로 보안 감사를 실시해야 합니다.
또한 에러 처리와 사용자 피드백을 충실히 구현하여, 문제가 발생했을 때 사용자가 당황하지 않고 적절한 조치를 취할 수 있도록 안내해야 합니다.
미래 방향성
OAuth 2.1과 같은 새로운 표준과 WebAuthn 같은 차세대 인증 기술의 등장으로, 인증 시스템은 계속 진화하고 있습니다. 지속적인 학습과 업데이트를 통해 최신 보안 트렌드를 따라가는 것이 중요합니다.
올바르게 구현된 OAuth 시스템은 사용자에게 편리함을 제공하면서도 높은 수준의 보안을 보장할 수 있습니다. 이 가이드를 참고하여 여러분의 애플리케이션에 안전하고 사용자 친화적인 OAuth 인증 시스템을 구축해보세요.