
기존 JWT로 인증, 인가 시스템을 서칭했을 때 Redis를 활용한 방법, DB를 활용하는 방법을 통해 RefreshToken을 관리하는 것을 알고 있었지만 Redis를 활용해 본적이 없기도 하고, 빠른 구현을 위해 DB를 선택해 프로젝트를 진행하고 있었다.
최근에 다시 알아보면서 성능, 관리, 확장성 부분에서 Redis가 훨씬 용이하다는 것을 알게 되어 시스템을 바꾸게 됐다
.
▼ - 기존 아키텍쳐
https://sada-dev.tistory.com/61
[Spring] Spring Security와 JWT로 구현하는 인증 시스템
Why?Spring Security- 강력한 인증 및 인가를 내장하여 로그인, 권한 검증, CSRF 보호, 세션 관리 등 여러 보안 기능을 쉽게 구현 가능하다.- 커스텀 필터, 핸들러 추가로 세밀한 보안 정책을 추가, 관리
sada-dev.tistory.com
Why?
예전에 백엔드 관련 강의를 들으면서 스쳐 들었던 것이 문득 생각났다.
개발을 진행하면서 성능 이슈적으로 가장 큰 요인들로 DB와의 속도(I/O), 네트워크 환경, 스레드 풀의 적절한 조정 등이 있었고, DB는 공유자원이기 때문에 가장 문제를 야기시킬 가능성이 높은 녀석이라고 생각한다.
Redis 방식을 알아보고 도입함으로 인해 얻을 수 있는 이점들이 많았지만 DB의 부하를 줄일 수 있다는 것이 가장 큰 매력이였고, 도입하게 되었다.
Redis와 DB의 차이점
| Redis | DB | |
| 성능 | 매우 빠름 (인메모리) | 느림 (디스크 IO) |
| 만료 | TTL로 자동 관리 | 배치 작업 필요 |
| 비용 | 인프라 비용 증가 | 기존 DB 비용으로 이용 가능 |
| 확장성 | 수평 확장 용이 | 확장 비교적 어려움 |
| 실시간 무효화 | 매우 빠름(Redis에서 바로 삭제) | 상대적으로 느림 |
간단히 살펴봐도 얻을 수 있는 이점이 굉장히 많다.
추가적으로 영속성에 관한 부분이 있는데 크게 불편할만한 사항은 아니라서 고려하지 않았다.
조사하면서 내가 결론은 다음과 같다.
일반적인 상황에서는 Redis에서 Token을 관리한다, 비즈니스적으로 RefreshToken의 유실이 일어나거나 백업에 민감한 경우는 DB를 활용해 관리한다.
How?
1. Build.gradle 의존성 추가
// build.gradle
dependencies {
// JJWT (JWT 라이브러리)
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
// Spring Data Redis (Redis 연동)
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
2. Redis 설치 ( Docker를 활용한 설치 )
로컬에도 설치할 수도 있지만 저는 Docker를 활용해 설치했습니다.
docker pull redis
docker run --name my-redis -p 6379:6379 -d redis
3. Spring Boot 설정 ( application.yml )
spring:
redis:
host: localhost # Redis 서버 IP (Docker 사용 시 'localhost' 또는 Docker 컨테이너 IP)
port: 6379 # Redis 서버 포트
# password: your_redis_password # Redis 비밀번호가 설정되어 있다면 추가
timeout: 1000ms
Redis 리포지토리 및 서비스 구현
1. Redis에 저장할 RefreshToken 정보를 담는 객체 정의
// RefreshToken.java
@Getter
@RedisHash("refreshToken") // Redis에 저장될 해시 이름. Key의 식별자
public class RefreshToken {
@Id
private String id;
private String refreshToken;
@TimeToLive
private Long expiration;
@Builder
public RefreshToken(String id, String refreshToken, Long expiration) {
this.id = id;
this.refreshToken = refreshToken;
this.expiration = expiration;
}
// Refresh Token 재발급 시 기존 Refresh Token 업데이트용
public RefreshToken updateToken(String refreshToken, Long expiration) {
this.refreshToken = refreshToken;
this.expiration = expiration;
return this;
}
}
2. 리포지토리 정의
// RefreshTokenRepository.java
import org.springframework.data.repository.CrudRepository;
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
}
인증/인가 컨트롤러 및 서비스 구현
Http 요청과 응답에 사용할 DTO를 생성해줍니다.
- LoginRequestDto.java
- TokenDto.java
// LoginRequestDto.java
import lombok.Data;
@Data
public class LoginRequestDto {
private String username;
private String password;
}
// TokenDto.java
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TokenDto {
private String grantType; // "Bearer"
private String accessToken;
private String refreshToken;
private Long accessTokenExpiresIn; // Access Token 만료 시간 (밀리초)
}
- 실제 Service 로직 정의
// AuthService.java
@Service
@RequiredArgsConstructor
public class AuthService {
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
@Value("${jwt.refresh-token-expiration-milliseconds}")
private long refreshTokenExpirationMilliseconds;
@Transactional
public TokenDto login(LoginRequestDto loginRequestDto) {
// 1. Login ID/PW 를 기반으로 Authentication 객체 생성
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginRequestDto.getUsername(), loginRequestDto.getPassword());
// 2. 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분
// authenticate 메서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT Access Token 생성
String accessToken = jwtTokenProvider.createAccessToken(authentication);
String refreshToken = jwtTokenProvider.createRefreshToken(); // Refresh Token은 사용자 정보 없이 생성
// 4. RefreshToken Redis에 저장 (사용자 ID를 Key로)
RefreshToken refreshTokenEntity = RefreshToken.builder()
.id(authentication.getName())
.refreshToken(refreshToken)
.expiration(refreshTokenExpirationMilliseconds / 1000)
.build();
refreshTokenRepository.save(refreshTokenEntity);
return TokenDto.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.accessTokenExpiresIn(jwtTokenProvider.getAccessTokenValidityInMilliseconds())
.build();
}
// Jwt 재발행
@Transactional
public TokenDto reissue(String accessToken, String refreshToken) {
// 1. Refresh Token 유효성 검사
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new RuntimeException("Refresh Token이 유효하지 않습니다.");
}
// 2. Access Token에서 사용자 이름(ID) 가져오기
String username = jwtTokenProvider.getUsernameFromToken(accessToken);
// 3. Redis에서 저장된 Refresh Token 가져오기
RefreshToken storedRefreshToken = refreshTokenRepository.findById(username)
.orElseThrow(() -> new RuntimeException("로그아웃 되었거나 만료된 사용자입니다. 다시 로그인 해주세요."));
// 4. Redis에 저장된 Refresh Token과 클라이언트가 보낸 Refresh Token이 일치하는지 확인
if (!storedRefreshToken.getRefreshToken().equals(refreshToken)) {
throw new RuntimeException("Refresh Token이 일치하지 않습니다. 보안 위험이 있을 수 있습니다.");
}
// 5. 새로운 Access Token 및 Refresh Token 생성
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken); // 이 부분은 만료된 access token에서도 subject와 authority를 가져옴
String newAccessToken = jwtTokenProvider.createAccessToken(authentication);
String newRefreshToken = jwtTokenProvider.createRefreshToken();
// 6. Redis에 새로운 Refresh Token으로 업데이트
storedRefreshToken.updateToken(newRefreshToken, refreshTokenExpirationMilliseconds / 1000);
refreshTokenRepository.save(storedRefreshToken);
return TokenDto.builder()
.grantType("Bearer")
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.accessTokenExpiresIn(jwtTokenProvider.getAccessTokenValidityInMilliseconds())
.build();
}
@Transactional
public void logout(String accessToken) {
String username = jwtTokenProvider.getUsernameFromToken(accessToken);
if (refreshTokenRepository.existsById(username)) {
refreshTokenRepository.deleteById(username);
}
}
}
이후 잘 로그인 된 것을 확인한 다음 실제로 Docker에 Token이 어떻게 저장되고 있는지 확인해봅니다.
1. 도커 컨테이너 내부 진입
> docker exec -it *여러분들의 도커 컨테이너 이름* redis-cli
2. 도커에서 Redis는 인메모리 상에 키-값 쌍으로 저장을 하게 됩니다. 아래의 명령어를 쳐보시면 어떤식으로 저장되는지 확인 가능합니다.
도커IP:포트번호> KEYS *
Ex) refreshToken:(*여러분들이 설정한 user 식별자*)
3. 실제 RefreshToken 값 확인해보기
> HGETALL refreshToken:*위에서 확인한 user값*
위 내용을 쳐보면
1) "refreshToken"
2) "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE2ODg5ODg4MDB9.someLongJwtTokenString"
3) "expiration"
4) "604800"
5) "id"
6) "user"
위와 같이 어떤값들이 저장되어 있는지 확인할 수 있습니다!!
Redis를 처음 만져보고 기존 시스템을 뜯어 고치는 것에 불안함을 안고 시작했지만, 지금 바꾸지 않으면 나중엔 더 일이 커지게 될 것만 같은 기분에 바꾸게 되었는데 현재 프로젝트가 상용되고 있지 않아서 무난하게 바꿀수 있었던 것 같다. 기존에 쓰고있던걸 병합하는 일들은 얼마나 더 복잡해질까..
그래도 큰 문제없이 도입해서 다행이였고 재밌는 경험인 것 같다. 오늘의 교훈은 처음부터 기술서칭을 잘해서 내가 요구하는 바와 일치하는 기술스택을 잘 찾아서 시작하자!!
'Backend > Spring' 카테고리의 다른 글
| [API문서화] Spring Boot 3.x 버전 Swagger 적용하기 (0) | 2025.03.20 |
|---|---|
| [Spring] Spring Security와 JWT로 구현하는 인증 시스템 (1) | 2025.02.18 |
| [querydsl] kotlin gradle에서 초기세팅하기 (0) | 2024.03.29 |
| [JPA] 쿼리 파라미터 로그로 직접 살펴보기 (0) | 2024.03.11 |