본문 바로가기
부트캠프 개발일기/Main-Project

101일차: Main-Project Day 8 (HandlerInterceptor, SecurityContextHolder)

by shyun00 2023. 7. 8.

하루하루 지날수록 새로운 경험들을 하고있다...🥲

그래서 이래저래 코드를 막 덧붙이다보니 코드가 지저분해진 것 같기도 해서 일부 리팩토링 작업도 진행했다.

 

오늘은 하루종일 쿠키와의 전쟁아닌 전쟁이었다.

ngrok으로 백엔드서버를 열고 프론트서버에서 회원가입, 로그인요청을 보냈을 땐 정상적으로 작동했다.

 

그런데 쿠키(리프레시토큰)로 액세스토큰을 재발급 받는 과정에서 계속 403 에러가 발생했다.

CORS 설정이나 SameSite 설정을 다 풀어두었는데도 계속 에러가 발생했다.

쿠키가 서버쪽으로 전송되지 않는 문제였는데, 구글링 해서 찾아본 바로는 쿠키의 범위(사용 가능 범위?)는 동일한 도메인 혹은 지정한 도메인에서만 가능하다고 한다.

프론트 측에 도메인 관련 문의했을 땐 http://localhost:XXXX에서 사용한다고 하는데 이것저것 시도해 봐도 답이 나오지 않아서🥲

내일 있을 멘토링 시간에 여쭤볼 예정이다.

 

쿠키가 전송되지 않는 부분을 제외하면 회원가입-로그인-액세스토큰인증-액세스토큰재발급-로그아웃까지는 구현을 했다.

(근데 나중에 배포 과정에서 또 어떤 에러가 발생할지 아주아주 살짝 걱정이 된다...ㅎㅎ)

 

이제 오후에는 회원 정보 조회, 회원 정보 수정 부분 로직을 작성했다.

프론트서버에서 요청을 보낼 때 헤더에 AccessToken을 보내게 되는데, 해당 토큰에서 사용자 정보를 가져올 수 있다.

수업에서 배운대로 HandlerInterceptor를 사용해서 작성할까 생각했는데,

팀원 중 한명이 인증 완료 후 SecurityContextHolder에 principal로 왜 회원아이디를 저장하는지 물어봤던 게 생각났다.

지금 생각해보니 principal에 식별자가 들어가야 추가 로직 없이 바로 사용자를 찾을 수 있는데 해당 내용이 없어서 물어봤던 것 같다. 

 

즉, HandlerInterceptor에서 정보를 가져오게되면 principal이 사용될 일이 없었다.(토큰에서 정보 추출)

반면 ContextHolder에서 정보를 가져오게되면 principal에 필요한 정보가 들어있어야 한다.(memberId 같은 식별자 등)

 

오늘은 두가지 방식으로 코드를 작성해 보았는데 코드 작성도 해보고 구글링도 해보니 각각의 장단점이 있는 것 같다.

1. HandlerInterceptor

이전에도 한번 구현한 적이 있는 부분이다. 그런데 지난번에 한 가지 놓쳤던 부분이 있다.

지난 글:

2023.06.21 - [부트캠프 개발일기/Pre-Project] - 89일차 - Pre-Project Day 9 (HandlerInterceptor, JWT 정보 가져오기)

HttpServletRequest(Response), HTTP 요청이나 응답을 한번 읽게 되면 다시 읽을 수 없다고 한다.

HandlerInterceptor를 사용해 HTTP 요청의 헤더에서 값을 가져오는 것도 해당 요청을 읽는 것이 되므로 이후에 제대로 된 요청을 처리하지 못하게 된다. (아마 지난번에 Answer 등록이 제대로 되지 않았던 것이 이것 때문인 것 같다.)

 

따라서 HandlerInterceptor를 통해 HTTP 요청 헤더에서 값을 가져오려면 해당 요청을 다시 읽을 수 있는 클래스로 래핑 해두어야 한다.

이를 위해 필요한 것이 "HttpServletWrappingFilter"이다. (이름은 상황에 따라 작성하면 된다.)

public class HttpServletWrappingFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        ContentCachingRequestWrapper wrappingRequest = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper wrappingResponse = new ContentCachingResponseWrapper(response);
        filterChain.doFilter(wrappingRequest, wrappingResponse);
        wrappingResponse.copyBodyToResponse();
    }
}

위 필터는 HTTP 요청과 응답을 ContentCachingRequest(Response) Wrapper로 감싸주어 다시 읽을 수 있도록 해준다.

그리고 아래와 같이 @Configuration을 통해 해당 필터를 설정해주어야 한다.

