
스택 트레이스와 로그 보기를 금 같이 하라🧜🏻♂️🪙

// 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를 다루는 데 있어 지레 겁먹었던 것도 사실이다. 하지만 코딩 공부를 하면서 깨달은 문장이 있다. 지금 내가 찾지 못할 뿐 답은 분명 있다.
포기하지 않으면 정말 어떻게든 해결하게 되더라. 다음 오류도 씩씩하게 해결해보자고!