Post

필터를 직접 만들어보면서 확인한 등록과 순서

필터를 직접 만들어보면서 확인한 등록과 순서

참고자료


배경

필터가 무엇인지는 앞 글에서 정리했다. 이번에는 직접 만들어보면서 문서만 봐서는 안 보이던 것들을 확인했다.

특히 등록하는 방법이 여러 가지인데 무엇을 쓰든 같은 결과가 나오는 것이 아니었다. 순서를 지정하는 방법도 등록 방식에 따라 달랐다.

정리하면서 확인하고 싶었던 것들이다.

  • 필터를 등록하는 방법이 여러 가지인데 무엇이 다른가?
  • 필터 여러 개의 실행 순서는 무엇으로 정해지는가?
  • 필터에서 던진 예외는 어떻게 처리해야 하는가?
  • 필터에 빈을 주입받아도 되는가?

시나리오

필터를 직접 만들어보기 위해 요구사항을 이렇게 잡았다.

  • POST /diger/join으로 회원가입한다. 파라미터는 userName(String), age(int)다.
  • POST /diger/login으로 로그인한다. 파라미터는 userName(String)이고 액세스 토큰 문자열을 돌려준다.
  • GET /diger/filtering을 요청할 수 있다. 헤더에 액세스 토큰을 담는다. 토큰에 들어 있는 나이가 서버가 정한 기준보다 적으면 이 요청은 거부된다.
  • 모든 요청은 필터 단계에서 카운팅되어 데이터베이스에 기록된다.

패키지 이름은 이 글을 쓸 당시의 javax.servlet 기준이다. 스프링 부트 3.x 이상에서는 jakarta.servlet으로 읽어야 한다.


필터를 두 개로 나눈다

  • CustomJwtFilter: JWT를 검증하고 나이 조건을 판정한다.
  • CustomApiRequestCountFilter: API 요청 수를 센다.

DTO와 Controller

RequestDto.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

public class RequestDto {

    @Data
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public static class JoinForm {
        private String userName;
        private int age;
    }

    @Data
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public static class LoginForm {
        private String userName;
    }
}

DigerController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import study.querydsl.entity.Member;
import study.querydsl.jwt.JwtUtilizer;
import study.querydsl.repository.member.MemberRepository;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class DigerController {

    private final MemberRepository memberRepository;
    private final JwtUtilizer jwtUtilizer;

    @PostMapping("/diger/join")
    public void join(@RequestBody RequestDto.JoinForm joinForm) {
        Member newMember = Member.builder()
                .age(joinForm.getAge())
                .username(joinForm.getUserName())
                .build();

        memberRepository.save(newMember);
    }

    @PostMapping("/diger/login")
    public String login(@RequestBody RequestDto.LoginForm loginForm) {
        List<Member> members = memberRepository.findByUsername(loginForm.getUserName());

        if (members.isEmpty()) {
            throw new IllegalArgumentException("존재하지 않는 사용자입니다.");
        }
        return jwtUtilizer.createAccessToken(members.get(0));
    }

    @GetMapping("/diger/filtering")
    public String filtered() {
        return "필터링을 거치고 요청이 성공했습니다!";
    }
}

로그인 실패를 "로그인 실패!"라는 문자열로 돌려주면 HTTP 상태가 200이 된다. 클라이언트가 본문을 파싱하기 전까지 성공과 실패를 구분할 수 없으므로 예외를 던지고 @RestControllerAdvice에서 상태 코드를 붙이는 편이 낫다.


Repository

ApiRequestCountRepository

1
2
3
4
5
6
import org.springframework.data.jpa.repository.JpaRepository;
import study.querydsl.entity.ApiRequestCount;

public interface ApiRequestCountRepository
        extends JpaRepository<ApiRequestCount, Long>, ApiRequestCountRepositoryCustom {
}

ApiRequestCountRepositoryCustom

1
2
3
4
public interface ApiRequestCountRepositoryCustom {

    void increaseApiRequestCount();
}

ApiRequestCountRepositoryCustomImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import static study.querydsl.entity.QApiRequestCount.apiRequestCount;

@RequiredArgsConstructor
@Repository
@Transactional
public class ApiRequestCountRepositoryImpl implements ApiRequestCountRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public void increaseApiRequestCount() {
        queryFactory
                .update(apiRequestCount)
                .set(apiRequestCount.count, apiRequestCount.count.add(1L))
                .where(apiRequestCount.id.eq(1L))
                .execute();
    }
}

