프로젝트

[프로젝트] Spring Boot 에서 인증 시스템 설계하기 - OTP 2FA + JWT + Redis로 보안 구현 (feat. 스프링부트, Spring Security, Redis TTL 기반 임시 저장소)

투웨이코더 2026. 2. 19. 01:27

상담사용 인증 시스템을 설계하며 고민한 보안 아키텍처, Redis 기반 OTP 챌린지, JWT Refresh Token Rotation 전략을 3단계 Phase로 정리했습니다.

 

 

1. [Phase 1] 로그인 → OTP 챌린지 생성

설계 원칙: "비밀번호만으로는 문을 열 수 없다"

 

상담사가 이메일과 비밀번호를 제출하면, 서버는 토큰을 발급하지 않습니다.

대신 OTP 챌린지를 생성하고 이메일로 6자리 인증 코드를 발송합니다.

 

이 단계에서 신경 쓴 보안 포인트는 세 가지입니다.

 

첫째, 이메일 열거(Email Enumeration) 방지.

이메일이 존재하지 않는 경우와 비밀번호가 틀린 경우를 동일한 에러 코드(INVALID_CREDENTIALS)로 응답합니다. 공격자가 "이 이메일은 가입되어 있다"는 정보를 얻지 못하도록 하기 위함입니다.

// 이메일 미존재와 비밀번호 불일치를 의도적으로 동일하게 처리
User user = userRepository.findByEmail(request.email())
    .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_CREDENTIALS));

if (!passwordEncoder.matches(request.password(), user.getPassword())) {
    throw new BusinessException(ErrorCode.INVALID_CREDENTIALS);
}

둘째, 역할 기반 접근 제어.

Role.COUNSELOR가 아닌 사용자는 로그인 자체가 차단됩니다. 내담자 계정으로 상담사 대시보드에 접근하는 경로를 원천 봉쇄합니다.

셋째, OTP는 SecureRandom으로 생성합니다.

Math.random()이 아닌 암호학적으로 안전한 난수 생성기를 사용하여, OTP 예측 공격을 방지합니다.

private final SecureRandom secureRandom = new SecureRandom();

private String generateOtp() {
    int otp = 100_000 + secureRandom.nextInt(900_000);  // 100000~999999
    return String.valueOf(otp);
}

 

 

Redis OTP 챌린지 구조

Key:   otp:challenge:{uuid}
Type:  Hash
TTL:   300초 (5분)

Fields:
  userId    → 1 (Long)
  otp       → "482917" (6자리)
  attempts  → 0 (최대 5회)
  email     → "아이디@프로젝트.com" (재전송용)

 

왜 Redis Hash인가?

OTP 검증 시 코드 비교, 시도 횟수 증가, 사용자 ID 조회를 하나의 키에서 원자적으로 처리할 수 있습니다.

String으로 직렬화하면 매번 역직렬화/재직렬화 오버헤드가 발생하고 시도 횟수만 증가시키는 것도 전체 값을 다시 써야 합니다.

 

2. [Phase 2] OTP 검증 → JWT 발급 + Refresh Token Rotation

OTP가 검증되면 비로소 토큰이 발급됩니다. 여기서 핵심은 Refresh Token Rotation 전략입니다.

토큰 구조

토큰 저장 위치  TTL 용도
Access Token Response Body → 클라이언트 메모리 15분 API 인증
Refresh Token HttpOnly Secure Cookie + Redis 1일 Access Token 갱신

Access Token을 쿠키가 아닌 Response Body로 반환하는 이유는 CSRF 공격 방지입니다.

쿠키에 넣으면 브라우저가 자동으로 포함시키므로 CSRF에 취약해집니다.

반면 Refresh Token은 HttpOnly; Secure; SameSite=Lax 속성으로 쿠키에 담아 XSS 공격으로부터 보호합니다.

토큰 저장 전략 - XSS vs CSRF 방어

 

Refresh Token Rotation 중요 이유

[정상 흐름]
Client → POST /refresh (Cookie: RT_v1)
Server → Redis에서 RT_v1 확인 → RT_v2 생성 → Redis 업데이트 → 응답(RT_v2)

[토큰 탈취 감지]
Attacker → POST /refresh (Cookie: RT_v1)  ← 탈취된 토큰
Server   → Redis 저장값은 RT_v2 → 불일치 → TOKEN_REUSED 예외 → 세션 무효화

 

Redis에 마지막으로 발급한 Refresh Token만 저장하기 때문에 이미 사용된(Rotation된) 토큰으로 재요청하면 즉시 탈취를 감지합니다. 이 시점에서 해당 사용자의 Refresh Token을 삭제하여 공격자와 정당한 사용자 모두 재로그인을 요구합니다.

public TokenRefreshResponse refresh(String refreshToken) {
    // 1. 토큰 검증
    jwtTokenProvider.validateToken(refreshToken);
    Long userId = jwtTokenProvider.getUserId(refreshToken);

    // 2. Redis 저장값과 비교 → Rotation 검증
    String stored = (String) redisTemplate.opsForValue().get("refresh:" + userId);
    if (!refreshToken.equals(stored)) {
        redisTemplate.delete("refresh:" + userId);  // 세션 전체 무효화
        throw new BusinessException(ErrorCode.TOKEN_REUSED);
    }

    // 3. 새 토큰 쌍 발급 (Rotation)
    String newAccess = jwtTokenProvider.createAccessToken(userId);
    String newRefresh = jwtTokenProvider.createRefreshToken(userId);
    redisTemplate.opsForValue().set("refresh:" + userId, newRefresh, Duration.ofDays(1));

    return new TokenRefreshResponse(newAccess, newRefresh);
}

 

