본문 바로가기
코비의 개발일지

필터에서 응답 데이터 가공

by 규난 2023. 4. 15.
728x90

이번에 저희 팀이 개발한 기업용 해외송금 서비스에 기능 추가 건이 생기면서 새로운 외부 API와 연동을 하게 되었는데 연동 조건에 서로 요청과 응답에 signature를 추가하고 검증해야 하는 조건이 있었습니다. 저는 요청 헤더의 signature 복호화 및 검증과 응답 시 헤더에 signature 추가를 필터에서 하는 것이 적합하다고 판단하였고 이 역할을 하는 필터의 개발을 맡게 되었습니다.

(밑의 코드들은 회사에서 작성한 코드를 공개할 수 없기 때문에 비슷한 역할을 하는 필터를 따로 만든 예제 코드입니다. 또한 이번 포스트에서는 암호화 복호화 방법에 대해서는 다루지 않습니다.)

Signature 검증 및 생성을 담당하는 필터

SecurerFilter Code

@Slf4j
@RequiredArgsConstructor
public class SecurerFilter implements Filter {

    private final ApplicationContext applicationContext;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        CustomHttpResponseWrapper customHttpResponseWrapper = new CustomHttpResponseWrapper(httpServletResponse);
        
        try {
            if (!checkSignature(httpServletRequest)) {
                throw new RuntimeException("유효하지 않은 시그니쳐입니다.");
            }

            chain.doFilter(request, customHttpResponseWrapper);

            if (!makeSignature(httpServletResponse)) {
                throw new RuntimeException("시그니쳐 생성 실패.");
            }
        } catch (RuntimeException e) {
            setExceptionResponse(httpServletResponse, e);
        }
    }

    private boolean makeSignature(HttpServletResponse httpServletResponse) {
        // signature 생성 코드 

        httpServletResponse.setHeader("signature", "~~~");
        return true;
    }
    // ... code
}

 

CustomHttpResponseWrapper Code

public class CustomHttpResponseWrapper extends HttpServletResponseWrapper {

    private final FastByteArrayOutputStream content = new FastByteArrayOutputStream(1024);
    private ServletOutputStream outputStream;

    public CustomHttpResponseWrapper(HttpServletResponse response) {
        super(response);
    }

    @Override
    public ServletOutputStream getOutputStream() throws IOException {
        if (outputStream == null) {
            outputStream = new CustomResponseServletOutputStream(getResponse().getOutputStream());
        }
        return outputStream;
    }

    public String getDataStreamToString() {
        return new String(content.toByteArray(), StandardCharsets.UTF_8);
    }

    /**
     * ContentCachingResponseWrapper의 ResponseServletOutputStream을 custom 한 것
     * 비동기 처리를 하지 않으면 ResponseServletOutputStream 처럼 데코레이터 패턴을 적용안 해도 됨
     * ResponseServletOutputStream은 서블리 3.1 스팩을 준수하기 위해 데코레이터 패턴으로 구현이 됨
     */
    private class CustomResponseServletOutputStream extends ServletOutputStream {
        
        private final ServletOutputStream servletOutputStream;
        
        public CustomResponseServletOutputStream(ServletOutputStream servletOutputStream) {
            this.servletOutputStream = servletOutputStream;
        }

        @Override
        public boolean isReady() {
            return false;
        }

        @Override
        public void setWriteListener(WriteListener listener) {

        }

        @Override
        public void write(int b) throws IOException {
            content.write(b);
        }

        @Override
        public void write(byte[] b, int off, int len) throws IOException {
            content.write(b, off, len);
        }
    }
}

 

개발한 필터를 빈으로 등록

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

    private ApplicationContext applicationContext;

    @Bean
    public FilterRegistrationBean<LoggingFilter> loggingFilter() {
        FilterRegistrationBean<LoggingFilter> loggingFilter = new FilterRegistrationBean<>();
        loggingFilter.setFilter(new LoggingFilter(applicationContext));
        loggingFilter.setOrder(2);
        return loggingFilter;
    }

    @Bean
    public FilterRegistrationBean<SecurerFilter> entryFilter() {
        FilterRegistrationBean<SecurerFilter> entryFilter = new FilterRegistrationBean<>();
        entryFilter.setFilter(new SecurerFilter(applicationContext));
        entryFilter.setOrder(1);
        return entryFilter;
    }
}

위 코드를 보시면 SecurerFilter의 order를 1로 설정해서 요청이 들어오게 되면 LoggingFilter보다 먼저 요청을 받게 끔 설정하였습니다.

 

SecurerFilter는 요청을 받게 되면 가장 먼저 checkSigature() 메소드를 이용하여 요청 헤더의 signature를 복호화 하여 검증하고 유효하지 않은 signature 면 예외를 발생시켜 다음 필터로 이동하지 않고 setExceptionResponse() 메서드를 통해 응답을 주게 됩니다. 정상적으로 signature 검증이 완료되면 필터 체인을 따라 다음 필터인 Logging 필터로 이동하게 되고 DispatcherServlet을 거쳐 HandlerMapping, HandlerAdapter 전략을 통해 실제 Controller로 요청이 위임되게 됩니다. 

 

