본문 바로가기

Language & Framework/삽질기록

삽질 기록(16) 이런 귀여운 스테이터스 코드를 보고 그냥 지나갈 수가 없었다. 418 I'm a teapot 적용하기.

 

현업에서는 대부분 400~404로 떼운다지만, 나는 배우는 입장이다보니 스테이터스 코드에 관심이 많다.

이번에도 여느때와 같이 팀원과 409 Conflict를 쓰느냐, 422 Unprocessable Entity를 쓰느냐에 대한 논의를 하던 중 팀원이 이런 귀여운 스테이터스 코드를 발견해서 들고왔다..

 

Im a teapot..?

1998년 만우절에 만들어진, 커피를 찻주전자에 끓이는 것을 거부하는 스테이터스 코드다.

 

보통 만우절 장난으로 만들어진 무언가는 그냥 가상 세계에만 존재하는데, 이것은 현실에 실존한다.

심지어

 

"Some website use this response for requests they do not whish to handle, such as automated queires"

"Some website use this response for requests they do not whish to handle, such as automated queires"

"Some website use this response for requests they do not whish to handle, such as automated queires"

"Some website use this response for requests they do not whish to handle, such as automated queires"

"Some website use this response for requests they do not whish to handle, such as automated queires"

 

이걸 진짜로 쓴다구요..?

 

크롤링을 차단하는 웹 사이트의 경우 종종 418 스테이터스 코드를 크롤링 차단용으로 사용한다고 한다.

그러면 나도 당장 적용해야지;

 

 

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class CrawlerFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (isMethodEqualsGet(request)) {
            String userAgent = request.getHeader("user-agent");
            if (!StringUtils.hasText(userAgent)) throw new SecurityException();
        }
 
        filterChain.doFilter(request, response);
    }
 
    private boolean isMethodEqualsGet(HttpServletRequest request) {
        return request.getMethod().equals("GET");
    }
}
cs

 

크롤러를 잡아낼 수 있는 아주 단순한 방법이자, 제일 쉽게 뚫리는 방법은 User-Agent를 검사하는 것이다.

일반적인 웹 브라우저를 통해 사이트에 접속했다면 user-agent는 절대 Null일 수 없다.

다만 크롤링 시 user-agent에 임의로 값을 넣는 것은 전혀 어려운 일이 아니며, 심지어 나조차도 크롤러에 해당 설정을 적용한 상태기 때문에 큰 의미는 없다.

 

아마도 정말 크롤링을 막아서 웹 트래픽을 방지하고 싶다면 레디스 같은 캐시 저장소를 이용해서 특정 ip가 단시간에 일정 횟수 이상 접속한다면 차단한다던가하는 방법이 현실적이지 않을까 싶다. 

 

하지만 크롤링을 막는 게 주 목적이 아닌 나의 즐거움이 가장 큰 목적이기 때문에, 더 이상 깊게 들어가지는 않겠다.

 

 

 

 

 

 

 

 

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
    @Test
    @DisplayName("user-agent가 null인 GET 요청 시 SecurityException이 발생한다.")
    void test1() throws ServletException, IOException {
        //given
        //when
        mockRequest.setMethod("GET");
        //then
        Assertions.assertThatThrownBy(() ->
                crawlerFilter.doFilter(mockRequest, mockResponse, mockFilterChain))
                .isInstanceOf(SecurityException.class);
    }
 
    @Test
    @DisplayName("user-agent가 null이 아닌 요청 시 SecurityException이 발생하지 않는다.")
    void test2() throws ServletException, IOException {
        //given
        mockRequest.addHeader("user-agent""Mozila 5.0");
        //when
        crawlerFilter.doFilter(mockRequest, mockResponse, mockFilterChain);
        //then
        BDDMockito.verify(mockFilterChain, Mockito.times(1)).doFilter(mockRequest,mockResponse);
    }
 
    @Test
    @DisplayName("user-agent의 키 값에 대문자가 포함되어도 SecurityException이 발생하지 않는다")
    void test3() throws ServletException, IOException {
        //given
        mockRequest.addHeader("User-Agent""Mozila 5.0");
        //when
        crawlerFilter.doFilter(mockRequest, mockResponse, mockFilterChain);
        //then
        BDDMockito.verify(mockFilterChain, Mockito.times(1)).doFilter(mockRequest,mockResponse);
    }
cs

 

재미로 만들었다고 해서 테스트를 건너뛰는 것은 안된다.

테스트 좋아~

 

이제 예외를 받아서 418 I'm a teapot으로 처리해줄 클래스가 필요하다.

근데 난 기존에 Jwt Exception을 처리해주던 Exception Filter가 이미 있기 때문에 이 친구를 재탕하도록 하겠다.

 

 

 

 

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
public class ExceptionFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (InvalidJwtTokenException e) {
            setStatus(response, UNAUTHORIZED.value());
            response.getWriter().write(e.getErrorCode().getMessage());
        } catch (SecurityException e) {
            setStatus(response, I_AM_A_TEAPOT.value());
            response.getWriter().write(DO_NOT_CRAWL);
        }
    }
 
    private void setStatus(HttpServletResponse response, int statusCode) {
        response.setStatus(statusCode);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
    }
}
cs

 

아주 간단하고 평범한 예외 처리용 필터다.

SecurityException이 오면 I_AM_A_TEAPOT을 반환하도록 했다.

 

이제 ExceptionFilter에 대한 테스트를 해야 한다.

 

 

 

 

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    @Test
    @DisplayName("InvalidJwtException을 catch할 경우 response로 403 status를 보낸다")
    void test1() throws ServletException, IOException {
        //given
        BDDMockito.willThrow(new InvalidJwtTokenException(MALFORMED_EXCEPTION)).given(mockFilterChain).doFilter(request,response);
        //when
        exceptionFilter.doFilter(request, response, mockFilterChain);
        //then
        Assertions.assertThat(response.getStatus()).isEqualTo(UNAUTHORIZED.value());
    }
 
    @Test
    @DisplayName("SecurityException을 catch할 경우 response로 416 status를 보낸다")
    void test2() throws ServletException, IOException {
        //given
        BDDMockito.willThrow(new SecurityException()).given(mockFilterChain).doFilter(request,response);
        //when
        exceptionFilter.doFilter(request, response, mockFilterChain);
        //then
        Assertions.assertThat(response.getStatus()).isEqualTo(I_AM_A_TEAPOT.value());
    }
cs

 

아주 잘 작동하고 있다.

 

통합테스트는 굳이 진행하지 않았다.

어차피 기존 통합테스트가 죄다 깨지면서 얼마나 잘 작동하고 있는지 증명했기 때문에.. ㅎ

 

이제 실제 요청을 보내야하는데, 크롤러는 돌리기 귀찮아서 그냥 포스트맨으로 해결하기로 했다.

 

결과는?

 

 

 

 

 

 

실제 페이지에서 뷰를 띄워놓고 내 사이트를 크롤링한다면 이런 귀여운 문구를 보게 될 것이다.

즐겁다.

 

끝.