코딩은 마라톤

[Spring] Spring Filter로 특정 API 응답 데이터 gzip 압축 적용하기 본문

Backend/Spring Boot

[Spring] Spring Filter로 특정 API 응답 데이터 gzip 압축 적용하기

anxi 2025. 11. 10. 21:19

중간지점 찾기 서비스인 모이삼에서는 중간지점 산출 및 출발지에서 중간지점까지의 상세 경로를 알려준다.

https://www.moisam.kr/

 

모이삼

최적의 중간장소 찾기, 약속장소 추천

www.moisam.kr

 

중간지점은 최대 3개까지 산출되며, 출발지마다 경로를 보여주는 만큼, 응답 데이터의 크기는 중간지점 수와 출발지 수에 비례한다.

그래서 모이삼은 응답 시간을 줄이기 위해 응답 데이터 캐싱과 멀티 스레드를 사용해 외부 API에서 경로를 가져오는 등 여러 처리를 진행했다. 위 과정을 통해 비약적으로 속도 개선을 이뤘지만, 응답 데이터의 크기는 줄이지 못했다.

 

줄일 수 있는 방법을 찾아보던 와중, 응답 데이터를 압축할 수 있는 포맷인 gzip을 알게 되었다.

 

🏠 gzip이란?

gzip은 파일 압축 및 압축 해제에 사용되는 파일 형식(file format)이다.

Deflate 알고리즘을 기반으로, 파일 크기를 줄여 네트워크 전송 속도를 향상시킬 수 있다.

 

gzip은 웹 서버 및 최신 브라우저에서 지원돼서, 서버는 응답을 전송 이전에 gzip으로 압축할 수 있고, 브라우저는 응답을 자동으로 압축해제 할 수 있다.


🍷 적용 근거

최범균 님의 "주니어 백엔드 개발자가 반드시 알아야 할 실무 지식"에서는

응답 시간에는 데이터 전송 시간이 포함되고, 데이터 전송 시간에는 '네트워크 속도', '전송 데이터 크기' 2가지 요인에 영향을 받는다고 한다.

 

위 2가지 요인 중에 서버가 핸들링할 수 있는 것은 '전송 데이터 크기' 뿐이다. '네트워크 속도'는 공유기나 통신사 등 여러 요인에 따라 영향받을 수 있지만, 전송 데이터 크기는 서버에서 압축할 수 있기 때문이다.

그래서 나는 '전송 데이터 크기'를 gzip으로 압축하여 서버에서 데이터 크기를 줄여 해결하고자 했다.

 

1. 브라우저 범용성이 높다.

 

Browser Compatibility

 

 

위 사진은 gzip뿐만 아닌 여러 압축 포맷에서의 브라우저 수용 가능성을 보여준다.

Brotli 알고리즘을 사용한 br 포맷도 Opera Android를 제외하고 대부분의 브라우저에서 사용 가능하기 때문에, 더 오래된 gzip은 더 범용성 있을 거라 생각했다.

 

2. 압축률이 좋다.

 

https://tools.paulcalvano.com/compression.php

 

위 사진은 gzip과 brotli, 그리고 압축 전의 응답 크기를 비교한 결과다. (중간지점 2개, 출발지 8개 기준)

level을 1로 계산했을 경우에 아래와 같다.

  • gzip: 1614 
  • Brotli: 1691
  • 비압축(Uncompressed):  2788

gzip을 사용할 경우, 원본 크기의 57% 압축을 이뤄낼 수 있다. level을 높게 설정할 경우 압축률은 더 늘릴 수 있다.

 

3. 적용하기 쉽다.

HTTP request의 'Accept-Encoding' 헤더를 통해 서버에 처리할 수 있는 압축 알고리즘을 알려준다.

 

HTTP request

 

HTTP response에서는 클라이언트에서 명시된 알고리즘 중에서 지원한 방식의 알고리즘을 'Content-Encoding' 헤더에 명시한다.

서버에서만 gzip으로 압축하고 'Content-Encoding'을 명시해 주면 끝이다. (br, deflate, zstd로 압축을 해도 상관없다.)

 

Nginx나 Traefik와 같은 웹 서버는 압축 기능을 제공하고 있어 백엔드 코드를 건드리지 않고도 해결할 수 있다.

다만 특정 경로에서의 처리는 불가능하기 때문에 나는 스프링 필터를 사용해 특정 경로에서만 gzip 압축을 하도록 구현했다.


🍀 Spring Filter를 사용하여 특정 API 응답 압축 구현기

java.util.zip에서 GZIPOutputStream을 제공하기 때문에 ServletOutputStream을 GZIPOutputStream으로 변경한다.

그리고 HttpServletResponseWrapper를 GZIPOutputStream을 사용하도록 수정하고 Filter에서 압축할 API의 엔드포인트를 검증하여 분기처리한다.

 

1️⃣ GzipHttpServletResponseWrapper

/** GZIP 압축 적용 응답 Wrapper */
public class GzipHttpServletResponseWrapper extends HttpServletResponseWrapper {

    public static final String GZIP = "gzip";

    private GzipServletOutputStream gzipServletOutputStream;

