Next.js 기초 입문 23회차: 인증(Authentication) 기초

📚
제23주차 학습 목표

인증(Authentication) 기초

사용자 인증의 기본 개념과 Next.js에서의 구현 방식을 알아봅니다.

Next.js 기초 입문 23회차: 인증(Authentication) 기초

안녕하세요! Next.js 기초 입문 과정의 23번째 시간입니다. 이번 회차에서는 웹 애플리케이션 개발의 필수 요소인 인증(Authentication)에 대해 심층적으로 다루고자 합니다. 사용자의 신원을 확인하고 적절한 권한을 부여하는 과정은 애플리케이션의 보안과 사용자 경험에 직접적인 영향을 미칩니다.

 

 

 

Next.js 환경에서 인증을 어떻게 구현하고 관리할 수 있는지 그 기초를 함께 살펴보겠습니다.

📌 이번 회차 학습 목표

  • 사용자 인증(Authentication)의 기본 개념을 정확히 이해할 수 있습니다.
  • 세션(Session) 기반 인증과 토큰(Token) 기반 인증의 차이점을 설명할 수 있습니다.
  • Next.js 애플리케이션에서 인증을 구현하기 위한 기본적인 접근 방식을 파악할 수 있습니다.
  • 간단한 인증 플로우를 구성하는 예제를 통해 실습 능력을 향상시킬 수 있습니다.
  • 인증 구현 시 발생할 수 있는 일반적인 문제점과 주의사항을 인지할 수 있습니다.
난이도: 3/5

이번 회차는 인증의 개념과 Next.js에서의 적용 방법을 다룹니다. 기본적인 웹 개발 지식이 있다면 충분히 이해할 수 있습니다.

📝 개념 설명

인증(Authentication)이란 무엇인가?

인증(Authentication)은 사용자가 누구인지 신원을 확인하는 과정을 의미합니다. 예를 들어, 웹사이트에 로그인할 때 사용자 이름과 비밀번호를 입력하는 것이 대표적인 인증 과정입니다. 시스템은 사용자가 제공한 자격 증명(credentials)이 유효한지 확인하여 해당 사용자가 주장하는 신원이 맞는지 검증합니다.

인가(Authorization)와의 차이점

인증과 함께 자주 언급되는 개념으로 인가(Authorization)가 있습니다. 인가는 인증된 사용자가 특정 리소스나 기능에 접근할 수 있는 권한이 있는지 확인하는 과정입니다. 즉, ‘당신이 누구인가?’가 인증이라면, ‘당신이 무엇을 할 수 있는가?’는 인가에 해당합니다. 이번 회차에서는 인증에 초점을 맞추고, 인가는 다음 회차에서 더 자세히 다룰 예정입니다.

인증의 주요 방식: 세션 vs. 토큰

웹 애플리케이션에서 사용자를 인증하는 방식은 크게 세션(Session) 기반 인증토큰(Token) 기반 인증으로 나눌 수 있습니다. 각각의 특징과 동작 방식을 이해하는 것이 중요합니다.

세션(Session) 기반 인증

  • 동작 원리: 사용자가 로그인하면 서버는 세션이라는 고유한 정보를 생성하고, 이 세션 정보를 서버 메모리나 데이터베이스에 저장합니다. 그리고 이 세션 ID를 사용자에게 쿠키(Cookie) 형태로 전달합니다. 사용자는 이후 요청마다 이 쿠키를 서버로 보내고, 서버는 쿠키의 세션 ID를 통해 사용자를 식별합니다.
  • 장점: 서버에서 세션 상태를 관리하므로, 필요시 강제로 로그아웃시키거나 세션을 무효화하기 용이합니다.
  • 단점: 서버가 세션 상태를 유지해야 하므로, 서버의 부하가 증가할 수 있습니다. 특히 여러 서버를 사용하는 분산 환경에서는 세션 공유 문제가 발생할 수 있습니다.
  • Next.js에서의 적용: Next.js는 서버리스 함수 환경에서도 동작할 수 있으므로, 전통적인 세션 관리는 백엔드 서버에서 이루어지고 Next.js 프론트엔드는 쿠키를 통해 세션 ID를 주고받는 형태로 구현됩니다.

