1. json-server 설치 및 환경 구성하기
json-server를 이용해서 간단한 로그인 인증 서버를 구현해 보겠습니다. 우선 아래의 명령어로 json-server을 설치해줍니다.
npm install -g json-server
그리고 프로젝트의 루트에 db 폴더를 생성하고 그 안에 db.json을 만들어 줍니다. db.json에는 로그인 인증을 위해 아래와 같이 간단한 유저 데이터를 추가합니다.
{ "users": [ { "id": 1, "username": "admin", "password": "admin123", "role": "admin" }, { "id": 2, "username": "user", "password": "user123", "role": "user" } ], "posts": [ { "id": 1, "title": "First Post", "content": "This is the first post.", "author": "admin" }, { "id": 2, "title": "Second Post", "content": "This is the second post.", "author": "user" } ] }
이제 json-server 설치는 끝났습니다. 아래의 명령어로 json-server를 구동시켜주면 http://localhost:3009/users
와 http://localhost:3009/posts
를 통해 users
와 posts
데이터를 받아올 수 있습니다.
json-server --watch db/db.json --port 3009
2. Next.js 프로젝트 생성하기
프로젝트 개요
- json-server로
3009
포트에 RESTful API를 실행한 상태에서 Next.js를 이용해 로그인 기능을 구현합니다. - 로그인 후에는 인증된 사용자를 위한 페이지로 이동합니다.
- json-server에서 사용할 db.json을 생성해 임시 데이터베이스로 활용합니다.
아직 Next.js 프로젝트가 설치되어 있지 않다면 아래의 명령어로 프로젝트를 생성해주세요. 이번 로그인 인증 서버 구현 예제에서는 타입스크립트를 사용합니다.
npx create-next-app {project_name} --typescript
프로젝트 구조
프로젝트의 구조는 다음과 같습니다.
project-directory/ │ ├── .db/ │ └── db.json ├── components/ │ └── LoginForm.tsx ├── entities/ │ └── services/ │ └── auth.ts ├── lib/ │ └── verifyToken.ts ├── pages/ │ └── api/ │ ├── auth.ts │ ├── posts.ts │ └── profile.ts │ └── login.tsx │ └── profile.tsx ├── next-env.d.ts ├── package.json ├── tsconfig.json └── README.md
관련 패키지 설치하고 프로젝트 초기화하기
package.json에 아래의 패키지들을 추가하고 초기화해주세요.
{ "name": "nextjs-auth-project", "version": "1.0.0", "scripts": { "dev": "next dev", "build": "next build", "start": "next start" }, "dependencies": { "axios": "^1.2.0", "jsonwebtoken": "^9.0.2", "next": "latest", "react": "latest", "react-dom": "latest" }, "devDependencies": { "@types/jsonwebtoken": "^9.0.2", "@types/node": "^18.7.14", "@types/react": "^18.0.19", "@types/react-dom": "^18.0.6", "typescript": "^4.8.3" } }
타입 스크립트 설정을 위해 tsconfig.json 파일을 열고 아래의 내용도 추가해줍니다.
{ "compilerOptions": { "target": "esnext", "module": "esnext", "strict": true, "jsx": "preserve", "esModuleInterop": true, "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "skipLibCheck": true }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] }
next-env.d.ts 파일에 아래의 내요이 없다면 추가해주세요. next-env.d.ts 파일은 Next.js 프로젝트에서 타입스크립트를 사용할 때 필요한 타입 정의를 포함하여 프로젝트 내에서 Next.js API를 쉽게 사용할 수 있도록 돕는 중요한 환경 설정 파일입니다. 보통 자동으로 생성되며 개발자가 직접 수정할 필요는 없습니다.
/// <reference types="next" /> /// <reference types="next/types/global" />
3. 인증 미들웨어와 서비스 구현하기
verifyToken 미들웨어 만들기
lib폴더에 verifyToken.ts 파일을 다음과 같이 추가합니다. verifyToken는 JWT를 검증하기 위한 미들웨어입니다.
import { NextRequest } from "next/server"; import jwt from "jsonwebtoken"; const SECRET_KEY = "your_secret_key"; interface JwtPayload { id: number; role: string; } export interface CustomNextApiRequest extends NextRequest { user?: JwtPayload; } export default async function verifyToken(req: CustomNextApiRequest): Promise<boolean> { const authHeader = req.headers.get("authorization"); if (authHeader && authHeader.startsWith("Bearer ")) { const token = authHeader.substring(7, authHeader.length); // "Bearer " 이후의 문자열을 추출 try { const decoded = jwt.verify(token, SECRET_KEY) as JwtPayload; req.user = decoded; return true; } catch (err) { return false; } } else { return false; } }
로그인 API 호출 모듈 만들기
services 폴더에 auth.ts 파일을 다음과 같이 추가합니다. auth.ts 파일은 로그인 API를 호출하는 auth 서비스 모듈입니다.
export interface LoginResponse { id: number; username: string; role: string; token: string; } export async function login(username: string, password: string): Promise<LoginResponse> { const response = await fetch("/api/auth", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ username, password }), }); if (!response.ok) { throw new Error("Invalid username or password"); } return await response.json(); }
4. Next.js API 라우트 구현하기
Next.js의 app 폴더에 아래와 같이 api 파일을 추가합니다. page 방식이라면 pages 폴더에 추가해줍니다.
app/api/auth/route.ts
// app/api/auth/route.ts import { NextRequest, NextResponse } from "next/server"; import jwt from "jsonwebtoken"; import axios from "axios"; const SECRET_KEY = "your_secret_key"; const TOKEN_EXPIRY = "1h"; // 1시간 interface User { id: number; username: string; password: string; role: string; } interface LoginResponse { id: number; username: string; role: string; token: string; } export async function POST(request: NextRequest) { const { username, password }: { username: string; password: string } = await request.json(); try { // json-server로부터 사용자 데이터를 가져오기 const { data: users } = await axios.get<User[]>("http://localhost:3009/users"); const user = users.find((u) => u.username === username && u.password === password); if (user) { const token = jwt.sign({ id: user.id, role: user.role }, SECRET_KEY, { expiresIn: TOKEN_EXPIRY, }); const response: LoginResponse = { id: user.id, username: user.username, role: user.role, token, }; return NextResponse.json(response, { status: 200 }); } else { return NextResponse.json({ message: "Invalid username or password" }, { status: 401 }); } } catch (error) { console.error("Error fetching users:", error); return NextResponse.json({ message: "Internal Server Error" }, { status: 500 }); } }
app/api/profile/route.ts
// app/api/profile/route.ts import { NextRequest, NextResponse } from "next/server"; import verifyToken, { CustomNextApiRequest } from "@/lib/verifyToken"; interface UserProfile { id: number; username: string; role: string; } export async function GET(request: NextRequest) { const customRequest = request as unknown as CustomNextApiRequest; const isValidToken = await verifyToken(customRequest); if (!isValidToken) { return NextResponse.json({ message: "Unauthorized" }, { status: 403 }); } const profile: UserProfile = { id: customRequest.user!.id, username: customRequest.user!.role === "admin" ? "Admin User" : "Regular User", role: customRequest.user!.role, }; return NextResponse.json(profile, { status: 200 }); }
로그인 및 프로필 페이지 만들기
LoginForm.tsx
아래의 폴더에 LoginForm.tsx 컴포넌트 파일을 만들어줍니다. 로그인용 컴포넌트로 사용합니다.
// components/LoginForm.tsx import React, { useState } from "react"; import { login, LoginResponse } from "@/services/auth"; interface LoginFormProps { onLoginSuccess: (token: string) => void; } const LoginForm: React.FC<LoginFormProps> = ({ onLoginSuccess }) => { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState<string | null>(null); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(null); try { const response: LoginResponse = await login(username, password); localStorage.setItem("token", response.token); onLoginSuccess(response.token); } catch (e) { setError("Invalid username or password"); } }; return ( <form onSubmit={handleSubmit}> <h2 className="text-2xl">Login</h2> {error && <p style={{ color: "red" }}>{error}</p>} <div> <label> Username: <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="border h-10" /> </label> </div> <div> <label> Password: <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="border h-10" /> </label> </div> <button type="submit">Login</button> </form> ); }; export default LoginForm;
로그인 페이지 만들기
아래에 로그인 페이지를 추가합니다.
// app/login/page.tsx "use client"; import { useRouter } from "next/navigation"; import React from "react"; import LoginForm from "@/components/LoginForm"; const LoginPage = () => { const router = useRouter(); const handleLoginSuccess = (token: string) => { router.push("/profile"); }; return ( <div> <LoginForm onLoginSuccess={handleLoginSuccess} /> </div> ); }; export default LoginPage;
프로필 페이지 만들기
로그인 후 이동할 프로필 페이지를 만들어줍니다.
// app/profile/page.tsx "use client"; import { useRouter } from "next/navigation"; import React, { useEffect, useState } from "react"; interface UserProfile { id: number; username: string; role: string; } const ProfilePage = () => { const router = useRouter(); const [profile, setProfile] = useState<UserProfile | null>(null); const [error, setError] = useState<string | null>(null); useEffect(() => { const fetchProfile = async () => { const token = localStorage.getItem("token"); if (!token) { router.push("/login"); return; } const response = await fetch("/api/profile", { headers: { Authorization: `Bearer ${token}`, }, }); if (response.ok) { const profileData: UserProfile = await response.json(); setProfile(profileData); } else { setError("Failed to fetch profile."); router.push("/login"); } }; fetchProfile(); }, [router]); const handleLogout = () => { localStorage.removeItem("token"); router.push("/login"); }; if (error) { return <div>Error: {error}</div>; } if (!profile) { return <div>Loading...</div>; } return ( <div> <h1 className="text-2xl">Profile Page</h1> <p> <strong>ID:</strong> {profile.id} </p> <p> <strong>Username:</strong> {profile.username} </p> <p> <strong>Role:</strong> {profile.role} </p> <button onClick={handleLogout}>Logout</button> </div> ); }; export default ProfilePage;
마치며
이번 프로젝트에서는 json-server와 Next.js를 이용해 간단한 로그인 인증 서버를 구현해 보았습니다. 이를 통해 JWT를 활용한 인증 미들웨어의 중요성을 이해하고, 다양한 페이지를 통해 인증된 사용자에게만 특정 정보를 제공하는 방법을 학습할 수 있었던 것 같습니다.