[What To Eat] JWT Refresh Token 적용하기

My_Code·2025년 6월 9일
0

What To Eat

목록 보기
4/6

이 프로젝트는 이것저것 적용해서 테스트하기 위한 프로젝트입니다. 이번에는 JWT Refresh Token을 적용하려고 합니다.

이번에는 사용자 인증 시스템의 핵심인 Refresh Token을 적용하는 과정을 단계별로 자세히 설명해드리겠습니다. 단순한 JWT 인증에서 한 단계 더 나아가, 보안성과 사용자 경험을 모두 만족시키는 인증 시스템을 만들어보겠습니다.


Refresh Token이란?

일반적인 JWT 토큰 시스템의 딜레마를 먼저 살펴보겠습니다.

짧은 수명 토큰의 문제점

  • 사용자가 자주 다시 로그인해야 함 (사용자 경험 악화)
  • 서비스 이용 중 갑자기 로그아웃됨

긴 수명 토큰의 문제점

  • 토큰이 탈취되면 오랫동안 악용 가능 (보안 위험)
  • 로그아웃해도 토큰이 여전히 유효함

Refresh Token은 이 딜레마를 해결 가능

  • Access Token: 실제 API 요청에 사용하는 짧은 수명의 토큰 (30분)
  • Refresh Token: Access Token을 갱신하기 위한 긴 수명의 토큰 (7일)

은행 시스템으로 비유

  • Access Token = 일회용 OTP (즉시 사용, 짧은 유효기간)
  • Refresh Token = 실제 은행 카드 (OTP 발급용, 긴 유효기간)


Access Token vs Refresh Token 상세 비교

구분Access TokenRefresh Token설명
목적API 접근 권한토큰 갱신Access Token은 실제 데이터 접근, Refresh Token은 새 토큰 발급만
수명짧음 (15분~1시간)길음 (7~30일)보안과 편의성의 균형점
저장 위치메모리, localStoragehttpOnly 쿠키 권장Refresh Token은 XSS 공격 방어 위해 쿠키 사용
전송 빈도모든 API 요청갱신 시에만네트워크 노출 최소화
검증 방식JWT 서명 검증만JWT + DB 검증Refresh Token은 서버에서 상태 관리
무효화불가능 (만료까지 유효)즉시 가능로그아웃 시 DB에서 삭제


장점과 단점

장점

  1. 보안성 향상:
    • Access Token 탈취 시 최대 30분만 유효
    • Refresh Token은 DB에 해싱되어 저장함으로 이중 보안
  2. 사용자 경험 개선:
    • 7일간 자동 로그인 연장
    • 백그라운드에서 투명한 토큰 갱신
  3. 세밀한 접근 제어:
    • 디바이스별 개별 로그아웃 가능
    • 의심스러운 활동 시 특정 세션만 무효화
  4. 확장성:
    • 마이크로서비스 환경에서 중앙 인증 서버 구축 가능
    • 다양한 클라이언트 (웹, 모바일) 동시 지원

단점

  1. 구현 복잡성:
    • 토큰 상태 관리를 위한 DB 저장소 필요
    • 프론트엔드에서 토큰 갱신 로직 구현 필요
  2. 성능 오버헤드:
    • Refresh Token 검증 시 DB 조회 필요 (Redis 활용 가능?)
    • 토큰 갱신을 위한 추가 네트워크 요청
  3. 동기화 복잡성:
    • 여러 탭/디바이스에서 토큰 상태 동기화 (Redis 활용 가능)
    • Race condition 방지 로직 필요 (lock 매커니즘 활용 가능)


전체 토큰 라이프사이클


구현 과정

환경 변수 설정

Access Token과 Refresh Token은 반드시 다른 시크릿 키를 사용해야 합니다.

# .env 파일
JWT_ACCESS_SECRET=
JWT_REFRESH_SECRET=

데이터베이스 스키마 수정

그런 경우는 많이 없지만, DB가 탈취되어도 원본 토큰을 알 수 없도록 해싱처리했습니다. 그리고 JWT 자체 만료 시간과 별도로 DB에서도 관리하기 위해 별도의 필드를 추가했습니다. 만약에 여러 탭에서 동시 로그인이 가능하게 만든다면 Refresh Token을 위한 별도의 테이블을 만들고 User와 1:N 관계를 맺을 수 있습니다.

