Spring 인터셉터에 url 경로 추가하는 방법. pathvariable도 포함하기

728x90

Spring interceptor란


spring의 controller는 각 사용자가 해당 url 경로로 접속 했을 때 각 경로에 맞는 서비스 로직을 매칭해주는 첫번째 관문이다.

 

@RestController
@RequestMapping("/customer/{foodKind}/store")
public class StoreController {
    private final MemberService memberService;
    private final StoreService storeService;

    public StoreController(MemberService memberService, StoreService storeService) {
        this.memberService = memberService;
        this.storeService = storeService;
    }
    
    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    public List<StorePartResponseDto> showStoreList(HttpServletRequest request, 
                                                    @PathVariable FoodKind foodKind,
                                                    HttpServletResponse response) throws IOException {
        String jwt = request.getHeader("Authorization");
        String cognitoUsername = parseJwtService.getCognitoUsernameFromJwt(jwt); if(!joinService.checkJoinedMember(cognitoUsername)){
            String email = parseJwtService.getEmailFromJwt(jwt);
            joinService.joinMember(cognitoUsername, email);
            log.info("Create member: id={}, email={}", cognitoUsername, email);
        }
        Optional<Point> coordinates = memberService.getCoordinates(customerId);
        if(coordinates.isEmpty()){
            throw new NullPointerException("Customer has no location information.");
        }
        List<StorePartResponseDto> storePartList = new ArrayList<>();
        List<Store> storeList = storeService.getStoreListNearCustomer(coordinates.get(), foodKind);
        storeList.forEach(store -> {
            storePartList.add(new StorePartResponseDto(store));
        });
        return storePartList;
    }
}



위는 컨트롤러 사용 예시를 보여준다. 특정 url에 해당 사용자가 접속했을 때 특정 서비스 로직을 가져와서 동작시키는 예시다. 내용은 별로 중요하지않으니 넘어가자.

그런데 이 controller를 사용하다 보면 여러 경로에서 공통적으로 사용하는 기능들이 있을 것이다. 이것을 모든 컨트롤러 메서드에 복사 붙여넣기 하는 방법도 있겠지만, 그렇게 한다면 코드 양이 늘어나서 가독성이 떨어지고 핵심되는 로직에 집중하기 어려워지기도 한다. 무엇보다 Mapping 메서드를 추가할 때마다 모든 기능들을 복사 붙여넣기 하는 것은 작성할 때도 상당히 번거롭다.

예를 들면 위 코드는 HTTP 헤더에 포함되어있는 jwt를 분석하여 사용자의 id를 꺼내오는 다음 코드가 포함되어있다. 사용자가 DB에 존재하면 그 id를 그대로 사용하고, 존재하지 않으면 새로 가입시키는 로직이다.

 

String jwt = request.getHeader("Authorization");
        String cognitoUsername = parseJwtService.getCognitoUsernameFromJwt(jwt);
        if(!joinService.checkJoinedMember(cognitoUsername)){
            String email = parseJwtService.getEmailFromJwt(jwt);
            joinService.joinMember(cognitoUsername, email);
            log.info("Create member: id={}, email={}", cognitoUsername, email);
        }

 


문제는 해당 코드가 사용자 id를 필요로 하는 모든 컨트롤러 메서드에서 앞 부분에 반드시 포함되어야 한다는 것이다. 중복되는 기능을 사용하려고 매번 매서드에 복사 붙여넣기를 하는 과정은 상당히 번거롭다.

Spring은 이러한 중복 작성을 피하기 위해 특정 경로들에서 공통적으로 수행할 수 있는 기능을 등록할 수 있도록 interceptor라는 것을 제공한다. 해당 기능은 서블릿의 필터의 기능과 거의 유사하다. 

interceptor 사용하는 방법


인터셉터를 만드는 방법을 간략하게 소개해본다.

1. HandlerInterceptor 구현 클래스 만들기


spring interceptor를 사용하려면 HandlerInterceptor를 implements 하면 된다.

 

 

public class JoinCheckInterceptor implements HandlerInterceptor {

    private final ParseJwtService parseJwtService;
    private final JoinService joinService;