그리고 내부적인 비지니스 로직을 완료 후 응답을 반환하게 되면 요청의 역순으로 동작하여 LoggingFIiter -> SecurerFilter 순으로 응답을 받게 되고 SecurerFilter에서는 makeSignature() 메서드를 통해 signature를 생성 후 응답 헤더에 추가하여 최종적으로 응답을 주게 되는데 checkSignature() 메소드와 마찬가지로 signature 생성에 실패하게 되면 setExceptionResponse() 메서드를 통해 응답을 주게 됩니다.

 

의도대로 동작하지 않는 코드

다시 위 코드를 보시면 정상적으로 동작할 거 같은 코드입니다.

하지만 실제 동작을 해보면 signature의 검증은 의도대로 동작하지만 signature의 생성은 의도대로 동작하지 않았습니다.

 

문제의 발견

왜 헤더에 정보가 추가되지 않을까 생각하면서 디버깅을 하던 중 ResponseFacade set 메소드안에 데이터를 추가하기 전 isCommitted() 메소드를 호출해 true를 반환받게 되면 바로 return을 하는 코드를 볼 수 있었습니다.

 

밑의 코드는 ResponseFacade의 setHeader 메소드입니다. 메소드를 보시면 헤더에 값을 추가하기 전 isCommited() 메소드를 호출하는 것을 볼 수 있습니다. 그리고 isCommited() 메소드는 response의 isAppCommited() 메소드를 호출하게 되어있는데, ResponseFacade는 Response Object에 디자인 패턴인  Facade Pattern이 적용된 Object입니다. Facade Pattern은 하위 클래스의 복잡한 로직을 감싸 기능을 사용하는 클라이언트가 간단하게 사용할 수 있도록 해주는 디자인 패턴입니다.

@Override
public void setHeader(String name, String value) {
    checkFacade();
    if (isCommitted()) {
        return;
    }
    response.setHeader(name, value);
}

@Override
public boolean isCommitted() {
    checkFacade();
    return response.isAppCommitted();
}

Response의 isAppComiited() 메소드

isAppComiited() 메소드를 보시면 or 조건으로 한 가지 조건이 만족하면 true를 반환하는 것을 볼 수 있습니다.

저는 저 부분에서 어느 조건이 만족하는지 궁금하여 디버깅을 더 해보기로 마음먹었습니다.

    /**
     * Application commit flag accessor.
     *
     * @return <code>true</code> if the application has committed the response
     */
    public boolean isAppCommitted() {
        return this.appCommitted || isCommitted() || isSuspended() ||
                ((getContentLength() > 0) && (getContentWritten() >= getContentLength()));
    }

 

copyBodyToResponse() 메서드에서 원인 발견

@Slf4j
@RequiredArgsConstructor
public class LoggingFilter implements Filter {

    private final ApplicationContext applicationContext;

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

        // code ...
        
        chain.doFilter(contentCachingRequestWrapper, contentCachingResponseWrapper);

        // save database .. 

        contentCachingResponseWrapper.copyBodyToResponse();
    }
}

디버깅을 하다 LoggingFilter에서 호출하는 contentCachingResponseWrapper.copyBodyToResponse() 메서드에서 원인을 발견하였습니다. 밑의 코드는 copyBodyToResponse() 메소드의 코드입니다. 코드를 자세히 보시면 getResponse() 메소드를 이용해 전 필터에서 받은즉, SecurerFilter에서 받은 CustomResponseWrapper의 인스턴스를 불러오고 this.content.writeTo() 메소드를 통해 ContentCachingResponseWrapper의 content 내용을 CustomResponseWrapper의 content에 써주는 것을 볼 수 있습니다. 

public void copyBodyToResponse() throws IOException {
    copyBodyToResponse(true);
}

/**
 * Copy the cached body content to the response.
 * @param complete whether to set a corresponding content length
 * for the complete cached body content
 * @since 4.2
 */
protected void copyBodyToResponse(boolean complete) throws IOException {
    if (this.content.size() > 0) {
        HttpServletResponse rawResponse = (HttpServletResponse) getResponse();
        if ((complete || this.contentLength != null) && !rawResponse.isCommitted()) {
            if (rawResponse.getHeader(HttpHeaders.TRANSFER_ENCODING) == null) {
                rawResponse.setContentLength(complete ? this.content.size() : this.contentLength);
            }
            this.contentLength = null;
        }
        this.content.writeTo(rawResponse.getOutputStream());
        this.content.reset();
        if (complete) {
            super.flushBuffer();
        }
    }
}

그리고 마지막에 super.flushBuffer() 메소드를 호출하게 되는데 이 부분이 헤더에 값이 써지지 않는 원인이었습니다.

밑의 ContentCachingResponseWrapper의 생성자를 보시면 파라미터로 HttpServletResponse 타입을 받게 되어있습니다.

public ContentCachingResponseWrapper(HttpServletResponse response) {
    super(response);
}

 