// prisma/schema.prisma

model User {
  id                     String    @id @default(uuid())
  email                  String    @unique
  password               String    
  nickname               String?   
  socialId               String?   @unique 
  refreshToken           String?   // bcrypt 해싱된 리프레시 토큰
  refreshTokenExpiresAt  DateTime? // 리프레시 토큰 만료 시간
  createdAt              DateTime  @default(now())
  updatedAt              DateTime  @updatedAt
  
  @@map("users")
}

JWT 서비스 구현 - 토큰 관리의 핵심

Access Token을 Refresh 용도로 사용하는 것을 방지하기 위해 타입별로 다른 시크릿 키 사용하도록 구현했습니다.
그리고 Refresh Token의 원본 토큰은 클라이언트에게, 해시 처리된 것은 DB에 저장합니다.

// src/services/jwt.service.ts
import jwt from 'jsonwebtoken';
import { prisma } from '../utils/prisma.util';
import bcrypt from 'bcrypt';

export class JwtService {
  private accessSecret: string;
  private refreshSecret: string;

  constructor() {
    this.accessSecret = process.env.JWT_ACCESS_SECRET || 'your-accessSecret-key';
    this.refreshSecret = process.env.JWT_REFRESH_SECRET || 'your-refreshSecret-key';
  }

  // 액세스 토큰 생성 (30분)
  generateAccessToken(userId: string): string {
    const payload = {
      id: userId,
      type: 'access',  // 토큰 타입 명시
      iat: Math.floor(Date.now() / 1000),
      exp: Math.floor(Date.now() / 1000) + 30 * 60, // 30분
    };

    return jwt.sign(payload, this.accessSecret);
  }

  // 리프레시 토큰 생성 및 DB 저장
  async generateRefreshToken(userId: string): Promise<string> {
    const payload = {
      id: userId,
      type: 'refresh',  // 토큰 타입 명시
      iat: Math.floor(Date.now() / 1000),
      exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7일
    };

    const refreshToken = jwt.sign(payload, this.refreshSecret);

    // bcrypt로 토큰 해싱
    const tokenHash = await bcrypt.hash(refreshToken, 10);

    const expiresAt = new Date();
    expiresAt.setDate(expiresAt.getDate() + 7);

    // DB에 해시형태의 리프레시 토큰 저장
    await prisma.user.update({
      where: { id: userId },
      data: {
        refreshToken: tokenHash,
        refreshTokenExpiresAt: expiresAt,
      },
    });

    return refreshToken; // 원본 토큰 반환 (클라이언트 전송용)
  }

  // 토큰 쌍 생성 - 로그인/갱신 시 사용
  async generateTokens(userId: string) {
    const accessToken = this.generateAccessToken(userId);
    const refreshToken = await this.generateRefreshToken(userId);

    return {
      accessToken,
      refreshToken,
    };
  }
}

로그인 구현 및 토큰 발급

  • Passport 인증: 이메일/비밀번호 검증을 Passport에 위임
  • 토큰 생성 위임: Controller는 JwtService에 토큰 생성 요청
  • 자동 DB 저장: JwtService 내부에서 Refresh Token 해싱 후 저장
  • 보안 응답: 비밀번호 제외한 사용자 정보만 반환
// src/controllers/auth.controller.ts
export class AuthController {
  constructor(
    private authService: AuthService,
    private jwtService: JwtService
  ) {}

  // Passport Local Strategy를 사용한 로그인
  signInWithPassport = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    passport.authenticate('local', async (error: any, user: any, info: any) => {
      if (error) {
        return next(error);
      }

      if (!user) {
        const errorResponse: ErrorResponseDTO = {
          success: false,
          message: info?.message || '로그인에 실패했습니다.',
        };
        res.status(401).json(errorResponse);
        return;
      }

      try {
        // JWT 토큰 생성 (JwtService 통해서)
        const { accessToken, refreshToken } = await this.jwtService.generateTokens(user.id);

        const { password: _, ...userWithoutPassword } = user;

        const successResponse: ApiResponse<AuthResponseDTO> = {
          success: true,
          message: '로그인이 완료되었습니다.',
          data: {
            user: userWithoutPassword,
            accessToken,
            refreshToken,
          },
        };
        res.json(successResponse);
      } catch (error) {
        next(error);
      }
    })(req, res, next);
  };
}