토큰(Token) 기반 인증

  • 동작 원리: 사용자가 로그인하면 서버는 사용자 정보를 담은 토큰(Token)을 생성하여 사용자에게 전달합니다. 이 토큰은 주로 JWT(JSON Web Token) 형식을 사용합니다. 사용자는 이후 요청마다 HTTP 헤더에 이 토큰을 포함하여 서버로 보냅니다. 서버는 토큰의 유효성을 검증하여 사용자를 식별하며, 서버는 별도의 세션 상태를 유지하지 않습니다 (Stateless).
  • 장점: 서버가 상태를 유지할 필요가 없어 확장성이 좋습니다. 모바일 앱 등 다양한 클라이언트에서 쉽게 사용할 수 있으며, CORS(Cross-Origin Resource Sharing) 문제에 유연합니다.
  • 단점: 토큰이 탈취될 경우 보안에 취약할 수 있습니다. 토큰 만료 전까지는 강제로 무효화하기 어렵습니다.
  • Next.js에서의 적용: Next.js API Routes를 통해 토큰을 발행하고 검증하는 로직을 구현할 수 있습니다. 클라이언트 측에서는 로컬 스토리지나 쿠키에 토큰을 저장하고, 요청 시 이를 포함하여 보냅니다.
⚖️ 세션 vs. 토큰 기반 인증 비교
항목세션 기반 인증토큰 기반 인증 (JWT)
상태 관리서버가 세션 상태 유지 (Stateful)서버가 상태 유지 안 함 (Stateless)
데이터 저장서버에 세션 정보 저장, 클라이언트에 세션 ID(쿠키) 전달클라이언트에 토큰 저장 (로컬 스토리지, 쿠키)
확장성세션 공유 문제로 확장성 제한적서버 상태 불필요, 확장성 우수
보안세션 하이재킹 위험, 서버에서 강제 로그아웃 가능토큰 탈취 시 위험, 만료 전 무효화 어려움 (Refresh Token으로 보완)
사용처전통적인 웹 애플리케이션SPA, 모바일 앱, 마이크로서비스 등

💡 예제 & 실습: Next.js에서 간단한 인증 플로우 구현

이번 실습에서는 Next.js API Routes를 활용하여 간단한 로그인 및 사용자 정보 조회 기능을 구현해봅니다. 여기서는 실제 데이터베이스 연동 대신 메모리 내 사용자 데이터를 사용하며, 토큰 기반 인증의 기본 원리를 이해하는 데 초점을 맞춥니다.

1. 프로젝트 설정

먼저 Next.js 프로젝트를 생성합니다.

npx create-next-app@latest nextjs-auth-demo --ts
cd nextjs-auth-demo

2. 로그인 API Route 생성

pages/api/login.ts 파일을 생성하고 다음과 같이 작성합니다. 이 API는 사용자 이름과 비밀번호를 받아 유효하면 JWT를 발행합니다.

// pages/api/login.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import jwt from 'jsonwebtoken';

// 실제 프로젝트에서는 .env 파일에서 관리해야 합니다.
const SECRET_KEY = 'your-secret-key'; // 실제 환경에서는 강력하고 복잡한 키를 사용하세요!

type Data = { message: string; token?: string; error?: string };

export default function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method Not Allowed' });
  }

  const { username, password } = req.body;

  // 실제 환경에서는 데이터베이스에서 사용자 정보를 조회하고 비밀번호를 해싱하여 비교합니다.
  if (username === 'user' && password === 'password') {
    // JWT 토큰 생성
    const token = jwt.sign({ userId: '123', username: 'user' }, SECRET_KEY, { expiresIn: '1h' });
    res.status(200).json({ message: 'Login successful', token });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
}
설명:
  • jwt 라이브러리를 사용하여 토큰을 생성합니다. npm install jsonwebtoken 또는 yarn add jsonwebtoken으로 설치해야 합니다.
  • SECRET_KEY는 토큰을 서명하고 검증하는 데 사용되는 중요한 키입니다. 실제 서비스에서는 환경 변수로 관리해야 합니다.
  • 사용자 이름과 비밀번호가 ‘user’/’password’인 경우에만 로그인을 성공시킵니다.
  • jwt.sign() 함수를 사용하여 페이로드(사용자 정보), 비밀 키, 만료 시간을 설정하여 토큰을 생성합니다.

3. 사용자 정보 조회 API Route (인증 필요) 생성

pages/api/user.ts 파일을 생성하고 다음과 같이 작성합니다. 이 API는 요청 헤더의 토큰을 검증하여 사용자 정보를 반환합니다.

// pages/api/user.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import jwt from 'jsonwebtoken';

const SECRET_KEY = 'your-secret-key'; // login.ts와 동일한 키 사용

type UserData = { userId: string; username: string };
type Data = { user?: UserData; error?: string };

