Spring Security, JWT 토큰을 사용하여 다 구현했다고 생각했는데
하나의 문제가 발생했다.
예를들어 현재 우리 프로젝트의 WebConfig 클래스에서
Spring SecurityFilterChain 을 구현해놓은 상태이다.
@Configuration
@EnableWebSecurity // SecurityFilterChain 빈 설정을 위해 필요.
@RequiredArgsConstructor
public class WebConfig {
private final JwtAuthFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
private final AuthenticationEntryPoint authEntryPoint;
private final AccessDeniedHandler accessDeniedHandler;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.cors(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(WHITE_LIST)
.permitAll()
.requestMatchers(HttpMethod.GET, "/products/*").permitAll()
//..이하 생략
.addFilterAfter(jwtAuthFilter, ExceptionTranslationFilter.class);
return http.build();
}
}
이 SecurityFilterChain 에서 따로구현된 jwtAuthFilter 를 추가해주는 방식으로 구현했다.
하지만 이 방식에는 하나의 문제가 있었다.
분명 WHITE_LIST 로 구현해둔 경로로 요청을보내도 등록해둔 jwtAuthFilter 가 동작하여
토큰이 없다는 에러 메시지가 출력되는 것이었다.
분명 SecurityFilterChain 에 화이트 리스트로 필터에 걸리지 않을 경로들을
지정해주었음에도 불구하고 왜 jwtAuthFilter 에서 authenticate() 가 진행될까??
Spring Security는 요청이 들어올 때 필터 체인을 구성한다. SecurityFilterChain
이 필터 체인은 다음과 같은 특징을 가진다.
등록된 모든 필터는 요청을 처리하려고 시도
화이트리스트 설정은 인증/인가를 수행하는 Spring Security의 인증 관련 필터에만 적용
화이트리스트에 등록된 요청이라도 필터 체인에 있는 사용자 정의 필터(Custom Filter)는 여전히 실행
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/users/login", "/users/signup").permitAll()
.anyRequest().authenticated()
);
JwtAuthFilter
는 모든 요청을 처리하도록 설계된 OncePerRequestFilter를 상속받는다
따라서 모든 요청에서 실행된다.
화이트리스트 설정은 Spring Security의 인증/인가를 우회시키지만,
JwtAuthFilter는 우리가 만든 커스텀 필터이기 때문에
SecurityFilterChain의 경로 설정과는 별개로 실행되는 것이다..
즉, JwtAuthFilter
는 Spring SecurityFilterChain 내에서 설정한 경로와 상관없이 항상 실행되며, 내부적으로 별도로 화이트리스트를 확인하지 않는 한 모든 요청에서 실행된다.
이것은 Spring Security의 필터 체인 구조 때문이다.
JwtAuthFilter
는 Spring Security의 인증/인가 관련 필터가 아니기 때문에, SecurityFilterChain
의 화이트리스트 설정이 적용되지 않는다.
SecurityFilterChain
의 경로 설정은
ExceptionTranslationFilter
또는 AuthorizationFilter
같은
Spring Security의 기본 필터에만 영향을 준다.
반면, JwtAuthFilter
는 사용자 정의 필터이므로
모든 요청에서 실행되도록 설계된다.
화이트리스트 경로에 대해 필터를 건너뛰도록 JwtAuthFilter
에서도 경로 설정을 해주어야한다.
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final UserDetailsService userDetailsService;
// 화이트리스트 경로 정의
private static final List<String> WHITE_LIST = List.of("/users/login", "/users/signup");
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String requestUri = request.getRequestURI();
// 화이트리스트 경로라면 필터링을 건너뜀
if (WHITE_LIST.contains(requestUri)) {
filterChain.doFilter(request, response);
return;
}
// 인증 처리
this.authenticate(request);
filterChain.doFilter(request, response);
}
//.. 이하 생략
}
이렇게 완벽하게 화이트 리스트를 처리하려면 직접 만든 JwtAuthFilter, SecurityFilterChain
모두에서 화이트 리스트를 적용시켜야 한다는 사실을 알았다.
그런데 너무 중복되지않나??
경로를 한 곳에서 관리하여 처리할 수는 없을까?? 물론 있다!
1번에서 알아낸 방법을 적용하면 WHITE_LIST 를 중복으로 적용하는 모습이 발생했다.
따라서 application.yml 설정파일에 화이트 리스트를 적용할 url 들을 추가하여
사용하는 방법을 선택했다.
security: # SecurityProperties의 prefix와 일치
white-list: # SecurityProperties의 whiteList 필드와 매핑
- "/users/login"
- "/users/signup"
- "/toss/fail"
- "/toss/success"
- "/toss/confirm"
seller-auth-list:
- "/users/sellers/**"
- "/products"
- "/products/**"
method-specific-patterns:
GET:
- "/products/*"
- "/products"
이렇게 화이트 리스트, 판매자 권한 경로, GET 메서드로 들어올 때만 인증을 하지 않는
경로들을 추가했다.
// application.yml의 security: 아래 설정들을 이 클래스와 매핑
@ConfigurationProperties(prefix = "security")
@Component
@Getter
@Setter
public class SecurityProperties {
private List<String> whiteList = new ArrayList<>(); // security.white-list와 매핑
private List<String> sellerAuthList = new ArrayList<>(); // security.seller-auth-list와 매핑
// security.method-specific-patterns와 매핑
private Map<HttpMethod, List<String>> methodSpecificPatterns = new HashMap<>();
}
그 후에 yml 파일의 내부의 security: 설정들을 매핑하는 SecurityProperties 클래스를
생성하였다.
@ConfigurationProperties(prefix = "security") 어노테이션을 사용하여
yml 파일의 security: 와 매핑한다는것을 명시해준다.
또한 Bean 으로 등록하여 사용하는 곳에서 의존성 주입을 받을 수 있도록 설정한다.
후에 설정 내부에 있는 필드들과 변수를 매핑하여 각각 리스트로 만든다.
마지막은 Method 에 따라 달라지기 때문에 Map으로 설정했다.
@Configuration
@EnableWebSecurity // SecurityFilterChain 빈 설정을 위해 필요.
@RequiredArgsConstructor
public class WebConfig {
private final JwtAuthFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
private final AuthenticationEntryPoint authEntryPoint;
private final AccessDeniedHandler accessDeniedHandler;
private final SecurityProperties securityProperties;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.cors(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(securityProperties.getWhiteList().toArray(new String[0]))
.permitAll()
.requestMatchers(HttpMethod.GET, "/products/*").permitAll()
.requestMatchers(HttpMethod.GET, "/products").permitAll()
.requestMatchers(HttpMethod.POST, "/persona/*").permitAll()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.dispatcherTypeMatchers(DispatcherType.FORWARD, DispatcherType.INCLUDE,
DispatcherType.ERROR).permitAll()
.requestMatchers(securityProperties.getSellerAuthList().toArray(new String[0]))
.hasRole("SELLER")
.anyRequest().authenticated()
)
이렇게 설정해둔 SecurityProperties 를 주입받아서 각각 리스트를 추출하여
사용하는 모습을 볼 수 있다.
private Map<HttpMethod, List<String>> methodSpecificPatterns = new HashMap<>();
이거는 왜 안쓰냐고 할 수 있는데 FilterChain 에서는 메서드와 url을 지정할 수가 있지만
1번 과정에서 알았듯이 JwtAuthFilter 에서도 화이트 리스트를 적용해줘야하기 때문에
Map 을 만든 것이다. 사용하는 것을 보도록 하자.
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final UserDetailsService userDetailsService;
private final SecurityProperties securityProperties;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 화이트 리스트를 판단하기 위한 uri 와 method
String requestUri = request.getRequestURI();
String method = request.getMethod();
// SecurityProperties에서 설정값들을 가져와서 사용
if (securityProperties.getWhiteList().stream()
.anyMatch(pattern -> pathMatcher.match(pattern, requestUri))) {
filterChain.doFilter(request, response);
return;
}
// HTTP Method 특정 패턴 체크, (GET /products/search 를 필터링 하지 않기위해 구현)
Map<HttpMethod, List<String>> methodPatterns = securityProperties.getMethodSpecificPatterns();
if (methodPatterns.containsKey(HttpMethod.valueOf(method))) {
if (methodPatterns.get(HttpMethod.valueOf(method)).stream()
.anyMatch(pattern -> pathMatcher.match(pattern, requestUri))) {
filterChain.doFilter(request, response);
return;
}
}
this.authenticate(request);
filterChain.doFilter(request, response);
}
//이하 생략...
JwtAuthFilter는 FilterChain 에서 처럼 메서드까지 지정할 수는 없기 때문에
매핑한 Map 을 가져와 어떤 메서드의 어떤 경로들이 화이트리스트에 포함되는지
확인하는 과정을 거쳐야한다.
이 때 AntPathMatcher
를 사용하여 매칭 여부를 판단한다.
AntPathMatcher
는 Spring Framework에서 패턴 매칭을 위해 제공되는 유틸리티 클래스다.
경로(URL)나 경로 템플릿과 HTTP 요청의 URI를 비교하여 매칭 여부를 판단하는 데 사용된다.
주로 지금처럼 화이트리스트(허용된 경로)를 검사하거나
특정 패턴에 따라 요청을 필터링하는 작업에서 유용하게 사용된다.
Ant 스타일 패턴 매칭:
*
: 경로의 특정 부분에 대해 와일드카드 매칭을 수행./products/*
→ /products/123
, /products/abc
매칭.**
: 하위 경로 전체를 매칭./products/**
→ /products/123/details
, /products/abc/edit
매칭.?
: 단일 문자와 매칭./products/??
→ /products/12
, /products/ab
매칭.정규식 지원:
doFilterInternal
에서의 동작화이트리스트 검사:
securityProperties.getWhiteList()
에서 허용된 경로(화이트리스트)를 가져온다.
각 요청 URI와 화이트리스트의 경로 패턴을
AntPathMatcher.match()
로 비교하여 요청이 화이트리스트에 포함되는지 확인.
화이트리스트에 매칭되면 필터링을 건너뛰고 바로 다음 필터로 요청을 전달.
if (securityProperties.getWhiteList().stream()
.anyMatch(pattern -> pathMatcher.match(pattern, requestUri))) {
filterChain.doFilter(request, response);
return;
}
HTTP 메서드별 특정 경로 검사:
securityProperties.getMethodSpecificPatterns()
에서
HTTP 메서드별로 설정된 패턴 리스트를 가져온다.
요청의 HTTP 메서드가 해당 리스트에 포함되어 있는지 확인하고,
매칭되면 필터링을 건너뛰고 바로 다음 필터로 요청을 전달.
if (methodPatterns.containsKey(HttpMethod.valueOf(method))) {
if (methodPatterns.get(HttpMethod.valueOf(method)).stream()
.anyMatch(pattern -> pathMatcher.match(pattern, requestUri))) {
filterChain.doFilter(request, response);
return;
}
}
JWT 인증 처리:
요청이 화이트리스트나 특정 HTTP 메서드별 패턴에 매칭되지 않는 경우, authenticate(request)
를 호출하여 JWT 검증 및 인증 처리를 진행
이후 요청을 다음 필터로 전달.
this.authenticate(request);
filterChain.doFilter(request, response);
유연한 경로 매칭:
Spring의 AntPathMatcher
는 Ant 스타일 패턴 매칭을 지원하므로,
특정 경로를 유연하게 정의하고 비교할 수 있도록 도와준다.
예: /users/**
와 같은 경로는 /users/login
, /users/signup
등
모든 하위 경로를 포함
화이트리스트 및 특정 경로 필터링 간소화:
Spring Security와의 자연스러운 통합:
화이트리스트 경로 확인:
/users/login
일 때,/users/login
이 화이트리스트에 포함되어 있으면 매칭.filterChain.doFilter()
를 호출하고 필터링을 건너뜀.HTTP 메서드와 경로 확인:
GET /products/search
일 때, 특정 메서드(GET
)와 경로(/products/search
)가 매칭되면 필터링을 건너뜀.JWT 인증 처리:
/products/123
인 경우, 화이트리스트나 특정 HTTP 메서드 경로와 매칭되지 않으면 authenticate()
메서드로 JWT 검증 수행.AntPathMatcher
는 화이트리스트 경로와 HTTP 메서드별 특정 경로를 유연하게 매칭하여,
필터 체인의 로직을 간소화하고 가독성을 높이는 데 유용하다.
doFilterInternal
에서는 이를 사용해 불필요한 필터링 작업을 건너뛰고,
JWT 인증 처리를 효율적으로 수행할 수 있도록 구현했다.
이렇게 구현을 마치고 나면 정상적으로 화이트 리스트가 적용되는 모습을 볼 수 있다.
프론트 단을 간단하게 구현해보고 회원가입 요청을 서버로 보내는 과정에서
CORS policy 오류가 발생한 모습을 볼 수 있었다.
왜지??
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.cors(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
WebConfig 에 있는 SecurityFilterChain 에서 disable 해서 비활성화 시키지 않았나?
근데 왜 정책에 걸리는걸까??
CORS
정책 문제는 주로 브라우저와 서버 간에
교차 출처 요청(Cross-Origin Request)이 올바르게 처리되지 않을 때 발생한다.
Spring Security에서 CORS
는 일반적으로
HttpSecurity.cors()
를 활성화해야 제대로 작동한다.
cors(AbstractHttpConfigurer::disable)
로 설정하면
CORS
처리가 비활성화되어 CORS
정책 문제가 발생할 수 있다.
현재 설정에서 CORS
를 비활성화했기 때문에 브라우저가
OPTIONS
요청(preflight 요청)을 보내도 적절한 응답을 받을 수 없다.
따라서 CORS 를 활성화 해주고 설정을 해주는 과정이 필요하다.
cors(AbstractHttpConfigurer::disable)
를 제거하고, HttpSecurity.cors()
를 활성화한 뒤, CorsConfigurationSource
빈을 정의하여 CORS 정책을 설정한다.
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOriginPattern("*"); // 모든 출처 허용
configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용
configuration.addAllowedHeader("*"); // 모든 헤더 허용
configuration.addExposedHeader("Authorization"); // 클라이언트가 접근 가능한 헤더
configuration.setAllowCredentials(true); // 인증 정보 포함 허용
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
WebConfig
에서 해당 cors()
를 활성화한다.:
http.cors(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(securityProperties.getWhiteList().toArray(new String[0])).permitAll()
.anyRequest().authenticated()
);
CORS
문제는 HttpSecurity.cors()
를 활성화하고,
CorsConfigurationSource
빈을 올바르게 설정하면 해결된다.
지금은 로컬에서만 실행하기 때문에 보안 같은 것을 신경쓰지 않고 적용했는데
나중에 배포를 하는 경우라면 보안 쪽에 더 신경을 쓸 필요가 있어보인다.
아무래도 지금 프론트를 구현하고 있기 때문에 액세스 토큰이 만료되었을 때
다시 로그인하는과정에서 불편함이 느껴졌다.
해결할 방법을 찾아보다 Refresh Token 이라는 것을 서버에서 구현하여 보내주면
프론트에서는 해당 Refresh Token 을 사용하여 자동적으로 액세스 토큰을 요청할 수 있다.
리프레시 토큰(Refresh Token)은 액세스 토큰(Access Token)과 함께 사용되어,
액세스 토큰이 만료된 경우 재발급을 처리할 수 있도록 설계된 토큰이다.
리프레시 토큰은 일반적으로 더 긴 유효 기간을 가지며, 서버에서 안전하게 관리된다.
항목 | 액세스 토큰 | 리프레시 토큰 |
---|---|---|
사용 목적 | 인증 및 권한 정보 전달 | 새로운 액세스 토큰 발급 요청 |
유효 기간 | 짧음 (예: 15~30분) | 김 (예: 7일, 30일 등) |
클라이언트 저장 | 로컬 스토리지, 쿠키 | 보통 쿠키에 저장 (HTTP-Only Secure) |
서버 저장 여부 | 보통 저장하지 않음 | 데이터베이스나 Redis에 저장 |
1) 리프레시 토큰 저장 위치:
2) 토큰 블랙리스트:
3) 리프레시 토큰 갱신:
이제 리프레시 토큰이 무엇인지는 알게되었으니 구현하는 과정의 흐름을 알아보자.
Redis 를 사용하면 좋다고 하지만 일단 첫 구현이니 DB 에 저장하는 방식으로 진행해보고자 한다.
사용자가 이메일과 비밀번호로 로그인 요청.
서버:
사용자 인증(예: 이메일과 비밀번호 확인).
액세스 토큰과 리프레시 토큰 생성.
리프레시 토큰을 DB에 저장하고, 클라이언트로 전달.
액세스 토큰
내용:
exp
) 등 인증에 필요한 정보 포함.유효 기간:
리프레시 토큰
내용:
sub
(사용자 식별자)와 만료일만 포함.유효 기간:
DB 저장:
서버는 리프레시 토큰을 DB에 저장.
DB 구조:
@Entity
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long userId; // 사용자 ID
private String refreshToken; // 리프레시 토큰 값
private LocalDateTime expiresAt; // 만료 시간
}
이유
클라이언트가 보호된 리소스에 접근했지만, 액세스 토큰 만료로 실패.
클라이언트는 리프레시 토큰을 사용해 새로운 액세스 토큰 발급 요청:
POST /refresh
Authorization: Bearer <refresh_token>
JWT 자체 검증:
exp
) 확인.DB와 일치 여부 확인:
리프레시 토큰 검증 성공 시 새로운 액세스 토큰 생성
새로운 액세스 토큰을 클라이언트로 반환
HTTP/1.1 200 OK
Authorization: Bearer <new_access_token>
리프레시 토큰 갱신:
보안 강화:
로그아웃 처리:
이러한 흐름이라고 이해를 했고 바로 개발에 들어갔다.
여기부터는 코드가 어떤 동작을 하는지 간단히 서술할 예정이니 빠르게 넘어가도 좋다.
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final RefreshTokenService refreshTokenService;
/**
* 사용자 로그인
*
* @param requestDto 로그인 관련 정보를 담고있는 요청 DTO
* @return 정상 처리시 헤더에 액세스토큰, 쿠키에 리프레시 토큰을 반환
*/
@PostMapping("/login")
public ResponseEntity<Void> login(
@Valid @RequestBody UserLoginRequestDto requestDto
) {
TokenResponse tokenResponse = userService.loginTokenGenerate(requestDto);
ResponseCookie refreshCookie = ResponseCookie.from("refreshToken",
tokenResponse.getRefreshToken())
.httpOnly(true)
.path("/")
.secure(false)
.maxAge(7 * 24 * 60 * 60)
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + tokenResponse.getAccessToken())
.build();
}
}
우선 UserService 에서 로그인 로직을 호출해서 액세스 토큰, 리프레시 토큰 값을 받아온다
리프레시 토큰값을 통해 ResponseCookie 객체를 생성하고 헤더의 쿠키에 설정,
액세스 토큰값을 AUTHORIZATION 헤더에 할당한다.
public TokenResponse loginTokenGenerate(UserLoginRequestDto requestDto) {
User user = findByEmail(requestDto.getEmail());
if (user.getStatus() == Status.WITHDRAW) {
throw new CustomResponseStatusException(ErrorCode.FORBIDDEN_DELETED_USER_LOGIN);
}
// 이 과정에서 Provider 가 인증 처리를 진행 (사용자 정보 조회, 비밀번호 검증)
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(requestDto.getEmail(),
requestDto.getPassword()));
// 인증 객체를 SecurityContext에 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
// JWT 생성 후 반환
return jwtProvider.generateToken(authentication);
// access, refresh 토큰 생성 후 반환
String accessToken = jwtProvider.generateToken(authentication);
String refreshToken = jwtProvider.generateRefreshToken(authentication);
refreshTokenService.saveRefreshToken(user.getId(), refreshToken);
return new TokenResponse(accessToken, refreshToken);
}
서비스 로직을 보면 JwtProvider의 generateRefreshToken 메서드를 이용해서
리프레시 토큰을 생성하고 그 값을 DB 에 저장한 후에 DTO 에 액세스 토큰과 함께 담아서
반환해주는 것을 볼 수 있다.
public String generateRefreshToken(Authentication authentication) {
String email = authentication.getName(); // Authentication에서 사용자 이메일 가져오기
return Jwts.builder()
.subject(email)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + this.refreshExpiryMillis))
.signWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)), Jwts.SIG.HS256)
.compact();
}
리프레시 토큰은 액세스 토큰과 달리 별다른 정보 없이 생성하는 것이지만
혹시 나중에 필요한 경우가 있지 않을까 싶어 email 정보만 포함하고
refreshExpiryMills 는 설정 파일에 추가된 것을 가져와 사용했다.
현재 시간 + 7일의 토큰이 결과적으로 생성된다.
@PostMapping("/logout")
public ResponseEntity<Void> logout(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication,
@CookieValue(value = "refreshToken", required = false) String refreshToken) {
if (authentication != null && authentication.isAuthenticated()) {
// SecurityContext 로그아웃
new SecurityContextLogoutHandler().logout(request, response, authentication);
// 리프레시 토큰 삭제
if (refreshToken != null) {
refreshTokenService.deleteRefreshToken(refreshToken);
}
// 쿠키 만료 처리
ResponseCookie expiredCookie = ResponseCookie.from("refreshToken", "")
.httpOnly(true)
.path("/")
.maxAge(0) // 만료 처리
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, expiredCookie.toString())
.build();
}
throw new UsernameNotFoundException("로그인이 먼저 필요합니다.");
}
물론 로그아웃 했을 때 처리도 있어야겠지?
SecurityContext 의 로그아웃 기능을 호출하고, DB 의 리프레시 토큰을 삭제한 후
쿠키를 만료처리하여 반환해주는 과정을 거쳤다
액세스 토큰은 어짜피 프론트 단에서 로그아웃했을 때 로컬 스토리지를 비우는 과정을
거치기 때문에 따로 하지 않았지만 나중에 추가하는 과정이 필요해보인다.
@RestController
@RequiredArgsConstructor
public class RefreshTokenController {
private final RefreshTokenService refreshTokenService;
@PostMapping("/refresh")
public ResponseEntity<Void> refresh(@CookieValue("refreshToken") String refreshToken) {
try {
// 리프레시 토큰으로 새로운 액세스 토큰 발급
String newAccessToken = refreshTokenService.generateAccessTokenFromRefreshToken(
refreshToken);
return ResponseEntity.ok()
.header(HttpHeaders.AUTHORIZATION, "Bearer " + newAccessToken)
.build();
} catch (CustomResponseStatusException e) {
// 리프레시 토큰이 만료되었거나 유효하지 않은 경우
if (e.getErrorCode() == ErrorCode.BAD_REQUEST_TOKEN) {
// 만료된 리프레시 토큰 삭제
refreshTokenService.deleteRefreshToken(refreshToken);
// 쿠키 삭제를 위해 만료시간을 0으로 설정
ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", "")
.httpOnly(true)
.path("/")
.maxAge(0)
.build();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
.build();
}
throw e;
}
}
}
public String generateAccessTokenFromRefreshToken(String refreshToken) {
validRefreshToken(refreshToken);
String email = jwtProvider.getUserName(refreshToken);
UserDetails userdetails = userDetailsService.loadByUserName(email);
Authentication authentication = new UsernamePasswordAuthenticationToken(
userdetails, null, userDetails.getAuthorities());
return jwtProvider.generateToken(authentication);
}
public void validRefreshToken(String refreshToken){
if(!jwtProvider.validToken(refreshToken) {
throw new CustomResponseStatusException(ErrorCode.BAD_REQUEST_TOKEN);
}
RefreshToken token = refreshTokenRepository.findByRefreshToken(refreshToken)
.orElseThrow(() -> new CustomResponseStatusException(ErrorCode.BAD_REQUEST_TOKEN));
if(token.getExpiredAt().isBefore(LocalDateTime.now()) {
refreshTokenRepository.delete(token);
throw new CustomResponseStatusException(ErrorCode.BAD_REQUEST_TOKEN);
}
}
프론트 단에서 /refresh 로 액세스 토큰 갱신을 요청했을 때
서비스단에서 새로운 액세스 토큰을 발급해주는데
이 과정에서 검증이 실패하면 CustomResponseStatusException 이 발생할 수 있다.
따라서 try catch 문으로 해당 에러를 감지하면 리프레시 토큰이 유효하지 않다고
판단하여 DB의 리프레시 토큰을 삭제하고 쿠키를 삭제하여 반환하는 과정이다.
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
//.. 생략
configuration.addExposedHeader("Set-Cookie");
configuration.setAllowCredentials(true); // 인증 정보 포함 허용
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
CORS 설정에서 프론트에서 쿠키 정보를 넘겨줄 수 있게 Header 를 허용하고
Credential 인증 정보 포함을 허용으로 설정해주어야한다.
리프레시 토큰을 DB에 저장하는 방식은 구현이 비교적 간단하여 편리하다.
하지만 편리한만큼 단점도 있는 법!
어떤 문제가 있는지 알아보고 대안책을 살펴보자
1) DB I/O 부하
사용자가 많아지면 리프레시 토큰을 검증하거나 저장하기 위해 DB 접근이 빈번해짐
특히, 고트래픽 애플리케이션에서는 DB I/O 부하가 시스템의 성능 병목을 초래할 수 있음
2) 대규모 트래픽 처리에 비효율적
리프레시 토큰 검증 및 삭제가 빈번하게 일어나는 경우,
DB의 Read/Write 작업이 과도해질 수 있음.
DB는 기본적으로 디스크 기반이므로 메모리 기반 저장소에 비해 처리 속도가 느림
현재 구현한 방식만 보아도 로그인을 할 때 리프레시 토큰 생성 -> 저장
로그아웃을 할 때 저장되어있는 리프레시 토큰을 삭제 하는 과정이 일어나기 때문에
사용자가 많아지면 성능의 문제가 생기는 것은 당연할 것으로 보인다
1) 동기화 문제
만약에 분산 환경에서 여러 DB 인스턴스를 사용하는 경우를 생각해보면,
리프레시 토큰의 상태(삽입/삭제)를 동기화해야 함
이를 관리하기 위해 추가적인 설정 및 코드 작성이 필요.
2) 관리 비용 증가
리프레시 토큰은 짧은 수명(예: 7일)을 가지므로 만료된 데이터를 주기적으로 삭제해야 함.
DB로 관리한다면 배치 작업(CRON) 또는 TTL 관리 로직이 필요해짐.
1) 데이터베이스 해킹
2) 중앙 저장소 의존성
Redis와 같은 메모리 기반 저장소는 TTL(Time-To-Live)을 지원하므로,
토큰 만료를 자동으로 관리할 수 있음.
1) 성능 향상
Redis는 메모리 기반이므로 DB에 비해 읽기/쓰기 속도가 훨씬 빠름.
수백만 건의 요청도 낮은 지연 시간으로 처리 가능.
2) 간단한 TTL 관리
토큰 생성 시 TTL(Time-To-Live)을 설정하면, 만료된 데이터를 자동으로 삭제.
주기적인 데이터 정리가 필요 없음.
3) 분산 환경에 적합
Redis는 클러스터링과 분산 캐시를 지원하므로, 대규모 트래픽 처리에 적합.
여러 인스턴스 간의 데이터 동기화 문제를 쉽게 해결 가능.
Redis 를 사용하여 리프레시 토큰을 관리하는 방법에서 얻을 수 있는 이득이 크다고 판단!
현재 MySQL DB 에 저장하던 리프레시 토큰을 Redis 로 사용하도록 변경하려고 한다.
Redis, 리프레시 토큰 관련하여 좋은 글이 있어 참고하였다.
RefreshToken은 왜 Redis를 사용해 관리할까?
우선 Redis 를 사용하기 위해서는 설치를 진행해야한다.
블로그 글을 참고하여 간편하게 설치를 하였고
편의를 위해 설치 후 비밀번호 설정은 하지 않고 진행할 예정이다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
우선 gradle 에 redis 의존성을 추가해주어야 한다.
spring:
data:
redis:
host: localhost
port: 6379
또한 application.yml 파일에 spring -> data -> redis 에 대한 host,port 를 설정
지금은 로컬에서 실행할 것이라 상관없지만 나중에 배포를하게 된다면
환경변수로 관리할 필요성이 있어보인다!
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
/**
* Redis 서버와 애플리케이션 간 연결을 설정하고 관리, LettuceConnectionFactory 사용
*
* @return 연결 팩토리를 생성
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
//LettuceConnectionFactory 는 Lettuce 클라이언트를 사용하여 연결 팩토리를 생성해주는 역할
//호스트와 포트 정보를 사용하여 Redis 서버와의 연결 설정을 해줌.
return new LettuceConnectionFactory(host, port);
}
/**
* Redis 와의 데이터 입출력을 위한 주요 인터페이스 RedisTemplate Key-Value 구조로 데이터를 저장, 조회, 삭제 등의 작업을 수행하는 역할
* 직렬화,역직렬화 설정을 통해 데이터 형식을 관리할 수 있음
*
* @return 설정된 template 리턴
*/
@Bean
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> template = new RedisTemplate<>();
// Redis에 저장할 Key를 String 형태로 직렬화
template.setKeySerializer(new StringRedisSerializer());
// Redis에 저장할 Value를 String 형태로 직렬화
template.setValueSerializer(new StringRedisSerializer());
// Redis 연결 팩토리 설정
template.setConnectionFactory(redisConnectionFactory());
return template;
}
}
Java의 Redis Client는 크게 Jedis, Lettuce 2가지가 있다.
나는 Lettuce를 사용하기로 결정했다.
특징 | Jedis | Lettuce |
---|---|---|
스레드 안전성 | 스레드 비안전 (싱글 스레드 전용) | 스레드 세이프 (싱글 및 멀티 스레드에서 사용 가능) |
비동기 지원 | 비동기 미지원 | 비동기 및 동기 방식 모두 지원 |
Reactive 지원 | 지원하지 않음 | Reactive Streams 지원 |
성능 | 멀티스레드 환경에서 추가 설정 필요 | 멀티스레드 환경에서도 기본적으로 안전 |
사용 편의성 | 동기 방식 전용으로 간단한 구현 | 고급 기능과 설정이 가능 |
스레드 안전: Lettuce는 멀티스레드 환경에서도 안전하므로 추가 설정 없이 여러 스레드에서 동시에 사용할 수 있다.
비동기 및 Reactive 지원: 현대의 비동기 기반 애플리케이션(Spring WebFlux 등)과 잘 통합된다.
성능 우수: Jedis와 비교해 더 나은 성능과 확장성을 제공한다.
참고한 블로그 글도 올려두겠다.
Jedis 보다 Lettuce를 쓰자
로그인 과정은 딱히 변한 것이 없다.
하지만 로그아웃 과정에 추가된 부분이 있어 설명하고자 한다.
/**
* 사용자 로그아웃
*
* @param request HTTP 요청 정보를 담고있는 객체
* @param response HTTP 응답 정보를 담고있는 객체
* @param authentication 토큰을 통해 얻어온 사용자 정보를 담고있는 인증 객체
* @param refreshToken 클라이언트에서 쿠키로 전달된 리프레시 토큰 값 (선택 사항)
* @param authHeader 클라이언트에서 Authorization 헤더로 전달된 액세스 토큰 값 (선택 사항)
* @return 정상적으로 로그아웃 처리 시 OK 상태코드를 반환
* @throws UsernameNotFoundException 로그인되지 않은 상태에서 로그아웃을 시도한 경우 발생
*/
@PostMapping("/logout")
public ResponseEntity<Void> logout(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication,
@CookieValue(value = "refreshToken", required = false) String refreshToken,
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authHeader
) {
// 인증 객체가 null이 아니고, 인증된 상태인지 확인
if (authentication != null && authentication.isAuthenticated()) {
try {
// 1. SecurityContext에서 사용자 정보 정리
new SecurityContextLogoutHandler().logout(request, response, authentication);
// 2. 클라이언트에서 전달된 리프레시 토큰이 존재하면 삭제
if (refreshToken != null) {
refreshTokenService.deleteRefreshToken(authentication); // DB 또는 Redis에서 삭제
}
// 3. Authorization 헤더에 Bearer 토큰이 포함되어 있다면 블랙리스트에 추가
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String accessToken = authHeader.substring(7); // "Bearer " 접두어 제거
refreshTokenService.addToBlacklist(accessToken); // 블랙리스트에 토큰 추가
}
// 4. 리프레시 토큰 쿠키 만료 처리
ResponseCookie expiredCookie = ResponseCookie.from("refreshToken", "") // 빈 값 설정
.httpOnly(true) // JavaScript에서 접근 불가
.path("/") // 쿠키의 경로 설정
.maxAge(0) // 만료 시간 0으로 설정
.secure(false) // HTTPS에서만 사용 여부 (false: HTTP에서도 사용 가능)
.build();
// 성공적으로 로그아웃 처리된 경우 응답 반환
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, expiredCookie.toString()) // 만료된 쿠키 추가
.build();
} catch (Exception ex) {
// 예기치 못한 에러가 발생한 경우 로그 기록 후 서버 오류 응답 반환
log.error("로그아웃 처리 중 예외 발생", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
// 인증 객체가 null이거나 인증되지 않은 상태인 경우 예외 발생
throw new UsernameNotFoundException("로그인이 먼저 필요합니다.");
}
뭔가 많이 추가된 느낌이지만 실제로는 액세스 토큰을 블랙리스트에 추가하여
해당 액세스 토큰이 만료시간이 남아있다 하더라도 접근하지 못하게 블랙리스트에 추가하는
과정만 추가되었다.
코드가 길어 링크로 대신하고 간단히 설명하겠다.
RedisConfig 에서 설정한 redisTemplate 을 주입받아 redis 명령어 실행
MySQL DB 에 저장하지않고 인메모리 DB인 redis 에 저장하며 만료 시간 설정
-> 따라서 Entity 나 repositry 가 필요없어짐
RefreshTokenService 는 Security Filter 에 걸리지않는 화이트 리스트 경로이므로
해당 refresh token 이 올바른지 검증하는 과정을
validateRefreshToken 메서드를 사용하여서비스 단에서 진행
로그아웃 시 호출하는 메서드 deleteRefreshToken, addToBlacklist 메서드 구현
전자는 리프레시 토큰을 redis 에서 삭제하여 사용하지 못하도록 하는 것
후자는 같이 들어온 액세스 토큰의 만료 기간이 남아있다면
블랙리스트로 redis 에 저장하여 사용하지 못하도록 하는 것
프론트 단에서 서버에 존재하는 유저의 이메일과 비밀번호로 로그인 요청
실제로 Redis 에 설정한 만료시간대로 리프레시 토큰이 저장된 모습
이 상태에서 로그아웃을 진행
리프레시 토큰은 삭제되고 액세스 토큰이 블랙리스트로 들어간 모습을 확인할 수 있다.