728x90
1. 도입
JWT 기반 인증 시스템을 구축하면서 가장 먼저 마주친 고민은 바로 Refresh Token의 설계였다.
Access Token은 stateless한 특성 때문에 자체 만료를 기반으로 검증이 가능하지만, Refresh Token은 사용자 인증 상태를 지속적으로 관리해야 한다는 부담이 있다.
이번 글에서는 Redis 기반으로 리프레시 토큰을 설계한 과정과,
실제 운영 중 발생할 수 있는 트러블슈팅 이슈 및 해결 방식까지 상세히 정리한다.
2. 설계 목표
- 기기 단위 인증 상태 관리: 사용자 단일 계정에 여러 디바이스 로그인 허용
- 로그아웃 및 재발급 처리 가능: 토큰 무효화 시 즉시 반영
- 보안성 확보: Refresh Token은 서버에서만 저장하고, 만료 시 자동 제거
- 스케일 아웃에 유리한 구조: Redis 기반 세션 스토리지 채택
3. 설계 고려사항
| 항목 | 고려 내용 |
|---|---|
| Storage | RDB vs Redis → Redis 선택 (TTL, 빠른 조회, 로그아웃 처리에 유리) |
| Key 전략 | refresh:{userId}:{deviceId} 로 구성 → 디바이스 단위 식별 가능 |
| Expiration | JWT 내 exp 클레임 + Redis TTL 병행 |
| 로그아웃 | Refresh 삭제 + AccessToken jti 기반 블랙리스트 관리 |
| TTL 동기화 | Redis TTL = JWT 내 exp 시간으로 정확히 동기화 필요 |
4. 구현 설명
🔹 RefreshTokenService
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final StringRedisTemplate redisTemplate;
private String key(String userId, String deviceId) {
return "refresh:" + userId + ":" + deviceId;
}
public void save(String userId, String deviceId, String token, Duration ttl) {
redisTemplate.opsForValue().set(key(userId, deviceId), token, ttl);
}
public Optional<String> get(String userId, String deviceId) {
return Optional.ofNullable(redisTemplate.opsForValue().get(key(userId, deviceId)));
}
public void delete(String userId, String deviceId) {
redisTemplate.delete(key(userId, deviceId));
}
public void deleteAllByUser(String userId) {
redisTemplate.keys("refresh:" + userId + ":*").forEach(redisTemplate::delete);
}
}
🔹 토큰 발급 시 저장
String refreshToken = jwtTokenProvider.issueRefreshToken(userId, deviceId);
refreshTokenService.save(userId, deviceId, refreshToken, jwtTokenProvider.getRefreshTokenTTL());
🔹 로그아웃 처리
logoutService.logout(accessToken, userId, deviceId);
→ Refresh 제거 + AccessToken jti → Redis blacklist 저장 (TTL 설정)
5. 테스트 전략
| 시나리오 | 검증 포인트 |
|---|---|
| 정상 로그인 | Access + Refresh 모두 발급되고, Redis에 저장됨 |
| 동일 디바이스 재로그인 | 이전 토큰은 삭제되고 새 Refresh 갱신 |
| 다른 디바이스 로그인 | 기존 디바이스 토큰 유지, 새로운 토큰 발급 |
| 로그아웃 | Refresh 삭제, Access 블랙리스트 등록 확인 |
| TTL 만료 | Redis에서 자동 삭제되는지 확인 |
6. 트러블슈팅 사례
(1) No setter found for property: access-expiration
JwtProperties에서access-expiration과refresh-expiration필드명을 Java 변수명과 YML 키가 다르게 매핑되면서 바인딩 실패- 해결:
@ConfigurationProperties에서@Setter추가 + YML 키명을 카멜케이스로 통일
@ConstructorBinding // 문제 유발
@ConfigurationProperties("jwt")
public class JwtProperties {
private long accessExpiration; // YML은 access-expiration → 바인딩 안 됨
}
→ 수정:
@Data
@ConfigurationProperties("jwt")
public class JwtProperties {
private long accessExpiration;
private long refreshExpiration;
}
(2) ${ENV_VAR} 포맷의 YML 변수 바인딩 실패
${ACCESSTOKEN_EXPIRATION}이 실제 값으로 치환되지 않고 그대로 들어옴- 원인:
.env로딩 라이브러리 적용 전, Spring이 바인딩 시점에서 해당 변수를 인식하지 못함 - 해결:
dotenv-java초기 로딩 위치 조정 or.env→ system environment에 export
7. 회고 및 확장 가능성
이번 설계를 통해 우리는 다음을 확보했다:
- 각 디바이스 단위로 RefreshToken을 독립 관리하여 부분 로그아웃이 가능해짐
- Redis TTL과 JWT
exp를 일치시켜 만료 자동 관리 구조 확보 - 로그아웃 시 jti 기반 블랙리스트 등록으로 AccessToken 무효화 가능
🔮 확장 가능성
- jti 기반 블랙리스트 → Redis TTL 관리 + 주기적 클린업 Job으로 확장
- Token Rotation 전략 적용 → RefreshToken도 매 요청 시 재발급
- 기기 등록/제한 정책 추가 → 허용된 디바이스 외 로그인 차단
마무리
JWT 기반 인증은 단순히 AccessToken만으로 끝나는 문제가 아니다.
장기 세션 유지와 보안 문제를 해결하려면 RefreshToken의 설계는 핵심 인프라 구성 요소가 된다.
우리 팀은 이번 설계를 통해 유연한 로그인 유지, 로그아웃 전략, Redis TTL 기반 토큰 관리 등
스케일러블한 인증 인프라로 한 단계 성장할 수 있었다.
728x90
'BindProject' 카테고리의 다른 글
| [Backend] Image 모듈 개발기 1편: 왜 우리는 직접 이미지 업로드 모듈을 만들었는가? (0) | 2025.06.21 |
|---|---|
| [초안] 합주실 탐색 기능의 설계: 정렬에서 표기로, 현실적인 MVP를 위한 선택 (3) | 2025.06.20 |
| [Backend] Auth모듈: JWT 로그아웃 설계와 Redis 기반 블랙리스트 구현 (1) | 2025.06.20 |
| [기획] 추후 목표 :Kafka 기반 이벤트 아키텍처 이식 설계 (0) | 2025.06.19 |
| [Backend]Auth모듈: 인증 시스템 설계와 구현 -FIN-!! (0) | 2025.06.19 |