지금 저의 코드에서는 LoggingFilter에서 ContentCachingResponseWrapper의 인스턴스를 생성하게 되는데 LoggingFilter는 SecurerFilter에서 필터 체인을 타고 넘어오기 때문에 ContentCachingResponseWrapper의 생성자 파라미터로 SecurerFliter에서 넘겨 준 CustomeResponserWrapper의 인스턴스를 받게 되고, ContentCachingResponseWrapper는 HttpServlertResponseWraaper를 상속받고 HttpServlertResponseWraaper은 ServletResponseWrapper를 상속받기 때문에 ContentCachingResponseWrapper의 인스턴스를 생성 시 ServletResponseWrapper의 인스턴스도 생성이 되면서 ServletResponseWrapper의 맴버 변수인 response 변수에 CustomeResponseWrapper의 인스턴스를 넣어주게 됩니다. (ServletResponseWrpper는 ServletResponse를 구현하고 ServletResponse의 기능을 확장하기 위해 ServletResponse 타입의 맴버 변수인 response를가지는 데코레이터 패턴이 적용 됨)그래서 super.flushBuffer() 메소드를 호출하게 되면 ServletResponseWrapper의 flushBuffer() 메소드가 호출되는데 이때 CustomeResponseWrapper에는 flushBuffer() 메소드가 없기 때문에  CustomResponseWrapper가 필드로 가지고 있는 response의(ResponseFacade의 인스턴스) flushBuffer() 메소드가 호출이됩니다.

// ResponseFacade
@Override
public void flushBuffer() throws IOException {
    checkFacade();
    if (isFinished()) {
        return;
    }

    if (SecurityUtil.isPackageProtectionEnabled()) {
        try {
            AccessController.doPrivileged(new FlushBufferPrivilegedAction(response));
        } catch (PrivilegedActionException e) {
            Exception ex = e.getException();
            if (ex instanceof IOException) {
                throw (IOException) ex;
            }
        }
    } else {
        response.setAppCommitted(true);
        response.flushBuffer();
    }
}

// Response
@Override
public void flushBuffer() throws IOException {
    outputBuffer.flush();
}

ResponseFacade는 org.apache.catalina.connector 패키지에 있는 Response의 appCommitted 필드를 true로 셋팅하게 되고 Response의 flushBuffer() 메서드를 호출하게 되는데 이때 outputBuffer의 flush() 메소드가 호출이되면서 org.apache.coyote 패키지에 있는 Response의 commited 필드를 true로 셋팅하게 됩니다.

 

그래서 Response에 있는데 isAppComitted() 메서드를 호출 시 appComitted 필드와 comitted 필드가 true이기 때문에 헤더에 값을 추가하지 못하고 의도대로 동작하지 않았던 것입니다.

 

이 부분을 해결하기 위해 CustomResponseWrapper에 flushBuffer를 빈 메서드로 오버라이드 하였습니다.

// CustomResponseWrapper
@Override
public void flushBuffer() throws IOException {
}

응답 데이터를 핸들링 할 수 있게 ContentChachingResponseWrapper도 flushBuffer를 빈 메서드로 오버라이드 하였더라고요... 진작에 주의 깊게 봤어야 했는데...

 

이렇게 빈 메서드를 오버라이드 해서 appComitted 필드와 comitted 필드가 true로 셋팅 되는것을 막아주면 응답에 추가하고 싶은 데이터들을 추가할 수 있게 됩니다. 밑의 코드는 최종적으로 변경된 CustomResponseWrapper 클래스 입니다.

@Slf4j
@RequiredArgsConstructor
public class SecurerFilter implements Filter {

    private final ApplicationContext applicationContext;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        CustomHttpResponseWrapper customHttpResponseWrapper = new CustomHttpResponseWrapper(httpServletResponse);

        try {
            if (!checkSignature(httpServletRequest)) {
                throw new RuntimeException("유효하지 않은 시그니쳐입니다.");
            }

            chain.doFilter(request, contentCachingResponseWrapper);

            if (!makeSignature(httpServletResponse)) {
                throw new RuntimeException("시그니쳐 생성 실패.");
            }

            response.getOutputStream().write(customHttpResponseWrapper.getDataStreamToString().getBytes(StandardCharsets.UTF_8));
            response.flushBuffer();
        } catch (RuntimeException e) {
            setExceptionResponse(httpServletResponse, e);
        }
    }

    private boolean makeSignature(HttpServletResponse httpServletResponse) {
        // signature 생성 코드

        httpServletResponse.setHeader("signature", "~~~");
        return true;
    }

    // code ...
}

 

필터 기능 개발을 들어가기 전에는 요청과 응답에 대한 데이터 핸들링이 간단하게 될 줄 알았는데 막상 개발을 해보니 쉽지 않았습니다. 그리고 디자인 패턴에 대한 공부를 하지 않았더라면 저 코드들을 이해하는데 더 많은 시간을 쏟았거나 어쩌면 아직까지 이해를 못 한 채 그냥 어찌어찌 돌아가는 코드로 기억하고 있을 가능성이 컸을 거 같아 평소에 나름 꾸준히 공부한 보람을 느끼고 앞으로도 지금처럼 꾸준히 공부하고 조금씩 성장하는 개발자가 되도록 노력해야겠다는 생각이 드는 추가 기능 개발건이었습니다.

728x90