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으로 동시성 처리
앞으로의 개선 방향
현재 시스템을 기반으로 다음과 같은 기능들을 추가로 고려하고 있습니다:
- 다중 기기 관리: 기기별 로그아웃 기능
- 토큰 암호화: JWE(JSON Web Encryption) 적용