CoreERP 인증 구조 개선 (Redis + Refresh Token Rotation 적용)

최병현·2026년 3월 28일

coreerp project

목록 보기
41/44

이번 단계에서는 기존 DB 기반 Refresh Token 구조를 제거하고, Redis를 활용한 인증 구조로 전환하였다. 단순한 저장소 변경이 아니라, 인증 보안 수준을 한 단계 끌어올리는 작업이었다.


1. 기존 구조의 문제점

기존에는 Refresh Token을 DB에 저장하는 방식이었다.

  • Refresh Token을 테이블에 저장
  • 로그인 시 새 토큰 생성
  • Refresh 요청 시 동일 토큰 계속 사용

이 구조의 문제는 다음과 같다.

  • 토큰이 탈취되면 무한 재발급 가능
  • DB I/O 부담 증가
  • TTL 기반 자동 만료 관리 어려움

2. Redis 기반 구조로 변경

Refresh Token 저장소를 DB → Redis로 변경하였다.

Redis를 선택한 이유는 다음과 같다.

  • TTL 기반 자동 만료
  • 빠른 조회 속도
  • 세션 관리에 적합

3. Redis Key 설계

양방향 조회를 위해 2개의 key 구조를 설계하였다.

  • auth:refresh:{token} → userId
  • auth:user-refresh:{userId} → token

이 구조를 통해 다음이 가능해진다.

  • token으로 user 조회
  • user로 token 조회
  • 로그아웃 시 빠른 삭제

4. RefreshTokenService 구현

이 로직은 Backend 인증 세션 관리 핵심이다.

@Service
@RequiredArgsConstructor
public class RefreshTokenService {

    private static final String REFRESH_TOKEN_KEY_PREFIX = "auth:refresh:";
    private static final String USER_REFRESH_KEY_PREFIX = "auth:user-refresh:";

    private final StringRedisTemplate redisTemplate;

    @Value("${coreerp.auth.jwt.refresh-expiration}")
    private long refreshExpiration;

    public RefreshTokenInfo create(Long userId) {
        revokeByUserId(userId);

        String token = UUID.randomUUID().toString();
        Duration ttl = Duration.ofMillis(refreshExpiration);

        redisTemplate.opsForValue().set(
                REFRESH_TOKEN_KEY_PREFIX + token,
                String.valueOf(userId),
                ttl
        );

        redisTemplate.opsForValue().set(
                USER_REFRESH_KEY_PREFIX + userId,
                token,
                ttl
        );

        return new RefreshTokenInfo(
                userId,
                token,
                LocalDateTime.now().plusSeconds(refreshExpiration / 1000),
                false,
                LocalDateTime.now()
        );
    }

    public RefreshTokenInfo verify(String token) {
        String userIdValue = redisTemplate.opsForValue()
                .get(REFRESH_TOKEN_KEY_PREFIX + token);

        if (userIdValue == null) {
            throw new IllegalArgumentException("리프레시 토큰이 유효하지 않거나 만료되었습니다.");
        }

        Long userId = Long.valueOf(userIdValue);

        String savedToken = redisTemplate.opsForValue()
                .get(USER_REFRESH_KEY_PREFIX + userId);

        if (!token.equals(savedToken)) {
            throw new IllegalStateException("이미 교체된 토큰입니다.");
        }

        return new RefreshTokenInfo(userId, token, null, false, null);
    }

    public void revoke(String token) {
        String userIdValue = redisTemplate.opsForValue()
                .get(REFRESH_TOKEN_KEY_PREFIX + token);

        if (userIdValue == null) return;

        Long userId = Long.valueOf(userIdValue);

        redisTemplate.delete(REFRESH_TOKEN_KEY_PREFIX + token);
        redisTemplate.delete(USER_REFRESH_KEY_PREFIX + userId);
    }

    public RefreshTokenInfo rotate(String oldToken) {
        RefreshTokenInfo verified = verify(oldToken);
        revoke(oldToken);
        return create(verified.userId());
    }
}

5. AuthService - Refresh Token Rotation 적용

기존에는 refresh 요청 시 동일 토큰을 유지했지만, 이제는 요청할 때마다 새로운 refresh token을 발급한다.

public AuthTokenResponse refresh(RefreshRequest req) {

    RefreshTokenInfo newRefreshToken =
            refreshTokenService.rotate(req.refreshToken());

    User user = userService.findById(newRefreshToken.userId());

    String accessToken = jwtTokenProvider.createToken(
            user.getId(),
            user.getLoginId(),
            user.getRole().name()
    );

    return new AuthTokenResponse(
            accessToken,
            newRefreshToken.token(),
            user.getId(),
            user.getLoginId(),
            user.getName(),
            user.getRole().name()
    );
}

핵심 변화는 다음과 같다.

  • 기존 토큰 삭제
  • 새 토큰 발급
  • 이전 토큰 재사용 불가

6. 인증 흐름 변화

변경 전

  • login → refresh → refresh → 계속 사용 가능

변경 후

  • login → R1
  • refresh(R1) → R2
  • refresh(R1) → 실패
  • refresh(R2) → R3

즉 refresh token은 1회용으로 동작하게 된다.


7. 테스트 결과

  • login 시 Redis key 생성 확인
  • refresh 시 기존 token 삭제 + 신규 생성 확인
  • logout 시 Redis key 완전 삭제 확인
  • logout 후 refresh 재요청 실패 확인

Redis CLI 결과

keys *
(empty array)

TTL 확인

ttl auth:user-refresh:5
(integer) -2

→ key 완전 삭제 상태 정상


8. 최종 구조

현재 CoreERP 인증 구조는 다음과 같이 분리된다.

  • Access Token → JWT (Stateless)
  • Refresh Token → Redis (Stateful)
  • 업무 데이터 → MariaDB

Frontend → Backend → Redis → DB 구조로 완전히 분리되었다.


9. 이번 단계의 의미

이번 작업은 단순한 기능 추가가 아니라, 인증 시스템을 실무 수준으로 끌어올린 단계이다.

  • DB 의존 인증 제거
  • Redis 기반 세션 관리 도입
  • Refresh Token Rotation 적용
  • 보안 수준 향상

10. 다음 단계

  • Docker Compose 구성
  • AWS EC2 + RDS 배포
  • 프론트 HttpOnly Cookie 적용

마무리

이번 단계로 CoreERP 인증 구조는 단순 JWT 인증을 넘어 실제 서비스 수준의 세션 관리 구조로 발전하였다.

다음 단계는 인프라 구성 및 배포를 통해 실제 서비스 환경으로 확장하는 것이다.

profile
Develop

0개의 댓글