문제 해결 과정 (Problem Solving Journey)/로직 개선 경험

JWT 기반 인증 시스템 개선: 보안과 성능 사이의 균형

코린이ch 2024. 10. 22. 14:11

들어가며

안녕하세요. 이번 포스트에서는 Spring Security와 JWT를 활용한 인증 시스템을 개선하는 과정에 대해 다뤄보겠습니다. 기존 시스템의 문제점을 파악하고, 이를 단계적으로 개선해 나간 과정을 다루어보겠습니다.


기존 시스템 분석

(1) 시스템 구조

기존 인증 시스템은 다음과 같은 구조로 되어 있었습니다:

- JwtTokenProvider: JWT 토큰 생성 및 검증
- CustomAuthenticationProvider: 사용자 인증 처리
- JwtAuthenticationFilter: 요청별 토큰 검증

(2) 발견된 문제점들

  1. 단일 토큰 사용의 한계
    • 토큰 탈취 시 즉각적인 대응이 어려움
    • 토큰 만료 시 사용자가 재로그인해야 하는 불편
  2. 제한적인 보안 기능
    • 기본적인 토큰 검증만 수행
    • 비정상적인 접근에 대한 제어 부재
  3. 운영 모니터링의 어려움
    • 로그 수준의 단순한 추적
    • 보안 사고 발생 시 대응이 어려움
  4. 성능 이슈
    • 잦은 데이터베이스 조회
    • 캐싱 전략 부재

JWT에 더해 사용한 Redis에 대한 간략한 설명이 필요하다면? ↓

더보기

Redis란?

Redis(Remote Dictionary Server)는 인메모리 데이터 구조 저장소로, 키-값 형태의 데이터를 고성능으로 처리할 수 있습니다. 주요 특징으로는:

  • 데이터를 메모리에 저장하여 빠른 읽기/쓰기 가능
  • TTL(Time To Live) 설정으로 데이터 자동 만료 관리
  • 다양한 데이터 구조(String, Hash, List, Set 등) 지원
  • Pub/Sub 기능으로 실시간 이벤트 처리 가능

JWT 인증에서의 Redis 활용

Redis는 JWT 기반 인증 시스템에서 다음과 같은 용도로 활용될 수 있습니다:

토큰 블랙리스트 관리

# 로그아웃된 토큰 저장 (TTL 설정)
SET "blacklist:{token}" "true" EX 3600

Refresh Token 저장소

# 사용자별 Refresh Token 관리
SET "refresh:{userId}" "{refreshToken}" EX 604800

사용자 세션 관리

# 디바이스별 세션 정보 저장
HSET "session:{userId}" 
     "deviceId" "{deviceToken}"
     "lastAccess" "{timestamp}"

Rate Limiting

# API 호출 횟수 제한
INCR "rate:{ip}"
EXPIRE "rate:{ip}" 60

Redis 활용의 장점

  1. 토큰 관리 효율성
    • 만료 시간 자동 관리로 메모리 효율적 사용
    • 분산 환경에서 토큰 상태 동기화 용이
  2. 보안성 강화
    • 실시간 토큰 무효화 가능
    • 비정상적인 접근 패턴 탐지 및 차단
  3. 성능 최적화
    • 인메모리 저장소로 빠른 토큰 검증
    • 사용자 세션 정보 캐싱
  4. 확장성
    • 클러스터 구성으로 고가용성 확보
    • 다양한 인증 시나리오 대응 가능

개선 과정

1단계: Refresh Token 도입

첫 번째로 착수한 것은 Refresh Token 도입이었습니다. 이는 보안과 사용자의 경험적인 측면을 모두 고려하기 위한 선택이라고 볼 수 있습니다.

public class TokenPair {
    private final String accessToken;
    private final String refreshToken;
    private final long accessTokenExpiration;
    private final long refreshTokenExpiration;
    
    // 생성자, getter 등
}

@Service
public class TokenService {
    public TokenPair generateTokenPair(Authentication auth) {
        String accessToken = generateAccessToken(auth);
        String refreshToken = generateRefreshToken(auth);
        
        // Redis에 Refresh Token 저장
        storeRefreshToken(auth.getName(), refreshToken);
        
        return new TokenPair(accessToken, refreshToken,
            accessTokenExpiration, refreshTokenExpiration);
    }
}