QueryDSL의 update는 JPQL 벌크 연산이므로 영속성 컨텍스트를 거치지 않고 DB에 직접 나간다. 그래서 카운터를 여러 스레드가 동시에 올려도 count = count + 1이 DB에서 수행되어 갱신 손실이 나지 않는다. 대신 같은 트랜잭션에서 이 엔티티를 이미 조회했다면 1차 캐시의 값이 낡은 채로 남는다.

구현체 이름이 ApiRequestCountRepositoryImpl인 것도 우연이 아니다. 스프링 데이터 JPA는 사용자 정의 구현체를 찾을 때 리포지토리 인터페이스명 + Impl 규칙을 쓴다. ApiRequestCountRepositoryCustomImpl로 지으면 기본 설정에서는 인식되지 않는다.


Filter

CustomJwtFilter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import study.querydsl.jwt.JwtUtilizer;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
@Component
@RequiredArgsConstructor
public class CustomJwtFilter implements Filter {

    private static final int MIN_AGE = 24;

    private final JwtUtilizer jwtUtilizer;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("CustomJwtFilter init, filterName={}", filterConfig.getFilterName());
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        String path = httpRequest.getServletPath();

        // 인증이 필요 없는 경로는 그대로 통과
        if (path.equals("/diger/join") || path.equals("/diger/login")) {
            chain.doFilter(request, response);
            return;
        }

        String token = httpRequest.getHeader("AccessToken");
        if (token == null || token.isBlank()) {
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "AccessToken이 없습니다.");
            return;
        }

        int requestedUserAge;
        try {
            requestedUserAge = Integer.parseInt(jwtUtilizer.getUserAgeByToken(token));
        } catch (Exception e) {
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "토큰이 유효하지 않습니다.");
            return;
        }

        if (requestedUserAge < MIN_AGE) {
            httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "이용 가능한 나이가 아닙니다.");
            return;
        }

        chain.doFilter(request, response);
    }
}

처음 작성했을 때는 나이 조건을 판정한 뒤 로그만 찍고 chain.doFilter()를 그대로 호출했다. 조건을 만족하지 않는 요청이 콘솔에는 “이용할 수 없다”고 찍히면서 컨트롤러까지 가서 200을 받았다. 필터에서 요청을 끊는 방법은 chain.doFilter()를 호출하지 않는 것뿐이다.

경로 판정을 contains에서 equals로 바꾼 이유도 있다. contains("/diger/join")/diger/join-admin 같은 경로까지 통과시킨다. 인증을 건너뛰는 조건은 넓게 잡을수록 위험하다.

CustomApiRequestCountFilter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import study.querydsl.repository.count.ApiRequestCountRepository;

import javax.servlet.*;
import java.io.IOException;

@Component
@RequiredArgsConstructor
@Slf4j
public class CustomApiRequestCountFilter implements Filter {

    private final ApiRequestCountRepository apiRequestCountRepository;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        apiRequestCountRepository.increaseApiRequestCount();

        chain.doFilter(request, response);
    }
}

뭔가 이상한데, 왜 카운트 필터가 먼저 실행되는가

구현을 마치고 요청을 보내니 로그가 이렇게 찍혔다.

1
2
Api Count 필터 가동!
CustomJwtFilter 가동!

CustomApiRequestCountFilter가 먼저 돌았다. 순서를 지정한 코드는 어디에도 없다. 그런데 왜 이 순서로 정해졌는가.

필터 빈이 등록되는 경로

Filter를 구현한 클래스에 @Component를 붙이면 스프링 부트가 이 빈을 찾아 서블릿 컨테이너에 자동으로 등록한다. 이 작업을 하는 것이 ServletContextInitializerBeans다. 등록과 정렬을 담당하는 부분을 보면 이렇다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private <T> List<Entry<String, T>> getOrderedBeansOfType(ListableBeanFactory beanFactory, Class<T> type,
        Seen seen) {
    String[] names = beanFactory.getBeanNamesForType(type, true, false);
    Map<String, T> map = new LinkedHashMap<>();
    for (String name : names) {
        if (!seen.contains(type, name) && !ScopedProxyUtils.isScopedTarget(name)) {
            T bean = beanFactory.getBean(name, type);
            if (!seen.contains(type, bean)) {
                map.put(name, bean);
            }
        }
    }
    List<Entry<String, T>> beans = new ArrayList<>(map.entrySet());
    beans.sort((o1, o2) -> AnnotationAwareOrderComparator.INSTANCE.compare(o1.getValue(), o2.getValue()));
    return beans;
}