Refresh Token 검증 미들웨어

JWT Service의 verifyRefreshToken() 메서드를 통해 JWT 서명 검증, 토큰 타입 확인, DB 확인, 토큰 해시 비교, 만료 시간 확인 과정을 거칩니다.

// src/middlewares/auth.middleware.ts

// Refresh Token 전용 미들웨어
export const authenticateRefreshToken = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const authHeader = req.headers.authorization;
    
    if (!authHeader?.startsWith('Bearer ')) {
      return res.status(401).json({
        success: false,
        message: 'Refresh Token이 필요합니다.',
      });
    }

    const token = authHeader.substring(7);
    const jwtService = new JwtService();
    
    // 복잡한 검증 과정 (JWT + DB + 해시 비교)
    const decoded = await jwtService.verifyRefreshToken(token);
    
    (req as AuthenticatedRequest).user = { id: decoded.id };
    (req as AuthenticatedRequest).refreshToken = token;
    next();
    
  } catch (error) {
    return res.status(401).json({
      success: false,
      message: '토큰 인증에 실패했습니다.',
    });
  }
};
// 리프레시 토큰 검증 (다층 보안)
async verifyRefreshToken(refreshToken: string): Promise<any> {
  try {
    // 1. JWT 서명 검증
    const decoded = jwt.verify(refreshToken, this.refreshSecret) as any;

    // 2. 토큰 타입 확인
    if (decoded.type !== 'refresh') {
      throw new Error('유효하지 않은 토큰 타입입니다.');
    }

    // 3. DB에서 사용자와 해시된 토큰 조회
    const user = await prisma.user.findUnique({
      where: { id: decoded.id },
      select: {
        id: true,
        refreshToken: true,
        refreshTokenExpiresAt: true,
      },
    });

    if (!user || !user.refreshToken || !user.refreshTokenExpiresAt) {
      throw new Error('유효하지 않은 리프레시 토큰입니다.');
    }

    // 4. bcrypt로 토큰 해시 비교
    const isTokenValid = await bcrypt.compare(refreshToken, user.refreshToken);
    if (!isTokenValid) {
      throw new Error('유효하지 않은 리프레시 토큰입니다.');
    }

    // 5. 만료 시간 확인
    if (new Date() > user.refreshTokenExpiresAt) {
      await this.revokeRefreshToken(user.id);
      throw new Error('만료된 리프레시 토큰입니다.');
    }

    return { id: user.id };
  } catch (error) {
    throw new Error('유효하지 않은 리프레시 토큰입니다.');
  }
}

API 요청 및 토큰 검증 흐름


토큰 갱신 (Refresh) 로직

  • 미들웨어 분리: 복잡한 검증은 authenticateRefreshToken에서 처리
  • 단순한 재생성: Controller에서는 새 토큰 생성만 담당
  • 자동 덮어쓰기: generateTokens()에서 기존 토큰을 새 토큰으로 교체
// 토큰 재발급
refreshToken = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
  try {
    const user = req.user as any;
    
    // 새로운 토큰 쌍 생성 (간단한 재생성)
    const tokens = await this.jwtService.generateTokens(user.id);

    const successResponse: ApiResponse = {
      success: true,
      message: '토큰이 갱신되었습니다.',
      data: {
        accessToken: tokens.accessToken,
        refreshToken: tokens.refreshToken,
      },
    };
    res.json(successResponse);
  } catch (error) {
    if (error instanceof Error) {
      const errorResponse: ErrorResponseDTO = {
        success: false,
        message: error.message,
      };
      res.status(401).json(errorResponse);
      return;
    }
    next(error);
  }
};


실행 결과

  • 로그인 (passport)
  • 로그아웃
  • 토큰 재발급
profile
조금씩 정리하자!!!

0개의 댓글