    public GzipHttpServletResponseWrapper(HttpServletResponse response) {
        super(response);
        // HTTP response에 Content-Encoding을 GZIP으로 설정
        response.addHeader(HttpHeaders.CONTENT_ENCODING, GZIP);
        
        // Vary: Accept-Encoding 헤더 설정
        response.addHeader(HttpHeaders.VARY, HttpHeaders.ACCEPT_ENCODING);
    }

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

    /** GZIP 출력 스트림 종료 */
    public void finish() throws IOException {
        if (gzipServletOutputStream != null) {
            gzipServletOutputStream.flush();
            gzipServletOutputStream.close();
        }
    }
}

 

  • HttpServletResponseWrapper를 상속받아 응답 스트림을 GZIPOutputStream으로 감싼다.
  • "Content-Encoding: gzip"과 "Vary: Accept-Encoding" 헤더를 추가한다.
    • "Vary: Accept-Encoding"는 캐시 시스템(CDN, Proxy, Cache)에게 클라이언트의 "Accept-Encoding" 헤더 값에 따라 응답이 달라질 수 있음을 알려준다. 만약 Vary를 적용하지 않는다면, gzip으로 압축한 응답이 캐시 했다가 gzip을 지원하지 않는 클라이언트에게 그대로 전달할 수 있는 문제가 발생한다. 따라서 "Vary: Accept-Encoding"도 추가하는 게 안전하다.
  • getOutputStream()에서 gzip 압축 스트림(GzipServletOutputStream)을 생성한다.
즉, 이 클래스는 기존 HttpServletResponse를 gzip 응답용으로 감싸는 역할을 한다.

 

2️⃣ GzipServletOutputStream

/** GZIP 출력 스트림 */
public class GzipServletOutputStream extends ServletOutputStream {

    private final GZIPOutputStream gzipOutputStream;

    public GzipServletOutputStream(OutputStream outputStream) throws IOException {
        this.gzipOutputStream = new GZIPOutputStream(outputStream);
    }

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

    @Override
    public void setWriteListener(WriteListener writeListener) {
    }

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

    @Override
    public void flush() throws IOException {
        gzipOutputStream.flush();
    }

    @Override
    public void close() throws IOException {
        gzipOutputStream.close();
    }
}

 

  • ServletOutputStream을 상속받아 내부적으로 GZIPOutputStream을 사용한다.
  • write(), flush(), close() 모두 java.util.zip GZIPOutputStream을 적용한다.
즉, 이 클래스는 실제 gzip 압축을 수행하는 역할을 한다.

 

3️⃣ CompressFilter

/** 응답 데이터 압축 필터 */
@Component
public class CompressFilter extends OncePerRequestFilter {

    /** GZIP 압축이 허용된 API 엔드포인트의 정규식 패턴 목록 */
    private static final List<Pattern> ALLOWED_COMPRESS_ENDPOINT_PATTERNS = List.of(
            Pattern.compile("^/events/[^/]+$")
    );

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        /** 압축 허용된 API가 아닐 경우 미압축 응답을 반환 */
        if (!shouldCompress(request)) {
            filterChain.doFilter(request, response);
            return;
        }

        /** Gzip 압축 응답 생성 Wrapper를 사용하여 필터 체인 실행 */
        GzipHttpServletResponseWrapper gzipResponse = new GzipHttpServletResponseWrapper(response);
        try {
            filterChain.doFilter(request, gzipResponse);
        } finally {
            gzipResponse.finish();
        }
    }

    /** 주어진 요청이 GZIP 압축 대상인지 검증 */
    private boolean shouldCompress(HttpServletRequest request) {
        String acceptEncoding = request.getHeader(HttpHeaders.ACCEPT_ENCODING);
        String uri = request.getRequestURI();
        String method = request.getMethod();

        boolean isGet = HttpMethod.GET.name().equalsIgnoreCase(method);

        return acceptEncoding != null
                && acceptEncoding.contains(GZIP)
                && isGet
                && matchesAllowedUri(uri);
    }

    /** 요청 URI가 허용된 압축 엔드포인트 패턴과 일치하는지 검사 */
    private boolean matchesAllowedUri(String uri) {
        return ALLOWED_COMPRESS_ENDPOINT_PATTERNS.stream()
                .anyMatch(pattern -> pattern.matcher(uri).matches());
    }
}

 

  • 특정 API 요청만 gzip으로 압축하도록 필터링한다.
  • 정규식으로 URI를 매칭하여 일치할 경우에만 압축한다.
  • request의 "Accept-Encoding" 헤더에 gzip이 포함된 경우에만 처리한다.
즉, 이 클래스는 클라이언트의 요청을 확인하고 gzip으로 반환 가능한지 검증 후 Wrapper를 사용해 응답을 압축하는 역할을 한다.

🧩 적용 결과

gzip 압축 전

 

gzip 압축 후

 

위 두 사진은 중간지점 1개, 출발지 8개 기준으로 압축 전후의 결과이다.

  • 응답 시간: 약 27% 단축
  • 응답 크기: 약 83% 감소

앞서 언급한 적용 근거처럼, 네트워크 속도가 느릴수록 압축 전후의 응답 시간 차이는 더 크게 나타날 것이다.


참고