세 가지를 읽을 수 있다.

첫째, 초기 순서는 빈 이름 목록의 순서다. getBeanNamesForType이 돌려준 배열을 LinkedHashMap에 그대로 담으므로 삽입 순서가 유지된다.

둘째, 그 목록을 AnnotationAwareOrderComparator로 정렬한다. 이 비교자는 @Order 애노테이션이나 Ordered 인터페이스에서 순서 값을 읽는다. 두 필터 모두 둘 중 어느 것도 없다.

셋째, 순서 값이 없으면 Ordered.LOWEST_PRECEDENCE를 쓴다. 값은 Integer.MAX_VALUE다. 두 필터의 순서 값이 같다는 뜻이다.

같은 코드 바로 아래에서 등록 시점의 order도 확인할 수 있다.

1
2
3
RegistrationBean registration = adapter.createRegistrationBean(beanName, bean, entries.size());
int order = getOrder(bean);
registration.setOrder(order);

그래서 순서를 가른 것은 무엇인가

List.sort는 안정 정렬(stable sort)이다. 비교 결과가 같은 원소들은 정렬 전 순서를 그대로 유지한다. 두 필터의 순서 값이 Integer.MAX_VALUE로 같으므로, 정렬은 아무것도 바꾸지 못하고 getBeanNamesForType이 돌려준 순서가 그대로 최종 순서가 된다.

그 빈 이름 순서는 컴포넌트 스캔이 클래스를 발견한 순서를 따른다. 클래스패스를 훑는 순서는 파일 시스템이 디렉터리 항목을 돌려주는 순서에 좌우되므로, 스프링이 명세로 보장하는 값이 아니다. 이 환경에서 CustomApiRequestCountFilterCustomJwtFilter보다 먼저 나온 것은 그 스캔 결과가 그랬기 때문이지, 어떤 규칙이 그렇게 정한 것이 아니다.

지금 이 순서는 우연이다. 클래스 이름을 바꾸거나 패키지를 옮기거나 빌드 도구가 달라지면 순서가 뒤집힐 수 있다. 인증 필터가 카운트 필터보다 나중에 도는 지금 구조에서는 인증에 실패한 요청까지 카운트에 잡히는데, 이것이 의도한 동작이든 아니든 우연에 기대고 있다는 사실은 그대로다.

순서를 명시하는 두 가지 방법

방법 1. @Order

1
2
3
4
5
6
7
@Component
@Order(1)
public class CustomJwtFilter implements Filter { ... }

@Component
@Order(2)
public class CustomApiRequestCountFilter implements Filter { ... }

숫자가 작을수록 먼저 실행된다. 인증에 실패한 요청을 카운트에서 빼려면 인증 필터가 앞에 와야 한다.

방법 2. FilterRegistrationBean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<CustomJwtFilter> jwtFilter(JwtUtilizer jwtUtilizer) {
        FilterRegistrationBean<CustomJwtFilter> registration =
                new FilterRegistrationBean<>(new CustomJwtFilter(jwtUtilizer));
        registration.addUrlPatterns("/diger/*");
        registration.setOrder(1);
        return registration;
    }

    @Bean
    public FilterRegistrationBean<CustomApiRequestCountFilter> countFilter(
            ApiRequestCountRepository repository) {
        FilterRegistrationBean<CustomApiRequestCountFilter> registration =
                new FilterRegistrationBean<>(new CustomApiRequestCountFilter(repository));
        registration.addUrlPatterns("/diger/*");
        registration.setOrder(2);
        return registration;
    }
}

이쪽을 권한다. 순서뿐 아니라 적용 URL 패턴까지 지정할 수 있기 때문이다.

