1.Next.js 인증 - Next-Auth V5를 사용한 Google & GitHub 로그인
소스 : https://github.com/braverokmc79/macaronics_react_next_auth/tree/chap1
1. 개요 및 목표
- Next.js 애플리케이션에서 인증을 구현하는 방법 설명
- Next-Auth V5를 활용하여 Google 및 GitHub OAuth 로그인 .네이버, 카카오, 페이스북, 애플 구축
- 인증 후 사용자 정보를 표시하고 로그아웃 기능 구현
- 향후 추가 기능(세션 관리, 데이터베이스 연동, 쿠키 활용 등) 확장 가능
Next-Auth V5 (Google & GitHub) 프로젝트 구조
Next.js 15(앱 라우터) 방식으로 Next-Auth V5를 활용한 인증 시스템을 구현할 때의 디렉토리 구조
next-auth-demo/ │── src/ │ ├── app/ │ │ ├── api/ │ │ │ ├── auth/ │ │ │ │ ├── [...nextauth]/route.js # Next-Auth 설정 API │ │ ├── layout.js # 전역 레이아웃 (SessionProvider 설정) │ │ ├── page.js # 홈 페이지 (로그인 UI) │ ├── components/ │ │ ├── LoginForm.jsx # 로그인/로그아웃 UI │ ├── actions/ │ │ ├── index.js # 서버 액션 (socialLogin 함수) │ ├── auth.js # Next-Auth 설정 파일 │── public/ │── .env # 환경 변수 파일 (OAuth 설정) │── package.json # 프로젝트 설정 파일 │── next.config.js # Next.js 설정 파일 │── README.md # 프로젝트 설명 문서
2. 설치 및 설정
1.Next.js 프로젝트 생성
npx create-next-app@latest next-auth-demo cd next-auth-demo
이 프로젝트에서는 전부 예로 설정
- TypeScript 사용: 예
- ESLint 사용: 예
- Tailwind CSS 사용: 예
- src 디렉터리 사용: 아니오
- App Router 사용: 예
- Import alias 설정: 아니오
2.Next-Auth 패키지 설치
npm install next-auth@beta
3.필수 파일 생성
- src/auth.js: 인증 설정 파일
- .env 파일: Google 및 GitHub OAuth 클라이언트 정보 저장
4. 추가 설치 파일
npm install js-cookie npm install lucide-react npm install react-icons
shadcn 설치
ShadCN을 설치하고, 그에 따라 Tooltip과 Button 컴포넌트도 설치하려면 아래 명령어를 사용합니다:
npx shadcn@latest init -d npm install @shadcn/ui-tooltip npm install @shadcn/ui-button
참조: ShadCN
https://macaronics.net/m04/react/view/2374
3. 기본 UI 구성 (로그인 폼 생성)
1)프로젝트 구조 정리
- src/components/LoginForm.jsx 생성
2)로그인 폼 구현 (LoginForm.jsx)
import { Button } from "@/components/ui/button"; import { doSocialLogin } from "@/app/actions/auth"; import { FcGoogle } from "react-icons/fc"; import { FaGithub, FaFacebook, FaApple } from "react-icons/fa"; import { SiNaver, SiKakaotalk } from "react-icons/si"; import React from "react"; const LoginForm: React.FC = () => { return ( <form action={doSocialLogin} className="w-96 space-y-2"> {/* 구글 로그인 */} <Button type="submit" name="action" value="google" className="w-full flex items-center gap-2 bg-white text-black border border-gray-300 hover:bg-gray-100" > <FcGoogle className="text-xl" /> 구글 로그인 </Button> {/* 깃허브 로그인 */} <Button type="submit" name="action" value="github" className="w-full flex items-center gap-2 bg-black text-white hover:bg-gray-800" > <FaGithub className="text-xl" /> 깃허브 로그인 </Button> {/* 네이버 로그인 */} <Button type="submit" name="action" value="naver" className="w-full flex items-center gap-2 bg-[#03C75A] text-white hover:bg-[#029d4d]" > <SiNaver className="text-xl" /> 네이버 로그인 </Button> {/* 카카오 로그인 */} <Button type="submit" name="action" value="kakao" className="w-full flex items-center gap-2 bg-[#FEE500] text-black hover:bg-[#ecd400]" > <SiKakaotalk className="text-xl" /> 카카오 로그인 </Button> {/* 페이스북 로그인 */} <Button type="submit" name="action" value="facebook" className="w-full flex items-center gap-2 bg-[#1877F2] text-white hover:bg-[#1558b6]" > <FaFacebook className="text-xl" /> 페이스북 로그인 </Button> {/* 애플 로그인 */} <Button type="submit" name="action" value="apple" className="w-full flex items-center gap-2 bg-black text-white hover:bg-gray-800" > <FaApple className="text-xl" /> 애플 로그인 </Button> </form> ); }; export default LoginForm;
3)페이지 연결 (src/app/page.js)
import LoginForm from "@/components/LoginForm"; export default function Home() { return ( <div className="w-full flex flex-col justify-center items-center m-4 "> <h1 className="text-3xl my-4">로그인</h1> <LoginForm /> </div> ); }
4. Next.js 서버 액션 (Server Actions) 활용한 인증 처리
서버 액션 설정 (src/actions/index.js)
"use server"; import { signIn, signOut } from "@/auth"; export async function doSocialLogin(formData: FormData): Promise<void> { const action = formData.get("action"); if (!action || typeof action !== "string") { throw new Error("Invalid action provided for login."); } await signIn(action, { redirectTo: "/home" }); } export async function doLogout(): Promise<void> { await signOut({redirectTo: "/"}); }
5. Next-Auth 설정 (src/auth.js)
1. Next-Auth 라이브러리 및 OAuth 공급자 설정
import NextAuth from "next-auth"; import GoogleProvider from "next-auth/providers/google"; import GitHubProvider from "next-auth/providers/github"; import FacebookProvider from "next-auth/providers/facebook"; import AppleProvider from "next-auth/providers/apple"; import KakaoProvider from "next-auth/providers/kakao"; import NaverProvider from "next-auth/providers/naver"; export const { handlers: { GET, POST }, auth, signIn, signOut, } = NextAuth({ providers: [ GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, authorization: { params: { prompt: "consent", access_type: "offline", response_type: "code", }, }, }), GitHubProvider({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET, authorization: { params: { prompt: "consent", access_type: "offline", response_type: "code", }, }, }), FacebookProvider({ clientId: process.env.FACEBOOK_CLIENT_ID, clientSecret: process.env.FACEBOOK_CLIENT_SECRET, }), AppleProvider({ clientId: process.env.APPLE_CLIENT_ID, clientSecret: process.env.APPLE_CLIENT_SECRET, }), KakaoProvider({ clientId: process.env.KAKAO_CLIENT_ID, clientSecret: process.env.KAKAO_CLIENT_SECRET, }), NaverProvider({ clientId: process.env.NAVER_CLIENT_ID, clientSecret: process.env.NAVER_CLIENT_SECRET, }), ], });
2. 환경 변수 설정 (.env 파일 생성)
구글 키 발급 참조 : https://macaronics.net/index.php/m01/spring/view/1632
깃허브 키발급 참조 : https://roots2019.tistory.com/417
테스트 콜백주소는
http://localhost:3000/api/auth/callback/google
GOOGLE_CLIENT_ID="your_google_client_id" GOOGLE_CLIENT_SECRET="your_google_client_secret" GITHUB_ID="your_github_client_id" GITHUB_SECRET="your_github_client_secret" KAKAO_CLIENT_ID="your_kakao_client_id" KAKAO_CLIENT_SECRET="your_kakao_client_secret" NAVER_CLIENT_ID="your_naver_client_id" NAVER_CLIENT_SECRET="your_naver_client_secret" FACEBOOK_CLIENT_ID="your_facebook_client_id" FACEBOOK_CLIENT_SECRET="your_facebook_client_secret" APPLE_CLIENT_ID="your_apple_client_id" APPLE_CLIENT_SECRET="your_apple_client_secret" #$ openssl rand -hex 32 AUTH_SECRET=""
git bash 또는 cmd 명령어로 nextjs 에 사용할 랜덤 시크릿 키 생성 (openssl 설치 되어 있어야 함)
openssl rand -hex 32
6. 로그인 및 로그아웃 기능 구현
1)로그인 버튼에 Next-Auth 기능 추가 (LoginForm.jsx)
import { doLogout } from '@/app/actions/auth' import React from 'react' const Logout: React.FC = () => { return ( <form action={doLogout}> <button type="submit" className='bg-blue-400 px-2 text-white p-1 rounded-md m-1 text-lg' >로그아웃</button> </form> ) } export default Logout
2)세션 제공자 설정 (_app.js 또는 layout.js)
import type { Metadata } from "next"; import { Noto_Sans_KR, Poppins, Geist } from "next/font/google"; import { cn } from "@/lib/utils";; import { cookies } from "next/headers"; import "./globals.css"; import { SessionProvider } from "next-auth/react"; // 폰트 설정 const notoSansKR = Noto_Sans_KR({ subsets: ["latin"], weight: ["400", "500", "600", "700", "800", "900"], variable: "--font-noto-sans-kr", }); const poppins = Poppins({ subsets: ["latin"], weight: ["400", "500", "600", "700", "800", "900"], variable: "--font-poppins", }); const geistSans = Geist({ subsets: ["latin"], variable: "--font-geist-sans", }); export const metadata: Metadata = { title: "Create Next App", description: "macaronics.net Next Auth 샘플", }; export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { // 서버에서 다크 모드 쿠키 가져오기 const darkModeCookie = (await cookies()).get("dark-mode")?.value; const isDarkMode = darkModeCookie === "true"; return ( <html lang="ko" > <body className={cn( `antialiased, ${notoSansKR.variable}, ${poppins.variable}, ${geistSans.variable}, ${isDarkMode ? "dark" : ""} `)} > <SessionProvider> {children} </SessionProvider> </body> </html> ); }
7. 기능 테스트
Google 및 GitHub OAuth 설정 (Google Cloud / GitHub Developer Settings)
- OAuth 클라이언트 ID 및 Secret 발급 후 .env 파일에 설정
- OAuth Redirect URI: http://localhost:3000/api/auth/callback/google
- GitHub Redirect URI: http://localhost:3000/api/auth/callback/github
테스트 과정
- npm run dev 실행
- 로그인 버튼 클릭 후 OAuth 인증 진행
- 로그인 성공 시 사용자 정보 표시 확인
- 로그아웃 버튼 동작 확인
8. 마무리 및 확장 가능성
- 추가 개발 가능 사항
- 데이터베이스 연동 (사용자 정보 저장)
- 쿠키 및 세션 관리 최적화
- 사용자 역할(Role) 및 권한 시스템 추가
- Magic Link / Email 로그인 방식 추가
정리하면
- Next-Auth V5를 사용하여 Google & GitHub OAuth 인증 구현
- useSession()을 활용해 로그인 상태 관리
- Next.js의 서버 액션(Server Actions) 및 환경 변수 활용
- 세션 제공자(SessionProvider) 적용으로 전역적으로 사용자 인증 유지
2.Next.js 인증 - Next-Auth V5 Credential Provider 사용하기, 아이디와 비밀번호로 로그인하기
소스 : https://github.com/braverokmc79/macaronics_react_next_auth/tree/chap2
1. 프로젝트 구조
/src ├── app │ ├── auth │ │ ├── signin │ │ │ ├── page.tsx # 로그인 페이지 │ │ ├── new-user │ │ ├── error │ ├── actions │ │ ├── auth │ │ │ ├── index.tsx # 로그인, 로그아웃 관련 서버 액션 ├── components │ ├── auth │ │ ├── LoginForm.tsx # 로그인 폼 │ │ ├── SocialLogin.tsx # 소셜 로그인 컴포넌트 ├── utils │ ├── CredentialsProviderError.ts # 인증 에러 처리 ├── data │ ├── users.ts # 사용자 정보 조회 함수 ├── auth.ts # NextAuth 설정
- src/auth.ts → NextAuth 설정
- src/data/users.ts → 더미 사용자 데이터
- src/components/auth/LoginForm.tsx → 로그인 폼
- src/app/actions/auth/index.tsx → 로그인 처리 로직
- src/app/auth/signin/page.tsx → 로그인 페이지
인증 프로세스 개요
- 사용자가 로그인 폼에서 이메일과 비밀번호 입력
- doCredentialsLogin을 통해 서버 액션 호출
- signIn("credentials")을 이용하여 NextAuth 인증 수행
- authorize 함수에서 사용자가 존재하는지 검증
- 검증이 완료되면 JWT 세션 생성 및 로그인 처리
- 로그인 성공 시 /home으로 이동, 실패 시 에러 메시지 표시
2.테스트를 위한 더미 파일 추가 (src/data/users.ts)
const users = [ { id: 'test1', name: "홍길동", email: "test1@gmail.com", password: "1111", }, { id: 'test2', name: "이하나", email: "test2@gmail.com", password: "1111", }, { id: 'test3', name: "이순신", email: "test3@gmail.com", password: "1111", } ] export const getUserByEmail = (email:string) => { return users.find(user => user.email === email); };
3.로그인시 에러 반환 클래스 추가 (src/utils/CredentialsProviderError.ts)
import { AuthError } from "next-auth"; export class CredentialsProviderError extends AuthError { field?: string; constructor(message: string, field?: string) { super(message); this.name = "AuthError"; this.field = field; } }
4. Next-Auth 설정 (src/auth.ts)
- CredentialsProvider를 설정하여 이메일/비밀번호 로그인 지원
- authorize 함수에서 사용자를 검증하고 로그인 처리
~ import NextAuth, { NextAuthConfig } from "next-auth"; import { getUserByEmail } from "./data/users"; import { CredentialsProviderError } from "./utils/CredentialsProviderError"; // 사용자 타입 정의 interface User { id: string; name: string; email: string; password: string; } // NextAuth 설정 export const { handlers: { GET, POST }, auth, signIn, signOut, } = NextAuth({ session: { strategy: "jwt", }, providers: [ CredentialsProvider({ name: "Credentials", credentials: { // email: { label: "Email", type: "email", required: true }, // password: { label: "Password", type: "password", required: true }, }, async authorize(credentials: Partial<Record<"email" | "password", unknown>>): Promise<{ id: string; name: string; email: string } | null> { if (!credentials || !credentials.email || !credentials.password) { throw new CredentialsProviderError( "이메일과 비밀번호를 입력해주세요.", "email"); } const email = credentials.email as string; const password = credentials.password as string; const user: User | null = getUserByEmail(email) as User; if (!user) throw new CredentialsProviderError("해당 이메일의 사용자를 찾을 수 없습니다.", "email"); const isMatch = user.password === password; if (!isMatch) throw new CredentialsProviderError("비밀번호가 일치하지 않습니다.","password"); return user; }, }), ~
- credentials에서 email, password를 받아 검증
- 사용자가 존재하지 않거나 비밀번호가 틀리면 예외 발생
- 검증이 통과되면 user 객체를 반환하여 로그인 성공
5. 로그인 폼 (src/components/auth/LoginForm.tsx)
- 사용자가 입력한 이메일/비밀번호를 받아 로그인 요청
- doCredentialsLogin을 호출하여 NextAuth 로그인 수행
- 로그인 성공 시 /home으로 리디렉션
shadcn 로그인 폼 제작 과정은 다음을
참조 : https://macaronics.net/m04/react/view/2374
~ // 3) 폼 제출 핸들러 const handleSubmit = async (data: z.infer<typeof formSchema>) => { setIsLoading(true); // 로그인 시작 → 로딩 상태 true try { const response = await doCredentialsLogin(data); if (response && response.error) { if (response.field && response.field === "password") { form.setError(response.field, { message: response.error }); } else { form.setError("email", { message: response.error }); // 기본적으로 이메일 필드에 표시 } return; } router.push("/home"); } catch (error) { console.error("handleSubmit Error: ", error); form.setError("email", { message: "로그인 중 오류가 발생했습니다." }); } finally { setIsLoading(false); } }; ~
✅ 핵심 포인트
- doCredentialsLogin을 호출하여 로그인 요청
- 오류 발생 시 form.setError를 이용해 사용자에게 피드백 제공
- 로그인 성공 시 /home으로 이동
6. 서버 액션 처리 ( src/app/actions/auth/index.tsx)
- signIn("credentials")을 이용해 로그인 요청
- 실패 시 에러 메시지 처리
~ export async function doCredentialsLogin({ email, password } :{email: string;password: string;}) { try { const response = await signIn("credentials", { redirect: false, email, password }); return response; } catch (error) { if (error instanceof CredentialsProviderError) { // 정규식을 사용하여 "Read more at https://errors.authjs.dev#autherror" 제거 const cleanedMessage = error.message.replace(/\.?\s*Read more at https:\/\/errors\.authjs\.dev#autherror/, ""); return { error: cleanedMessage, field: error?.field ?? 'unknown' }; } throw error; } } ~
✅ 핵심 포인트
- signIn("credentials") 호출하여 로그인 진행
- 인증 실패 시 error 처리하여 폼에 반영
※ 서버 액션 vs API 라우트
● 어떤 경우에 어떤 방식을 선택할까?
✅ 서버 액션을 쓰는 게 좋은 경우
- 페이지 내부에서 간단한 데이터 처리가 필요할 때
- DB 쿼리를 서버에서 직접 실행하고 싶을 때 (MongoDB, Prisma 등)
- fetch 없이 바로 호출할 때 더 깔끔할 때
✅ API 라우트를 쓰는 게 좋은 경우
- 외부 서비스(모바일 앱, 다른 프론트엔드)에서도 데이터를 요청할 때
- GET, POST, PUT, DELETE 등 RESTful API 방식이 필요할 때
- 백엔드 서버를 분리하여 사용하거나, 기존 API 구조를 유지할 때
● 예제 코드 비교
✅1. 서버 액션 방식 (server-actions.ts)
"use server"; import { db } from "@/lib/db"; // 가정: MongoDB 또는 Prisma 사용 export async function createUser(name: string, email: string) { const user = await db.user.create({ data: { name, email }, }); return user; }
● 사용 방법 (서버 컴포넌트에서 호출)
import { createUser } from "@/app/actions/auth"; export default function SignupPage() { async function handleSignup(formData: FormData) { "use server"; // 서버 액션 실행 await createUser(formData.get("name"), formData.get("email")); } return ( <form action={handleSignup}> <input type="text" name="name" /> <input type="email" name="email" /> <button type="submit">가입</button> </form> ); }
✅ 장점:
✔ fetch 없이 createUser()를 직접 실행
✔ API 라우트 없이 바로 서버에서 DB 처리
✅2. API 라우트 방식 (src/app/api/user/route.ts)
import { NextResponse } from "next/server"; import { db } from "@/lib/db"; // MongoDB 또는 Prisma 사용 export async function POST(req: Request) { const { name, email } = await req.json(); const user = await db.user.create({ data: { name, email }, }); return NextResponse.json(user); }
● 사용 방법 (클라이언트에서 fetch 요청)
async function handleSignup(event: React.FormEvent) { event.preventDefault(); const res = await fetch("/api/user", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "홍길동", email: "test@example.com" }), }); const data = await res.json(); console.log("User created:", data); }
✅ 장점:
✔ RESTful API 구조 유지 → 외부 앱에서도 사용 가능
✔ 클라이언트에서 직접 fetch 호출 가능
● 결론: 어떤 방식을 선택할까?
- Next.js 내부에서만 사용 → 서버 액션(Server Actions) 추천
- 외부 API 요청이 필요 → API 라우트(Route.ts) 추천
- 혼합 사용 가능: 서버 액션으로 처리하다가, 특정 API는 /api/ 폴더에 분리 가능
실무에서는 보통 API 라우트를 기본으로 사용하고, 내부 데이터 처리에는 서버 액션을 병행하는 방식이 많습니다!
3. 서버 액션(Server Actions) 공통 인터페이스 생성 MongoDB & Prisma 선택적 사용 설정
actions 에서 mongodb 와 prisma 간의 공통 인터페이스를 생성합니다. 보통 mongodb 또는 prisma 를 선택해서 개발 진행하는 것이
정상적입니다. 그러나, 이 프로젝트에서는 다양한 DB 및 백연동을 위해 다음과 같은 공통 인터페이스를 생성 합니다.
Next.js 15 서버 액션을 이용한 MongoDB & Prisma 선택적 사용
프로젝트 디렉토리 구조
src/ ├── app/ │ ├── actions/ │ │ ├── auth/ │ │ │ ├── loginActions.tsx │ │ ├── mongo/ │ │ │ ├── users/ │ │ │ │ ├── usersActions.ts │ │ ├── prisma/ │ │ │ ├── users/ │ │ │ │ ├── usersActions.ts │ │ ├── userService.ts ├── lib/ │ ├── mongo.ts │ ├── prisma.ts ├── model/ │ ├── mongo/ │ │ ├── user-model.ts ├── types/ │ ├── UserType.ts │ ├── ResponseType.ts
1. 공통 인터페이스 (userService.ts)
서버 액션을 통해 MongoDB 또는 Prisma를 유동적으로 사용할 수 있도록 구성.
"use server" import { UserType } from "@/types/UserType"; import { createUser as mongooseCreateUser } from "./mongo/users/usersActions"; import { createUser as prismaCreateUser } from "./prisma/users/usersActions"; import { ResponseType } from "@/types/ResponseType"; // 사용할 백엔드 타입을 설정 (환경 변수나 설정 파일로 관리 가능) const SERVER_ACTIONS_TYPE = process.env.NEXT_SERVER_ACTIONS_TYPE || "mongoose"; // 기본값: mongoose //1.Action(인터페이스): 유저 생성 export async function createUsersAction(userData: Omit<UserType, "createdAt" | "updatedAt" | "passwordConfirm">): Promise<ResponseType<Omit<UserType, "password" | "passwordConfirm">>> { if (SERVER_ACTIONS_TYPE === "mongoose") { return await mongooseCreateUser(userData); } else if(SERVER_ACTIONS_TYPE === "prisma"){ return await prismaCreateUser(userData); } return { status: 500, message: "서버 타입이 Mongoose 또는 Prisma가 아닙니다.", }; }
✅ 환경 변수를 활용해 mongoose 또는 prisma를 선택하여 실행 가능 ✅ createUsersAction을 통해 통합된 인터페이스 제공
2. MongoDB 유저 생성 (mongo/users/usersActions.ts)
MongoDB를 사용하는 경우 실행되는 서버 액션
"use server"; import bcrypt from "bcryptjs"; import { dbConnect } from "@/lib/mongo"; import { User } from "@/model/mongo/user-model"; import { ResponseType } from "@/types/ResponseType"; import { UserType } from "@/types/UserType"; export async function createUser(userData: Omit<UserType, "createdAt" | "updatedAt" | "passwordConfirm">): Promise<ResponseType<Omit<UserType, "password">>> { try { await dbConnect(); const hashedPassword = await bcrypt.hash(userData.password, 5); const newUser = { ...userData, password: hashedPassword, }; const createdUser = await User.create(newUser); return { status: 201, message: "성공적으로 회원 가입 처리 되었습니다.", data: { ...createdUser.toObject(), password: undefined, }, }; } catch (error) { if (error instanceof Error && error.message.includes("E11000 duplicate key error collection:")) { return { status: 400, message: "이메일이 중복되었습니다.", field: "email" }; } return { status: 500, message: "회원 가입 실패" }; } }
✅ dbConnect()를 통해 MongoDB 연결 ✅ 비밀번호 해싱 후 MongoDB에 저장 ✅ 이메일 중복 에러 처리 포함
3. Prisma 유저 생성 (prisma/users/usersActions.ts)
Prisma를 사용하는 경우 실행되는 서버 액션
"use server"; import bcrypt from "bcryptjs"; import { prisma } from "@/lib/prisma"; import { ResponseType } from "@/types/ResponseType"; import { UserType } from "@/types/UserType"; export async function createUser(userData: Omit<UserType, "createdAt" | "updatedAt" | "passwordConfirm">): Promise<ResponseType<Omit<UserType, "password">>> { try { const hashedPassword = await bcrypt.hash(userData.password, 5); const createdUser = await prisma.user.create({ data: { ...userData, password: hashedPassword, }, }); return { status: 201, message: "성공적으로 회원 가입 처리 되었습니다.", data: { ...createdUser, password: undefined, }, }; } catch (error) { return { status: 500, message: "회원가입 중 오류 발생" }; } }
✅ prisma.user.create()를 이용해 Prisma에 저장 ✅ MongoDB 버전과 동일한 구조로 유지
4. 환경 변수 설정
.env 파일에서 사용할 데이터베이스를 설정 가능
NEXT_SERVER_ACTIONS_TYPE=prisma # Prisma 사용 시 NEXT_SERVER_ACTIONS_TYPE=mongoose # MongoDB 사용 시
✅ .env에서 설정 변경만으로 데이터베이스 변경 가능
요약
✅ userService.ts를 통해 MongoDB와 Prisma를 선택적으로 사용할 수 있는 인터페이스 제공
✅ SERVER_ACTIONS_TYPE 환경 변수로 DB 선택 가능
✅ MongoDB와 Prisma의 유저 생성 로직을 분리하여 유지보수 용이
✅ 비밀번호 해싱 및 보안 고려
이제 환경 변수만 변경하면 MongoDB 또는 Prisma를 자유롭게 선택할 수 있습니다!
4. API 라우트 선택적 설정 : mongo DB 와 spring boot 백엔드
.env
# mongoose, prisma NEXT_SERVER_ACTIONS_TYPE="mongoose" # /api/mongo, /api/springboot NEXT_PUBLIC_API_ROUTES_TYPE="/api/mongo"
signup-form.tsx 의 회원 가입 핸들러
// 회원가입 핸들러 const handleSubmit = async (data: z.infer<typeof signupValidationSchema>) => { setIsLogining(true); try { // 비밀번호 확인 필드 제거 & User 모델에 맞게 데이터 정리 const userData = { name: data.name, email: data.email, password: data.password, dob: new Date(data.dob), // 문자열을 Date 객체로 변환 accountType: data.accountType, companyName: data.companyName, numberOfEmployees: data.numberOfEmployees, } as UserType; //1.서버 액션(Server Actions) 처리 방식 //const response = await createUsersAction( userData); //2.API 라우트(API Routes) 방식 const response = await fetch(`${process.env.NEXT_PUBLIC_API_ROUTES_TYPE}/users/register`, { method: 'POST', headers: { "content-type": "application/json", }, body: JSON.stringify(userData) }).then(response => response.json()); if (response.status === 201) { toast({ description: "✅ 회원 가입을 축하 합니다.", position:"top", action: ( <ToastAction altText="확인" onClick={() => router.push("/auth/signin")}> 확인 </ToastAction> ), }); setTimeout(() => router.push("/auth/signin"), 5200); }else{ toast({ title: "❌ 회원가입 실패.", description: response.message, position:"top", variant:"default", }); form.setError("email", { message: response.message }); return; } } catch (error) { console.error("회원가입 실패:", error); toast({ description: `❌ 회원가입 실패:` }); }finally{ setIsLogining(false); } };
프로젝트 구조
MACARONICS_REACT_NEXT_AUTH │── .next │── node_modules │── public │── src │ │── app │ │ │── actions │ │ │ │── auth │ │ │ │ │── loginActions.tsx │ │ │ │── mongo │ │ │ │ │── users │ │ │ │ │ │── usersActions.ts │ │ │ │── prisma │ │ │ │ │── users │ │ │ │ │ │── usersActions.ts │ │ │ │── userService.ts │ │ │── api │ │ │ │── auth │ │ │ │ │── [...nextauth] │ │ │ │ │── route.ts │ │ │ │── mongo │ │ │ │ │── users │ │ │ │ │── register │ │ │ │ │── route.ts │ │ │ │── springboot │ │ │ │── users │ │ │ │── register │ │ │ │── route.ts │ │ │── auth │ │ │ │── components │ │ │ │ │── log-out.tsx │ │ │ │ │── login-form.tsx │ │ │ │ │── signup-form.tsx │ │ │ │ │── social-login.tsx │ │ │ │── error │ │ │ │ │── page.tsx │ │ │ │── new-user │ │ │ │ │── page.tsx │ │ │ │── signin │ │ │ │ │── page.tsx │ │ │ │── verify-request │ │ │ │ │── page.tsx │ │ │ │── layout.tsx │ │ │── home │ │ │ │── page.tsx │ │ │── favicon.ico │ │ │── globals.css │ │ │── layout.tsx │ │ │── page.tsx │ │ │── providers.tsx │ │── ui │ │ │── badge.tsx │ │ │── button.tsx │ │ │── calendar.tsx │ │ │── card.tsx │ │ │── checkbox.tsx │ │ │── custom_calendar.tsx │ │ │── custom-toast.tsx │ │ │── custom-toaster.tsx │ │ │── dropdown-menu.tsx │ │ │── form.tsx │ │ │── input.tsx │ │ │── label.tsx │ │ │── LightDarkToggleProps.tsx │ │ │── password-input.tsx │ │ │── popover.tsx │ │ │── select.tsx │ │ │── toast.tsx │ │ │── toaster.tsx │ │ │── tooltip.tsx │ │ │── theme-provider.tsx │ │ │── ThemeToggle.tsx │ │── data │ │ │── users.ts │ │── hooks │ │ │── custom-use-toasts.ts │ │ │── use-toast.ts │ │── lib │ │ │── mongo.ts │ │ │── utils.ts │ │── model │ │ │── mongo │ │ │── user-model.ts │ │── types │ │ │── ResponseType.ts │ │ │── UserType.ts │ │── utils │ │ │── commonFn.ts │ │ │── CredentialsProviderError.ts │ │ │── auth.ts │ │── validation-schemas │ │ │── authValidationSchema.ts │ │── auth.ts │── .env │── .gitignore │── components.json │── eslint.config.mjs │── next-env.d.ts │── next.config.ts │── package-lock.json │── package.json │── pnpm-lock.yaml │── postcss.config.mjs │── README.md │── tailwind.config.ts │── tsconfig.json
※Next.js 15(또는 14)와 Spring Boot를 백엔드로 사용할 경우,
서버 액션(Server Actions)과 API 라우트(API Routes) 중 어떤 방식을 선택?
1️⃣ Spring Boot 백엔드와 함께 사용할 때 적합한 방식
✅ API Routes (API 라우트)
Next.js → Spring Boot API 호출 (REST API 또는 GraphQL)
장점
- Spring Boot 백엔드를 완전히 독립적으로 유지 가능 (백엔드와 프론트엔드 분리)
- Next.js의 API Routes에서 Spring Boot 서버로 데이터를 프록시(proxy) 요청할 수도 있음
- 다양한 인증 방식(JWT, OAuth, Session 등) 적용이 용이
- Next.js API Routes를 백엔드 게이트웨이 역할로 활용 가능
- 클라이언트에서 SSR(서버사이드 렌더링) 및 CSR(클라이언트 사이드 렌더링) 을 자유롭게 선택 가능
단점
- Spring Boot API와 통신해야 하므로 네트워크 요청 비용 발생
- Next.js의 API Routes가 단순히 백엔드의 중간 API 역할만 하게 될 가능성이 있음
예제: Next.js API Route에서 Spring Boot API 호출
// app/api/user/route.ts export async function GET() { const res = await fetch('http://localhost:8080/api/user', { cache: 'no-store' }); const data = await res.json(); return Response.json(data); }
Next.js API Route를 통해 백엔드(Sprint Boot)와 통신하고, 클라이언트는 /api/user를 호출하면 됨.
✅ Server Actions (서버 액션)
Next.js Server Component → Spring Boot API 직접 호출
장점
- API Routes 없이 서버 컴포넌트에서 바로 Spring Boot API 호출 가능
- 상태 관리가 쉬움: 클라이언트-서버 간 데이터 주고받기가 간단함
- API 레이어를 줄여서 성능 최적화 가능
- 데이터베이스 직접 연결 가능(하지만 Spring Boot를 백엔드로 쓸 경우, DB 직접 연결보다는 API 호출 방식이 일반적임)
단점
- 클라이언트에서 직접 서버 액션 호출 불가능 (항상 서버 컴포넌트에서 실행)
- Spring Boot API가 있는 경우, 서버 액션이 불필요한 중간 계층이 될 수도 있음
예제: Server Action에서 Spring Boot API 호출
// app/users/page.tsx async function getUsers() { const res = await fetch('http://localhost:8080/api/user', { cache: 'no-store' }); return res.json(); } export default async function UsersPage() { const users = await getUsers(); return ( <div> {users.map((user: any) => ( <p key={user.id}>{user.name}</p> ))} </div> ); }
서버 액션을 사용하면 API Routes 없이도 Next.js 서버 컴포넌트에서 데이터를 가져올 수 있음.
2️⃣ 어떤 방식을 선택해야 할까?
정리하면:
- Spring Boot를 메인 백엔드로 사용한다면? → API Routes 사용
- Next.js에서 DB를 직접 접근하는 경우? → 서버 액션 사용
- 클라이언트에서 API를 직접 호출해야 한다면? → API Routes + 클라이언트 Fetch 사용
3️⃣ 결론
Spring Boot를 백엔드로 개발한다면, 일반적으로 API Routes를 사용하고, 클라이언트에서 API를 호출하는 방식이 가장 적합합니다.
서버 액션은 Next.js에서 직접 데이터베이스를 관리하는 경우에 유용하지만, Spring Boot와 함께 사용할 때는 필요성이 떨어집니다.
따라서 Next.js API Routes + Spring Boot REST API 방식이 가장 효율적인 선택입니다.
※Spring Boot를 백엔드로 사용하면서 Next.js API Routes를 활용할 경우,
반드시 NextAuth.js를 사용해야 하는 것은 아니지만, NextAuth를 사용을 권장
1️⃣ Spring Boot + Next.js에서 인증을 처리하는 방식
Spring Boot를 백엔드로 사용할 경우, 보통 다음과 같은 인증 방식을 고려할 수 있습니다.
✅ 1. NextAuth.js + Spring Boot (권장)
Next.js의 API Routes에서 인증을 처리하고, Spring Boot API 호출 시 인증 정보 전달
구현 방식
- NextAuth.js를 Next.js의 API Routes(/api/auth/[...nextauth])에 설정
- 로그인 후, Next.js가 JWT 또는 세션을 관리
- 클라이언트에서 Next.js API Routes를 호출할 때 session을 통해 인증 정보를 전달
- Next.js API Routes에서 Spring Boot API로 요청 시 인증 토큰 추가
장점
✅ Next.js에서 인증을 처리하므로, 클라이언트가 직접 Spring Boot와 통신하지 않아도 됨
✅ NextAuth.js의 OAuth, Credentials, JWT 등 다양한 인증 방식 지원
✅ API Routes를 사용하면 인증과 API 호출을 분리할 수 있어 보안 강화
단점
❌ API Routes를 인증 프록시로 사용하면 약간의 추가 오버헤드 발생
❌ Spring Boot에서도 인증이 필요할 경우, 중복된 인증 관리 필요
예제: NextAuth.js를 사용한 인증 설정 (app/api/auth/[...nextauth]/route.ts)
import NextAuth from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; export const authOptions = { providers: [ CredentialsProvider({ name: "Credentials", credentials: { username: { label: "Username", type: "text" }, password: { label: "Password", type: "password" }, }, async authorize(credentials) { const res = await fetch("http://localhost:8080/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(credentials), }); const user = await res.json(); if (res.ok && user) return user; return null; }, }), ], session: { strategy: "jwt" }, secret: process.env.NEXTAUTH_SECRET, }; const handler = NextAuth(authOptions); export { handler as GET, handler as POST };
클라이언트에서 로그인 처리 (app/login/page.tsx)
"use client"; import { signIn, signOut, useSession } from "next-auth/react"; export default function LoginPage() { const { data: session } = useSession(); return ( <div> {session ? ( <div> <p>Welcome, {session.user?.name}!</p> <button onClick={() => signOut()}>Logout</button> </div> ) : ( <button onClick={() => signIn()}>Login</button> )} </div> ); }
Spring Boot에서 JWT 기반 인증 (예제 SecurityConfig.java)
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf().disable() .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login").permitAll() .anyRequest().authenticated() ) .sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .oauth2Login(); // OAuth 로그인도 가능 return http.build(); } }
✅ 2. Spring Boot에서 직접 JWT 기반 인증 처리 (API 호출 시 토큰 포함)
Next.js에서 직접 Spring Boot API를 호출하고, Spring Boot에서 인증을 검증하는 방식
구현 방식
- Next.js 클라이언트에서 로그인 시 Spring Boot에 로그인 요청을 보내고 JWT 발급
- Next.js 클라이언트가 API 호출 시 Authorization 헤더에 JWT 포함
- Spring Boot에서 JWT를 검증하고 API 응답 반환
장점
✅ Next.js API Routes 없이 바로 Spring Boot와 통신 가능
✅ 인증 로직을 백엔드(Spring Boot)에서 일괄적으로 처리 가능단점
❌ Next.js에서 세션/인증 관리 없이 클라이언트에서 직접 JWT를 관리해야 함
❌ API 호출마다 Authorization 헤더를 추가해야 함
예제: Next.js 클라이언트에서 로그인 후 JWT 저장 (app/login/page.tsx)
"use client"; import { useState } from "react"; export default function LoginPage() { const [token, setToken] = useState<string | null>(null); async function handleLogin() { const res = await fetch("http://localhost:8080/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "admin", password: "password" }), }); const data = await res.json(); if (data.token) { localStorage.setItem("jwt", data.token); setToken(data.token); } } return ( <div> <button onClick={handleLogin}>Login</button> {token && <p>JWT: {token}</p>} </div> ); }
Next.js에서 Spring Boot API 호출 시 JWT 포함
async function getUserData() { const token = localStorage.getItem("jwt"); const res = await fetch("http://localhost:8080/api/user", { headers: { Authorization: `Bearer ${token}` }, }); return res.json(); }
2️⃣ 결론: NextAuth.js를 꼭 써야 할까?
NextAuth.js를 사용해야 할 경우
- ✅ Next.js에서 인증을 중앙에서 관리하고 싶다면
- ✅ OAuth, Credentials, JWT 등 다양한 로그인 방식이 필요하다면
- ✅ Next.js API Routes에서 인증을 프록시 처리하고 싶다면
- ✅ React 컴포넌트에서 useSession()을 쉽게 사용하고 싶다면
Spring Boot에서 직접 인증할 경우
- ✅ JWT 기반으로 API를 호출하는 방식이 익숙하다면
- ✅ Next.js API Routes 없이 클라이언트에서 바로 Spring Boot API를 호출하고 싶다면
- ✅ Next.js는 프론트엔드 역할만 하고, 인증은 Spring Boot에서 일괄 처리하고 싶다면
3️⃣ 추천 방식
- OAuth(Google, GitHub 등) 로그인 지원 + JWT 기반 인증 관리 → ✅ NextAuth.js 사용 (API Routes 활용)
5. 회원가입하기
소스 : https://github.com/braverokmc79/macaronics_react_next_auth/tree/signup
zod 및 shadcn 사용방법은 다음을 참조 =========> https://macaronics.net/m04/react/view/2374
1)MongoDB+서버 액션(Server Actions) + shadcn을 이용한 회원가입 개발 로직
1. 회원 가입처리에 관한 프로젝트 디렉토리 구조
MACARONICS_REACT_NEXT_AUTH │── .next │── node_modules │── public │── src │ │── app │ │ │── actions │ │ │ │── auth │ │ │ │ │── loginActions.tsx │ │ │ │── mongo │ │ │ │ │── users │ │ │ │ │ │── usersActions.ts---------------------->서버 액션 몽고DB 를 이용한 가입처리 │ │ │ │── prisma │ │ │ │ │── users │ │ │ │ │ │── usersActions.ts---------------------->서버 액션 prisma 를 이용한 가입처리 │ │ │ │── userService.ts │ │ │── api │ │ │ │── auth │ │ │ │ │── [...nextauth] │ │ │ │ │── route.ts │ │ │ │── mongo │ │ │ │ │── users │ │ │ │ │── register │ │ │ │ │── route.ts---------------------------->api 라우터를 이용한 몽공 DB 회원 가입처리 │ │ │ │── springboot │ │ │ │── users │ │ │ │── register │ │ │ │── route.ts --------------------------->api 라우터를 이용한 스프링부트 가입처리 │ │ │── auth │ │ │ │── components │ │ │ │ │── log-out.tsx │ │ │ │ │── login-form.tsx │ │ │ │ │── signup-form.tsx │ │ │ │ │── social-login.tsx │ │ │ │── error │ │ │ │ │── page.tsx │ │ │ │── new-user │ │ │ │ │── page.tsx │ │ │ │── signin │ │ │ │ │── page.tsx │ │ │ │── verify-request │ │ │ │ │── page.tsx │ │ │ │── layout.tsx │ │ │── home │ │ │ │── page.tsx │ │ │── favicon.ico │ │ │── globals.css │ │ │── layout.tsx │ │ │── page.tsx │ │ │── providers.tsx │ │── ui │ │── data │ │── hooks │ │ │── custom-use-toasts.ts │ │ │── use-toast.ts │ │── lib │ │ │── mongo.ts--------------------->몽고 DB 접속 │ │ │── utils.ts │ │── model │ │ │── mongo │ │ │── user-model.ts │ │── types │ │ │── ResponseType.ts │ │ │── UserType.ts │ │── utils │ │ │── commonFn.ts │ │ │── CredentialsProviderError.ts------------------------>nextauth 에러 반환 │ │ │── auth.ts │ │── validation-schemas │ │ │── authValidationSchema.ts │ │── auth.ts │── .env
2. NextAuth v5 설정 ([...nextauth].ts)
- CredentialsProvider를 활용하여 이메일 및 비밀번호 기반 인증 처리.
- bcrypt를 이용한 비밀번호 해싱 및 검증.
- 사용자 존재 여부 및 비밀번호 확인 후, 인증된 사용자 정보 반환.
3. 회원가입 폼 및 유효성 검사 (SignupForm.tsx)
- react-hook-form과 zod를 활용하여 입력 값 검증.
- shadcn UI 컴포넌트를 사용하여 사용자 입력 UI 구성.
- useState 및 useToast를 활용하여 회원가입 처리 상태 관리.
src/validation-schemas/authValidationSchema.ts
import { z } from "zod"; //1.로그인 유효성 검사 스키마 정의 export const signinValidationSchema = z.object({ email: z.string().email("유효한 이메일을 입력하세요."), password: z.string().min(4, "비밀번호는 최소 4자 이상이어야 합니다."), }); //2.회원 가입 유효성 검사 스키마 정의 export const signupValidationSchema = z.object({ email: z.string().email("유효한 이메일을 입력하세요."), name: z.string({ required_error: "이름을 을 입력해주세요." }).optional(), accountType: z.enum(["personal", "company"]), companyName: z.string({ required_error: "기업명을 입력해주세요." }).optional(), numberOfEmployees: z.coerce.number({ required_error: "직원수를 입력해주세요." }).optional(), dob:z.date({ required_error: "생년월일을 선택해주세요." }).refine((date)=>{ const today = new Date(); const eighteedYearsAgo =new Date( today.getFullYear() - 18, today.getMonth(), today.getDate() ); return date <= eighteedYearsAgo; }, "18세 이상의 사람만 회원가입 가능합니다."), password: z.string({ required_error: "비밀번호를 입력해 주세요." }).min(8, "8자 이상 입력하세요.") .refine((password) => { return /^(?=.*[!@#$%^&*])(?=.*[A-Z]).*$/.test(password); },"비밀번호는 특수문자 1개 이상, 대문자 1개 이상을 포함해야 합니다."), passwordConfirm: z.string({ required_error: "비밀번호 확인을 입력해 주세요." }), acceptTerms: z.boolean({required_error: "이용약관에 동의해야 합니다."}) //.refine((checked) => checked, "You must accept the terms and conditions"), }).superRefine((data, ctx) => { // 1️⃣ 기업 계정인 경우 기업명과 직원 수 먼저 확인 if (data.accountType === "company") { if (!data.companyName) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["companyName"], message: "기업명을 입력해주세요.", }); } if (!data.numberOfEmployees || data.numberOfEmployees < 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["numberOfEmployees"], message: "직원수를 입력해주세요.", }); } } // 2️⃣ 비밀번호 확인 필드 체크 if (data.password !== data.passwordConfirm) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["passwordConfirm"], message: "비밀번호와 비밀번호 확인은 일치해야 합니다.", }); } // 3️⃣ 이용약관 동의 마지막에 체크 if (!data.acceptTerms) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["acceptTerms"], message: "이용약관에 동의해야 합니다.", }); } });
/src/app/auth/components/signup-form.tsx
~ ~ ~ const form = useForm<z.infer<typeof signupValidationSchema>>({ resolver: zodResolver(signupValidationSchema), defaultValues: { email: "", name: "", accountType: "personal", dob: undefined, password: "", passwordConfirm: "", }, }); const handleSubmit = async (data: z.infer<typeof signupValidationSchema>) => { try { setIsLogining(true); const userData = { name: data.name, email: data.email, password: data.password, dob: new Date(data.dob), accountType: data.accountType, }; const response = await createUser(userData); if (response.status === 201) { toast({ description: "✅ 회원 가입을 축하합니다." }); router.push("/auth/signin"); } else { toast({ title: "❌ 회원가입 실패.", description: response.message }); form.setError("email", { message: response.message }); } } catch (error) { toast({ description: "❌ 회원가입 실패." }); } finally { setIsLogining(false); } }; ~ ~ ~
4. MongoDB 기반 회원 데이터 모델 (user-model.ts)
import mongoose, { Schema } from "mongoose"; const userSchema = new Schema({ name: { type: String, required: true }, email: { type: String, required: true, unique: true }, password: { type: String, required: true }, dob: { type: Date, required: true }, accountType: { type: String, enum: ["personal", "company"], required: true }, }, { timestamps: true }); export const User = mongoose.models.User || mongoose.model("User", userSchema);
타입정의
1)반환 타입 정의 :
ResponseType.ts
//프로젝트 공통 반환 타입 정의 export interface ResponseType<T = any> { status: number; message: string; field?: string; // 특정 필드에 대한 에러 메시지 (선택적) data?: T; // 성공 시 반환할 데이터 (선택적) }
2)유저 타입정의
UserType.ts
export interface UserType { email: string; accountType: "personal" | "company"; dob: Date; password: string; passwordConfirm: string; name?: string | undefined; companyName?: string | undefined; numberOfEmployees?: number | undefined; createdAt: Date; updatedAt: Date; }
5. 서버 액션을 활용한 회원가입 처리 (usersActions.ts)
"use server"; import bcrypt from "bcryptjs"; import { dbConnect } from "@/lib/mongo"; import { User } from "@/model/mongo/user-model"; import { ResponseType } from "@/types/ResponseType"; import { UserType } from "@/types/UserType"; // 1. 유저 생성 함수 export async function createUser( userData: Omit<UserType, "createdAt" | "updatedAt" | "passwordConfirm"> ): Promise<ResponseType<Omit<UserType, "password">>> { try { await dbConnect(); // 비밀번호 해싱 const hashedPassword = await bcrypt.hash(userData.password, 5); // 유저 데이터 생성 const newUser = await User.create({ name: userData.name, email: userData.email, password: hashedPassword, // 해싱된 비밀번호 저장 dob: userData.dob, accountType: userData.accountType, companyName: userData.companyName, numberOfEmployees: userData.numberOfEmployees, }); // Mongoose 문서를 JS 객체로 변환 후 필드 가공 const createdUser = newUser.toObject(); return { status: 201, message: "성공적으로 회원 가입 처리 되었습니다.", data: { ...createdUser, _id: createdUser._id.toString(), // ObjectId를 문자열로 변환 createdAt: createdUser.createdAt.toISOString(), // Date 변환 updatedAt: createdUser.updatedAt.toISOString(), password: undefined, // 보안상 반환하지 않음 }, }; } catch (error) { if (error instanceof Error) { if (error.message.includes("E11000 duplicate key error collection:")) { return { status: 400, message: "이메일이 중복되었습니다.", field: "email", }; } console.error("회원 가입 실패:", error.message); return { status: 500, message: error.message, }; } throw error; } }
6. 정리
signup-form.tsx 회원가입 컨포넌트에서 zod 를 이용한 authValidationSchema.ts 유효성 검사를 진행 ->
->유효성이 이상이 없으면 서버 액션에서 usersActions.ts 몽고 DB 스키마와 연동해서 회원 가입 처리를 진행 합니다.
- NextAuth v5를 사용하여 로그인 인증 처리.
- shadcn을 활용한 UI 및 react-hook-form + zod를 이용한 유효성 검사.
- MongoDB에 Mongoose를 이용하여 회원 데이터를 저장.
- 서버 액션(Server Actions)을 이용해 회원가입을 비동기 처리.
이 구조를 활용하면 보안이 강화된 회원가입 로직을 구축할 수 있습니다.
2)MongoDB+API 라우터 + shadcn을 이용한 회원가입 개발 로직
1. MongoDB 설치 및 설정
1) MongoDB 계정 생성 및 클러스터 설정 (MongoDB Atlas)
- MongoDB Atlas에 가입 및 로그인합니다.
- "Create a Cluster" 버튼을 클릭하고 무료 Shared Cluster (M0)를 선택합니다.
- Region과 Cluster Name을 설정한 후 "Create Cluster"를 클릭합니다.
- 데이터베이스 접근을 위한 Database Access (사용자 생성) 및 Network Access (IP 허용) 을 설정합니다.
- Connect > Connect your application을 선택하고 MongoDB Connection URI를 복사합니다.
.env
MONGO_DB_CONNECTION_STRING=mongodb+srv://<username>:<password>@<cluster-url>/myDatabase?retryWrites=true&w=majority
2)MongoDB 패키지 설치
pnpm install mongoose pnpm install mongodb
3)MongoDB 연결 설정
src/lib/mongo.ts
import mongoose from "mongoose"; export async function dbConnect() { try { const conn = await mongoose.connect(String(process.env.MONGO_DB_CONNECTION_STRING)); return conn; } catch (e) { throw new Error(String(e)); } }
/api/mongo/users/register/route.ts
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { dbConnect } from "@/lib/mongo"; import { ResponseType } from "@/types/ResponseType"; import { UserType } from "@/types/UserType"; import { User } from "@/model/mongo/user-model"; //API 라우트(mongodb) : /api/mongo/users/register export const POST = async (request: Request) => { try { console.log("받은 데이터 :", request); const userData: UserType = await request.json(); await dbConnect(); // 비밀번호 해싱 const hashedPassword = await bcrypt.hash(userData.password, 5); // MongoDB에 저장할 데이터 구조 정의 const newUser = { name: userData.name, email: userData.email, password: hashedPassword, dob: userData.dob, accountType: userData.accountType, companyName: userData.companyName, numberOfEmployees: userData.numberOfEmployees, }; // 유저 생성 const createdUser = await User.create(newUser); // NextResponse.json()을 사용해 응답 return NextResponse.json<ResponseType<Omit<UserType, "password">>>({ status: 201, message: "성공적으로 회원 가입 처리 되었습니다.", data: { ...createdUser.toObject(), password: undefined, // 비밀번호 숨김 }, }); } catch (error) { if (error instanceof Error) { if (error.message.includes("E11000 duplicate key error collection:")) { return NextResponse.json({ status: 400, message: "이메일이 중복되었습니다.", field: "email", }); } console.error("회원 가입 실패:", error.message); return NextResponse.json({ status: 500, message: error.message, }); } throw error; } };
src/app/auth/components/signup-form.tsx
const SignupForm: React.FC<SignupFormProps> = ({socialLoginHandleOpenModal}) => { const { toast } = useToast(); const router =useRouter(); const [isLogining, setIsLogining] = useState(false); const form = useForm<z.infer<typeof signupValidationSchema>>({ resolver: zodResolver(signupValidationSchema), defaultValues: { email: "", name: "", accountType: "personal", dob: undefined, companyName: "", numberOfEmployees: 0, password: "", passwordConfirm: "", }, }); // 회원가입 핸들러 const handleSubmit = async (data: z.infer<typeof signupValidationSchema>) => { setIsLogining(true); try { // 비밀번호 확인 필드 제거 & User 모델에 맞게 데이터 정리 const userData = { name: data.name, email: data.email, password: data.password, dob: new Date(data.dob), // 문자열을 Date 객체로 변환 accountType: data.accountType, companyName: data.companyName, numberOfEmployees: data.numberOfEmployees, } as UserType; //1.서버 액션(Server Actions) 처리 방식 //const response = await createUsersAction( userData); //2.API 라우트(API Routes) 방식 const response = await fetch(`${process.env.NEXT_PUBLIC_API_ROUTES_TYPE}/users/register`, { method: 'POST', headers: { "content-type": "application/json", }, body: JSON.stringify(userData) }).then(response => response.json()); if (response.status === 201) { toast({ description: "✅ 회원 가입을 축하 합니다.", position:"top", action: ( <ToastAction altText="확인" onClick={() => router.push("/auth/signin")}> 확인 </ToastAction> ), }); setTimeout(() => router.push("/auth/signin"), 5200); }else{ toast({ title: "❌ 회원가입 실패.", description: response.message, position:"top", variant:"default", }); form.setError("email", { message: response.message }); return; } } catch (error) { console.error("회원가입 실패:", error); toast({ description: `❌ 회원가입 실패:` }); }finally{ setIsLogining(false); } };
3)Prisma+서버 액션(Server Actions) + API 라우터 + shadcn을 이용한 회원가입 개발 로직
1. Prisma 설치 및 설정
1-1. Prisma & SQLite 설치
npm install @prisma/client npm install -D prisma npm list @prisma/extension-accelerate
1-2. Prisma 초기화 및 SQLite 설정
npx prisma init
1-3. prisma generate 실행
PrismaClient 에 오류 메시지가 나올 시에 다음을 실행
Prisma 클라이언트가 올바르게 생성되지 않으면 PrismaClient를 가져올 때 문제가 발생할 수 있습니다. 다음 명령어를 실행해 주세요.
npx prisma generate
이후 prisma/schema.prisma 파일을 수정하여 SQLite를 사용하도록 설정합니다.
generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = "file:./dev.db" } model User { id String @id @default(uuid()) email String @unique accountType String // "personal" | "company" dob DateTime password String name String? companyName String? numberOfEmployees Int? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
1-3. 마이그레이션 실행
npx prisma migrate dev --name init
2. Prisma 클라이언트 설정
Prisma 클라이언트를 lib/prisma.ts 파일로 생성합니다.
lib/prisma.ts
import { PrismaClient } from '@prisma/client' import { withAccelerate } from '@prisma/extension-accelerate' const prisma = new PrismaClient().$extends(withAccelerate()) export default prisma;
★ PrismaClient 싱글턴 패턴 적용
Next.js에서는 PrismaClient가 여러 번 인스턴스화되면서 문제가 발생할 수 있습니다. 다음과 같이 globalThis를 활용하여 싱글턴 패턴을 적용해 보세요.
import { PrismaClient } from '@prisma/client' import { withAccelerate } from '@prisma/extension-accelerate' const prismaClient = new PrismaClient().$extends(withAccelerate()) as unknown as PrismaClient const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient } export const prisma = globalForPrisma.prisma ?? prismaClient if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma export default prisma
Next.js와 Turbopack 관련 문제 해결
현재 Next.js 15에서 Turbopack을 사용 중이라면, Prisma가 Turbopack에서 올바르게 동작하지 않을 수 있습니다. 해결 방법으로 next.config.js에서 turbo를 끄고 확인해 보세요.
/** @type {import('next').NextConfig} */ const nextConfig = { experimental: { turbo: {}, }, } module.exports = nextConfig
3. 회원가입 API 구현 API 라우터 방식
/app/api/prisma/users/register/route.ts 생성 후 다음과 같이 구현합니다.
route.ts
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import prisma from "@/lib/prisma"; export const POST = async (request: Request) => { try { const userData = await request.json(); // 중복 이메일 체크 const existingUser = await prisma.user.findUnique({ where: { email: userData.email }, }); if (existingUser) { return NextResponse.json({ status: 400, message: "이메일이 이미 사용 중입니다.", field: "email", }); } // 비밀번호 해싱 const hashedPassword = await bcrypt.hash(userData.password, 5); // 유저 생성 const newUser = await prisma.user.create({ data: { email: userData.email, accountType: userData.accountType, dob: new Date(userData.dob), password: hashedPassword, name: userData.name, companyName: userData.companyName, numberOfEmployees: userData.numberOfEmployees, }, }); return NextResponse.json({ status: 201, message: "성공적으로 회원 가입이 완료되었습니다.", data: { id: newUser.id, email: newUser.email, accountType: newUser.accountType, dob: newUser.dob, name: newUser.name, companyName: newUser.companyName, numberOfEmployees: newUser.numberOfEmployees, createdAt: newUser.createdAt, updatedAt: newUser.updatedAt, }, }); } catch (error) { console.error("회원 가입 실패:", error); return NextResponse.json({ status: 500, message: "서버 오류가 발생했습니다.", }); } };
4. 회원가입 서버 액션 방식
src/app/actions/prisma/users/usersActions.ts
"use server"; import bcrypt from "bcryptjs"; import prisma from "@/lib/prisma"; import { ResponseType } from "@/types/ResponseType"; import { UserType } from "@/types/UserType"; // 1. 유저 생성 함수 export async function createUser( userData: Omit<UserType, "createdAt" | "updatedAt" | "passwordConfirm"> ): Promise<ResponseType<Omit<UserType, "password" | "passwordConfirm">>> { try { // 중복 이메일 체크 const existingUser = await prisma.user.findUnique({ where: { email: userData.email }, }); if (existingUser) { return { status: 400, message: "이메일이 중복되었습니다.", field: "email", }; } // 비밀번호 해싱 const hashedPassword = await bcrypt.hash(userData.password, 5); // 유저 데이터 생성 const newUser = await prisma.user.create({ data: { email: userData.email, password: hashedPassword, // 해싱된 비밀번호 저장 dob: new Date(userData.dob), accountType: userData.accountType, name: userData.name, companyName: userData.companyName, numberOfEmployees: userData.numberOfEmployees, }, }); return { status: 201, message: "성공적으로 회원 가입 처리 되었습니다.", data: { id: newUser.id, email: newUser.email, accountType: userData.accountType === "personal" || userData.accountType === "company" ? userData.accountType : "personal", dob: newUser.dob, name: newUser.name ?? undefined, companyName: newUser.companyName ?? undefined, numberOfEmployees: newUser.numberOfEmployees ?? undefined, createdAt: newUser.createdAt, updatedAt: newUser.updatedAt }, }; } catch (error) { console.error("회원 가입 실패:", error); return { status: 500, message: "서버 오류가 발생했습니다.", }; } }
4)supabase+서버 액션(Server Actions) + API 라우터 + shadcn을 이용한 회원가입 개발 로직
참조:
https://macaronics.net/m04/react/view/2384
superbase 에 sql Editor 에서 다음과 같이 users 테이블 등록, postsql 이라 대소문자를 인식하려면 따옴표
CREATE TABLE users ( "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), "email" TEXT UNIQUE NOT NULL, "accountType" TEXT NOT NULL, -- "personal" | "company" "dob" TIMESTAMP NOT NULL, "password" TEXT NOT NULL, "name" TEXT, "companyName" TEXT, "numberOfEmployees" INT, "createdAt" TIMESTAMP DEFAULT now(), "updatedAt" TIMESTAMP DEFAULT now() ); -- updated_at 자동 갱신을 위한 트리거 함수 생성 CREATE OR REPLACE FUNCTION update_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- 트리거 생성: users 테이블의 데이터가 업데이트될 때 updated_at을 자동 변경 CREATE TRIGGER set_timestamp BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_timestamp(); alter table users add column auth_id uuid references auth.users(id) on delete cascade;
. Supabase Auth의 기본 users 테이블과 연결 (선택 사항)
Supabase에는 이미 auth.users 테이블이 존재합니다. 이를 활용하려면 users 테이블과 관계를 맺을 수 있습니다.
alter table users add column auth_id uuid references auth.users(id) on delete cascade;
2. Next.js 15 프로젝트에 Supabase 설치
npm install @supabase/supabase-js 또는 pnpm install @supabase/supabase-js
3. 환경 변수 설정 (.env)
루트 디렉터리에 .env 파일 또는 .env.local 파일을 만들고 다음 정보를 추가하세요.
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
YOUR_SUPABASE_URL → Supabase 대시보드에서 확인한 Project URL
YOUR_SUPABASE_ANON_KEY → API 페이지에서 확인한 anon 키
4. Supabase 클라이언트 설정 (lib/supabaseClient.ts)
프로젝트 루트에 lib/supabaseClient.ts 파일을 만들고 다음 내용을 추가합니다.
import { createClient } from '@supabase/supabase-js'; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; export const supabase = createClient(supabaseUrl, supabaseAnonKey);
5.서버 액션 방법
/src/app/actions/supabase/users/usersActions.ts
"use server"; import bcrypt from "bcryptjs"; import { ResponseType } from "@/types/ResponseType"; import { UserType } from "@/types/UserType"; import { supabase } from "@/lib/supabaseClient"; // 유저 생성 함수 export async function createUser( userData: Omit<UserType, "createdAt" | "updatedAt" | "passwordConfirm"> ): Promise<ResponseType<Omit<UserType, "password" | "passwordConfirm">>> { try { // 중복 이메일 체크 const { data: existingUser, error: existingUserError } = await supabase.from("users") .select("*").eq("email", userData.email).single(); console.log(" =============existingUser :",existingUser); if (existingUser) { return { status: 400, message: "이메일이 중복되었습니다.", field: "email", }; } // 비밀번호 해싱 const hashedPassword = await bcrypt.hash(userData.password, 5); // accountType 유효성 검사 const validAccountTypes = ["personal", "company"]; const accountType = validAccountTypes.includes(userData.accountType)? userData.accountType: "personal"; // 유저 데이터 생성 const insertData = { email: userData.email, password: hashedPassword, dob: new Date(userData.dob), accountType: accountType, name: userData.name, companyName: userData.companyName, numberOfEmployees: userData.numberOfEmployees, }; const { data: resUsers, error } = await supabase .from("users") .insert(insertData) .select() .single(); // 삽입된 단일 행을 반환 if (error) throw new Error(error.message); if (!resUsers) throw new Error("Failed to create user"); const newUser = resUsers as UserType; return { status: 201, message: "성공적으로 회원 가입 처리 되었습니다.", data: { id: newUser.id, email: newUser.email, accountType: newUser.accountType, dob: newUser.dob, name: newUser.name ?? undefined, companyName: newUser.companyName ?? undefined, numberOfEmployees: newUser.numberOfEmployees ?? undefined, createdAt: newUser.createdAt, updatedAt: newUser.updatedAt, }, }; } catch (error) { console.error("회원 가입 실패:", error); return { status: 500, message: "서버 오류가 발생했습니다.", }; } }
6.API 라우터
/src/app/api/supabase/users/register/route.ts
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { UserType } from "@/types/UserType"; import { supabase } from "@/lib/supabaseClient"; export const POST = async (request: Request) => { try { const userData = await request.json(); // 중복 이메일 체크 const { data: existingUser, error: existingUserError } = await supabase.from("users") .select("*").eq("email", userData.email).single(); console.log(" =============existingUser :",existingUser); if (existingUser) { return NextResponse.json({ status: 400, message: "이메일이 이미 사용 중입니다.", field: "email", }); } // 비밀번호 해싱 const hashedPassword = await bcrypt.hash(userData.password, 5); // accountType 유효성 검사 const validAccountTypes = ["personal", "company"]; const accountType = validAccountTypes.includes(userData.accountType)? userData.accountType: "personal"; // 유저 데이터 생성 const insertData = { email: userData.email, password: hashedPassword, dob: new Date(userData.dob), accountType: accountType, name: userData.name, companyName: userData.companyName, numberOfEmployees: userData.numberOfEmployees, }; const { data: resUsers, error } = await supabase .from("users") .insert(insertData) .select() .single(); // 삽입된 단일 행을 반환 if (error) throw new Error(error.message); if (!resUsers) throw new Error("Failed to create user"); const newUser = resUsers as UserType; return NextResponse.json({ status: 201, message: "성공적으로 회원 가입이 완료되었습니다.", data: { id: newUser.id, email: newUser.email, accountType: newUser.accountType, dob: newUser.dob, name: newUser.name, companyName: newUser.companyName, numberOfEmployees: newUser.numberOfEmployees, createdAt: newUser.createdAt, updatedAt: newUser.updatedAt, }, }); } catch (error) { console.error("회원 가입 실패:", error); return NextResponse.json({ status: 500, message: "서버 오류가 발생했습니다.", }); } };
5)mongodb +prisma+supabase + 로그인 처리 방법
UserType 의 User 는 next-auth 를 상속 받습니다.
import { User } from "next-auth"; export interface UserType extends User{ id?: string name?: string | null email: string image?: string | null accountType: "personal" | "company"; dob: Date; password: string; passwordConfirm: string; companyName?: string | undefined; numberOfEmployees?: number | undefined; createdAt: Date; updatedAt: Date; }
로그인 성공시 next-auth 의 User 데이터 값을 반화 처리 되며 저장 됩니다.
export interface User { id?: string name?: string | null email?: string | null image?: string | null }
src/auth.ts
import NextAuth, { NextAuthConfig } from "next-auth"; import GoogleProvider from "next-auth/providers/google"; import GitHubProvider from "next-auth/providers/github"; import FacebookProvider from "next-auth/providers/facebook"; import AppleProvider from "next-auth/providers/apple"; import KakaoProvider from "next-auth/providers/kakao"; import NaverProvider from "next-auth/providers/naver"; import CredentialsProvider from "next-auth/providers/credentials"; import bcrypt from "bcryptjs"; import { CredentialsProviderError } from "./utils/CredentialsProviderError"; import { UserType } from "./types/UserType"; import { User } from "./model/mongo/user-model"; import { dbConnect } from "./lib/mongo"; import prisma from "@/lib/prisma"; import { supabase } from "@/lib/supabaseClient"; export const { handlers: { GET, POST }, auth, signIn, signOut, } = NextAuth({ session: { strategy: "jwt", }, providers: [ CredentialsProvider({ name: "Credentials", credentials: { email: { label: "Email", type: "email", required: true }, password: { label: "Password", type: "password", required: true }, }, async authorize(credentials: Partial<Record<"email" | "password", unknown>>):Promise<UserType|null> { if (!credentials || !credentials.email || !credentials.password) { throw new CredentialsProviderError( "이메일과 비밀번호를 입력해주세요.", "email"); } const email = credentials.email as string; const password = credentials.password as string; let user =null; const SERVER_ACTIONS_TYPE = process.env.NEXT_SERVER_ACTIONS_TYPE || "mongoose"; // 기본값: mongoose if(SERVER_ACTIONS_TYPE=== "mongoose"){//1.몽고 데이터베이스 연결 await dbConnect(); user = await User.findOne({email: email}) as UserType; }else if(SERVER_ACTIONS_TYPE === "prisma"){//2. prisama 데이터베이스 연결 user=await prisma.user.findUnique({where: { email:email }}) as UserType; }else if(SERVER_ACTIONS_TYPE === "supabase"){//3. supabase 데이터베이스 연결 const { data: supabaseUser} = await supabase.from("users").select("*").eq("email", email).single(); user = supabaseUser; } if (!user) throw new CredentialsProviderError("해당 이메일의 사용자를 찾을 수 없습니다.", "email"); const isMatch = await bcrypt.compare(password, user.password); if (!isMatch) throw new CredentialsProviderError("비밀번호가 일치하지 않습니다.","password"); return user; }, }), GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID as string, clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, }), GitHubProvider({ clientId: process.env.GITHUB_ID as string, clientSecret: process.env.GITHUB_SECRET as string, }), FacebookProvider({ clientId: process.env.FACEBOOK_CLIENT_ID as string, clientSecret: process.env.FACEBOOK_CLIENT_SECRET as string, }), AppleProvider({ clientId: process.env.APPLE_CLIENT_ID as string, clientSecret: process.env.APPLE_CLIENT_SECRET as string, }), KakaoProvider({ clientId: process.env.KAKAO_CLIENT_ID as string, clientSecret: process.env.KAKAO_CLIENT_SECRET as string, }), NaverProvider({ clientId: process.env.NAVER_CLIENT_ID as string, clientSecret: process.env.NAVER_CLIENT_SECRET as string, }), ], pages: { signIn: '/auth/signin', signOut: '/auth/signout', error: '/auth/error', verifyRequest: '/auth/verify-request', newUser: '/auth/new-user', }, debug: false, // 디버그 로그 비활성화 } satisfies NextAuthConfig);
로그인 성공시 url 값이 반화 처리 되며, redirect는 false 지정해야 오류를 반화처리 할수 있습니다.
src/app/actions/auth/loginActions.tsx
"use server"; import { signIn, signOut } from "@/auth"; import { ResponseType } from "@/types/ResponseType"; import { UserType } from "@/types/UserType"; import { CredentialsProviderError } from "@/utils/CredentialsProviderError"; /** * 로그인처리 * @param param0 * @returns */ export async function doCredentialsLogin({ email, password } :{email: string;password: string;}) :Promise<ResponseType<Omit<UserType, "password">>> { try { const response = await signIn("credentials", { redirect: false, email, password , }); //성공시 : response http://localhost:3000/auth/signin ,http://localhost:3000/ //console.log("로그인 성공 시 :",response); return response; } catch (error) { if (error instanceof CredentialsProviderError) { console.log(" 1. 로그인 오류 : " ,error.message); // 정규식을 사용하여 "Read more at https://errors.authjs.dev#autherror" 제거 const cleanedMessage = error.message.replace(/\.?\s*Read more at https:\/\/errors\.authjs\.dev#autherror/, ""); console.log(" 2로그인 오류 : " ,error.message); return { status: 401, message: cleanedMessage, field: error?.field ?? 'unknown' }; } throw error; } }
src/app/auth/components/login-form.tsx
"use client"; import { Button } from "@/components/ui/button"; import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { doCredentialsLogin } from "@/app/actions/auth/loginActions"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { signinValidationSchema } from "@/validation-schemas/authValidationSchema"; const LoginForm: React.FC = () => { const router = useRouter(); const [isLoading, setIsLoading] =useState(false); // 2) useForm 설정 const form = useForm<z.infer<typeof signinValidationSchema>>({ resolver: zodResolver(signinValidationSchema), // zodResolver를 사용하여 유효성 검사를 적용 defaultValues: { email: "", password: "" }, // 기본값을 빈 문자열로 설정 }); // 3) 폼 제출 핸들러 const handleSubmit = async (data: z.infer<typeof signinValidationSchema>) => { setIsLoading(true); // 로그인 시작 → 로딩 상태 true try { const response = await doCredentialsLogin(data); if (response && response.field) { if (response.field && response.field === "password") { form.setError(response.field, { message: response.message }); } else { form.setError("email", { message: response.message }); // 기본적으로 이메일 필드에 표시 } return; } router.push("/home"); } catch (error) { console.error("handleSubmit Error: ", error); form.setError("email", { message: "로그인 중 오류가 발생했습니다." }); } finally { setIsLoading(false); } }; return ( <Form {...form}> <form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-4 mb-2" > {/* 이메일 입력 필드 */} <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>이메일</FormLabel> <FormControl> <Input placeholder="example@email.com" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> {/* 비밀번호 입력 필드 */} <FormField control={form.control} name="password" render={({ field }) => ( <FormItem> <FormLabel>비밀번호</FormLabel> <FormControl> <Input type="password" placeholder="비밀번호" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> {/* 로그인 버튼 */} <Button type="submit" className="w-full bg-destructive hover:bg-red-800" disabled={isLoading}> {isLoading ? "로그인 중..." : "로그인"} </Button> </form> </Form> ); }; export default LoginForm;
※Next.js NextAuth 세션 정보 불러오기
소스 : https://github.com/braverokmc79/macaronics_react_next_auth/tree/signup
1. 기본적인 auth() 사용법
import { auth } from "@/auth"; const Dashboard = async () => { const session = await auth(); // 서버에서 세션 가져오기 return ( <div> {session ? ( <h1>환영합니다, {session.user?.name}님!</h1> ) : ( <h1>로그인이 필요합니다.</h1> )} </div> ); }; export default Dashboard;
✅ 설명
- auth()를 사용하여 세션을 서버에서 직접 가져옴.
- session이 존재하면 사용자 이름을 표시, 없으면 로그인 필요 메시지 출력.
- 클라이언트에서 useSession()을 호출하는 방식보다 초기 렌더링이 더 자연스러움.
2. 서버에서 세션을 가져와 클라이언트 컴포넌트에 전달
서버 컴포넌트에서 세션 정보를 받아, 클라이언트 컴포넌트에서 UI를 렌더링하는 방식도 가능합니다.
서버 컴포넌트 (세션 가져오기)
import { auth } from "@/auth"; import DashboardClient from "./dashboard-client"; const Dashboard = async () => { const session = await auth(); return <DashboardClient user={session?.user} />; }; export default Dashboard;
클라이언트 컴포넌트 (UI 렌더링)
"use client"; interface DashboardClientProps { user?: { name?: string; email?: string; }; } const DashboardClient: React.FC<DashboardClientProps> = ({ user }) => { return ( <div> {user ? ( <h1>환영합니다, {user.name}님!</h1> ) : ( <h1>로그인이 필요합니다.</h1> )} </div> ); }; export default DashboardClient;
✅ 이 방식의 장점
- 서버에서 데이터를 가져오므로 초기 렌더링이 빠름.
- 클라이언트 컴포넌트는 UI 렌더링만 담당하여 구조가 깔끔함.
※분리 방법 예
아래 코드를 사용할 경우 클라이언트 useMediaQuery 와 서버 auth 가 공존 할수 없이 오류 가발생합니다.
layout.tsx
따라서, layout.tsx 파일에서 클라이언트에서 사용하는 것만 추출하여 분리 합니다. layout.tsx ===========> layout.tsx , layoutClient.tsx
"use client"; import React from 'react' import MainMenu from './components/main-menu' import MenuTitle from './components/menu-title' import MobileMenu from './components/mobile-menu' import { useMediaQuery } from '@/hooks/use-media-query' import { auth } from '@/auth'; interface DashboardLayoutProps { children: React.ReactNode } const DashboardLayout:React.FC<DashboardLayoutProps> = async ({children}) => { const session = await auth(); const isDesktop = useMediaQuery("(min-width: 768px)"); return ( <div className='grid md:grid-cols-[250px_1fr] px-3 md:px-0 h-screen'> <MainMenu className="hidden md:flex" /> {!isDesktop && ( <div className='p-4 flex justify-between md:hidden sticky top-0 left-0 bg-background border-b border-border'> <MenuTitle /> <MobileMenu /> </div> )} <div className='overflow-auto py-2 px-6'> <h1 className='pb-4 text-2xl font-bold'>환영합니다. 홍길동님!</h1> {children} </div> </div> ) } export default DashboardLayout
=============> 변경 처리
1)layout.tsx
import React from "react"; import { auth } from "@/auth"; import DashboardLayoutClient from "./layoutClient"; import { redirect } from "next/navigation"; interface DashboardLayoutProps { children: React.ReactNode; } const DashboardLayout = async ({ children }: DashboardLayoutProps) => { const session = await auth(); const username = session?.user?.name; if(!username)redirect("/"); return <DashboardLayoutClient username={username} session={session}>{children}</DashboardLayoutClient>; }; export default DashboardLayout;
2) layoutClient.tsx
"use client"; import React from 'react' import MainMenu from './components/main-menu' import MenuTitle from './components/menu-title' import MobileMenu from './components/mobile-menu' import { useMediaQuery } from '@/hooks/use-media-query'; interface DashboardLayoutClientProps { children: React.ReactNode; username: string; session: any } const DashboardLayoutClient: React.FC<DashboardLayoutClientProps> = ({ children, username,session }) => { const isDesktop = useMediaQuery("(min-width: 768px)"); return ( <div className='grid md:grid-cols-[250px_1fr] px-3 md:px-0 h-screen'> <MainMenu session={session} className="hidden md:flex" /> {!isDesktop && ( <div className='p-4 flex justify-between md:hidden sticky top-0 left-0 bg-background border-b border-border'> <MenuTitle /> <MobileMenu /> </div> )} <div className='overflow-auto py-2 px-6'> <h1 className='pb-4 text-2xl font-bold'>환영합니다. {username}님!</h1> {children} </div> </div> ); }; export default DashboardLayoutClient;
위와 같은 방법은 서버에서 클라이언트에서 useSession()을 쓰는 대신, 서버에서 auth()로 세션을 가져와서 props로 넘기는 방법입니다.
이 방식은 페이지 로딩 시 세션 정보가 바로 보이는 장점이 있습니다.
설명
- 서버에서 auth()를 사용해 세션을 가져오고, username을 props로 전달
- 이 방식은 페이지 로딩 시 세션 정보가 이미 렌더링됨 → 깜빡임 없이 UI가 표시됨
- 하지만 서버에서 먼저 데이터를 가져와야 하므로, 서버 응답 시간이 길어질 수 있음
클라이언트 코드 (useSession() 사용)
"use client"; import { useSession } from "next-auth/react"; const DashboardHeader = () => { const { data: session, status } = useSession(); if (status === "loading") { return <p>로딩 중...</p>; } return ( <h1 className="pb-4 text-2xl font-bold"> 환영합니다. {session?.user?.name || "사용자"}님! </h1> ); }; export default DashboardHeader;
설명
- useSession()을 사용하면 클라이언트에서 세션 정보를 가져올 수 있음
- 주의: status === "loading"인 동안 session 값이 null이므로, 로딩 처리를 해줘야 함
- 서버에서 가져오는 방식보다 초기 렌더링이 조금 느릴 수 있음
3. Middleware를 사용해 세션 보호하기
보안이 중요한 페이지라면, 미들웨어에서 인증된 사용자만 접근하도록 설정할 수 있습니다.
middleware.ts 설정
import { withAuth } from "next-auth/middleware"; export default withAuth({ pages: { signIn: "/login", // 로그인 페이지 경로 설정 }, });
✅ 이 방식의 장점
- 미들웨어에서 인증을 확인하므로 보안성이 높음.
- 로그인되지 않은 사용자는 자동으로 로그인 페이지로 리디렉션됨.
✅ 정리: Next.js 15에서 서버에서 세션 정보를 가져오는 방법
Next.js 15에서는 미들웨어를 사용을 추천하며 그다음 auth()를 사용한 서버 컴포넌트 방식이 추천합니다. 그리고
필요한 경우 클라이언트 컴포넌트와 함께 조합하여 사용하면 됩니다!
6.Next.js 미들웨어 완벽 가이드 - App Router를 활용한 보안처리
소스 : https://github.com/braverokmc79/macaronics_react_next_auth/tree/middleware
1. 디렉토리 구조
src/ ├── app/ │ └── utils/ │ └── MiddlewareRoutes.ts ├── auth.config.ts ├── middleware.ts ├── auth.ts ├── lib/ │ ├── mongo.ts │ ├── prisma.ts │ └── supabaseClient.ts ├── model/ │ └── mongo/ │ └── user-model.ts ├── types/ │ └── UserType.ts └── utils/ └── CredentialsProviderError.ts
2. 코드 설명 및 개발 과정
1) auth.config.ts
// src/auth.config.ts import { NextAuthConfig } from "next-auth"; export const authConfig: Partial<NextAuthConfig> = { session: { strategy: "jwt", }, providers: [], };
설명: 이 파일은 NextAuth의 기본 설정을 정의합니다.
session 전략으로 JWT를 사용하며, providers는 나중에 추가될 인증 제공자를 위한 빈 배열로 초기화됩니다.
2) middleware.ts
// src/middleware.ts import { NextRequest, NextResponse } from "next/server"; import NextAuth from "next-auth"; import { authConfig } from "./utils/auth/Auth.config"; import {PUBLIC_ROUTES, LOGIN, PROTECTED_INNSER_API_PATHS} from "./utils/MiddlewareRoutes"; import { NextAuthConfig } from "next-auth"; // ✅ NextAuth 설정을 가져와 미들웨어에서 활용할 수 있도록 설정 const nextAuthConfig: NextAuthConfig = { ...authConfig, // auth.config.ts에 정의된 기본 설정을 불러옴 providers: authConfig.providers || [], // 프로바이더가 없을 경우 빈 배열을 기본값으로 설정 }; // ✅ NextAuth 인스턴스를 생성하여 auth 함수를 가져옴 const { auth } = NextAuth(nextAuthConfig); // 내부 요청인지 확인하는 함수 function isInternalRequest(request: NextRequest) { const referer = request.headers.get("referer") || ""; const host = request.headers.get("host") || ""; const baseURL = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; try { const baseHost = new URL(baseURL).host; // 환경 변수의 호스트만 추출 return referer.startsWith(baseURL) || host === baseHost; } catch (error) { console.error("Invalid BASE_URL format:", baseURL); return false; // 기본적으로 내부 요청이 아니라고 판단 } } const matchesPattern = (path: string, patterns: string[]) => { return patterns.some(route => { const regex = new RegExp("^" + route.replace(/\*/g, ".*") + "$"); return regex.test(path); }); }; /** * ✅ 미들웨어 함수: 모든 요청에 대해 실행됨 * - 사용자의 인증 상태를 확인하고 접근 제어를 수행 * - 인증되지 않은 사용자가 보호된 페이지에 접근하려 하면 로그인 페이지로 리디렉트 */ export async function middleware(request:NextRequest ) { const { nextUrl } = request; const session = await auth(); // 현재 사용자 세션을 가져옴 const isAuthenticated = !!session?.user && request.cookies.has("refreshToken"); // 사용자가 로그인한 상태인지 확인 console.log("====================middleware :" ,isAuthenticated, nextUrl.pathname); // ✅ 내부 API 보호 여부 확인 const isProtected = matchesPattern(nextUrl.pathname, PROTECTED_INNSER_API_PATHS); if (isProtected && !isInternalRequest(request)) { return NextResponse.json( { status: 403, success: false, message: "Forbidden: Internal access only" }, { status: 403 } ); } // ✅ 공개 라우트 여부 확인 (로그인 없이 접근 가능한 페이지) const isPublicRoute = matchesPattern(nextUrl.pathname, PUBLIC_ROUTES); if (!isAuthenticated && !isPublicRoute) { return Response.redirect(new URL(LOGIN, nextUrl)); } console.log(isPublicRoute); // ✅ 로그인하지 않은 사용자가 보호된 페이지에 접근할 경우 로그인 페이지로 리디렉트 if (!isAuthenticated && !isPublicRoute) { return Response.redirect(new URL(LOGIN, nextUrl)); // 로그인 페이지로 이동 } return NextResponse.next(); } export const config = { matcher: [ // ✅ 특정 파일 확장자가 있는 요청, `_next`, `api/auth` 경로를 제외하고 모든 경로에 미들웨어 적용 "/((?!.+\\.[\\w]+$|_next|api/auth).*)", // ✅ 루트 경로(`/`)에도 미들웨어 적용 "/", // ✅ `/api/mongo`, `/api/prisma`, `/api/springboot`, `/api/supabase`, `/trpc` 및 그 하위 경로에 미들웨어 적용 "/(api/mongo|trpc)(.*)" , // ✅ 미들웨어 적용할 API 경로 설정 "/api/:path*", ] };
/src/app/utils/MiddlewareRoutes.ts
// src/app/utils export const LOGIN = '/auth/signin'; export const ROOT = '/'; // 내부 api 보호 설정 /api/* => /api 이하 보호 가능 export const PROTECTED_INNSER_API_PATHS = [ "/api/admin/*", // ✅ /api/admin 이하 모든 경로 내부 보호 "/api/authToken/*", // ✅ /api/authToken 이하 모든 경로 내부 보호 "/api/backend/*", ]; export const PUBLIC_ROUTES = [ '/auth/signin', '/auth/signout', '/auth/error', '/auth/verify-request', '/auth/signup', '/products', '/api/*', ] export const PROTECTED_SUB_ROUTES = [ '/checkout', ]
주요 설명
NextAuth 설정 분리
- auth.config.ts에서 설정을 가져와 middleware.ts에서 사용하도록 분리.
- authConfig.providers || []를 통해 프로바이더가 없을 경우 빈 배열을 기본값으로 설정.
- 미들웨어 기능
- auth()를 호출해 현재 사용자 세션을 가져오고, 로그인 상태를 확인함.
- 공개 경로(PUBLIC_ROUTES 및 ROOT)인지 검사 후 인증되지 않은 사용자가 보호된 페이지에 접근하면 로그인 페이지로 리디렉트.
matcher 설정
- .css, .js 등 정적 파일 요청과 _next, api/auth 경로를 제외하고 미들웨어를 적용.
- /(루트), /api/mongo, /api/prisma 등의 API 요청에도 적용.
3) auth.ts
// src/auth.ts import NextAuth, { NextAuthConfig } from "next-auth"; import GoogleProvider from "next-auth/providers/google"; import GitHubProvider from "next-auth/providers/github"; import FacebookProvider from "next-auth/providers/facebook"; import AppleProvider from "next-auth/providers/apple"; import KakaoProvider from "next-auth/providers/kakao"; import NaverProvider from "next-auth/providers/naver"; import CredentialsProvider from "next-auth/providers/credentials"; import bcrypt from "bcryptjs"; import { CredentialsProviderError } from "./utils/CredentialsProviderError"; import { UserType } from "./types/UserType"; import { User } from "./model/mongo/user-model"; import { dbConnect } from "./lib/mongo"; import prisma from "@/lib/prisma"; import { supabase } from "@/lib/supabaseClient"; import { authConfig } from "./auth.config"; export const { handlers: { GET, POST }, auth, signIn, signOut, } = NextAuth({ ...authConfig, providers: [ CredentialsProvider({ name: "Credentials", credentials: { // email: { label: "Email", type: "email", required: true }, // password: { label: "Password", type: "password", required: true }, email: {}, password: {}, }, async authorize(credentials: Partial<Record<"email" | "password", unknown>>):Promise<UserType|null> { 생략~ }, }), 생략~ ], pages: { signIn: '/auth/signin', signOut: '/auth/signout', error: '/auth/error', verifyRequest: '/auth/verify-request', newUser: '/auth/new-user', }, debug: false, // 디버그 로그 비활성화 } satisfies NextAuthConfig);
설명:
이 파일은 NextAuth를 초기화하고 다양한 인증 제공자(Google, GitHub, Facebook 등)를 설정합니다.
CredentialsProvider를 사용하여 이메일과 비밀번호로 로그인할 수 있도록 설정합니다.
authorize 함수는 사용자 인증을 처리합니다.
★.auth.ts에서 auth.config.ts로 분리한 핵심적인 이유?
✅ middleware.ts에서 const session = await auth(); 호출 시 오류를 방지하기 위해서입니다.
오류 발생 원인
auth.ts에서 auth를 생성할 때 NextAuth의 설정 객체를 사용해야 합니다.
그런데 middleware.ts에서도 같은 설정을 사용하여 auth를 생성해야 하지만,
Next.js 에서는 미들웨어에서 직접 auth.ts의 auth를 불러오면 오류가 발생할 수 있습니다.
이 문제를 해결하기 위해 auth.config.ts에서 authConfig를 분리하면,
- middleware.ts에서는 auth 함수 대신 authConfig만 사용할 수 있고
- auth.ts에서는 NextAuth 설정을 초기화하는 데만 집중할 수 있습니다.
★핵심 왜냐하면은 auth.ts 에서는 mongo/user-model, prisma, supabaseClient, 의 라이브러리들을 불러오기 때문입니다.
✅ 1.첫번째 가장 큰 이유는 오류
- middleware.ts에서 auth.ts의 auth()를 직접 호출하면 오류가 발생함
- 그래서 auth.config.ts로 공통 설정(authConfig)을 따로 분리
- middleware.ts에서는 auth() 대신 authConfig만 사용하여 NextAuth 설정을 가져옴
- 이렇게 하면 middleware.ts에서도 NextAuth 설정을 공유하면서 오류 없이 실행 가능
✅ 2. 두번째 이유는
middleware.ts에서 auth() 호출 시 발생하는 문제 해결: Edge Runtime에서도 NextAuth를 사용할 수 있도록 순수한 설정 객체를 제공합니다.
설정의 재사용성: middleware.ts와 auth.ts에서 동일한 설정을 재사용할 수 있습니다.
관심사의 분리: auth.ts는 인증 로직에, auth.config.ts는 설정에 집중하도록 역할을 분리합니다.
확장성: 새로운 설정을 쉽게 추가하거나 변경할 수 있습니다.
4) MiddlewareRoutes.ts
// src/app/utils export const LOGIN = '/auth/signin'; export const ROOT = '/'; export const PUBLIC_ROUTES = [ '/auth/signin', '/auth/signout', '/auth/error', '/auth/verify-request', '/auth/new-user', '/products', '/api/auth/callback/google', '/api/auth/callback/github', '/api/mongo/users/register', '/api/prisma/users/register', '/api/springboot/users/register', '/api/supabase/users/register', ] export const PROTECTED_SUB_ROUTES = [ '/checkout', ]
설명: 이 파일은 미들웨어에서 사용할 공개 경로(PUBLIC_ROUTES)와 보호된 하위 경로(PROTECTED_SUB_ROUTES)를 정의합니다.
이 경로들은 미들웨어에서 사용자의 인증 상태를 확인하고 적절한 조치를 취하는 데 사용됩니다.
1)예 ) 유저 정보 가저오기 샘플
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import prisma from "@/lib/prisma"; import { auth } from "@/auth"; //샘플 export const GET = async (request: Request) => { try { const session = await auth(); if (!session?.user?.email) { return NextResponse.json({ error: "User ID not found in session" }, { status: 400 }); } const findUser = await prisma.user.findUnique({ where: { email: session.user.email }, }); return NextResponse.json({ user: findUser }); } catch (error) { console.error("Error fetching user:", error); return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); } };
2) 결제 페이지 같은 경우 로그인한 유저만 접근이 가능도록 설정 된다.
3. 요약
auth.config.ts: NextAuth의 기본 설정을 정의합니다.
middleware.ts: 사용자 인증 상태를 확인하고, 인증되지 않은 사용자를 로그인 페이지로 리디렉션합니다.
auth.ts: 다양한 인증 제공자를 설정하고, 사용자 인증을 처리합니다.
MiddlewareRoutes.ts: 공개 경로와 보호된 하위 경로를 정의합니다.
이 구조를 통해 Next.js 애플리케이션에서 사용자 인증과 라우트 보호를 효과적으로 관리할 수 있습니다.
7.Next-Auth V5에서 리프레시 토큰 회전 - 커스텀 백엔드로 토큰 관리하기
소스 : https://github.com/braverokmc79/nextjs-app-router-auth
1)Next.js 15 + NextAuth v5 기반 인증 시스템 정리
1. 개요
본 문서는 Next.js 15와 NextAuth v5를 활용하여 구축한 인증 시스템을 정리한 내용입니다. 해당 시스템은 Node.js + Express 백엔드와 MongoDB 데이터베이스를 활용하여 사용자 인증을 처리하며, JWT 기반 인증 방식(액세스 토큰 + 리프레시 토큰)을 적용하였습니다.
2. API 구조
2.1 회원가입 (Register)
- 요청: POST /register
- 요청 바디: { "email": "user@email.com", "password": "1234" }
- 응답:
{ "id": "4889374", "email": "user@email.com" }
- 설명: 사용자가 이메일과 비밀번호를 제공하면, 데이터베이스에 저장하고 사용자 ID와 이메일을 반환합니다. 비밀번호는 보안상의 이유로 응답에 포함되지 않습니다.
2.2 로그인 (Login)
- 요청: POST /login
- 요청 바디: { "email": "user@email.com", "password": "1234" }
- 응답:
{ "accessToken": "ey...", "refreshToken": "ey...", "userInfo": { "email": "user@email.com", "role": "user" } }
- 설명: 로그인이 성공하면, 액세스 토큰과 리프레시 토큰을 발급하고 사용자 정보를 반환합니다.
2.3 사용자 목록 조회 (Users)
- 요청: GET /users
- 헤더: { "Authorization": "Bearer ey..." }
- 응답:
[ {}, {}, {} ]
- 설명: 액세스 토큰을 이용하여 인증된 사용자만 조회 가능하며, 만료된 토큰을 사용하면 JWT token expired 오류가 반환됩니다.
2.4 토큰 갱신 (Refresh)
- 요청: GET /refresh
- 헤더: { "Authorization": "Bearer ey..." }
- 응답:
{ "accessToken": "new_ey...", "refreshToken": "new_ey..." }
- 설명: 만료된 액세스 토큰을 리프레시 토큰을 이용해 갱신합니다. 개발 테스트를 위해 액세스 토큰의 유효기간은 1분이며, 리프레시 토큰의 유효기간은 24시간입니다.
3. 인증 흐름
- 사용자가 /register API를 통해 회원가입.
- /login API를 통해 액세스 토큰과 리프레시 토큰을 발급받음.
- GET /users 요청 시 액세스 토큰을 사용하여 인증.
- 액세스 토큰이 만료되면 GET /refresh API를 호출하여 새로운 액세스 토큰을 발급받음.
- 새로운 액세스 토큰으로 다시 GET /users 요청 수행 가능.
4. 정리
- JWT 기반 인증 시스템으로, 액세스 토큰과 리프레시 토큰을 활용.
- 개발 테스트를 위해 액세스 토큰은 1분, 리프레시 토큰은 24시간 유지됨.
- 만료된 액세스 토큰을 갱신하는 /refresh API 제공.
- MongoDB를 이용하여 사용자 정보를 저장하고 관리함.
- NextAuth v5를 활용하여 Next.js 프론트엔드와 연결 가능.
실제 운영 환경에서는 보안과 사용자 경험을 균형 있게 토큰 유효 시간 설정은 일반적으로 다음과 같은 설정을 권장합니다.
1. 액세스 토큰(Access Token)
- 유효기간: 15분 ~ 1시간
- 짧을수록 보안이 강화되지만, 너무 짧으면 사용자 경험이 불편해질 수 있음.
- 모바일 앱이나 SPA(Single Page Application)의 경우 30분 정도가 적절함.
- 민감한 정보(금융, 의료 등)가 포함된 경우 10~15분 수준으로 설정.
2. 리프레시 토큰(Refresh Token)
- 유효기간: 1일 ~ 7일 (최대 30일)
- 보안을 강화하려면 1일 정도로 설정하고, 사용자가 자주 로그인하는 서비스는 7일~14일 유지.
- 보안이 가장 중요한 경우 로그인 시마다 새 리프레시 토큰 발급하는 방식 적용 가능.
- 만료된 리프레시 토큰을 갱신하는 방식도 고려 가능.
3. 추가적인 보안 조치
- 리프레시 토큰을 DB 또는 레디스 에 저장하고 한 번 사용 후 폐기(One-time use) 설정.
- 리프레시 토큰 탈취 방지를 위해 IP, User-Agent 검사 및 로그아웃 시 즉시 폐기.
- 액세스 토큰을 쿠키(httpOnly, Secure, SameSite=Strict)에 저장하여 XSS 방지.
- 리프레시 토큰을 쿠키 또는 데이터베이스 저장 방식으로 관리.
✅ 추천 설정
- 액세스 토큰: 15~30분
- 리프레시 토큰: 1~7일
조정 예시
- 보안이 중요한 경우
- 액세스 토큰: 15분
- 리프레시 토큰: 1일 (매일 로그인 필요)
- 일반적인 웹 서비스
- 액세스 토큰: 30분
- 리프레시 토큰: 7일 (1주일 동안 자동 로그인 가능)
- 편의성이 중요한 경우 (예: SNS, 쇼핑몰 등)
- 액세스 토큰: 1시간
- 리프레시 토큰: 14일~30일
리프레시 토큰이 길어질수록 보안 위협이 커지므로, 사용자 활동을 감지하여 비정상적인 사용이 있으면 강제 로그아웃하는 방식도 고려해야 합니다.
2)Next.js 15 + NextAuth.js v5 인증 흐름 정리
샘플드
import NextAuth from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; export const authOptions = { providers: [ CredentialsProvider({ name: "Credentials", credentials: { username: { label: "Username", type: "text" }, password: { label: "Password", type: "password" } }, async authorize(credentials) { const user = { id: "1", name: "User", email: "user@example.com" }; if (credentials.username === "user" && credentials.password === "password") { return user; } return null; } }) ], callbacks: { async jwt({ token, user }) { if (user) { token.id = user.id; } return token; }, async session({ session, token }) { session.user.id = token.id; return session; } } }; export default NextAuth(authOptions);
1. authorize (인증 단계)
- 사용자가 로그인 요청을 하면 authorize 함수에서 인증을 처리한다.
- 이 단계에서 액세스 토큰(access token), 리프레시 토큰(refresh token), 사용자 정보(user info) 등을 반환한다.
2. callbacks (콜백 단계)
- callbacks는 비동기 함수(Async Function)로, 특정 이벤트(로그인, API 호출, 리디렉션 등) 발생 시 추가적인 처리를 할 수 있다.
- callbacks 내부에는 jwt와 session 두 가지 주요 함수가 존재한다.
2-1. jwt (토큰 변환 단계)
- authorize에서 반환된 사용자 정보 및 토큰을 jwt 함수에서 가공한다.
- 이 과정에서 ID, 액세스 토큰, 리프레시 토큰 등의 정보를 추출하여 JWT 내부에 저장할 수 있다.
- 필요한 추가 데이터(예: 사용자 권한, 역할 등)를 포함시킬 수도 있다.
2-2. session (세션 변환 단계)
- jwt에서 처리된 데이터를 session 함수로 전달한다.
- 최종적으로 세션 정보를 클라이언트에서 사용할 수 있도록 변환하는 단계이다.
- 브라우저에서 세션 정보를 쉽게 접근할 수 있으며, 사용자 인증 상태를 유지하는 데 활용된다.
- 만약 오류가 발생하면, 오류 메시지나 에러 코드도 세션을 통해 클라이언트에서 확인할 수 있다.
3. 최종적인 흐름
- 사용자 로그인 → authorize에서 인증 수행 후 토큰 및 사용자 정보 반환
- callbacks.jwt에서 받은 정보를 변환 및 가공
- callbacks.session에서 클라이언트에서 사용할 수 있는 세션 정보 생성
- 클라이언트는 session을 통해 사용자 정보 및 인증 상태를 확인
- 오류 발생 시, session을 통해 클라이언트에서 오류 처리 가능
이와 같은 구조로 NextAuth.js v5에서는 인증 흐름을 관리하며, authorize → jwt → session 순으로 데이터가 전달된다. 이를 활용하면 보다 세밀한 인증 및 세션 관리를 구현할 수 있다.
3)Next.js 15 + NextAuth.js v5 인증 흐름 정리
1. 인증 흐름 개요
사용자 인증 (authorize)
- 사용자가 로그인하면 인증 절차가 시작된다.
토큰 정보 콜백 전달
- 로그인 성공 시 토큰 정보가 콜백 함수로 전달된다.
JWT 콜백 실행
- 토큰 정보를 JWT 콜백에서 확인한다.
Access Token 만료 확인
- Access Token이 유효한지 검사한다.
- 이 단계에서 토큰 만료 여부를 체크하며, 테스트를 위해 기본적으로 1분 만료 설정이 되어 있다.
토큰 만료 여부 판단
- 만료된 경우: 리프레시 토큰을 사용하여 새로운 Access Token을 발급받는다.
- 유효한 경우: 기존 Access Token을 그대로 사용하여 API 요청을 수행한다.
토큰 갱신 (Refresh Token Flow)
- 만료된 Access Token이 감지되면 Refresh Token을 이용해 새로운 Access Token을 발급받는다.
- 새롭게 발급된 Access Token을 사용하여 요청을 수행한다.
요청 수행 (Complete the call)
- Access Token이 유효하다면 인증된 상태에서 API 요청을 처리한다.
반복적인 유효성 검사
- 이후 API 요청마다 Access Token의 만료 여부를 검사하고, 만료되었을 경우 위의 리프레시 과정이 반복된다.
2. 인증 흐름 다이어그램 설명
첨부된 다이어그램을 보면 인증 과정이 다음과 같이 진행된다:
- authorize (인증 요청) → 사용자가 로그인하면 인증 과정이 시작됨.
- send token info to the callbacks (콜백으로 토큰 정보 전달) → 토큰 정보를 콜백에서 처리.
- jwt callback (JWT 콜백 실행) → 전달된 토큰 정보가 JWT 콜백에서 처리됨.
- Check accessToken for Expiry (Access Token 만료 여부 확인) → 토큰이 만료되었는지 체크.
- Expired (만료 여부 판단)
- 만료됨 (Yes) → refresh token (리프레시 토큰 요청) → Get Access Token (새 Access Token 발급)
- 만료되지 않음 (No) → complete the call (요청 수행)
- 완료된 이후에도 새로운 요청이 발생하면 다시 만료 여부 확인 → 필요하면 토큰을 갱신하는 과정 반복.
3. 보안 고려 사항
- Access Token이 만료된 경우, 보안 강화를 위해 사용자를 로그인 페이지로 리디렉션할 수 있음.
- Refresh Token도 일정 기간이 지나면 만료되도록 설정하여 보안을 강화해야 함.
- JWT 토큰은 안전한 저장소(예: HttpOnly 쿠키)에 보관하는 것이 권장됨.
4. NextAuth.js v5 적용 시 유의할 점
JWT 콜백에서 Access Token 유효성 검사 구현
callbacks: { async jwt({ token, account }) { if (account) { token.accessToken = account.access_token; token.accessTokenExpires = Date.now() + account.expires_in * 1000; } if (Date.now() < token.accessTokenExpires) { return token; } return refreshAccessToken(token); } }
Refresh Token 로직 구현
async function refreshAccessToken(token) { try { const response = await fetch("https://example.com/api/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken: token.refreshToken }) }); const refreshedTokens = await response.json(); return { ...token, accessToken: refreshedTokens.accessToken, accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, refreshToken: refreshedTokens.refreshToken ?? token.refreshToken }; } catch (error) { return { ...token, error: "RefreshAccessTokenError" }; } }
이와 같이 NextAuth.js v5에서 Access Token과 Refresh Token을 관리하면 보다 안전한 인증 시스템을 구축할 수 있다.
4) ★Next.js 15 + NextAuth v5 + HttpOnely 쿠키 기반 + 백엔드 인증 연동 개요
소스 :
https://github.com/braverokmc79/macaronics_react_next_auth/tree/07-token-management-express
1. 개요
- NextAuth를 사용하여 다양한 로그인 제공자(Google, GitHub, Facebook 등)와 함께 CredentialsProvider를 활용하여 ID/PW 기반 로그인 구현
- HTTP-Only 쿠키를 활용하여 보안 강화 (Access Token, Refresh Token 저장)
- SERVER_TYPE 변수를 이용하여 MongoDB, Prisma, Supabase, Backend 서버 중 선택적으로 인증 처리
- 백엔드 연동 시 JWT 기반 토큰 관리 및 쿠키 설정을 통해 인증 유지
백엔드에서 보내는 데이터 형식
ApiResponse { status: 200, success: true, message: '토큰 발급 성공', data: { user: { id: 8, username: 'user1', name: '홍길동3', email: 'user1@gmail.com', image: '/uploads/profileImage/stream-8504869_12801741332988797.png', roles: [Array], accessToken: '접근코드값', refreshToken: '갱신 토큰값', accessTokenExpires: 1741573973, refreshTokenExpires: 1742779973 } }, error: null }
2. 로그인 흐름
- 사용자가 로그인 요청 (signIn 호출)
- CredentialsProvider에서 사용자의 아이디/비밀번호 검증
- 서버 타입에 따라 MongoDB, Prisma, Supabase, Backend 인증 방식 결정
- 로그인 성공 시, signIn 이벤트에서 accessTokenCookieset, refreshTokenCookieset을 호출하여 쿠키에 Access Token 및 Refresh Token 저장
- 세션 유지 및 요청마다 쿠키를 이용하여 인증 검증
3. 인증 흐름 상세 도표 표현
※백엔드와 연동을 하기 위해서는 아래 로직을 이해 및 개발해야 한다.
Next Auth 5 + HTTP-Only 쿠키
일반적인 리액트와 백엔드 연동 로직에 비하여 2~3배는 어렵다.
1. 백엔드에서 반환하는 데이터 형식
user: { id: 1, username: 'test1', name: '홍길동7', email: 'test1@gmail.com', image: '/uploads/이미지.jpg', roles: [ 'user' ], accessToken: '11', refreshToken: '22', accessTokenExpires: 1742340624, refreshTokenExpires: 1743550164 }
2. 프론트엔드 Next.js 쿠키 및 Auth 세션 값 형식
user: { id: 1, username: 'test1', name: '홍길동7', email: 'test1@gmail.com', image: '/uploads/이미지.jpg', roles: [ 'user' ], accessToken: '11', refreshToken: '22', accessTokenExpires: 1742340624, refreshTokenExpires: 1743550164 }, sub: 'fd63e95c-6341-4246-a4ae-53068d669ef4', provider: 'local'
3. 토큰 만료 시간 동기화
- 백엔드 서버의 토큰 만료 시간과 동일하게 유지하기 위해 다음 정보를 포함하여 반환
- accessToken, refreshToken, accessTokenExpires, refreshTokenExpires
4. NextAuth JWT 콜백 로직
- account와 user 값이 존재하면 최초 로그인 상태로 판단
- 로그인 시 token에 값을 저장하고 반환
- 로그인 후 token 값은 변경되지 않음
- 쿠키에 저장된 토큰 값을 불러와 token으로 반환
- 세션 영역에서 token 정보를 저장 후 반환
JWT 처리 주의사항
- 로그인 후 token 값은 변경되지 않음
- 최초 로그인 시 쿠키 저장 가능하지만, 두 번째부터는 쿠키 저장 불가 (오류 발생)
- API 라우터에서 쿠키 및 세션 값 접근 불가 (일반 API 라우터처럼 동작하지 않음)
NextAuth의 기본 API 라우터는 세션/쿠키에 접근하지 못하는 문제
5. Axios 인스턴스 정리
- AxiosInstance.ts: Next.js 내부 API 라우터 호출용 Axios 인스턴스
- AxiosServerToken.ts: API 라우터에서 백엔드로 요청하는 Axios 인스턴스
AxiosServerToken.ts 인증 인터셉터
- 요청 시 accessToken을 헤더에 포함하여 전송
- accessTokenExpires가 없거나 만료되었을 경우 refreshTokenGenerator 호출하여 갱신
- 갱신된 토큰을 /api/authToken/refreshToken으로 전송하여 새로운 accessToken 및 refreshToken 요청
- API 라우터에서 쿠키 및 세션 값 접근 불가하므로 별도 세션 값 전달 필요
instance.interceptors.request.use( async (config) => { const session = await auth() as CustomSession; if (session) { // 인증이 필요한 경우 const cookieInstance = await cookies(); const accessTokenExpires = cookieInstance.get("accessTokenExpires")?.value; if (!accessTokenExpires || Date.now() >= Number(accessTokenExpires) * 1000) { console.log("???????????????????????? 만료 updateTokenGenerator 호출 :"); await refreshTokenGenerator(session as CustomSession, cookieInstance); } //토큰 값 복호화 const accessToken = decrypt(cookieInstance.get("accessToken")?.value); config.headers['Authorization'] = process.env.NODE_ENV === "production" ? '' : `Bearer ${accessToken}`; config.headers['credentials'] = "include"; } return config; }, (error) => Promise.reject(error) );
6. 디바운스 처리
- AxiosInstance.ts 및 AxiosServerToken.ts는 짧은 시간 내 중복 호출 방지를 위해 디바운스 처리 적용
7. 미들웨어 인증 로직
- 세션 정보와 refreshToken 존재 여부를 기반으로 로그인 상태 확인
const isAuthenticated = !!session?.user && request.cookies.has("refreshToken");
8. 보안 처리
- 쿠키 데이터는 암호화 후 저장
- 토큰 발급 및 Axios 인스턴스 오류 발생 시 토큰 삭제 처리
디렉토리 구조
src/ ├── auth.ts ├── utils/ │ ├── auth/ │ │ ├── Auth.config.ts │ │ ├── AuthCallbacks.ts │ │ ├── AuthCookieSet.ts │ │ ├── AuthEvents.ts │ │ ├── CredentialsProvider.ts │ │ ├── CredentialsProviderError.ts │ │ ├── OauthProvider.ts │ ├── AxiosInstance.ts │ ├── AxiosServerToken.ts ├── app/ │ ├── api/ │ │ ├── auth/ │ │ │ ├── [...nextauth]/route.ts │ │ ├── authToken/ │ │ │ ├── refreshToken/route.ts
※백엔드 - Express 회원가입 및 로그인 JWT 토큰 발급 설정
1) util/tokenProvider.js
const { sign, verify, decode } = require('jsonwebtoken'); const { compare } = require('bcryptjs'); const RefreshToken = require('../models/refreshTokens'); const { sequelize } = require('../models'); const { ApiResponse } = require('./response'); const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET; const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET; if (!process.env.ACCESS_TOKEN_SECRET || !process.env.REFRESH_TOKEN_SECRET) { throw new Error('환경 변수 ACCESS_TOKEN_SECRET 또는 REFRESH_TOKEN_SECRET이 설정되지 않았습니다.'); } //const ACCESS_TOKEN_EXPIRATION = '1h'; // 1시간 //const REFRESH_TOKEN_EXPIRATION = 14 * 24 * 60 * 60; // 14일 (초 단위) // 개발 환경에서 액세스 토큰 만료 시간을 1분, 리프레시 토큰 만료 시간을 3분으로 설정 const ACCESS_TOKEN_EXPIRATION = '1m'; // 1분 =>1m const REFRESH_TOKEN_EXPIRATION = 14 * 24 * 60 * 60; //20 * 60; // 3분 (초 단위) /** * 1.사용자가 입력한 비밀번호가 저장된 비밀번호와 일치하는지 확인하는 함수 * @param {string} password 입력된 비밀번호 * @param {string} storedPassword 저장된 해시된 비밀번호 * @returns {Promise<boolean>} 비밀번호가 일치하면 true, 아니면 false */ async function isValidPassword(password, storedPassword) { try { return await compare(password, storedPassword); } catch (error) { return false; // 예외 발생 시 안전한 기본값 반환 } } /** * 2.액세스 토큰을 생성하는 함수 * @param {string} id 사용자 ID * @param {string} username 사용자 이름 * @param {string} roles 사용자 역할 * @returns {ApiResponse} 생성된 액세스 토큰과 만료 시간 반환 */ function createAccessToken(id, username, roles) { const accessToken = sign({ id, username, roles }, ACCESS_TOKEN_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRATION }); return { accessToken, accessTokenExpires: decode(accessToken).exp, }; } /** * 3.리프레시 토큰을 생성하는 함수 * @param {string} id 사용자 ID * @param {string} username 사용자 이름 * @param {string} roles 사용자 역할 * @returns {ApiResponse} 생성된 리프레시 토큰과 만료 시간 반환 */ function createRefreshToken(id, username, roles) { const refreshToken = sign({ id, username, roles }, REFRESH_TOKEN_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRATION }); return { refreshToken, refreshTokenExpires: decode(refreshToken).exp, }; } /** * 4.JWT 토큰의 유효성을 검증하는 함수 * @param {string} token 검증할 토큰 * @param {string} secret 서명 검증을 위한 비밀키 * @returns {ApiResponse} 검증 성공 여부와 토큰 데이터 반환 */ async function validateToken(token, secret) { try { return await verify(token, secret); } catch (error) { console.log("토큰 검증 실패:", error.message); if(error.message=== 'jwt expired'){ console.log("토큰 인증 만료"); return new ApiResponse(403, false, 'ACCESS_TOKEN_EXPIRED', null, { code: 'jwt expired', message: error.message }); }else{ return null; } } } /** * 5.리프레시 토큰을 데이터베이스에 저장하는 함수 * @param {string} refreshToken 저장할 리프레시 토큰 * @param {bigint} userId 사용자 이름 * @returns {ApiResponse} 저장 성공 여부 반환 */ //????트랜잭션을 사용하여 사용자의 토큰이 여러 개 생성되는 문제를 방지 async function storeRefreshToken(refreshToken, userId, expiresIn) { const transaction = await sequelize.transaction(); try { //const expiresIn = new Date(Date.now() + REFRESH_TOKEN_EXPIRATION * 1000); const existingToken = await RefreshToken.findOne({ where: { userId } }, { transaction }); if (existingToken) { await existingToken.update({ refreshToken, expiresIn }, { transaction }); } else { await RefreshToken.create({ userId, refreshToken, expiresIn }, { transaction }); } await transaction.commit(); return new ApiResponse(200, true, '리프레시 토큰이 성공적으로 저장되었습니다.'); } catch (error) { await transaction.rollback(); return new ApiResponse(500, false, '리프레시 토큰 저장에 실패했습니다.', null, { code: 'DatabaseError', message: error.message }); } } /** * 6.데이터베이스에서 저장된 리프레시 토큰을 가져오는 함수 * @param {bigint} userId 사용자 이름 * @returns {ApiResponse} 저장된 리프레시 토큰 반환 */ async function getStoredRefreshToken(userId) { try { const tokenData = await RefreshToken.findOne({ where: { userId } }); return new ApiResponse(200, true, '저장된 리프레시 토큰을 가져왔습니다.', tokenData ? tokenData.refreshToken : null); } catch (error) { return new ApiResponse(500,false, '리프레시 토큰을 가져오는 데 실패했습니다.', null, { code: 'DatabaseError', message: error.message }); } } /** * 7.사용자 계정의 리프레시 토큰을 삭제하는 함수 * @param {bigint} userId 사용자 이름 * @returns {ApiResponse} 삭제 성공 여부 반환 */ async function deleteRefreshToken(userId) { try { await RefreshToken.destroy({ where: { userId } }); return new ApiResponse(true, `${userId} 사용자의 리프레시 토큰이 삭제되었습니다.`); } catch (error) { return new ApiResponse(false, '리프레시 토큰 삭제에 실패했습니다.', null, { code: 'DatabaseError', message: error.message }); } } // 모듈 내보내기 module.exports = { createAccessToken, createRefreshToken, validateToken, isValidPassword, storeRefreshToken, getStoredRefreshToken, deleteRefreshToken, };
2)controllers/auth.js
const fs = require("fs"); const path = require("path"); const { signupService, loginService, refreshTokenService , logoutService, oauth2LoginService} = require('../service/auth'); const { handleError } = require("../util/errors"); const { profileImageUpdateService } = require("../service/user"); const { saveProfileImage } = require("../util/upload"); /** * ✅1. 회원가입 * POST /api/auth/signup * @param req.body { username, email, password, name, phoneNumber, address, birthDate } */ exports.signupController = async (req, res) => { let savedFile = null; // 저장된 파일 정보를 추적하기 위한 변수 try { // 1️⃣ 먼저 회원가입을 시도 const response = await signupService(req.body); // 2️⃣ 회원가입이 성공하면 파일 저장 if (response && response.success && req.file) { savedFile = await saveProfileImage(req.file); console.log("✅ 저장된 프로필 이미지:", savedFile.url); // ✅ 디버깅 로그 추가 if(!await profileImageUpdateService({ userId: response.data.userId, profileImage: savedFile.url })) { throw new Error("프로필 이미지 업데이트 실패"); } } return res.status(response.status).json(response); } catch (error) { // 3️⃣ 회원가입 실패 시, 저장된 이미지가 있으면 삭제 if (savedFile && savedFile.filePath) { fs.unlink(savedFile.filePath, (err) => { if (err) console.error("회원가입 실패로 인해 이미지 삭제 실패:", err); }); } return handleError(res, error, "회원 가입 실패"); } }; /** * ✅2. 로그인 * POST /api/auth/login * @param req.body { username, password } */ exports.loginController = async (req, res, next) => { try { const response = await loginService(req, res, next); return res.status(response.status).json(response); } catch (error) { return handleError(res, error, '로그인 실패'); } }; /** * ✅3.갱신 토큰 발급 컨트롤러 * POST /api/auth/refresh * @param req.headers { Authorization: "Bearer {토큰값}" } */ exports.refreshTokenController = async (req, res, next) => { try { console.log(" /api/auth/refresh"); const refreshToken = req.headers.authorization?.split(' ')[1]; // "Bearer {토큰값}"에서 추출 const refreshToken2 = req.cookies.refreshToken; // ✅ 직접 쿠키에서 가져옴 console.log("=✔️✔️Bearer {토큰값}✔️ :",refreshToken ); console.log("=✔️✔️✔️직접 쿠키에서 가져옴✔️✔️✔️ :",refreshToken2 ); const response = await refreshTokenService({ refreshToken }, next); console.log("????????????????????????갱신 토큰 발급 결과:", response); return res.status(response.status).json(response); } catch (error) { console.error("갱신 토큰 발급 실패:", error); return handleError(res, error, '갱신 토큰 발급 실패'); } }; /** * ✅ 4.로그아웃 * GET /api/auth/logout * @param req.user */ exports.logoutController = async(req, res) => { try{ const response = await logoutService(req.user.id); return res.status(response.status).json(response); }catch(error){ console.error("로그아웃 실패:", error); return handleError(res, error, '로그아웃 실패'); } }; /** * ✅ 5. OAuth2 로그인 * @param {*} req * @param {*} res * @returns */ exports.oauth2LoginController = async(req, res) => { try{ const response = await oauth2LoginService(req.body); return res.status(response.status).json(response); }catch(error){ console.error("로그인 실패:", error); return handleError(res, error, '로그인 실패'); } };
3) service/auth.js
const bcrypt = require('bcryptjs'); const { Op, where } = require('sequelize'); const User = require('../models/user'); const Role = require('../models/role'); const { ApiResponse } = require('../util/response'); const UserRole = require('../models/userRole'); const passport = require('passport'); const { createAccessToken, createRefreshToken, storeRefreshToken, validateToken, deleteRefreshToken } = require('../util/tokenProvider'); const { generatePassword } = require('../util/randomPwd'); const RefreshToken = require('../models/refreshTokens'); const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET; if ( !REFRESH_TOKEN_SECRET) { throw new Error('환경 변수 ACCESS_TOKEN_SECRET 또는 REFRESH_TOKEN_SECRET이 설정되지 않았습니다.'); } /** * ✅ 공통 함수: 토큰 생성 및 저장 */ const generateTokensAndStore = async (userId) => { try { // ✅ 여기서 User 정보와 Roles를 한 번에 조회 const getUser = await User.findOne({ where: { id: userId }, include: [{ model: Role, through: { attributes: [] } }], }); if (!getUser) { return new ApiResponse(400, false, "사용자 정보 없음", null, { code: "AuthenticationError", message: "사용자 계정을 찾을 수 없습니다." }); } // ✅ 역할 목록 추출 (추가 쿼리 없이) const roles = getUser.Roles.map(role => role.name); // ✅ 토큰 생성 const { accessToken: newAccessToken, accessTokenExpires } = createAccessToken(getUser.id, getUser.username, roles); const { refreshToken: newRefreshToken, refreshTokenExpires } = createRefreshToken(getUser.id, getUser.username, roles); // ✅ 갱신 토큰 저장 const storeTokenResult = await storeRefreshToken(newRefreshToken, getUser.id, refreshTokenExpires); if (!storeTokenResult.success) { return new ApiResponse(400, false, "갱신 토큰 저장 실패", null, storeTokenResult.error); } // ✅ 최종 반환 데이터 return new ApiResponse(200, true, '토큰 발급 성공', { user:{ id: getUser.id, username: getUser.username, name: getUser.name, email: getUser.email, image: getUser.profileImage, roles , accessToken: newAccessToken, refreshToken: newRefreshToken, accessTokenExpires, refreshTokenExpires, }, }); } catch (error) { console.error("토큰 생성 및 저장 오류:", error); return new ApiResponse(500, false, "토큰 생성 중 오류 발생", null, { code: "ServerError", message: error.message }); } }; /** * ✅1. 회원가입 서비스 */ exports.signupService = async ({ username, email,name,profileImage,birthDate, phoneNumber, zipCode,address,detailedAddress, password, accountType, companyName, numberOfEmployees, authProvider, authProviderId }) => { try { if (!username || !email || !password || !name) { return new ApiResponse(400, false, "잘못된 요청입니다.", null, { code: "ValidationError", message: "아이디, 이메일, 이름, 비밀번호 필드는 필수 입니다.", fields: "commonErrors" }).toJSON(); } const existingUser = await User.findOne({ where: {username: username} }); if (existingUser) { return new ApiResponse(400, false, "이미 존재하는 사용자명 입니다.", null, { code: "isExistsUser", message: "이미 존재하는 사용자명 입니다.", field:"username" }).toJSON(); } const existingEmail = await User.findOne({ where: {email: email} }); if (existingEmail) { return new ApiResponse(400, false, "이미 존재하는 이메일입니다.", null, { code: "isExistsEmail", message: "이미 존재하는 이메일입니다.", field:"email" }).toJSON(); } const hash = await bcrypt.hash(password, 10); const role = await Role.findOne({ where: { name: "user" } }); const user = await User.create({ username, email, name, profileImage: profileImage || null, birthDate: birthDate || null, phoneNumber: phoneNumber || null, zipCode: zipCode || null, address: address || null, detailedAddress: detailedAddress || null, password: hash, accountType: accountType || "personal", companyName: companyName || null, numberOfEmployees: numberOfEmployees || null, authProvider: authProvider || null, authProviderId: authProviderId || null }); if (role) { await UserRole.create({ userId: user.id, roleId: role.id }); } return new ApiResponse(201, true, '회원가입에 성공하였습니다.', { userId: user.id }).toJSON(); } catch (error) { console.error("회원가입 오류:", error); return new ApiResponse(500, false, '서버 내부 오류가 발생했습니다.', null, { code: "ServerError", message: error.message, field:"commonErrors" }).toJSON(); } }; /** * ✅2. 로그인 서비스 */ exports.loginService = async (req, res, next) => { return new Promise((resolve, reject) => { passport.authenticate('local', async (authError, user, info) => { if (authError) return reject(new ApiResponse(400, false, authError.message, null, { code: "AuthenticationError", message: authError.message, field:"username" }).toJSON()); if (!user) return reject(new ApiResponse(400, false, info.message, null, { code: "AuthenticationError", message: info.message , field:"username"}).toJSON()); try { const response = await generateTokensAndStore(user.id); resolve(response); } catch (error) { console.error("로그인 처리 중 오류:", error); reject(new ApiResponse(500, false, "로그인 처리 중 오류 발생", null, { code: "ServerError", message: error.message }).toJSON()); } })(req, res, next); }); }; /** * ✅ 3.갱신 토큰 서비스 */ exports.refreshTokenService = async ({ refreshToken }, next) => { if (!refreshToken) { return new ApiResponse(400, false, 'refresh token 없음', null, { code: 'RefreshTokenError', message: '갱신 토큰이 존재하지 않습니다.' }).toJSON(); } console.log("✅****** 받아온 갱신 토큰값 : ", refreshToken); // ✅ Refresh Token 검증 const refreshTokenValid = await validateToken(refreshToken, REFRESH_TOKEN_SECRET); if (!refreshTokenValid) { console.log("유효하지 않는 갱신토큰값:1"); return new ApiResponse(400, false, '유효하지 않은 refresh token', null, { code: 'RefreshTokenError', message: '갱신 토큰이 유효하지 않습니다.' }).toJSON(); } const refreshTokenDB = await RefreshToken.findOne({where: {refreshToken: refreshToken} }); if (!refreshTokenDB || refreshTokenDB.userId !== refreshTokenValid.id) { console.log("유효하지 않는 갱신토큰값:2:",refreshTokenDB.userId, refreshTokenValid.id); return new ApiResponse(400, false, '유효하지 않은 refresh token', null, { code: 'RefreshTokenError', message: '갱신 토큰이 유효하지 않습니다.' }).toJSON(); } try { return await generateTokensAndStore(refreshTokenValid.id); } catch (error) { console.error("갱신 토큰 처리 중 오류:", error); return new ApiResponse(500, false, "갱신 토큰 처리 중 오류 발생", null, { code: "ServerError", message: error.message }).toJSON(); } }; /** * ✅ 4.로그아웃 처리 * @param {Request} req - 요청 객체 * @param {Response} res - 응답 객체 */ exports.logoutService = async (userId) => { try { // 1. DB에서 갱신 토큰 삭제 const result = await deleteRefreshToken(userId); if (result === 0) { return new ApiResponse(400, false, '로그아웃 실패',null, { message: '사용자의 갱신 토큰을 찾을 수 없습니다.' }).toJSON(); } // 2. 성공적으로 로그아웃 처리 return (new ApiResponse(200, true, '로그아웃 성공', null)).toJSON(); } catch (error) { console.error('로그아웃 처리 중 에러 발생:', error); return new ApiResponse(500, false, '서버 오류', null, { code: 'ServerError', message: error.message }).toJSON(); } }; /** * ✅ 5. OAuth2 로그인 서비스 * */ exports.oauth2LoginService = async ({ provider, profile }) => { try { console.log("oauth2Process =========>:", provider, profile); if (!profile) { return new ApiResponse(401, false, "인증 실패", null, { code: "AuthenticationError", message: "인증 실패", }).toJSON(); } const { authProvider, authProviderId, email, image, name } = extractProviderData(provider, profile); const userId = `${authProvider}_${authProviderId}`; // 1️⃣ 기존 사용자 조회 let getUser = await User.findOne({ where: { authProvider, authProviderId }, }); if (getUser) { console.log("이미 가입된 사용자, 로그인 처리"); return await generateTokensAndStore(getUser.id); } // 2️⃣ 이메일로 기존 사용자 조회 if (email) { const existingUser = await User.findOne({ where: { email } }); if (existingUser) { console.log("이메일이 존재, 계정 업데이트 후 로그인 처리"); await User.update({ authProvider, authProviderId }, { where: { email } }); return await generateTokensAndStore(existingUser.id); } } // 3️⃣ 신규 회원 가입 후 로그인 처리 console.log("신규 가입 후 로그인 처리"); const signupResponse = await exports.signupService({ username: userId, email, name, profileImage: image, birthDate: null, phoneNumber: null, zipCode: null, address: null, detailedAddress: null, password: generatePassword(10), accountType: "personal", companyName: null, numberOfEmployees: null, authProvider, authProviderId, }); // 회원가입 실패 시 에러 반환 if (!signupResponse.success) { return signupResponse; } // `userId` 추출 후 로그인 처리 const newUserId = signupResponse.data.userId; return await generateTokensAndStore(newUserId); } catch (error) { console.error("로그인 처리 중 오류:", error); return new ApiResponse(500, false, "로그인 처리 중 오류 발생", null, { code: "ServerError", message: error.message, }).toJSON(); } }; function extractProviderData(provider, profile) { console.log("1Extracting provider data for provider:", provider); console.log("2Extracting provider data for provider:", profile); switch (provider) { case "google": return { authProvider: "google", authProviderId: profile.sub, email: profile.email, image: profile.picture, name: profile.name, }; case "github": return { authProvider: "github", authProviderId: profile.id, email: profile.email, image: profile.avatar_url, name: profile.name, }; case "kakao": return { authProvider: "kakao", authProviderId: profile.id, email: profile?.kakao_account?.email, image: "", name: profile?.properties?.nickname, }; case "naver": return { authProvider: "naver", authProviderId: profile?.response?.id, email: profile?.response?.email, image: profile?.response?.profile_image, name: profile?.response?.name, }; case "facebook": return { authProvider: "facebook", authProviderId: profile?.id, email: profile?.email, image: profile?.picture?.data?.url, name: profile?.name, }; default: throw new ApiResponse(401, false, "인증 실패", null, { code: "AuthenticationError", message: "지원하지 않는 인증 제공자", }).toJSON(); } }
4) routes/auth.js
const express = require("express"); const multer = require("multer"); const { authTokenMiddleware } = require("../middlewares"); const { signupController, loginController, logoutController, refreshTokenController,oauth2LoginController } = require("../controllers/auth"); const router = express.Router(); // ✅ 파일을 메모리에 저장 (디스크에 바로 저장하지 않음) const upload = multer({ storage: multer.memoryStorage() }); //1. 회원가입 - 파일을 메모리에 저장 후 컨트롤러에서 처리 router.post("/signup", upload.single("profileImage"), signupController); //2.POST : /api/auth/login -> 로그인처리 router.post("/login", loginController); //3.POST : /api/auth/refresh -> 갱신 토큰 발급 router.post("/refresh", refreshTokenController); //4.GET : /api/auth/logout -> 로그아웃 router.get("/logout", authTokenMiddleware, logoutController); //5.GET : /api/auth/oauth2Login router.post("/oauth2Login", oauth2LoginController); module.exports = router;
※ NextJs Oauth 설정
/src/utils/auth/OauthProvider.ts
import GoogleProvider from "next-auth/providers/google"; import GitHubProvider from "next-auth/providers/github"; import FacebookProvider from "next-auth/providers/facebook"; import AppleProvider from "next-auth/providers/apple"; import KakaoProvider from "next-auth/providers/kakao"; import NaverProvider from "next-auth/providers/naver"; // api/auth/callback/google const googleProvider = GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID as string, clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, authorization: { params: { prompt: "consent", access_type: "offline", response_type: "code", }, }, }); /** 구글 반환 형식 { provider: 'google', profile: { iss: 'https://accounts.google.com', azp: '~.apps.googleusercontent.com', aud: '~.apps.googleusercontent.com', sub: '1234', hd: 'gmail.com', email: 'test@gmail.com', email_verified: true, at_hash: '1234', name: '홍길동', picture: '~', given_name: '홍길동', family_name: '홍길동그룹', iat: 1234, exp: 5678 } } */ // /api/auth/callback/github const githubProvider = GitHubProvider({ clientId: process.env.GITHUB_ID as string, clientSecret: process.env.GITHUB_SECRET as string, authorization: { params: { prompt: "consent", access_type: "offline", response_type: "code", }, }, }); /** { provider: 'github', profile: { login: 'test1', id: 12345, node_id: 'UU12345', avatar_url: '이미지.jpg', gravatar_id: '', url: '이미지.jpg', html_url: '이미지.jpg', followers_url: '이미지.jpg', following_url: '이미지.jpg', gists_url: '이미지.jpg', starred_url: '이미지.jpg', subscriptions_url: '이미지.jpg', organizations_url: '이미지.jpg', repos_url: '이미지.jpg', events_url: '이미지.jpg', received_events_url: '이미지.jpg', type: 'User', user_view_type: 'private', site_admin: false, name: '홍길동', company: 'test1', blog: '', location: null, email: 'test1@gmauil.com', hireable: null, bio: null, twitter_username: null, notification_email: null, public_repos: 1, public_gists: 0, followers: 0, following: 0, created_at: '2024-01-22T14:06:57Z', updated_at: '2024-03-28T05:59:10Z', private_gists: 0, total_private_repos: 0, owned_private_repos: 0, disk_usage: 0, collaborators: 0, two_factor_authentication: false, plan: { name: 'free', space: 976562499, collaborators: 0, private_repos: 10000 } } } */ // /api/auth/callback/naver const naverProvider = NaverProvider({ clientId: process.env.NAVER_CLIENT_ID as string, clientSecret: process.env.NAVER_CLIENT_SECRET as string, authorization: "https://nid.naver.com/oauth2.0/authorize", token: "https://nid.naver.com/oauth2.0/token", userinfo: "https://openapi.naver.com/v1/nid/me", }); /* { provider: 'naver', profile: { resultcode: '00', message: 'success', response: { id: '12345', nickname: 'test1', profile_image: 'test1jpg', email: 'test1@naver.com', name: '홍길동' } } } */ const kakaoProvider = KakaoProvider({ clientId: process.env.KAKAO_CLIENT_ID as string, clientSecret: process.env.KAKAO_CLIENT_SECRET as string, authorization: "https://kauth.kakao.com/oauth/authorize", token: "https://kauth.kakao.com/oauth/token", userinfo: "https://kapi.kakao.com/v2/user/me", // profile(profile) { // return { // // id: profile?.id.toString(), // // name: profile?.kakao_account?.profile?.nickname ?? '', // email: profile?.kakao_account?.email?? '', // image: profile?.kakao_account?.profile?.thumbnail_image_url ?? '', // }; // }, }); // /api/auth/callback/facebook const facebookProvider = FacebookProvider({ clientId: process.env.FACEBOOK_CLIENT_ID as string, clientSecret: process.env.FACEBOOK_CLIENT_SECRET as string, authorization: { url: "https://www.facebook.com/v18.0/dialog/oauth", params: { scope: "email,public_profile" }, // 필수 권한 설정 }, profile(profile) { return { id: profile.id, name: profile.name, email: profile.email, image: profile.picture?.data?.url, }; }, }); // provider: 'facebook', // profile: { // id: '1234', // name: '홍길동동', // email: 'test1@gmail.com', // picture: { data: [Object] } // } // } // /api/auth/callback/apple const appleProvider = AppleProvider({ clientId: process.env.APPLE_CLIENT_ID as string, clientSecret: process.env.APPLE_CLIENT_SECRET as string, authorization: { params: { scope: "email name", // 필수 정보 요청 }, }, }); export const OauthProviders = [ googleProvider, githubProvider, facebookProvider, appleProvider, kakaoProvider, naverProvider, ];
2)/src/utils/auth/AuthCallbacks.ts
import { CustomSession } from "@/types/UserType"; import { cookies } from "next/headers"; import { postRequest } from "../AxiosInstance"; import { ResponseType } from "@/types/ResponseType"; import { saveTokenCookie } from "./AuthCookieSet"; import { decrypt } from "../crypto"; const SERVER_TYPE = process.env.NEXT_SERVER_ACTIONS_TYPE || "mongo"; // 기본값: mongoose /** * jwt token 세션을 거치지 않면 실질적으로 반환하는 함수 */ const authCallbacks = (SERVER_TYPE === "backend" ) ? { async signIn({ user, account, profile }: { user: any; account: any; profile?: any; }): Promise<string | boolean>{ if (!account) { console.error("OAuth 로그인 실패: account 객체가 존재하지 않습니다."); return false; } if (['google', 'github', 'facebook', 'apple', 'kakao', 'naver'].includes(account?.provider || '')) { const param = { provider: account?.provider, profile }; console.log("OAuth 로그인 후 백엔드에서 회원가입 처리 및 토큰 발행 처리:", param); try { const response =await postRequest<ResponseType>(`/api/backend/auth/oauth2`, param); if (!response || !response.success) { throw new Error(response.message || "OAuth 로그인 실패"); } const data = response.data; const customUser = user as unknown as CustomSession ; customUser.provider=account.provider; Object.assign(customUser, data); // 데이터를 커스텀 사용자 객체에 병합 return true; } catch (error) { console.error("OAuth 로그인 중 오류 발생:", error); return false; } } return true; }, ~ 생략
3)/src/app/api/backend/auth/oauth2/route.ts
import { NextResponse } from "next/server"; import { ResponseType } from "@/types/ResponseType"; import { handleApiError } from "@/utils/HandleApiError"; import { postRequestTokenServer } from "@/utils/AxiosServerToken"; import { UserRegisterType } from "@/types/UserType"; //API 라우트(backend) : /api/backend/auth/oauth2 export const POST = async (request: Request) : Promise<NextResponse<ResponseType>> => { try { console.log("????POST ========================= request" ); const userData: UserRegisterType = await request.json(); const response :ResponseType = await postRequestTokenServer<ResponseType>(`/api/auth/oauth2Login`,userData); return NextResponse.json(response); } catch (error :any) { return handleApiError(error); } };
댓글 ( 2)
댓글 남기기