Spring 7기 프로젝트/코드 개선 + 테스트 코드 프로젝트

[트러블슈팅] Spring Boot JWT 인증 시스템의 보안 업그레이드 (JWT 토큰 블랙리스트 + Rate Limiting 시스템)

JuNo_12 2025. 6. 8. 22:12

들어가며

JWT 기반 인증 시스템을 구현하면서 "실무에서도 바로 쓸 수 있는 보안 시스템을 만들어보자!"는 목표를 세웠습니다.

단순한 로그인/로그아웃을 넘어서, 실제 서비스에서 겪을 수 있는 보안 위협에 대응할 수 있는 시스템을 구축하는 과정을 공유합니다.


기존 시스템의 한계

처음 구현한 JWT 인증 시스템은 기본적인 기능은 잘 동작했지만, 보안적으로 몇 가지 취약점이 있었습니다:

// 기존 로그인 로직 - 단순함
@Transactional
public SigninResponse signin(SigninRequest request) {
    User user = userRepository.findByEmail(request.getEmail())
        .orElseThrow(() -> new AuthException("인증 실패"));
    
    if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
        throw new AuthException("인증 실패");
    }
    
    // JWT 토큰 발급
    return new SigninResponse(createToken(user), createRefreshToken(user));
}

 

문제점들:

  • 무차별 대입 공격 취약: 몇 번이고 시도 가능
  • 로그아웃된 토큰 재사용 가능
  • 일관성 없는 예외 처리
  • 보안 이벤트 추적 부족

해결 과정 1: JWT 토큰 블랙리스트 구현

JTI(JWT ID) 추가

먼저 각 JWT 토큰을 개별적으로 추적할 수 있도록 고유 식별자를 추가했습니다:

// JWT 생성 시 JTI 추가
public String createToken(Long userId, String email, UserRole userRole) {
    String jti = UUID.randomUUID().toString(); // 고유 식별자
    
    return Jwts.builder()
        .setId(jti)  // JWT ID 설정
        .setSubject(String.valueOf(userId))
        .claim("email", email)
        .claim("userRole", userRole.name())
        // ... 기타 설정
        .compact();
}

 

블랙리스트 시스템 구축

로그아웃된 토큰의 재사용을 방지하기 위한 블랙리스트 시스템을 구현했습니다:

@Entity
@Table(name = "token_blacklist")
public class TokenBlacklist {
    @Id
    private String jti;           // JWT ID
    private Long userId;          // 사용자 ID
    private LocalDateTime expiresAt;  // 토큰 만료 시간
}
 
@Service
public class TokenBlacklistService {
    
    public void addToBlacklist(String jti, Long userId, LocalDateTime expiresAt) {
        if (tokenBlacklistRepository.existsById(jti)) {
            return; // 중복 방지
        }
        
        TokenBlacklist blacklistToken = new TokenBlacklist(jti, userId, expiresAt);
        tokenBlacklistRepository.save(blacklistToken);
    }
    
    public boolean isBlacklisted(String jti) {
        return tokenBlacklistRepository
            .existsByJtiAndExpiresAtAfter(jti, LocalDateTime.now());
    }
}

 

자동 정리 스케줄러

메모리 효율성을 위해 만료된 블랙리스트 토큰을 자동으로 정리하는 스케줄러를 추가했습니다:

@Scheduled(fixedRate = 300000) // 5분마다 실행
@Transactional
public void cleanupExpiredTokens() {
    try {
        LocalDateTime now = LocalDateTime.now();
        tokenBlacklistRepository.deleteExpiredTokens(now);
    } catch (Exception e) {
        log.error("블랙리스트 토큰 정리 중 오류 발생", e);
    }
}

해결 과정 2: Rate Limiting 시스템 구현

로그인 시도 추적

무차별 대입 공격을 방지하기 위해 이메일별 로그인 시도 횟수를 추적하는 시스템을 구현했습니다:

@Service
public class LoginAttemptService {
    
    private static final int MAX_ATTEMPTS = 5;
    private static final int BLOCK_DURATION_MINUTES = 15;
    
    private final ConcurrentHashMap<String, AttemptInfo> loginAttempts = new ConcurrentHashMap<>();
    
    public void recordFailedAttempt(String email) {
        String key = email.toLowerCase();
        
        loginAttempts.compute(key, (k, attemptInfo) -> {
            if (attemptInfo == null) {
                return new AttemptInfo(1, LocalDateTime.now());
            } else {
                return new AttemptInfo(
                    attemptInfo.attemptCount() + 1,
                    LocalDateTime.now()
                );
            }
        });
    }
    
    public boolean isBlocked(String email) {
        // 차단 로직 구현
        AttemptInfo attemptInfo = loginAttempts.get(email.toLowerCase());
        if (attemptInfo == null || attemptInfo.attemptCount() < MAX_ATTEMPTS) {
            return false;
        }
        
        LocalDateTime blockUntil = attemptInfo.lastAttemptTime().plusMinutes(BLOCK_DURATION_MINUTES);
        return LocalDateTime.now().isBefore(blockUntil);
    }
}

 

AuthService에 Rate Limiting 통합