@Component만 붙인 필터는 /*에 매핑된다. 정적 자원 요청, 파비콘 요청, 헬스체크 요청까지 전부 이 필터를 지나간다. 카운트 필터에는 이 차이가 곧바로 드러난다. 브라우저가 페이지 하나를 여는 동안 CSS와 이미지 요청이 함께 나가므로, “API 요청 수”를 세려던 카운터가 실제로는 정적 자원 요청까지 더한 값을 기록한다. DB UPDATE가 그만큼 더 나가는 것도 함께 따라온다.

이 필터를 @Component로 두면서 URL 패턴만 제한하고 싶다면, FilterRegistrationBean으로 등록한 뒤 자동 등록을 꺼야 한다. 같은 필터가 두 번 등록되는 것을 막기 위해서다.

1
2
3
4
5
6
@Bean
public FilterRegistrationBean<CustomJwtFilter> disableAutoRegistration(CustomJwtFilter filter) {
    FilterRegistrationBean<CustomJwtFilter> registration = new FilterRegistrationBean<>(filter);
    registration.setEnabled(false);
    return registration;
}

정리하며

처음 던진 질문은 “순서를 준 적이 없는데 왜 이 순서인가”였다. 답은 “순서가 정해진 게 아니라 정렬이 아무것도 하지 않았고, 스캔 순서가 남은 것”이다.

AnnotationAwareOrderComparator는 순서 값이 없는 빈을 전부 Integer.MAX_VALUE로 보고, 안정 정렬이라 동률은 입력 순서를 유지한다. 그 입력 순서는 클래스패스 스캔 결과이므로 보장되지 않는다.

필터가 두 개 이상이고 그중 하나라도 요청을 끊을 수 있다면, 순서는 반드시 @OrderFilterRegistrationBean으로 명시해야 한다. 지금 잘 도는 것처럼 보이는 것과 그렇게 동작하도록 정해둔 것은 다르다.


정리하며

처음 던진 질문들에 대한 답이다.

등록 방법의 차이. @Component로 올리면 스프링 부트가 알아서 모든 요청에 등록한다. 간편하지만 URL 패턴을 제한할 수 없다. 정적 파일 요청까지 전부 지나간다.

FilterRegistrationBean으로 등록하면 URL 패턴, 순서, 이름, 활성화 여부를 전부 지정할 수 있다. 대신 필터 클래스에 @Component를 붙이면 안 된다. 붙이면 자동 등록과 수동 등록이 겹쳐서 같은 필터가 두 번 지나간다.

실행 순서를 무엇이 정하는가. FilterRegistrationBeansetOrder()이거나 필터가 구현한 Ordered의 값이다. 숫자가 작을수록 먼저 실행된다.

여기서 주의할 것이 있다. @Order 애노테이션은 필터 등록 순서에 반영되지 않는 경우가 있다. 확실하게 하려면 FilterRegistrationBean에서 명시하는 편이 낫다.

순서가 중요한 이유는 뒤 필터가 앞 필터의 결과에 의존하기 때문이다. 인증 필터가 먼저 돌아야 요청 카운팅 필터가 사용자 정보를 쓸 수 있다.

예외를 어떻게 처리하는가. 던지면 안 된다. 필터는 디스패처 서블릿 바깥이라 @ControllerAdvice가 못 받고, 컨테이너의 기본 에러 페이지로 떨어진다. JSON을 기대하는 클라이언트에게 HTML이 간다.

그래서 응답을 직접 써야 한다.

1
2
3
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"message\":\"토큰이 유효하지 않습니다\"}");

에러 응답 형식이 컨트롤러 쪽과 달라지기 쉬우므로, 공통 응답 객체를 필터에서도 쓸 수 있게 빼두는 편이 낫다.

빈을 주입받아도 되는가. 된다. FilterRegistrationBean으로 등록하면 필터 자체를 빈으로 만들어 생성자 주입을 받을 수 있다.

다만 요청마다 달라지는 빈은 조심해야 한다. 필터는 싱글턴이므로 요청 범위 빈을 직접 주입받으면 안 되고, 프록시를 거치거나 필요할 때 꺼내 써야 한다.

DB 접근을 필터에 넣는 것도 다시 생각할 부분이었다. 모든 요청이 지나가는 자리라서 여기서 조회를 하면 그만큼 부하가 곱해진다. 이 글에서 만든 요청 카운팅도 매 요청마다 저장하는 방식이라, 실제로 쓴다면 메모리에 모았다가 주기적으로 반영하는 쪽이 맞다.

만들어보고 나서 남은 감각은 필터가 싸 보이지만 모든 요청이 지나가는 자리라는 것이었다. 여기 넣은 코드는 전체 트래픽만큼 실행되므로, 편의를 위해 넣은 것 하나가 전체 응답 시간에 그대로 더해진다.

This post is licensed under CC BY 4.0 by the author.