🔍 개요
JWT 기반 인증 시스템을 운영하면서 Refresh Token 저장소를 기존 RDB(MySQL)에서 Redis로 전환하였습니다.
이번 작업은 성능 향상뿐 아니라, 인증 시스템의 Stateless 구조를 더욱 명확히 구현하는 과정이었습니다.
📈 성능 개선 / 코드 개선 요약
- Refresh Token을 Redis 기반 Key-Value 구조로 전환
- 인증 흐름에서 발생하는 불필요한 DB 접근 제거
- TTL 기반 자동 만료와 빠른 조회로 인증 시스템 성능 최적화
🧩 문제 정의
기존에는 Refresh Token을 RDB에 저장하는 방식으로 구현되어 있었습니다. 하지만 이 방식은 다음과 같은 문제를 갖고 있었습니다:
- RDB 부하 증가: 로그인 시 INSERT, 재로그인 시 UPDATE, 재발급 시 SELECT 쿼리 발생
- I/O 병목: 트래픽이 급증할 경우 DB 성능 저하 가능성
- 수동 만료 처리: expiredAt 필드를 매번 비교해야 했음
💡 가설
Redis 기반 저장소로 전환하면 다음과 같은 이점을 기대할 수 있습니다:
- 인메모리 기반의 빠른 읽기/쓰기 성능 확보
- TTL(Time To Live)을 통한 자동 만료 및 데이터 정리
- Redis Key 삭제를 통한 간편한 무효화 처리
- 클러스터링/샤딩을 통한 수평 확장 구조에 최적화
🛠️ 해결 방법 & 기술 선택
🔸 왜 Redis인가?
- 성능 향상
- RDB는 매 로그인 시 INSERT/UPDATE, 토큰 검증 시 SELECT 쿼리 필요
- Redis는 인메모리 기반이므로, Refresh Token 검증과 저장이 빠름 - 확장성
- Redis는 수평 확장이 가능한 구조 (클러스터링, 샤딩 지원)
- 분산 서버 환경에서 중앙 집중식 토큰 저장소로 활용하기 적합 - 간편한 만료 처리
- 기존 RDB에서는 `expiredAt`을 수동으로 비교해야 했지만,
- Redis에서는 TTL 설정만으로 자동 만료 및 데이터 정리 가능 - Stateless 인증 구조에 최적화
- JWT 자체는 Stateless하지만, Refresh Token을 저장하려면 서버 상태를 일부 관리해야 함
- Redis는 상태 저장 공간으로 최소한의 정보를 빠르게 관리하는 데 적합
- 로그아웃 시 Redis 키 삭제로 바로 토큰 무효화 가능
항목 | RDB | Redis |
저장소 구조 | MySQL | In-memory Key-Value |
토큰 조회 | `findByToken()` | `get()` |
만료 처리 | `expiredAt` 수동 비교 | TTL 자동 삭제 |
로그아웃 처리 | `delete` | Key 삭제 |
동시 로그인 | 1개 제한 | 기기별 여러 개 허용 (로그인 시 리프레시 토큰 발급 / 계정당 제한 두지 않음) |
성능 | I/O 비용 존재 | 빠른 응답 속도 |
🔸 RedisTemplate 사용 이유
항목 | `@RedisHash` + `CrudRepository` | `RedisTemplate` |
구조 | 해시(`HSET`) 기반 | 단순 Key-Value(`SET`) |
TTL 관리 | 전체 객체 기준 TTL (`@RedisHash(timeToLive=604800)`) |
Key 단위 TTL 설정 가능 |
키 생성 | 자동 (`refreshToken:{@Id}`) |
직접 명시 가능 (`refreshToken:{token}`) |
Refresh Token은 단일 값(유저ID)만 저장하고 TTL로 만료 시간을 관리할 때는, Hash 구조를 사용할 필요가 없다.
→ Redis의 Key-Value를 직접 사용 (RedisTemplate `opsForValue().set()`, `get()`, `delete()`사용)
🧱 구현 내용
🔸 엔티티 구조 변경
⛔ 변경 전 (RDB)
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshToken extends TimeStamped {
private static final long EXPIRATION_DAYS = 7; // 만료 시간 7일
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long userId;
@Column(nullable = false)
private String token;
@Column(nullable = false)
private LocalDateTime expiredAt;
// 생성자 등 생략
}
✅ 변경 후 (DTO) → 단순 데이터 전달
@Builder
@Getter
public class RefreshToken {
private String token;
private Long userId;
}
- 데이터 저장/삭제는 `TokenService`에서 `RedisTemplate`으로 명시적으로 수행
- `@Entity`, `@RedisHash`, `CrudRepository`를 사용하지 않음
- Redis 저장소의 리프레시 토큰: TTL을 명시
- Cookie의 리프레시 토큰: setMaxAge
- → DTO에서 `expiredAt`을 따로 관리할 필요가 없음
- → 토큰 값과 userId만 담아서 전달하는 역할
🔸 서비스 로직 변경
- 기존 RefreshTokenRepository 제거 → RedisTemplate<String, String> 사용
- 토큰 저장: opsForValue().set(key, value, ttl)
- 토큰 삭제: delete(key)
https://github.com/spring-team-7/table-now/pull/111
[refactor] RefreshToken 저장 방식 Redis로 전환 및 테스트 개선 by mannaKim · Pull Request #111 · spring-team-7/tab
🔗 Issue Number close #101 📝 작업 내역 RefreshToken 저장소를 RDB에서 Redis로 변경 Redis TTL 설정을 통해 리프레시 토큰 자동 만료 처리 적용 로그아웃 시 Redis에서 토큰 삭제 처리 구현 불필요한 @Transactio
github.com
🎉 결과 및 효과
- 로그인 응답 시간 단축 (DB 접근 제거)
- Refresh Token 만료 로직 단순화 및 안정성 확보
- Stateless 인증 구조에 맞는 저장소 구성 달성
- 다중 기기 로그인 대응 가능 (토큰 중복 저장 가능)
🪞 회고 & 개선 방향
배운 점
- JWT 기반 인증 흐름과 RefreshToken 관리의 실제 운영 관점 이해
- Redis TTL, Key 관리 방식에 대한 실전 경험
- 인증 시스템 설계에서 보안성과 성능을 동시에 고려해야 한다는 교훈
고민했던 점
- @RedisHash를 사용할지, RedisTemplate으로 명시적으로 관리할지 → TTL 설정과 관리 유연성 때문에 후자 선택
- 다중 로그인 정책 적용 시 userId 기반 저장보다 token 기반 저장이 더 유연하다는 점 고려
다음 개선 목표
- Refresh Token 저장 방식 외에도 AccessToken 블랙리스트 처리 기능 적용 예정
- Redis 기반 세션/토큰 관리 전반을 고도화하여 보안성 향상 도모
🏁 마무리
이번 리팩토링을 통해 RDB 기반 인증 시스템에서 발생하는 병목 요소를 제거하고, 더 나은 성능과 확장성을 확보할 수 있었습니다.
JWT의 Stateless 특성을 살리면서도 필요한 상태 정보(RefreshToken)를 가볍고 효율적인 Redis로 전환하여 구조적 일관성도 유지할 수 있었습니다.
'Dev Projects' 카테고리의 다른 글
[table-now] AccessToken 블랙리스트 기반 토큰 무효화 기능 구현 (0) | 2025.04.22 |
---|---|
[table-now] 소셜 로그인(Kakao, Naver) 통합 구현기 (1) | 2025.04.21 |
[Spring Data JPA_일정 관리 앱 Develop] 필수 & 도전 기능 구현 기록 🖋️ (1) | 2025.02.13 |
[Spring JDBC_일정 관리 앱 만들기] 회고 및 트러블 슈팅 (0) | 2025.02.04 |
[Java Project_키오스크 과제] 도전 기능 구현 및 트러블 슈팅 (0) | 2025.01.20 |