스택 트레이스와 로그 보기를 금 같이 하라🧜🏻♂️🪙
// AuthController.java
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestParam("refreshToken") String refreshToken) {
String refreshTokenKey = "refreshToken: " + refreshToken;
if (!redisTemplate.delete(refreshTokenKey)) {
throw new IllegalArgumentException("Invalid refresh token");
}
return ResponseEntity.ok().build();
}
PostMapping을 하고 POST 요청을 보냈으나 GET 요청이 불가하다는 오류가 발생했다. 진심으로 황당했다.
스택 트레이스를 살펴볼 때 큰 실마리가 되어주는 Caused by:
조차 없어서 더 오랜 시간이 걸리는 녀석이었다.
HTTP 메서드 매핑 방식
구글링 했을 때 나오는 대부분의 원인은 HTTP 메서드 매핑에 있었다. 하지만 내 경우는 아니었다.
스택 트레이스가 doFilter
에서 일어난 오류임을 여러 번 찍어줘서 JWT 토큰의 인증 정보를 저장하는 JwtFilter
의 문제인가 싶었지만, 요청은 정상적으로 JwtFilter를 통과하고 있었음. ‘그렇다면 JwtFilter를 거친 이후 다른 필터나 핸들러에서 문제가 발생하나?’ (←악몽의 시작)
JwtSecurityConfig
의 JwtFilter 등록 여부를 확인했으나 잘 등록되어 있었다.
refresh token이 redis에 올바른 형식으로(key, value) 저장되지 않아 토큰 데이터를 GET 해올 수 없는 건가? (아님)
redis를 처음 사용해보았기에, refresh token을 저장하고 유효성을 검증하는 로직이 잘못된 줄 알고 access token만 발급해 사용하도록 코드를 뜯어고쳤다.
// AuthController.java
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestHeader("Authorization") String token) {
// Authorization 헤더에서 토큰 추출 (토큰 형식이 "Bearer <token>"인 경우)
String authToken = token.substring(7);
// Redis에서 토큰 유효성 확인 후 삭제
if (tokenProvider.isTokenValidInRedis(authToken)) {
tokenProvider.removeTokenFromRedis(authToken);
// SecurityContext 초기화하여 현재 세션 무효화
SecurityContextHolder.clearContext();
return new ResponseEntity<>(HttpStatus.OK);
} else {
// 토큰이 유효하지 않거나 이미 만료되었을 경우 오류 응답 반환
return new ResponseEntity<>("유효하지 않거나 만료된 토큰입니다.", HttpStatus.BAD_REQUEST);
}
}
여전히 같은 오류 발생.
아니라고만 하지 말고 이유를 알려줘...........
그리고 다시 한참 동안 클래스들을 분석했지만 문제점을 발견하지 못했다.
이쯤 되니 로그아웃 기능이 꼭 있어야 하나 싶고(^^) 포기하고 싶었으나... 기능 하나 테스트할 때마다 애뮬레이터를 껐다 켰다 할 프론트 팀원을 생각하니 도저히 놓을 수가 없었다. 나에게 주어진 힌트들을 다시 들여다보았다.
프로그램이 어떤 순서로 실행되었는지, 어느 시점까지 실행하고 오류를 발생시켰는지 확인하기 위해 실행 로그를 살펴보던 중 이상한 점이 눈에 들어왔다.
HTTP 메소드가 GET
이고(이건 그렇다고 쳐도), 엔드포인트가 "/login"이다..?
아 이거 뭔가 단단히 잘못되었다. 검색 결과, 오류의 원인을 어느 정도 알아낼 수 있었다.
Spring Security는 기본적으로 로그아웃 URL을 제공하며, 이 URL은 GET 메서드를 사용하여 처리한다. 결국 코드 내에서 /logout 에 대해 PostMapping을 해두고선 GET 요청을 보내어 로그아웃 처리를 시도한 셈이기 때문에 Request method ‘GET’ is not supported 오류가 발생한 것.
이 부분은 정확히 파악하지 못했으나, JwtFilter 때문이라고 추측한다.
// SecurityConfig.java
public class SecurityConfig {
// 생략 ..
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// 생략 ..
.and()
.authorizeHttpRequests()
.requestMatchers("/signup", "/login", "survey").permitAll()
.anyRequest().authenticated()
.and()
.apply(new JwtSecurityConfig(tokenProvider));
return httpSecurity.build();
}
}
하지만 GET 요청은 일반적으로 데이터를 조회하는 데에만 사용되기 때문에 인증이 필요하지 않다. 아마 이 간극에서 문제가 발생했다고 생각된다. 나도 헷갈리니 정리해보자.
1. “/logout” GET 요청 인식 (GET 요청이기 때문에 인증정보 없음)
2. JwtFilter는 해당 요청에 대해 토큰 인증정보 확인 시도
3. 인증정보 확인 불가
4. 인증정보가 없어도 되는 요청 중 “/login”으로 인식, 함께 들어온 /logout과 access token은 파라미터로 인식
라고 예상해보았으나, 인증정보 확인이 불필요한 요청은 /login 외에도 /signup, survey가 있는데 콕 집어 /login?logout= 이 된 이유는 파악하지 못했다.
이 문제를 해결하기 위해서는 두 가지 접근 방법이 있다.
Spring Security에서 제공하는 로그아웃 기능을 사용한다.
커스텀 로그아웃 엔드포인트를 구현한다.
두 번째 방법을 선택하기로 결정했다.
로그아웃 구현 시 GET/POST 어떤 것을 사용해도 무방하다고 하나 prefetch
시 실수로 사용자를 로그아웃 시킬 수 있으며, HTTP/1.1 RFC
역시 컨텐츠를 반환하는 용도로만 GET 메서드를 사용할 것을 권고하고 있기 때문이다.
@PostMapping("/service-logout")
public ResponseEntity<?> logout(@RequestHeader("Authorization") String token) {
// Authorization 헤더에서 토큰 추출 (토큰 형식이 "Bearer <token>"인 경우)
String authToken = token.substring(7);
// Redis에서 토큰 유효성 확인 후 삭제
if (tokenProvider.isTokenValidInRedis(authToken)) {
tokenProvider.removeTokenFromRedis(authToken);
// SecurityContext 초기화하여 현재 세션 무효화
SecurityContextHolder.clearContext();
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.add("Authorization", "");
return ResponseEntity.ok().headers(responseHeaders).body("로그아웃 성공");
} else {
// 토큰이 유효하지 않거나 이미 만료되었을 경우 오류 응답 반환
return new ResponseEntity<>("유효하지 않거나 만료된 토큰입니다.", HttpStatus.BAD_REQUEST);
}
}
어라 이렇게 성공인가?
로그아웃하면 리턴하도록 설정한 메시지와 상태코드는 잘 받아냈지만, 하나 더 확인할 게 남아있었다. 바로 로그아웃시킨 토큰이 제대로 무효화되었는지 체크하는 것!
로그인해서 토큰 발급 받고, 로그아웃해서 무효화시키고, 해당 토큰으로 데이터 조회하면 401 Unauthorized
오류가 나겠지?ㅎㅎ
어림도 없지
데이터가 잘 나오는데 마음이 힘들어지는 경우는 또 처음이다.
이번에는 요청이 제대로 들어가고 응답값까지 잘 반환되는 상황이기 때문에 스택 트레이스는 없었고 특별히 활용할 로그도 나오지 않았다.
다행히 짐작 가는 곳은 있었다. redis에서 삭제된 토큰인지 확인하는 부분이 누락되었을 거라는 것. 코드 상에서는 JwtFilter
의 doFilter
메소드를 확인해야 했다.
// JwtFilter.java
public class JwtFilter extends GenericFilterBean {
// 생략 ..
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String jwt = resolveToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();
System.out.println("jwt : " + jwt);
System.out.println("requestURI : " + requestURI);
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { // <- 바로 이 부분!!
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
System.out.println("Security Context에 인증 정보를 저장했습니다.");
} else {
logger.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
System.out.println("유효한 JWT 토큰이 없습니다.");
}
filterChain.doFilter(servletRequest, servletResponse);
}
// 생략 ..
}
validateToken
메서드를 확인해보자.
// TokenProvider.java
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true; // 문제 없음
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
logger.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
logger.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
logger.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
logger.info("JWT 토큰이 잘못되었습니다.");
}
return false; // 문제 있음
}
validateToken
메서드는 다른 팀원이 로그인 구현 시 작성한 부분이기 때문에, redis 내 데이터를 확인하는 코드는 포함되어 있지 않다. doFilter
가 validateToken 메서드 대신 isTokenValidInRedis
메서드를 사용하도록 수정했다.
아래가 isTokenValidInRedis 메서드이다.
// TokenProvider.java
// 토큰이 Redis에 유효한지 확인하는 메서드
public boolean isTokenValidInRedis(String token) {
String key = "access_token:" + token;
return redisTemplate.hasKey(key);
}
이제는 토큰 무효화가 잘 되는지 확인해보자.
clear!
clear!
clear!
clear!
오예!💃🏻🕺🏻
이번 오류와 싸우면서 가장 뼈저리게 느낀 건 본문 맨 위에도 적어뒀다.
"스택 트레이스와 로그 보기를 금 같이 하라......."
그동안은 오류가 발생했을 때 스택 트레이스의 가장 첫 문장, 즉 HTTP 상태 코드만 읽고 구글링하곤 했다. 그렇기에 조금 더 까다로운 문제를 만나게 되면 코드를 일일이 분석하고 헤매느라 오랜 시간을 써야 했다. 앞으로 오류가 발생하면 상태코드 뿐만 아니라 스택 트레이스의 Caused by:
와 실행 로그까지 꼭꼭 확인하자!
정말 오랜 시간이 걸린 문제였다. 지난 3일간 팀원들에게 1일 1포기각을 외쳤다.
일전에 댓글 기능을 구현하면서 Spring Security를 사용했다가 빨간 문장 폭탄을 맞고 백스텝했던 기억이 있었기에, JWT를 다루는 데 있어 지레 겁먹었던 것도 사실이다. 하지만 코딩 공부를 하면서 깨달은 문장이 있다. 지금 내가 찾지 못할 뿐 답은 분명 있다.
포기하지 않으면 정말 어떻게든 해결하게 되더라. 다음 오류도 씩씩하게 해결해보자고!