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

89일차: Pre-Project Day 9 (HandlerInterceptor, JWT 정보 가져오기)

by shyun00 2023. 6. 21.

이제 회원가입/로그인/로그아웃 관련 기능들을 어느정도 마무리하고 무엇을 해야할까 고민하던중

"코드가 merge되지 않은 상태인데 다른 팀원분들은 질문/답변 등록할때 memberId 같은 정보를 어디서 받아올 수 있을까?"라는 의문이 생겼다.

질문/답변 조회의 경우 별도의 사용자 인증이 필요 없는 상태이지만 등록이나 수정할때는 인증된 사용자만 가능하다.

그렇다면 클라이언트가 질문이나 답변을 POST, PATCH 할 때 내용 뿐만 아니라 memberId와 같은 정보도 계속 보내야하는가? 하는 생각이 들었다.

현재 JWT를 통해 인증 여부를 확인하고 있으므로 JWT에서 회원 정보 관련 내용을 추출할 수 있다면 클라이언트측에서 추가로 회원 정보를 보낼 필요가 없다.

 

그렇다면 JWT에서 어떻게 회원 정보를 가져올 수 있을까?

-> HandlerInterceptor를 통해 요청을 처리하기 전에 필요한 정보를 가져올 수 있다.

+ 추가내용 참고:

2023.07.08 - [부트캠프 개발일기/Main-Project] - 101일차 - Main-Project Day 8 (Interceptor, SecurityContextHolder)

 

 HandlerInterceptor는 Spring MVC에서 요청 처리를 가로채고 조작하는 인터페이스이다. 클라이언트 요청이 컨트롤러에 전달되기 전, 컨트롤러에서 처리가 완료된 후 , 뷰가 랜더링 되기 전 작업을 수행할 수 있도록 해준다.

 

각 작업은 preHandle(), postHandle(), afterCompletion() 메서드를 오버라이드하여 지정해줄 수 있다.

 

HandlerInterceptor까지는 구현했는데 아무리 테스트해도 동작하지 않아서 한참 애를 먹었었다.

중요한 이유가 하나 있었는데, 인터셉터 클래스를 구현한다고 바로 적용되는 것이 아니라 WebMvcConfigurer를 구현하고 addInterceptors()메서드를 오버라이드해서 사용할 인터셉터를 추가해주어야한다.

뭔가 만들고나면 적용하기 위한 작업을 해야한다는걸 잊지 말자!

심지어 6월 11일에 학습했던 내용인데 잊고있었던 부분이었다. 이번 작업을 통해 확실히 기억에 남게되었다. (역시 반복학습이 최고..!)

2023.06.11 - [추가 공부/개념학습] - [Spring] HandlerInterceptor

 

WebMvcConfigurer는 SpringMVC 구성을 커스터마이징 하기 위한 메서드를 제공한다. 웹 애플리케이션의 MVC 구성을 세밀하게 조정할 수 있다. 커스텀 HandlerInterceptor, ViewResolver, MessageConverter 등을 등록하고 설정할 수 있으며, 전역적으로 적용되는 CORS 설정도 구성할 수 있다고 한다. 이 외에도 다양한 기능이 있어서 다음에 추가적으로 학습을 더 해야할것같다.

 

현재 우리 애플리케이션은 단일 스레드를 사용하고 있으므로 ThreadLocal 변수에 값을 설정하면 다른 컨트롤러나 메서드에서 해당 값을 사용할 수 있다. 질문이나 답변을 등록할 때 필요한 회원 식별자(memberId)를 ThreadLocal변수로 설정하는 로직을 구현해보기로 했다.

 

다음과 같은 순서로 진행했다.

  • HandlerInterceptor 구현
  • WebMvcConfigurer에 인터셉터 적용
  • 인터셉터가 제대로 동작하는지 확인

[HandlerInterceptor로 JWT 정보 가져오기]

1. JwtInterceptor 구현: HandlerInterceptor를 구현하는 JwtInterceptor 클래스를 작성하였다.

    인증된 사용자식별자를 ThreadLocal<Long> 타입의 객체, 변수명 authenticatedMemberId로 선언하였다.

    ThreadLocal 인스턴스를 통해 해당 스레드에서 고유한 값을 저장할 수 있으며 컨트롤러에서 해당 값을 사용할 수 있다.

    memberId를 값으로 하므로 ThreadLocal타입은 <Long>으로 설정해주었다.

    preHandle() 메서드를 통해 JWT에서 memberId 값을 가져와서 authenticatedMemberId에 값으로 설정해주었다.

    preHandle 메서드 리턴값이 boolean인 이유는, try문이 정상적으로 수행되면 true를 리턴해 실행 체인을 계속 진행시키고,

    false인 경우  preHandle에서 문제가 발생하거나 처리가 되지 않았음을 인식하여 에러가 발생하도록 되어있기 때문이다.

    postHandle()에서는 authenticatedMemberId를 다시 초기화 하고 있는데,

    이는 이후에 동일 스레드를 사용해 다른 작업이 수행되었을때 스레드풀에 해당 내용이 남아있지 않도록 하기 위해서이다.

    사용 이후에는 값을 지워줌으로써 값의 충돌을 방지할 수 있다.

@Component
@Slf4j
public class JwtInterceptor implements HandlerInterceptor {
    private final JwtTokenizer jwtTokenizer;
    // ThreadLocal은 스레드에서 독립적인 값을 유지하는데 사용되는 클래스이다. 
    // static final로 선언해서 다른곳에서 변경할 수 없도록 한다. 다른 메서드에서 이 값을 사용할 수 있으며 전역적으로 접근할 수 있다.
    private static final ThreadLocal<Long> authenticatedMemberId = new ThreadLocal<>();