@Transactional
public SigninResponse signin(SigninRequest signinRequest) {
    String email = signinRequest.getEmail();

    // 1. 로그인 차단 확인
    if (loginAttemptService.isBlocked(email)) {
        long remainingMinutes = loginAttemptService.getRemainingBlockTimeMinutes(email);
        throw new RateLimitException("로그인이 일시적으로 차단되었습니다.", remainingMinutes);
    }

    // 2. 사용자 조회
    User user = userRepository.findByEmail(email)
        .orElseThrow(() -> {
            loginAttemptService.recordFailedAttempt(email); // 실패 기록
            return new AuthException("이메일 또는 비밀번호가 올바르지 않습니다.");
        });

    // 3. 비밀번호 검증
    if (!passwordEncoder.matches(signinRequest.getPassword(), user.getPassword())) {
        loginAttemptService.recordFailedAttempt(email); // 실패 기록
        throw new AuthException("이메일 또는 비밀번호가 올바르지 않습니다.");
    }

    // 4. 성공 시 카운터 리셋
    loginAttemptService.recordSuccessfulLogin(email);
    
    // 5. 토큰 발급
    String accessToken = jwtTokenProvider.createToken(user.getId(), user.getEmail(), user.getUserRole());
    String refreshToken = refreshTokenService.createRefreshToken(user.getId());

    return new SigninResponse(accessToken, refreshToken);
}

해결 과정 3: 예외 처리 체계 개선

일관된 에러 응답 형태

클라이언트가 쉽게 처리할 수 있도록 일관된 에러 응답 형태를 만들었습니다:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(AuthException.class)
    public ResponseEntity<Map<String, Object>> handleAuthException(AuthException ex) {
        return createErrorResponse("E2001", ex.getMessage(), HttpStatus.UNAUTHORIZED);
    }

    @ExceptionHandler(RateLimitException.class)
    public ResponseEntity<Map<String, Object>> handleRateLimitException(RateLimitException ex) {
        Map<String, Object> errorResponse = createErrorResponse(
            "E2006", ex.getMessage(), HttpStatus.TOO_MANY_REQUESTS);
        
        // Rate Limit 전용 정보 추가
        errorResponse.put("remainingTimeMinutes", ex.getRemainingTimeMinutes());
        errorResponse.put("retryAfter", ex.getRemainingTimeMinutes() * 60);
        
        return new ResponseEntity<>(errorResponse, HttpStatus.TOO_MANY_REQUESTS);
    }

    private Map<String, Object> createErrorResponse(String code, String message, HttpStatus status) {
        Map<String, Object> errorResponse = new HashMap<>();
        errorResponse.put("code", code);
        errorResponse.put("message", message);
        errorResponse.put("status", status.name());
        errorResponse.put("timestamp", LocalDateTime.now());
        return errorResponse;
    }
}

 

클라이언트 친화적 응답

// 일반 인증 실패
{
  "code": "E2001",
  "message": "이메일 또는 비밀번호가 올바르지 않습니다.",
  "status": "UNAUTHORIZED",
  "timestamp": "2024-03-15T10:30:00"
}

// Rate Limit 초과
{
  "code": "E2006",
  "message": "로그인이 일시적으로 차단되었습니다.",
  "status": "TOO_MANY_REQUESTS",
  "timestamp": "2024-03-15T10:30:00",
  "remainingTimeMinutes": 12,
  "retryAfter": 720
}

기술적 선택과 고민

1. Redis vs Database

블랙리스트 저장소로 Redis를 고려했지만, 현재 프로젝트 규모에서는 Database가 더 효율적이라고 판단했습니다:

Database 선택 이유:

  • 트랜잭션 안정성
  • 데이터 영속성 (서버 재시작 시에도 유지)
  • 인프라 복잡성 감소
  • 현재 규모에서는 성능상 차이 미미

 

2. 메모리 기반 Rate Limiting

Rate Limiting은 메모리 기반 ConcurrentHashMap을 사용했습니다:

메모리 선택 이유:

  • 빠른 성능
  • 서버 재시작 시 자동 리셋 (오히려 장점)
  • 구현 단순성
  • 분산 환경은 추후 고려

 

3. 단순한 예외 처리 체계

복잡한 예외 상속 구조 대신 기본 예외 클래스에 에러 코드만 추가하는 방식을 선택했습니다:

단순함의 장점:

  • 개발 속도 향상
  • 유지보수 용이성
  • 팀원들의 이해도 높임
  • 오버엔지니어링 방지

결과 및 효과

보안 강화 측면

  • 무차별 대입 공격 방지 (5회 실패 시 15분 차단)
  • 로그아웃된 토큰 재사용 불가
  • 개인정보 로그 노출 방지

개발 경험 측면

  • 일관된 에러 응답으로 프론트엔드 개발 편의성 증대
  • 에러 코드 기반 분기 처리 가능
  • 디버깅 및 모니터링 용이성 향상

성능 측면

  • 자동 정리 스케줄러로 메모리 효율성 확보
  • 인덱스 기반 빠른 블랙리스트 조회
  • ConcurrentHashMap으로 동시성 처리

 

앞으로의 개선 방향

현재 시스템을 기반으로 다음과 같은 기능들을 추가로 고려하고 있습니다:

  1. 다중 기기 관리: 기기별 로그아웃 기능
  2. 토큰 암호화: JWE(JSON Web Encryption) 적용