들어가며
최근 구현한 기능 중 특정 API 의 응답 크기가 100kb 가 초과하는 것들이 있었다. 배포 후 큰 문제는 없었지만, 네트워크 대역폭 제한이 있어서, 사용자가 몰릴 경우 잠재적인 문제가 될 수도 있다는 생각이 들었다.
이를 해결하기 위해 응답을 압축하는 방법을 사용했는데, 그 과정에 대해 예제와 함께 글을 적어본다.
Gzip 압축을 사용한 이유
응답 크기가 큰 특정 몇 API 는 사용자가 최초 접속 시 반드시 호출 되어야만 하고, 데이터 특성상 자주 변경되어 HTTP 의 Cache-Control 을 사용해 캐싱하기 어려웠다. ETag 를 적용하는 것도 고려했지만, 데이터가 너무 크고 빈번하게 변동되어 매번 식별자를 생성하는 작업이 비효율적이라 판단했다. 대신, Last-Modified 를 적용해서 일부 문제를 해소해 보았다.
하지만, 문제는 데이터가 자주 바뀌어서 캐시무효화가 자주 발생한다는 점이었다. 또한, 캐시가 없는 상태에서의 요청은 여전히 부담이 될 수 밖에 없었다. API 구조상 응답 자체를 분리하거나 Lazy 로딩으로 변경 하기도 어려워, 고민하다가 압축을 선택하게 되었다.
응답 압축하기
사전준비
응답이 485kb 정도 되는 API 를 준비했다.
Spring Boot Compression
Spring Boot에서는 Http Response Body를 압축하는 기본 기능을 제공한다. 이 기능을 활성화 하는 방법은 간단하다.
// application.yml
server:
compression:
enabled: true
mime-types: text/html,text/plain,text/css,application/javascript,application/json
min-response-size: 1024
- server.compression.enabled: 응답 압축을 사용할지 여부 (default = false)
- server.compression.mime-types : 압축 대상 mime-type 들
- server.compression.min-response-size : 압축을 수행할 최소 byte size
이 설정을 적용한 후, Response 를 확인해보면 Response Headers 에 Content-Encoding: gzip 헤더가 추가되고 응답 크기가 줄어든 것을 확인할 수 있다.
여기까지 보면 문제가 없어 보이지만, 실제로는 주의할 점이 있다.
server.compression.min-response-size 의 값은 org.apache.coyote.CompressionConfig.useCompression 메서드 내부에서 Response 의 Content-Length 값과 비교해 압축 여부를 판단하고 있다.
디버깅을 해보면 contentLength 의 값이 -1 로만 들어오고 있는데 그 이유는 Transfer-Encoding 가 사용되고 있어서 이다. (-1 은 Content-Length 가 존재하지 않는다는 뜻이다). 일반적으로 Transfer-Encoding의 chunked를 많이 사용하는데 이 경우 Content-Length 가 Response에 포함되지 않는다. Apache Tomcat 문서에 따르면 Content-Length 가 없을 때 응답을 압축을 한다는 이야기도 나온다.
If the content-length is not known and compression is set to "on" or more aggressive, the output will also be compressed. - 참고문서
단순히 Content-Length 헤더를 직접 지정해주면 되지 않나? 라고 생각할 수 있지만, RFC 7230 section 3.3.3 을 보면 Transfer-Encoding 이 있을때, Content-Length 는 무시된다고 한다. 또한, 큰 응답에 대해 일일이 Content-Length 를 설정하는 것은 매우 비효율적이다.
따라서 이 설정을 적용하면 모든 API 응답이 압축되는데, 무조건적으로 압축하는 것이 과연 최선인지 고민을 했다. 가장 먼저 떠오른 것은 압축하는데 드는 비용이다. Gzip 압축의 알고리즘은 deflate 을 사용한다. 응답을 압축하는 과정에서 CPU 자원을 사용하고, 이로 인해 응답 속도가 느려질 수 있다. 모든 API 에서 압축을 하게되면 그만큼의 성능이 하락 되기 때문에, 특정 API 만 압축을 적용하는 방법을 찾고자 했다.
Filter 로 특정 path 만 압축하기
특정 API의 응답을 조작하려면 Servlet Filter를 사용하면 된다. 이를 위해 HttpServletResponseWrapper 를 상속한 커스텀 응답 객체를 만들어, FilterChain.doFilter 메서드에 전달하여 응답을 조작할 수 있다. 응답을 압축하려면 Response 의 OutputStream 을 java.util.zip.GZIPOutputStream 으로 교체하면 된다. 이 방법은 이 블로그글 을 보고 참고했다.
// 한번의 Request 당 한번만 작동하면 된다 판단해 Spring 의 OncePerRequestFilter 를 사용했다
public class GzipResponseCompressionFilter extends OncePerRequestFilter {
private static final String CONTENT_ENCODING_GZIP = "gzip";
// Filter 의 urlPattern 보다는, 좀 더 유연한 사용과 일관성을 위해 Spring 의 AntPathMatcher 를 사용했다.
private final AntPathMatcher pathMatcher;
private final Set<String> gzipPaths;
public GzipResponseCompressionFilter() {
this.pathMatcher = new AntPathMatcher();
this.gzipPaths = Set.of("/min/api/test");
}
/**
* acceptEncoding 에 GZIP 이 포함되어 있는지, 설정한 path 와 match 되는지 확인한다.
*/
@Override
protected boolean shouldNotFilter(final HttpServletRequest request) {
return notAcceptGzipEncoding(request) || pathNotMatched(request);
}
private boolean notAcceptGzipEncoding(final HttpServletRequest request) {
final String acceptEncoding = request.getHeader(HttpHeaders.ACCEPT_ENCODING);
return acceptEncoding == null || !acceptEncoding.contains(CONTENT_ENCODING_GZIP);
}
private boolean pathNotMatched(final HttpServletRequest request) {
final String requestURI = request.getRequestURI();
return gzipPaths.stream().noneMatch(path -> pathMatcher.match(path, requestURI));
}
@Override
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain) throws ServletException, IOException {
final CustomHttpServletResponseWrapper gzipResponse = new CustomHttpServletResponseWrapper(response);
gzipResponse.setHeader(HttpHeaders.CONTENT_ENCODING, CONTENT_ENCODING_GZIP);
filterChain.doFilter(request, gzipResponse);
gzipResponse.close();
}
static class CustomHttpServletResponseWrapper extends HttpServletResponseWrapper {
private final GZIPOutputStream gzipOutputStream;
private ServletOutputStream outputStream;
private PrintWriter writer;
/**
* HttpServletResponse 의 outputStream 과 Writer 을 교체한다
*/
public CustomHttpServletResponseWrapper(final HttpServletResponse response) throws IOException {
super(response);
this.gzipOutputStream = new CustomGZIPOutputStream(response.getOutputStream());
}
@Override
public ServletOutputStream getOutputStream() {
if (this.outputStream == null) {
this.outputStream = new CustomServletOutputStream(this.gzipOutputStream);
}
return this.outputStream;
}
@Override
public PrintWriter getWriter() throws IOException {
if (this.writer == null) {
this.writer = new PrintWriter(new OutputStreamWriter(this.gzipOutputStream, getCharacterEncoding()));
}
return this.writer;
}
@Override
public void flushBuffer() throws IOException {
if (this.writer != null) {
this.writer.flush();
}
if (this.outputStream != null) {
this.outputStream.flush();
}
this.gzipOutputStream.flush();
}
public void close() throws IOException {
this.gzipOutputStream.close();
}
}
/**
* 압축 레벨 설정하는 기능이 없어 기존 GZIPOutputStream 을 상속해서 레벨 설정 기능 추가
*/
static class CustomGZIPOutputStream extends GZIPOutputStream {
public CustomGZIPOutputStream(final OutputStream out) throws IOException {
this(out, Deflater.BEST_SPEED);
}
public CustomGZIPOutputStream(final OutputStream out, final int compressionLevel) throws IOException {
super(out);
setLevel(compressionLevel);
}
/**
* 압축 레벨 설정
* 높을수록 응답속도가 느려지는 대신 크기가 작아진다.
*/
public void setLevel(final int level) {
def.setLevel(level);
}
}
static class CustomServletOutputStream extends ServletOutputStream {
private final GZIPOutputStream gzipOutputStream;
public CustomServletOutputStream(final GZIPOutputStream gzipOutputStream) {
this.gzipOutputStream = gzipOutputStream;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setWriteListener(final WriteListener writeListener) {
}
@Override
public void write(final int b) throws IOException {
this.gzipOutputStream.write(b);
}
}
}
코드에 주석을 추가해 각 부분의 역할을 적어보았다.
해당 Filter 를 적용한 뒤, 응답을 확인해보면 잘 압축 되는 것을 확인 할 수 있다.
여기서 두 가지점을 주목해야한다.
첫째, 두개의 응답 시간 차이이다. 압축을 적용하고 나면, 응답 시간이 조금 늘어난 것을 확인 할 수 있다. (약 100번가량씩 호출했을때 두개의 평균 응답 시간은 압축 - 70ms, 비압축 - 20ms 정도 였다.)
둘째, 압축률이다. Spring Boot Compression 을 적용했을때는 185kb 였는데, 여기선 213kb 로 늘어났다. 그 이유는 압축 레벨에 있다.
위의 코드를 보면 GZIPOutputStream 를 상속해서 사용했는데, 그 이유는 GZIPOutputStream 에 압축 레벨을 설정하는 기능이 없었기 때문이다. 해당 기능은 Open JDK PR 이 추가되면 가능하지만 현재는 상속을 통해 구현해야한다.
Apache Tomcat 이 사용하는 org.apache.coyote.http11.filters.GzipOutputFilter 를 보면 기본 GZIPOutputStream을 사용하고 있는데, GZIPOutputStream 의 Default 압축 레벨은 -1이다. 이 값은 레벨 6과 동일하다고 한다.
The java code uses the class "new ZStreamRef(init(level, DEFAULT_STRATEGY, nowrap));" the init method is a native call and the ZStreamRef is a reference to zlib. So its what ever zlib uses as default. In the version 1.2.8 the default is 6 like devnull stated. - 참고링크
즉, 압축레벨을 위의 코드에 적용 되어있는 BEST_SPEED(1) 대신 DEFAULT(6)으로 올려 테스트해보면 Spring Boot Compression 과 비슷한 결과가 나온다.
보면 다시 183kb 로 사이즈가 줄었으며, 시간 또한 조금 늘어났다. (100번 가량 호출시 평균 약 90ms)
이 결과를 통해 압축 레벨이 높을수록 사이즈는 줄어들지만, 응답 속도가 느려진다는 점을 확인 할 수 있다.
Annotation 을 사용한 특정 API 응답 압축하기
특정 API 의 응답을 압축하고 싶을때, 매번 Filter 클래스를 수정하는건 매우 번거롭다. 이를 개선하기 위해 Annotation 기반으로 설정을 할 수 있도록 변경해보았다. 또한, API 마다 압축 레벨을 변경해가며 사용할 수 있도록 추가해보았다.
@Target({ElementType.METHOD, ElementType.TYPE}) // 메서드와 클래스 레벨에 지원
@Retention(RetentionPolicy.RUNTIME)
public @interface GzipResponse {
// 레벨 설정 1~9 까지 설정이 가능하다
int level() default Deflater.BEST_SPEED;
}
public class GzipLevel {
public static final GzipLevel DEFAULT = new GzipLevel();
public static final int MIN_LEVEL = 1;
public static final int MAX_LEVEL = 9;
private final int level;
private GzipLevel() {
this.level = Deflater.BEST_SPEED;
}
public GzipLevel(final int level) {
validate(level);
this.level = level;
}
private void validate(final int level) {
if (level < MIN_LEVEL || level > MAX_LEVEL) {
throw new IllegalArgumentException("level must be between %d and %d".formatted(MIN_LEVEL, MAX_LEVEL));
}
}
public int getLevel() {
return level;
}
}
public class GzipResponseCompressionFilter extends OncePerRequestFilter {
private static final String CONTENT_ENCODING_GZIP = "gzip";
// Filter 의 urlPattern 보다는, 좀 더 유연한 사용과 일관성을 위해 Spring 의 AntPathMatcher 를 사용했다.
private final AntPathMatcher pathMatcher;
// Controller 에서 @GzipResponse 를 가진 urlPattern 과 압축 레벨 정보를 담은 자료구조
private final Map<String, GzipLevel> gzipPathInfo;
public GzipResponseCompressionFilter(final RequestMappingHandlerMapping requestMappingHandlerMapping) {
this.pathMatcher = new AntPathMatcher();
// 모든 Controller 의 정보를 가진 RequestMappingHandlerMapping 에서 @GzipResponse 를 가진 메서드를 추출해 가공한다
this.gzipPathInfo = requestMappingHandlerMapping.getHandlerMethods().entrySet()
.stream()
.filter(entry -> isGzipAnnotationPresent(entry.getValue()))
.flatMap(entry -> entry.getKey()
.getPatternValues()
.stream()
.map(urlPattern -> Map.entry(urlPattern, new GzipLevel(entry.getValue().getMethod().getAnnotation(GzipResponse.class).level()))))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
// 메서드 혹은 클래스레벨에 @GzipResponse 이 있는지 확인한다
private boolean isGzipAnnotationPresent(final HandlerMethod handlerMethod) {
return handlerMethod.getMethod().isAnnotationPresent(GzipResponse.class) || handlerMethod.getBeanType().isAnnotationPresent(GzipResponse.class);
}
/**
* acceptEncoding 에 GZIP 이 포함되어 있는지, 설정한 path 와 match 되는지 확인한다.
*/
@Override
protected boolean shouldNotFilter(final HttpServletRequest request) {
return notAcceptGzipEncoding(request) || pathNotMatched(request);
}
private boolean notAcceptGzipEncoding(final HttpServletRequest request) {
final String acceptEncoding = request.getHeader(HttpHeaders.ACCEPT_ENCODING);
return acceptEncoding == null || !acceptEncoding.contains(CONTENT_ENCODING_GZIP);
}
private boolean pathNotMatched(final HttpServletRequest request) {
final String requestURI = request.getRequestURI();
return gzipPathInfo.keySet().stream().noneMatch(path -> pathMatcher.match(path, requestURI));
}
@Override
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain) throws ServletException, IOException {
// gzipPathInfo 에서 requestURI 가 urlPattern 과 match 하는게 있는지 찾는다.
final GzipLevel gzipLevel = gzipPathInfo.entrySet().stream()
.filter(path -> pathMatcher.match(path.getKey(), request.getRequestURI()))
.findAny()
.orElseThrow(() -> new IllegalArgumentException("no matched uri for GzipResponseFilter"))
.getValue();
final GzipHttpServletResponseWrapper gzipResponse = new GzipHttpServletResponseWrapper(response, gzipLevel);
gzipResponse.setHeader(HttpHeaders.CONTENT_ENCODING, CONTENT_ENCODING_GZIP);
filterChain.doFilter(request, gzipResponse);
gzipResponse.close();
}
static class GzipHttpServletResponseWrapper extends HttpServletResponseWrapper {
private final GZIPOutputStream gzipOutputStream;
private ServletOutputStream outputStream;
private PrintWriter writer;
/**
* HttpServletResponse 의 outputStream 과 Writer 을 교체한다
*/
public GzipHttpServletResponseWrapper(final HttpServletResponse response) throws IOException {
this(response, GzipLevel.DEFAULT);
}
public GzipHttpServletResponseWrapper(final HttpServletResponse response, final GzipLevel gzipLevel) throws IOException {
super(response);
this.gzipOutputStream = new CustomGZIPOutputStream(response.getOutputStream(), gzipLevel.getLevel());
}
@Override
public ServletOutputStream getOutputStream() {
if (this.outputStream == null) {
this.outputStream = new CustomServletOutputStream(this.gzipOutputStream);
}
return this.outputStream;
}
@Override
public PrintWriter getWriter() throws IOException {
if (this.writer == null) {
this.writer = new PrintWriter(new OutputStreamWriter(this.gzipOutputStream, getCharacterEncoding()));
}
return this.writer;
}
@Override
public void flushBuffer() throws IOException {
if (this.writer != null) {
this.writer.flush();
}
if (this.outputStream != null) {
this.outputStream.flush();
}
this.gzipOutputStream.flush();
}
public void close() throws IOException {
this.gzipOutputStream.close();
}
}
/**
* 압축 레벨 설정하는 기능이 없어 기존 GZIPOutputStream 을 상속해서 레벨 설정 기능 추가
*/
static class CustomGZIPOutputStream extends GZIPOutputStream {
public CustomGZIPOutputStream(final OutputStream out) throws IOException {
this(out, Deflater.BEST_SPEED);
}
public CustomGZIPOutputStream(final OutputStream out, final int compressionLevel) throws IOException {
super(out);
setLevel(compressionLevel);
}
/**
* 압축 레벨 설정
* 높을수록 응답속도가 느려지는 대신 크기가 작아진다.
*/
public void setLevel(final int level) {
def.setLevel(level);
}
}
static class CustomServletOutputStream extends ServletOutputStream {
private final GZIPOutputStream gzipOutputStream;
public CustomServletOutputStream(final GZIPOutputStream gzipOutputStream) {
this.gzipOutputStream = gzipOutputStream;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setWriteListener(final WriteListener writeListener) {
}
@Override
public void write(final int b) throws IOException {
this.gzipOutputStream.write(b);
}
}
}
Spring 이 가지고 있는 RequestMappingHandlerMapping bean 을 주입 받아서 모든 Controller 의 정보를 가져 온 뒤, @GzipResponse 를 가진 메서드들을 추출해 미리 가지고 있는다. 요청이 들어올때, 가진 gzipPathInfo 를 순회하며 알맞는 응답 압축 level 찾아서 적용해 응답을 압축한다.
여담이지만 RequestMappingHandlerMapping 이나 HandlerMethod 같은 것들은 NEXTSTEP 의 만들면서 배우는 Spring 교육을 통해 자세한 구조를 알게 되었다!
다만, gzipPathInfo에 너무 많은 urlPattern이 등록되면 매 요청마다 순회하는 비용이 커질 수 있다. 현재는 몇개 안되어 적용하지 않았지만, 이 점은 추후 캐싱과 같은 기능으로 성능 최적화를 고려해보아야 한다.
해당 Filter 를 등록한 뒤 응답을 압축하고 싶은 Controller 에 사용하면 된다.
@RestController
@RequestMapping("/min/api")
public class TestController {
private final TestService testService;
public TestController(final TestService testService) {
this.testService = testService;
}
@GetMapping("/test")
public ResponseEntity<Map<String, String>> testBigData() {
return ResponseEntity.ok(testService.getBigData());
}
@GetMapping("/test-no-zip")
public ResponseEntity<Map<String, String>> testBigDataWithoutZip() {
return ResponseEntity.ok(testService.getBigData());
}
@GzipResponse(level = 6)
@GetMapping("/test-annotation-level6")
public ResponseEntity<Map<String, String>> testBigDataWithAnnotationLevel6() {
return ResponseEntity.ok(testService.getBigData());
}
@GzipResponse
@GetMapping("/test-annotation-level1")
public ResponseEntity<Map<String, String>> testBigDataWithAnnotationLevel1() {
return ResponseEntity.ok(testService.getBigData());
}
}
마치며
응답 크기가 큰 API 를 압축을 통해 개선해보는 시간을 가져보았다. 응답값에 따라 압축 알고리즘으로 인해 더 압축 될 수도, 덜 압축 될 수도 있으므로 사용해보며 적절하게 판단해야 한다. 또한, 압축 레벨이 올라갈수록 압축하는 비용이 올라가므로, 적용한 API 특성을 생각하며 압축 레벨을 고려해야한다.
실제로 운영환경에 몇 개의 API 에 적용 해보니, 한 API 는 90% 가 넘는 압축률을 보여주었다.
전체 코드는 깃허브를 통해 확인 할 수 있다. (json 파일은 작게 변경되어 있음)
참고
https://www.baeldung.com/json-reduce-data-size
https://gunju-ko.github.io/spring/spring-boot/2018/06/16/SpringBootCompression.html
https://howtodoinjava.com/spring-boot/response-gzip-compression/
'back-end > spring' 카테고리의 다른 글
Spring Security 6 hasIpAddress 사용하는법 (feat.Spring boot 3) (0) | 2023.10.03 |
---|---|
Spring Redisson 분산락(Distribute Lock) 좀 더 잘 써보기 (3/3) - 한계와 극복 (0) | 2023.07.24 |
Spring Redisson 분산락(Distribute Lock) 좀 더 잘 써보기 (2/3) - AOP (0) | 2023.07.23 |
Spring Redisson 분산락(Distribute Lock) 좀 더 잘 써보기 (1/3) - 템플릿 콜백 패턴 (0) | 2023.07.22 |
Spring 의 TransactionalEventListener 실행 순서 (0) | 2023.07.09 |