OTP 보안 정책

정책 사유
최대 시도 횟수 5회 6자리 OTP Brute-force 방지
챌린지 TTL 5분 코드 노출 시간 최소화
재전송 쿨다운 60초 이메일 폭탄 방지
재전송 시 OTP 재생성 O 이전 코드 무효화

5회 초과 시 챌린지 자체를 Redis에서 삭제하여 같은 challengeId로는 더 이상 시도할 수 없도록 합니다.

사용자는 로그인부터 다시 시작해야 합니다.

 

3. [Phase 3] Spring Security JWT 필터 체인

인증된 상담사만 보호된 API에 접근할 수 있도록 JwtAuthenticationFilter를 구현했습니다.

 

JWT 필터 인증 시퀀스

 

필터 동작 흐름

1. Request 수신
2. Authorization: Bearer {token} 에서 토큰 추출
3. JwtTokenProvider.validateToken() — 서명 검증 + 만료 확인
4. 토큰에서 userId 추출 → UserRepository.findById()
5. UserPrincipal(UserDetails 구현체) 생성
6. SecurityContext에 Authentication 객체 설정
7. 다음 필터/컨트롤러로 전달

 

Security 설정 핵심

http
    .csrf(AbstractHttpConfigurer::disable)           // Stateless이므로 CSRF 불필요
    .sessionManagement(session ->
        session.sessionCreationPolicy(STATELESS))     // 서버 세션 생성 안 함
    .authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/v1/counselor/auth/**").permitAll()  // 인증 API는 열어둠
        .requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll()
        .anyRequest().authenticated())                // 나머지는 JWT 필수
    .addFilterBefore(jwtAuthenticationFilter(),
        UsernamePasswordAuthenticationFilter.class);  // JWT 필터를 앞단에 배치

 

CSRF를 비활성화한 이유는 세션 기반 인증에서는 CSRF 토큰이 필수지만 JWT Stateless 인증에서는 브라우저가 자동으로 인증 정보를 보내지 않으므로(Access Token은 메모리에서 수동으로 헤더에 추가) CSRF 방어가 불필요합니다.

 

Redis 키 생명주기

1) OTP 챌린지 — otp:challenge:{uuid}

구분 타입 TTL 필드
Hash 5분 userId, otp, attempts, email

상태 전이

생성 - POST /login 성공 시 HSET + EXPIRE 300초

↓ OTP 불일치 → HINCRBY attempts +1 → 5회 미만이면 재시도 가능

↓ 5회 초과 → DEL (챌린지 삭제, 로그인부터 재시작)

↓ OTP 일치 → DEL (챌린지 소멸) → JWT 발급 단계로 진행

↓ TTL 5분 만료 → 자동 삭제 (CHALLENGE_EXPIRED)

 

2) Refresh Token — refresh:{userId}

구분 타입 TTL
String 1일 Refresh Token JWT

상태 전이

생성 - OTP 검증 성공 시 SET refresh:{userId} + EXPIRE 86400초

↓ POST /refresh (정상) → Redis 저장값과 일치 → 새 RT로 SET 교체 (Rotation)

↓ POST /refresh (불일치) → 🚨 TOKEN_REUSED → DEL (세션 전체 무효화)

↓ POST /logout → DEL (정상 로그아웃)

↓ TTL 1일 만료 → 자동 삭제 (재로그인 필요)

 

4. 되돌아보며 (설계 시 가장 많이 고민한 것)

4.1. "왜 세션 토큰이 아니라 JWT인가?"

JWT는 토큰 자체에 인증 정보가 담겨 있어 Stateless하게 연결을 인증할 수 있습니다.

4.2. "Refresh Token을 왜 Redis에 저장하나?"

JWT는 발급 후 서버가 무효화할 수 없습니다. 로그아웃이나 토큰 탈취 시 즉시 무효화하려면 서버 측 상태가 필요합니다.

Redis를 선택한 이유는 TTL 자동 만료, O(1) 조회 성능, 그리고 이미 OTP 챌린지 저장소로 사용 중이었기 때문입니다.

4.3. "Phase를 왜 3단계로 나눴나?"

처음부터 완벽한 인증 시스템을 만들려 하면 복잡도에 압도됩니다.

Phase 1(로그인+OTP 생성) → Phase 2(OTP 검증+JWT 발급) → Phase 3(Security 필터 체인)으로 나누니

각 단계에서 테스트와 검증이 가능했고, 코드 리뷰 단위도 적절했습니다.

 

5. 마치며

상담 플랫폼의 인증 시스템을 설계하면서 보안은 기능이 아니라 아키텍처 결정의 연속이라는 것을 체감했습니다.

이메일 열거 방지를 위해 에러 메시지를 통일하고

Refresh Token Rotation으로 탈취를 감지하며

Redis TTL로 OTP 생명주기를 관리하는 것

하나하나는 단순하지만 이것들이 결합되어 비로소 민감한 데이터를 보호할 수 있는 인증 체계가 완성됩니다.