    public JwtInterceptor(JwtTokenizer jwtTokenizer) {
        this.jwtTokenizer = jwtTokenizer;
    }

    public static long getAuthenticatedMemberId() {
        return authenticatedMemberId.get();
    }

    // 컨트롤러 작동 이전에 수행하는 메서드
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        try {
            Map<String, Object> claims = jwtTokenizer.getJwsClaimsFromRequest(request);  // claims 얻기
            authenticatedMemberId.set(Long.valueOf(claims.get("memberId").toString()));  // ThreadLocal에 memberId 설정
            return true;
        } catch (Exception e) {
            log.info("HttpStatus.UNAUTHORIZED");
            return false;
        }
    }


    // 컨트롤러 작업 이후에 수행하는 메서드
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
                           @Nullable ModelAndView modelAndView) {
        this.authenticatedMemberId.remove();
    }
}

2. JwtAuthenticationFilter에 memberId  관련 정보 추가: 인증이 완료된 후 JWT를 발급할 때 claims를 설정할 수 있었다. 

    처음부터 필요한 모든 정보를 넣을 수 있었으면 좋았겠지만 빠진 부분이 있어서 추가했다.

    현재 JWT에는 claims으로 loginId만 넣어두었었는데, 이후 작업에 memberId 도 필요하여 해당 내용을 추가해주었다.

@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    
    ...

    private String delegateAccessToken(Member member) {
        // accessToken에 필요한 정보를 담아 생성한다.
        Map<String, Object> claims = new HashMap<>();
        claims.put("loginId", member.getLoginId());
        claims.put("memberId", member.getMemberId()); // memberId 정보 추가

        String subject = member.getLoginId();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());

        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

        return accessToken;
    }

3. WebMvcConfigurer 구현: 현재 작성되어있는 Configuration 클래스에 WebMvcConfigurer를 추가(구현)해주었다.

    또한 위에서 작성한 인터셉터를 적용하기 위해 addInterceptors() 메서드로 해당 인터셉트를 추가하고,

    어느 경로에서 해당 인터셉터를 적용시킬지 설정할 수 있다.

    아직 다른 팀원들과 코드를 합치지 않은 상태라서 /test 경로를 통해 인터셉터가 제대로 동작하는지 확인해보기로 했다.

// implements WebMvcConfigurer 를 통해 WebMvcConfigurer를 구현했다.
@Configuration
public class SecurityConfiguration implements WebMvcConfigurer {
    
    ...

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new JwtInterceptor(jwtTokenizer))
                .addPathPatterns("/test/**");
    }

4. 테스트용 Controller 설정: 요청이 들어왔을때 해당 요청을 보낸 memberId 를 정상적으로 추출하는지 확인하는 로직을 추가했다.

@RestController
@RequestMapping("/")
public class MemberController {
    ...

    @GetMapping("/test")
    public ResponseEntity test(){
        long memberId = JwtInterceptor.getAuthenticatedMemberId();
        String answer = "memberId: "+memberId;
        return new ResponseEntity<>(answer, HttpStatus.OK);
    }

[결과 확인]

회원식별자(memberId)가 1인 회원이 액세스토큰을 헤더에 넣어 /test 경로로 Get 요청을 보냈을때

인터셉터가 정상적으로 작동하여 memberId를 리턴하는것을 확인했다.

 

이제 어느정도 회원, 인증 관련 기능은 마무리가 된것같다.

남은 기간동안은 다른 팀원분들 코드와 합쳐보면서 생기는 문제들을 처리하게 될 것 같다.

또한 프론트엔드 코드와 백엔드 코드를 배포하고 통합테스트도 진행을 해야하는데 어떤 배포 방법이 좋을지 한번 고민해봐야겠다.

 

+ 추가(6.23.)

코드 테스트를 돌려보다보니 지정한 경로에서 무조건 인터셉터가 동작을 해서 인증이 필요하지 않은 GET 요청에도 UnAuthorized 예외를 발생시켰다. 해당 부분을 수정하기 위해 JwtInterceptor를 수정했다.

수정 이후에 GET 요청을 보냈을때 헤더에 토큰이 없어도 정상적으로 작동했다.

@Component
@Slf4j
public class JwtInterceptor implements HandlerInterceptor {
    ...
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String htttpMethod = request.getMethod();
        if(!httpMethod.equals("GET")) { // GET 요청이 아닐때만 해당 값을 토큰에서 가져오고
        try {
            Map<String, Object> claims = jwtTokenizer.getJwsClaimsFromRequest(request);  // claims 얻기
            authenticatedMemberId.set(Long.valueOf(claims.get("memberId").toString()));  // ThreadLocal에 memberId 설정
            return true;
        } catch (Exception e) {
            log.info("HttpStatus.UNAUTHORIZED");
            return false;
        }
        }
        else return true; // GET 요청일때는 검증과정 없이 다음 단계로 넘어간다.
    }


    // 컨트롤러 작업 이후에 수행하는 메서드
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
                           @Nullable ModelAndView modelAndView) {
        this.authenticatedMemberId.remove();
    }
}

 

참고자료

인터셉터와 필터로 토큰 인증, 인가 하기(with ThreadLocal)

자바 ThreadLocal: 사용법과 주의사항

[java] ThreadLocal에 관하여

ThreadLocal 사용법과 활용