2단계: 예외 처리 체계화

운영 안정성 확보를 위해 예외 처리를 체계화했습니다. 기존 Demo 버전 구현 시에는 보안 공격이나 오류에 대한 접근을 배제하며 초기 개발을 해서 추가하는 과정이라고 볼 수 있습니다.

@ControllerAdvice
public class SecurityExceptionHandler {
    @ExceptionHandler(TokenExpiredException.class)
    public ResponseEntity<ErrorResponse> handleTokenExpired(TokenExpiredException ex) {
        logSecurityEvent("TOKEN_EXPIRED", ex);
        return ResponseEntity
            .status(HttpStatus.UNAUTHORIZED)
            .body(new ErrorResponse("AUTH001", "토큰이 만료되었습니다"));
    }
    
    @ExceptionHandler(InvalidTokenException.class)
    public ResponseEntity<ErrorResponse> handleInvalidToken(InvalidTokenException ex) {
        logSecurityEvent("INVALID_TOKEN", ex);
        return ResponseEntity
            .status(HttpStatus.UNAUTHORIZED)
            .body(new ErrorResponse("AUTH002", "유효하지 않은 토큰입니다"));
    }
}

3단계: 보안 모니터링 강화

보안 사고 대응을 위한 모니터링 시스템을 구축했습니다.

@Component
public class SecurityAuditLogger {
    private final AuditEventRepository auditRepository;
    
    public void logAuthEvent(String principal, String type, Map<String, Object> data) {
        AuditEvent event = new AuditEvent(
            LocalDateTime.now(),
            principal,
            type,
            data
        );
        auditRepository.save(event);
    }
}

4단계: Rate Limiting 구현

API 보호를 위한 Rate Limiting을 구현했습니다.

@Component
public class RateLimitingFilter extends OncePerRequestFilter {
    private final RedisTemplate<String, Integer> redisTemplate;
    private static final int MAX_REQUESTS = 100;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                  HttpServletResponse response,
                                  FilterChain filterChain) {
        String key = getClientKey(request);
        if (isRateLimitExceeded(key)) {
            throw new TooManyRequestsException();
        }
        filterChain.doFilter(request, response);
    }
}

5단계: 성능 최적화

마지막으로 캐싱을 통한 성능 최적화를 진행했습니다.

@Service
public class CachingUserDetailsService implements UserDetailsService {
    private final LoadingCache<String, UserDetails> userCache;
    
    @Override
    public UserDetails loadUserByUsername(String username) {
        try {
            return userCache.get(username);
        } catch (ExecutionException e) {
            throw new UsernameNotFoundException("User not found", e);
        }
    }
}

개선 효과

  1. 보안성 강화
    • Access Token 유효 기간 단축 (기존 24시간 → 15분)
    • Refresh Token 기반 관리로 토큰 탈취 위험 감소
    • 비정상 접근 시도에 대한 추적 및 차단 가능
  2. 사용자 경험 개선
    • 자동 토큰 갱신으로 불필요한 재로그인 감소
    • Rate Limiting 도입으로 시스템 안정성 향상
    • 구체적인 에러 메시지로 문제 해결 용이성 증가
  3. 운영 효율성 향상
    • 보안 이슈 실시간 모니터링 가능
    • 사용자별, API별 접근 패턴 분석 가능
    • 캐싱 도입으로 데이터베이스 부하 분산

마치며

이번 개선 작업을 통해 얻은 깨달음(?)은 다음과 같습니다:

  1. 보안과 사용자 경험은 때로 상충되지만, 적절한 기술 선택으로 두 가지를 모두 개선할 수 있습니다.
  2. 모니터링과 로깅은 선택이 아닌 필수입니다.
  3. 점진적인 개선이 한번에 모든 것을 바꾸려는 것보다 효과적입니다.

 

다음 포스팅은 JWT와 Session 기반의 인증을 비교 분석해볼 계획입니다. 이에 대한 내용은 추후 포스팅으로 다루도록 하겠습니다. 감사합니다.