[Spring] Request method ‘GET’ is not supported

Oayenn·2023년 7월 24일
2

Spring

목록 보기
3/3
post-thumbnail

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

💡개요

// 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: 조차 없어서 더 오랜 시간이 걸리는 녀석이었다.

💡원인 생각해보기

  1. HTTP 메서드 매핑 방식
    구글링 했을 때 나오는 대부분의 원인은 HTTP 메서드 매핑에 있었다. 하지만 내 경우는 아니었다.

  2. 스택 트레이스가 doFilter에서 일어난 오류임을 여러 번 찍어줘서 JWT 토큰의 인증 정보를 저장하는 JwtFilter의 문제인가 싶었지만, 요청은 정상적으로 JwtFilter를 통과하고 있었음. ‘그렇다면 JwtFilter를 거친 이후 다른 필터나 핸들러에서 문제가 발생하나?’ (←악몽의 시작)

  3. JwtSecurityConfig의 JwtFilter 등록 여부를 확인했으나 잘 등록되어 있었다.

  4. refresh token이 redis에 올바른 형식으로(key, value) 저장되지 않아 토큰 데이터를 GET 해올 수 없는 건가? (아님)

  5. 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"이다..?
아 이거 뭔가 단단히 잘못되었다. 검색 결과, 오류의 원인을 어느 정도 알아낼 수 있었다.

1. GET 요청이 보내진 이유

Spring Security는 기본적으로 로그아웃 URL을 제공하며, 이 URL은 GET 메서드를 사용하여 처리한다. 결국 코드 내에서 /logout 에 대해 PostMapping을 해두고선 GET 요청을 보내어 로그아웃 처리를 시도한 셈이기 때문에 Request method ‘GET’ is not supported 오류가 발생한 것.

2. 엔드포인트가 "/login"로 인식된 이유

이 부분은 정확히 파악하지 못했으나, 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();
    }
}

`SecurityConfig`에서 확인할 수 있듯, 본 프로그램에서 사용하는 JwtFilter는 "/signup", "/login", "survey"를 제외한 모든 요청에 대해 JWT 토큰의 인증 정보를 확인한다. 따라서 “/logout” 엔드포인트에 대한 GET 요청 시에도 JWT 토큰의 인증정보를 확인하려고 했을 것이다.

하지만 GET 요청은 일반적으로 데이터를 조회하는 데에만 사용되기 때문에 인증이 필요하지 않다. 아마 이 간극에서 문제가 발생했다고 생각된다. 나도 헷갈리니 정리해보자.

1. “/logout” GET 요청 인식 (GET 요청이기 때문에 인증정보 없음)
2. JwtFilter는 해당 요청에 대해 토큰 인증정보 확인 시도
3. 인증정보 확인 불가
4. 인증정보가 없어도 되는 요청 중 “/login”으로 인식, 함께 들어온 /logout과 access token은 파라미터로 인식

라고 예상해보았으나, 인증정보 확인이 불필요한 요청은 /login 외에도 /signup, survey가 있는데 콕 집어 /login?logout= 이 된 이유는 파악하지 못했다.

💡어떻게 해결할 것인가?

이 문제를 해결하기 위해서는 두 가지 접근 방법이 있다.

  1. Spring Security에서 제공하는 로그아웃 기능을 사용한다.
    • Spring Security의 기본 로그아웃 URL인 “/logout”을 사용하고, POST 요청이 아니라 GET 요청을 보내기

  2. 커스텀 로그아웃 엔드포인트를 구현한다.
    • “/service-logout”과 같이 커스텀 로그아웃 엔드포인트를 구현해 JwtFilter에서 해당 엔드포인트를 처리하도록 수정하기
    • 이 경우, 원래대로 POST 요청을 보낼 수 있음

두 번째 방법을 선택하기로 결정했다.
로그아웃 구현 시 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에서 삭제된 토큰인지 확인하는 부분이 누락되었을 거라는 것. 코드 상에서는 JwtFilterdoFilter 메소드를 확인해야 했다.

// 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);
    }

💡마지막의 마지막의 마지막 테스트

이제는 토큰 무효화가 잘 되는지 확인해보자.

  1. 로그인 시 access token 발급 clear!
  1. 발급한 access token으로 리소스 조회 clear!
  1. 로그아웃 clear!
  1. 토큰 무효화 확인 clear!

오예!💃🏻🕺🏻

🪺회고

이번 오류와 싸우면서 가장 뼈저리게 느낀 건 본문 맨 위에도 적어뒀다.
"스택 트레이스와 로그 보기를 금 같이 하라......."
그동안은 오류가 발생했을 때 스택 트레이스의 가장 첫 문장, 즉 HTTP 상태 코드만 읽고 구글링하곤 했다. 그렇기에 조금 더 까다로운 문제를 만나게 되면 코드를 일일이 분석하고 헤매느라 오랜 시간을 써야 했다. 앞으로 오류가 발생하면 상태코드 뿐만 아니라 스택 트레이스의 Caused by:와 실행 로그까지 꼭꼭 확인하자!

정말 오랜 시간이 걸린 문제였다. 지난 3일간 팀원들에게 1일 1포기각을 외쳤다.
일전에 댓글 기능을 구현하면서 Spring Security를 사용했다가 빨간 문장 폭탄을 맞고 백스텝했던 기억이 있었기에, JWT를 다루는 데 있어 지레 겁먹었던 것도 사실이다. 하지만 코딩 공부를 하면서 깨달은 문장이 있다. 지금 내가 찾지 못할 뿐 답은 분명 있다.

포기하지 않으면 정말 어떻게든 해결하게 되더라. 다음 오류도 씩씩하게 해결해보자고!

profile
차근차근 쌓아올리기

0개의 댓글

관련 채용 정보