export default function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
  if (req.method !== 'GET') {
    return res.status(405).json({ error: 'Method Not Allowed' });
  }

  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, SECRET_KEY) as UserData;
    // 실제 환경에서는 decoded.userId를 사용하여 데이터베이스에서 사용자 정보를 조회합니다.
    res.status(200).json({ user: { userId: decoded.userId, username: decoded.username } });
  } catch (err) {
    res.status(401).json({ error: 'Invalid or expired token' });
  }
}
설명:
  • 요청 헤더의 Authorization 필드에서 ‘Bearer [토큰]’ 형식으로 전달된 토큰을 추출합니다.
  • jwt.verify() 함수를 사용하여 토큰의 유효성을 검증합니다. 서명이 올바르고 만료되지 않았다면 페이로드를 반환합니다.
  • 유효한 토큰일 경우 사용자 정보를 반환하고, 그렇지 않으면 401 Unauthorized 에러를 반환합니다.

4. 프론트엔드 컴포넌트 구현

pages/index.tsx 파일을 수정하여 로그인 폼과 사용자 정보 표시 영역을 만듭니다.

// pages/index.tsx
import { useState, useEffect } from 'react';

export default function Home() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [token, setToken] = useState<string | null>(null);
  const [user, setUser] = useState<{ userId: string; username: string } | null>(null);
  const [message, setMessage] = useState('');

  // 컴포넌트 마운트 시 로컬 스토리지에서 토큰 불러오기
  useEffect(() => {
    const storedToken = localStorage.getItem('authToken');
    if (storedToken) {
      setToken(storedToken);
      fetchUser(storedToken);
    }
  }, []);

  const handleLogin = async () => {
    setMessage('');
    try {
      const res = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password }),
      });
      const data = await res.json();

      if (res.ok && data.token) {
        setToken(data.token);
        localStorage.setItem('authToken', data.token);
        setMessage('로그인 성공!');
        fetchUser(data.token);
      } else {
        setMessage(data.error || '로그인 실패');
      }
    } catch (error) {
      setMessage('네트워크 오류');
      console.error('Login error:', error);
    }
  };

  const fetchUser = async (authToken: string) => {
    setMessage('');
    try {
      const res = await fetch('/api/user', {
        headers: { Authorization: `Bearer ${authToken}` },
      });
      const data = await res.json();

      if (res.ok && data.user) {
        setUser(data.user);
        setMessage('사용자 정보 로드 성공!');
      } else {
        setUser(null);
        setMessage(data.error || '사용자 정보 로드 실패');
        // 토큰이 유효하지 않으면 로컬 스토리지에서 제거
        localStorage.removeItem('authToken');
        setToken(null);
      }
    } catch (error) {
      setMessage('네트워크 오류');
      console.error('Fetch user error:', error);
    }
  };

  const handleLogout = () => {
    setToken(null);
    setUser(null);
    localStorage.removeItem('authToken');
    setMessage('로그아웃 되었습니다.');
  };

  return (
    <div style={{ padding: '20px', maxWidth: '400px', margin: 'auto' }}>
      <h1>Next.js 인증 데모</h1>
      {!token ? (
        <div>
          <h2>로그인</h2>
          <input
            type='text'
            placeholder='사용자 이름 (user)'
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            style={{ display: 'block', marginBottom: '10px', padding: '8px', width: '100%' }}
          />
          <input
            type='password'
            placeholder='비밀번호 (password)'
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            style={{ display: 'block', marginBottom: '10px', padding: '8px', width: '100%' }}
          />
          <button onClick={handleLogin} style={{ padding: '10px 15px', backgroundColor: '#0070f3', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>
            로그인
          </button>
        </div>
      ) : (
        <div>
          <h2>환영합니다!</h2>
          {user ? (
            <p>사용자 ID: <strong>{user.userId}</strong>, 사용자 이름: <strong>{user.username}</strong></p>
          ) : (
            <p>사용자 정보를 불러오는 중...</p>
          )}
          <button onClick={handleLogout} style={{ padding: '10px 15px', backgroundColor: '#dc3545', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>
            로그아웃
          </button>
        </div>
      )}
      {message && <p style={{ marginTop: '20px', color: token ? 'green' : 'red' }}>{message}</p>}
    </div>
  );
}
설명:
  • useState 훅을 사용하여 사용자 입력, 토큰, 사용자 정보, 메시지 상태를 관리합니다.
  • useEffect 훅을 사용하여 컴포넌트가 마운트될 때 로컬 스토리지에서 기존 토큰을 불러와 자동 로그인 처리를 시도합니다.
  • handleLogin 함수는 /api/login API를 호출하여 토큰을 받아 로컬 스토리지에 저장하고, fetchUser를 호출하여 사용자 정보를 가져옵니다.
  • fetchUser 함수는 /api/user API를 호출할 때 Authorization 헤더에 토큰을 포함하여 보냅니다.
  • handleLogout 함수는 토큰과 사용자 정보를 초기화하고 로컬 스토리지에서 토큰을 제거합니다.

5. 실행

프로젝트를 실행하고 브라우저에서 http://localhost:3000에 접속합니다.

npm run dev

이제 ‘user’ / ‘password’로 로그인하고 사용자 정보가 표시되는 것을 확인할 수 있습니다.

🔄 인증 플로우 (토큰 기반)
1. 사용자 로그인 요청 (ID/PW)
2. 서버 (API Route) 인증 & JWT 발행
3. 클라이언트 JWT 저장 (로컬 스토리지)
4. 클라이언트 리소스 요청 (JWT 포함)
5. 서버 (API Route) JWT 검증 & 리소스 응답

⚠️ 자주 틀리는 것 / 주의사항

  • 보안 키 노출: JWT의 SECRET_KEY는 절대 외부에 노출되어서는 안 됩니다. 실제 서비스에서는 환경 변수(.env.local 등)를 통해 관리하고, Git 저장소에 포함되지 않도록 .gitignore에 추가해야 합니다.
  • 비밀번호 해싱: 예제에서는 비밀번호를 평문으로 비교했지만, 실제 서비스에서는 사용자 비밀번호를 데이터베이스에 저장하기 전에 반드시 해싱(Hashing)해야 합니다. bcrypt와 같은 라이브러리를 사용하여 단방향 암호화를 적용해야 합니다.
  • 토큰 저장 위치: 예제에서는 로컬 스토리지에 토큰을 저장했지만, XSS(Cross-Site Scripting) 공격에 취약할 수 있습니다. 보안이 더 중요한 경우에는 httpOnly 옵션이 설정된 쿠키에 토큰을 저장하는 것을 고려할 수 있습니다.
  • 토큰 만료 및 갱신: JWT는 만료 시간이 지나면 사용할 수 없습니다. 장기적인 사용자 세션을 위해 Refresh Token 개념을 도입하여 Access Token이 만료될 때마다 새로운 Access Token을 발급받는 메커니즘을 구현하는 것이 일반적입니다.
  • 인가(Authorization) 누락: 인증만으로는 충분하지 않습니다. 인증된 사용자가 특정 작업을 수행할 권한이 있는지 확인하는 인가 로직이 반드시 필요합니다.

🔗 다음 회차 예고

이번 회차에서는 사용자 인증의 기본 개념과 Next.js에서의 토큰 기반 인증 구현 기초를 학습했습니다. 다음 24회차에서는 인가(Authorization) 개념을 더 깊이 다루고, Next.js에서 미들웨어(Middleware)를 활용하여 라우트 보호 및 권한 관리를 어떻게 구현할 수 있는지 상세히 알아보겠습니다. 또한, NextAuth.js와 같은 전문 인증 라이브러리를 활용하는 방법도 함께 살펴보며 실제 서비스에 적용 가능한 인증/인가 시스템을 구축하는 방법을 탐구할 예정입니다.

✅ 이번 회차 핵심 정리
  • 인증(Authentication)은 사용자의 신원을 확인하는 과정이며, 인가(Authorization)는 인증된 사용자의 권한을 확인하는 과정입니다.
  • 세션 기반 인증은 서버가 상태를 유지하며 쿠키를 통해 세션 ID를 주고받고, 토큰 기반 인증(JWT)은 서버가 상태를 유지하지 않고 클라이언트가 토큰을 저장하여 요청 시 포함합니다.
  • Next.js에서는 API Routes를 활용하여 로그인 및 사용자 정보 조회와 같은 인증 관련 백엔드 로직을 구현할 수 있습니다.
  • 프론트엔드에서는 fetch API를 사용하여 API Routes와 통신하고, 로컬 스토리지나 쿠키에 토큰을 저장하여 인증 상태를 관리합니다.
  • 인증 구현 시 보안 키 관리, 비밀번호 해싱, 토큰 저장 위치, 토큰 만료 및 갱신 등의 보안 고려사항이 중요합니다.

댓글 남기기

Wordpress Social Share Plugin powered by Ultimatelysocial
Copy link
URL has been copied successfully!
THREADS
RSS
error: 저작권 콘텐츠보호를 부탁드립니다.