이미지 다운로드 / 업로드 서버를 개발 중에 해결했던 문제를 적어보고자 합니다.
현재 서버는 이미지를 다운로드할 때 이미지의 크기를 조정한 후 조정된 이미지를 내려주는 로직을 수행하고 있습니다.
이미지같은 정적 리소스는 애플리케이션 서버에서 반환하는 것이 아닌 웹 서버에서 반환을 할 수 있습니다. 이를 위해 애플리케이션 서버 앞에 웹 서버를 두기로 결정했습니다.
이미지 파일은 정적 리소스이기 때문에 NGINX의 빠른 처리 능력과 단순한 구성설정이 이점이 될 것으로 판단되고 프록시 서버로서의 기능도 사용할 수 있어 결국 NGINX 웹 서버를 선택하기로 하였습니다.
여기까지의 개발한 흐름을 이미지로 나타내면 다음과 같습니다.
아직 클라이언트가 동일한 이미지를 계속해서 요청할 때 이미지 리사이징 로직을 매번 수행해야하는 문제가 있습니다. 이를 위해 캐싱을 적용해보도록 하겠습니다.
NGINX의 reverse proxy 캐싱을 적용해 보겠습니다.
NGINX는 애플리케이션 서버로 가는 요청을 앞에서 처리하는 reverse proxy 기능과 처리된 요청을 캐싱할 수 있는 기능을 제공합니다. 이를 이용하여 동일한 크기의 이미지를 요청하는 경우, 애플리케이션에서 처리한 이미지를 NGINX에 캐싱하도록 설정하겠습니다.
NGINX에서 캐싱에 대한 기능을 제공해주고 있기 때문에 nginx.conf
파일을 수정해 캐싱을 적용했습니다.
proxy_cache_key
지시어를 사용하여 캐시 키를 생성합니다. 이때, $scheme
$request_method
, $host
, $request_uri
등의 변수를 사용하여 캐시 키를 생성합니다.$scheme
변수는 요청에 사용된 프로토콜 (http 또는 https)을 나타내며,$request_method
변수는 요청에 사용된 HTTP 메소드 (GET, POST 등)를 나타냅니다.$host
변수는 요청한 호스트 이름을 나타내며,$request_uri
변수는 요청된 URI를 나타냅니다.$upstream_cache_status
변수를 사용하여 프록시 캐시에서 응답을 받았는지를 확인합니다. 이 변수는 다음과 같은 값을 가질 수 있습니다. ($upstream_cache_status
변수는 Nginx의 proxy_cache
모듈에서 사용되는 변수입니다.)MISS
: 캐시에서 응답을 받지 못함BYPASS
: 캐시를 건너뛰고 원본 서버에서 응답을 받음EXPIRED
: 캐시의 유효 기간이 지나 응답을 받을 수 없음HIT
: 캐시에서 응답을 성공적으로 받음여기까지의 흐름을 이미지로 나타내보면 다음과 같습니다.
캐싱은 NGINX에서도 할 수 있지만 브라우저단에서도 적용할 수 있습니다. 바로 ETag
값과 If-None-Match
헤더를 사용해서 캐싱에 적용할 수 있습니다.
서버에서 응답하는 결과가 자주 바뀌는 API에 대해서는 ETag
값을 적용하면 오히려 애플리케이션 서버에 부하를 야기할 수 있지만, 개발하는 서버에서 동일한 크기의 이미지 요청이 많이 온다고 가정했기 때문에 ETag
를 적용하기로 했습니다.
ETag
는 HTTP 프로토콜에서 캐시 유효성 검증을 위한 방법 중 하나로 클라이언트가 조건부검사를 할 수 있도록 해줍니다.ETag
는 웹 서버가 URL에서 탐색한 리소스의 특정 버전에 할당한 식별자 값입니다. 클라이언트는 이 식별자 값을If-None-Match
헤더에 담아 값이 일치하지 않으면 서버에서 변경된 리소스를 가지고 오게 됩니다.
Spring framework 에서 ETag
를 사용하고 싶다면 ShallowEtagHeaderFilter
를 빈으로 등록해주면 됩니다.
@Configuration(proxyBeanMethods = false)
public class ETagHeaderFilter {
@Bean
public ShallowEtagHeaderFilter shallowEtagHeaderFilter() {
return new ShallowEtagHeaderFilter();
}
}
API 테스트코드로 다음을 검증합니다.
ETag
가 존재하는지If-None-Match
헤더로 응답받은 ETag
값을 같이 넘겨 재요청시 응답코드가 304 NOT MODIFIED
인지 @DisplayName("한 번 응답을 받은 후 동일한 요청을 etag 헤더와 같이 보내면 304응답코드가 반환된다.")
@Test
void givenResponseWasRetrieved_whenRetrievingAgainWithEtag_thenNotModified() {
// given
ResponseEntity<byte[]> res = testRestTemplate.getForEntity(
"http://localhost:8080/api/resize/images/1cad34b7-55bc-49c3-a5a8-d384c3d30c26.jpeg?width=500",
byte[].class
);
SoftAssertions.assertSoftly(softAssertions -> {
softAssertions.assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK);
softAssertions.assertThat(res.getHeaders().getCacheControl()).isNotNull();
softAssertions.assertThat(res.getHeaders().getETag()).isNotNull();
});
HttpHeaders headers = new HttpHeaders();
headers.setIfNoneMatch(res.getHeaders().getETag());
HttpEntity<Object> request = new HttpEntity<>(headers);
// when
ResponseEntity<byte[]> secondRes = testRestTemplate.exchange(
"http://localhost:8080/api/resize/images/1cad34b7-55bc-49c3-a5a8-d384c3d30c26.jpeg?width=500",
HttpMethod.GET,
request,
byte[].class
);
// then
assertThat(secondRes.getStatusCode()).isEqualTo(HttpStatus.NOT_MODIFIED);
}
이후 실제로 서버를 동작시켜 서버로부터 온 응답값을 확인해보면 다음과 같습니다.
ETag
헤더에 해시값을 같이 넘겨주는 것을 확인할 수 있었습니다.
이후 Request Headers에도 If-None-Match
헤더에 ETag
값을 넘겨 요청을 보내는 것을 확인할 수 있습니다.
캐시 히트되어 StatusCode가 304 Not Modified 로 오는 것을 확인했고 기존 5.59s 정도 걸리던 요청이 캐시 적용시 68ms로 단축된 것을 확인할 수 있습니다.
캐시를 통해 동일한 응답에 대해서는 응답시간을 대폭 줄일 수 있었습니다. 하지만 여전히 캐시히트에 의존하고 있고 해당 서버를 확장한다면 어떻게 데이터를 넣을지, 경로는 어떻게 설정해야할 것인지도 고민해야 할 것 같습니다.
이동욱님 NGINX Cache 문제 해결
NGINX reverse proxy caching
Tecoble - ETag