@Configuration
public class WebConfiguration {
    @Bean
    public FilterRegistrationBean<HttpServletWrappingFilter> firstFilterRegister()  {
        FilterRegistrationBean<HttpServletWrappingFilter> registrationBean =
                new FilterRegistrationBean<>(new HttpServletWrappingFilter());
        registrationBean.setOrder(Integer.MIN_VALUE);

        return registrationBean;
    }
}

즉, HandlerInterceptor를 사용해 request에서 정보를 추출해서 사용(혹은 로그 기록 등)하려면 

인터셉터 정의 -> SecurityConfiguration에 해당 인터셉터 설정 추가 -> Wrapper 정의 및 설정 추가 과정을 거쳐야 한다.


2. SecurityContextHolder

Jwt 인증 과정에서 Jwt 인증이 정상적으로 완료되면 아래 코드를 통해 Authentication을 SecurityContextHolder에 저장했다.

principal은 인증된 사용자의 정보를 나타내는 속성이다. Object 타입이므로 필요에 따라 설정할 수 있다.

필요에 따라 UserDetails나 다양한 타입의 객체를 등록할 수 있다.

우선 지금 프로젝트에서는 memberId 값만 사용할 예정이므로 memberId(식별자)만 principal로 설정해 두었다.

(나중에 필요에 따라 권한정보 등이 추가될 수 있다.)

// JwtVerificationFilter

private void setAuthenticationToContext(Jws<Claims> claims) {
    Long memberId = claims.getBody().get("memberId", Long.class);
    Authentication authentication = new UsernamePasswordAuthenticationToken(memberId, null, null);
    SecurityContextHolder.getContext().setAuthentication(authentication);
}

이제 Jwt 인증이 완료되면 SecurityContextHolder에 memberId 정보가 등록된다.

SecurityContextHolder는 기본적으로 ThreadLocal을 사용하므로 현재 스레드에 대한 정보를 저장하고 전역적으로 접근이 가능하다.

Http 요청에서 memberId를 조회하는 작업이 많이 수행될 예정이므로, 중복 코드를 줄이기 위해 해당 부분을 별도의 메서드로 작성했다.

public class SecurityUtil {
    public static Long getLoginMemberId() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        Object principal = authentication.getPrincipal();
        
        if (principal instanceof Long) {
            return (Long) principal;
        } else {
            throw new BusinessLogicException(ExceptionCode.INVALID_ACCESS_TOKEN_STATE);
        }
    }
}

그런데 생각해 보니 SecurityContextHolder에는 인증된 정보만 저장되므로 예외나 에러가 발생할 가능성은 적다.

아래와 같이 간단하게 작성할 수도 있을 것 같다.

public class SecurityUtil {
    public static Long getLoginMemberId() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        Object principal = authentication.getPrincipal();
        
            return (Long) principal;
    }
}

마지막으로, 해당 정보가 필요한 컨트롤러나 서비스 부분에서 아래와 같이 값을 추출할 수 있다.

long authenticatedMemberId = SecurityUtil.getLoginMemberId();

 

[결과확인]

인증 정보가 필요한 /members 경로에서 토큰만 첨부해도 관련 회원 정보가 잘 찾아지는 것을 확인할 수 있다.

Get 요청, Patch 요청 모두 잘 이루어지고 있다.


1. HandlerInterceptor 사용 장단점

  • 장점: 서버에서 처리하는 로직이나 인증을 더 세밀하게 제어할 수 있다.
  • 단점: HandlerInterceptor를 사용하기 위해 매 요청마다 토큰을 파싱하고 정보를 추출한다.
            처리 로직이 추가되므로 코드가 복잡해질 수 있다.

2. SecurityContextHolder 사용 장단점

  • 장점: 인증 완료된 후 정보를 가져오므로 인증 절차를 거치는 오버헤드가 없다.
            사용방법이 편리하고 코드를 간결하게 작성할 수 있다.
  • 단점: 비동기처리나 멀티스레드 환경에서는 스레드 간의 정보공유가 어려울 수 있으므로 주의해야 한다.

일단 현재 우리 프로젝트는 단일 스레드 환경에서 운영되므로 SecurityContextHolder를 사용한 방법이 더 적절할 것 같다.

두 가지 방법을 다 진행하느라 시간은 조금 걸렸지만 덕분에 또 많이 배운 것 같다!

 

[참고자료]

Spring Interceptor

Spring Logging (Interceptor로 Request, Response body json 값 로깅하기)

[Spring Security] SecurityContextHolder와 사용자 정보