    public JoinCheckInterceptor(ParseJwtService parseJwtService, JoinService joinService) {
        this.parseJwtService = parseJwtService;
        this.joinService = joinService;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception{
        String jwt = request.getHeader("Authorization");
        String cognitoUsername = parseJwtService.getCognitoUsernameFromJwt(jwt);
        if(!joinService.checkJoinedMember(cognitoUsername)){
            String email = parseJwtService.getEmailFromJwt(jwt);
            joinService.joinMember(cognitoUsername, email);
            log.info("Create member: id={}, email={}", cognitoUsername, email);
        }
        request.setAttribute("cognitoUsername", cognitoUsername);
        return true;
    }
}



이처럼 HandlerInterceptor의 메소드를 Override하여 사용하기만 하면 된다. HandlerInterceptor에는 용도에 따라 preHandle, postHandle, afterCompletion이라는 메서드가 각각 override 할 수 있게 주어지는데, 여기서는 컨트롤러 동작 전에 실행시킬 로직을 처리해야 하므로 preHandle을 사용한다.

해당 메서드를 override하면 메서드 인자로 HttpServletRequest와 HttpservletResponse가 주어지는데, 위처럼 필요에 따라 가져와서 사용하면 된다. 

그 외에는 그냥 컨트롤러에서 사용하듯이 생성자를 통해 서비스 로직을 가져와서 인터셉터 메서드 안에서 서비스 메서드를 넣으면 된다.

그런데 해당 코드에는 클래스에 어떤 애노테이션이 붙지 않는다. @Controller, @Service, @Repository같은 애노테이션이 여기에선 붙지 않는다. Spring에서는 @Interceptor라는 애노테이션을 지원하지 않는다. 그렇다면 Spring이 어떻게 해당 인터셉터를 컨트롤러들에 적용시킬 수 있을까? 


2. WebConfig에 등록하기


Spring에는 addInterceptor라는 메서드를 통해 인터셉터를 스프링 컨테이너에 직접 등록할 수 있게 해준다. 스프링의 WebMvcConfigurer라는 인터페이스를 구현하면 addInterceptor라는 메서드를 상속하여 사용할 수 있고 해당 메서드를 통해 기존에 만들어둔 interceptor 클래스를 등록할 수 있다.

아래와 같이 WebMvcConfigurer를 구현하는 Configuration용 클래스를 하나 만들어서 인터셉터를 적용해주자.

 

@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final ParseJwtService parseJwtService;
    private final JoinService joinService;

    public WebConfig(ParseJwtService parseJwtService, JoinService joinService, StoreService storeService) {
        this.parseJwtService = parseJwtService;
        this.joinService = joinService;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new JoinCheckInterceptor(parseJwtService, joinService))
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/customer", "/customer/main", "/error");

    }
}



addInterceptor라는 메소드를 오버라이딩 하면 위처럼 registry라는 인자를 사용할 수 있고 인자의 메소드로 addInterceptor가 존재하는데 위처럼 메소드를 사용하면 기존에 만들어둔 인터셉터를 추가할 수 있다.

다만 해당 클래스는 @Configuration을 붙여서 Spring이 볼 수 있게 해줘야 한다.

매서드를 잠깐 분석해보자면, 
addInterceptor는 말그대로 기존에 만들어둔 인터셉터를 넣는 역할을 하고, 
order는 동작 순서를 나타낸다. 인터셉터를 여러개 넣을 수 있으므로 그 순서를 정하는 역할을 한다.
addPathPatterns는 해당 인터셉터가 적용될 경로를 추가해준다. /** 는 해당 경로를 포함하여 모든 하위 경로까지 포함하는 경로를 의미한다. 그러므로 위 예시에서는 모든 url 경로를 의미한다. 
excludePathPatterns는 제외할 경로를 의미한다. addPathPatterns와 사용법은 동일하다. addPathpatterns에 들어간 경로보다 우선적으로 적용되어 인터셉터의 경로에서 제외된다.


Interceptor에 path variable도 추가할 수 있나요?

본 글을 작성한 이유다.

보통 url 매핑을 할 때는 위 예시처럼 저렇게 얌전하게(?) 정적 url을 사용하진 않는다. 사용자에 따라서 달라질 수 있는 데이터를 응답으로 보내주려면 pathvariable을 사용하게 된다. 

 

@GetMapping("/customer/{foodKind}/store/{storeId}")



그런데 이 경우 사용자가 보는 store 정보에 대해서만 공통적으로 작용하는 특정 인터셉터를 만들고 싶다면, 경로를 어떻게 설정하면 좋을까?

해답은 간단하다. 위에 GetMapping에 들어간 url 경로의 양식 그대로 작성하면 된다.

 

@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final ParseJwtService parseJwtService;
    private final JoinService joinService;
    private final StoreService storeService;

    public WebConfig(ParseJwtService parseJwtService, JoinService joinService, StoreService storeService) {
        this.parseJwtService = parseJwtService;
        this.joinService = joinService;
        this.storeService = storeService;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new JoinCheckInterceptor(parseJwtService, joinService))
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/customer", "/customer/main", "/error");
        registry.addInterceptor(new FoodKindCheckInterceptor())
                .order(2)
                .addPathPatterns("/customer/{foodKind}/**");
        registry.addInterceptor(new StoreCheckInterceptor(storeService))
                .order(3)
                .addPathPatterns("/customer/{foodKind}/store/{storeId}/**");
    }
}


그러니까 중괄호 사이에 넣은 문자는 pathVariable로 인식되는 것이고, /** 는 하위 경로를 포함하는 모든 경로를 의미하는 것은 java 공통적인 url 경로 문법인 것이다. 

이처럼 경로를 추가하면 특정 경로에 대해서만 interceptor를 적용할 수 있다.