서론
데이터 통신 비용 절감의 필요성
안녕하세요. 최근에 환율이 올라가게 되면서 자연스럽게 서버의 지출 비용이 늘어나게 되었습니다. 아래 사진은 최근 6개월간의 환율 변동입니다.
회사에서는 불필요한 비용 지출이 없는지 점검하는 시간을 가졌습니다. 이 과정에서 데이터 전송 비용에서 이상치 데이터가 발견되었고, 해당 원인을 분석하고 개선하는 업무를 맡게 되었습니다. 이번 글에서는 그 과정과 해결 방안을 공유하고자 합니다.
비용이 발생하는 주요 원인
클라우드 비용을 추적하기 위해 메가존에서 제공하는 Hyperbilling 서비스를 사용하고 있습니다. 해당 서비스에서 특정 리소스의 Out 대비 비용이 유독 높게 발생하고 있음을 확인했습니다.특히 비용이 발생하는 카테고리는 APN2-DataTransfer-Out-Bytes로, 이는 서울 리전에서 외부 인터넷으로 나가는 데이터 전송량을 의미합니다.
AWS에서 외부 인터넷으로 데이터를 전송 할 때의 비용으로 실제 비용은 다음과 같이 부과 된다고 명시되어 있습니다.
$0.126 per GB - first 10 TB / month data transfer out beyond the global free tier
즉, 비용을 줄이기 위한 가장 기본적인 방법은 외부로 나가는 데이터 전송량을 최소화하는 것입니다.
데이터 통신 비용 분석 과정
ALB에서의 데이터 Transfer 비용 확인
Hyperbilling을 통해 비용이 많이 발생하는 리소스를 확인한 결과, 특정 도메인의 ELB(Elastic Load Balancer)에서 높은 비용이 발생하고 있었습니다. 하지만 해당 도메인의 API를 외부에서 호출할 수 있는 영역이 많아, 하나씩 추적하는 것은 비효율적이었습니다.
다행히 트렌비에서는 별도 게이트웨이에서 호출되는 ALB의 Access Log를 Loki와 Grafana를 통해 조회할 수 있는 환경이 마련되어 있었습니다. 이를 활용하여 데이터 전송량이 많은 API를 분석하기로 했습니다.
Loki와 Grafana를 활용한 데이터 분석
트렌비에서 사용하는 Access Log의 주요 포맷은 다음과 같습니다.
[
"timestamp",
"@version",
"message",
"logger_name",
"thread",
...
]
이 중 가장 중요한 필드는 message이며, 해당 필드의 line protocol 포맷은 다음과 같이 구성되어 있습니다.
[
"ip",
"timestamp",
"http_method",
"request_uri",
"http_version",
"status",
"response_size",
"response_time_ms"
]
여기서 response_size 필드를 활용하면, 특정 도메인의 API 중 데이터 전송량이 많은 URI를 식별할 수 있습니다. 이를 위해 Loki의 LogQL을 활용하여 분석 쿼리를 작성하였습니다.
Loki Query로 시계열 데이터 획득
LogQL에서는 sum_over_time 이라는 함수로 특정 시간 범위 내에서 발생하는 Numeric 한 필드의 값의 총합을 계산 할 수 있도록 해주는 API가 있습니다. 이를 이용하여 집계 쿼리를 만들어봤습니다.
sum by (uri) (sum_over_time({app="발생하는 app"} | json msg="message" | line_format "{{.msg}}" | pattern `<ip> - - <_> "<method> <uri>?<_> <_>" <status> <size> <_> "<agent>" <_>` | status=~"2.+" | unwrap size [$__interval]))
- 2XX 상태 코드(성공한 응답)만 필터링하여 실제 트래픽을 기반으로 분석
- URI별 데이터 전송량을 집계하여 가장 비용이 많이 드는 API를 식별
데이터 집계 결과
분석 결과, 6시간 동안 133GB의 데이터를 전송하는 API를 발견하였습니다. 해당 API는 고객 및 운영진이 확인하는 데이터였으며, 한 번의 호출당 13.0MB의 데이터를 전송하고 있었습니다.
이 API는 서버 사이드 렌더링(SSR) 방식으로 HTML 파일을 반환하고 있었으며, 메모리 캐싱이 적용되지 않아 매번 요청 시 동일한 데이터가 반복적으로 전송되고 있었습니다.
추가 분석 결과, Response Header의 Content-Encoding이 존재하지 않음을 확인 할 수 있었습니다.
아래 사진의 첫번째는 있는 경우, 두 번째는 없는 경우입니다.
HTTP 진영에서는 위에서 보시는 것처럼 Content-Encoding 헤더를 통해 미디어 타입을 압축하기 위한 프로토콜을 제공해주고 있습니다.
이제, 데이터의 전송 비용이 많이 나오게 된 원인이 해당 API에 대한 압축이 적용되지 않았다는 것을 알았습니다. 해당 애플리케이션에서 응답하는 API를 압축하도록 변경해보도록 하겠습니다.
응답 데이터 압축 적용
스프링 프로젝트에서 압축 적용 방법
현재 해당 API를 호출하는 애플리케이션의 환경은 Spring Boot 2.3.9 환경입니다.
다행히 스프링에서는 간단한 설정 변경으로 압축을 적용할 수 있습니다.
server:
compression:
enabled: true
mime-types:
- text/html
- text/xml
- text/plain
- text/css
- text/javascript
- text/csv
- application/json
해당 Properties는 org.springframework.boot.autoconfigure.web.ServerProperties 클래스 내에 Nested 프로퍼티로 명시되어 있습니다. 아래는 ServerProperties의 일부입니다.
@ConfigurationProperties(
prefix = "server",
ignoreUnknownFields = true
)
public class ServerProperties {
private Integer port;
...
@NestedConfigurationProperty
private final Compression compression;
...
}
Compression 클래스가 이름 그대로 압축에 대한 속성을 명시한 파일입니다. 실제 내부에 있는 기본 설정은 아래 코드와 같습니다.
public class Compression {
private boolean enabled = false;
private String[] mimeTypes = new String[] { "text/html", "text/xml", "text/plain", "text/css", "text/javascript",
"application/javascript", "application/json", "application/xml" };
private String[] excludedUserAgents = null;
private DataSize minResponseSize = DataSize.ofKilobytes(2);
...
}
압축 과정
그럼 실제 Compression 옵션을 enable 했을 때 압축은 어떻게 이루어지고 있는 것일까요?
Http의 처리를 담당하는 org.apache.coyote.http11 패키지에 있는 Http11Processor를 살펴보면 응답 데이터를 준비하는 prepareResponse() 메서드를 찾아 볼 수 있습니다.
Http11Processor
@Override
protected final void prepareResponse() throws IOException {
...
boolean useCompression = false;
if (entityBody && sendfileData == null) {
useCompression = protocol.useCompression(request, response);
}
}
AbstractHttp11Protocol
위에 보이는 것처럼 compression을 적용하기 위한 useCompression() 메서드가 정의되어 있는데요. 이를 자세히 살펴보겠습니다.
AbstractHttp11Protocol 클래스의 useCompression은 다시 다음과 같은 메서드를 호출하고 있습니다.
private final CompressionConfig compressionConfig = new CompressionConfig();
public boolean useCompression(Request request, Response response) {
return compressionConfig.useCompression(request, response);
}
CompressConfig
CompressionConfig 속성 클래스는 다음과 같은 형태를 가지고 있습니다.
public class CompressionConfig {
...
private int compressionLevel = 0;
private Pattern noCompressionUserAgents = null;
private String compressibleMimeType = "text/html,text/xml,text/plain,text/css," +
"text/javascript,application/javascript,application/json,application/xml";
private String[] compressibleMimeTypes = null;
private int compressionMinSize = 2048;
private boolean noCompressionStrongETag = true;
public void setCompression(String compression) {
if (compression.equals("on")) {
this.compressionLevel = 1;
} else if (compression.equals("force")) {
this.compressionLevel = 2;
} else if (compression.equals("off")) {
this.compressionLevel = 0;
} else {
try {
// Try to parse compression as an int, which would give the
// minimum compression size
setCompressionMinSize(Integer.parseInt(compression));
this.compressionLevel = 1;
} catch (Exception e) {
this.compressionLevel = 0;
}
}
}
public String getCompression() {
switch (compressionLevel) {
case 0:
return "off";
case 1:
return "on";
case 2:
return "force";
}
return "off";
}
...
}
어디선가 본 것 같은 형태이지 않나요? 아까 위에서 SpringBoot의 Compression 코드와 유사한 부분을 볼 수 있습니다. 따라서, 스프링에서 명시한 Property를 어디선가 주입을 해주어야 할텐데요. 이는 스프링 부트의 embedded.tomcat 패키지의 CompressionConnectorCustomizer 클래스에서 찾아 볼 수 있습니다.
CompressionConnectorCustomizer
class CompressionConnectorCustomizer implements TomcatConnectorCustomizer {
private final Compression compression;
CompressionConnectorCustomizer(Compression compression) {
this.compression = compression;
}
@Override
public void customize(Connector connector) {
if (this.compression != null && this.compression.getEnabled()) {
ProtocolHandler handler = connector.getProtocolHandler();
if (handler instanceof AbstractHttp11Protocol) {
customize((AbstractHttp11Protocol<?>) handler);
}
for (UpgradeProtocol upgradeProtocol : connector.findUpgradeProtocols()) {
if (upgradeProtocol instanceof Http2Protocol) {
customize((Http2Protocol) upgradeProtocol);
}
}
}
}
private void customize(AbstractHttp11Protocol<?> protocol) {
Compression compression = this.compression;
protocol.setCompression("on");
protocol.setCompressionMinSize(getMinResponseSize(compression));
protocol.setCompressibleMimeType(getMimeTypes(compression));
if (this.compression.getExcludedUserAgents() != null) {
protocol.setNoCompressionUserAgents(getExcludedUserAgents());
}
}
...
}
실제 protocol에 있는 compression에 주입을 해주는 것을 볼 수 있습니다.
Compression의 주입이 완료됨을 확인했고 이제 useCompression 메서드를 확인해보겠습니다.
useCompression에서는 아래와 같이 acceptEncoding을 탐색하여 저장하는 것을 볼 수 있습니다.
public boolean useCompression(Request request, Response response) {
if (compressionLevel == 0) {
return false;
}
MimeHeaders responseHeaders = response.getMimeHeaders();
ResponseUtil.addVaryFieldName(responseHeaders, "accept-encoding");
Enumeration<String> headerValues = request.getMimeHeaders().values("accept-encoding");
boolean foundGzip = false;
while (!foundGzip && headerValues.hasMoreElements()) {
List<AcceptEncoding> acceptEncodings = null;
try { // AcceptEncoding 탐색 후 저장
acceptEncodings = AcceptEncoding.parse(new StringReader(headerValues.nextElement()));
} catch (IOException ioe) {
// If there is a problem reading the header, disable compression
return false;
}
for (AcceptEncoding acceptEncoding : acceptEncodings) {
if ("gzip".equalsIgnoreCase(acceptEncoding.getEncoding())) {
foundGzip = true;
break;
}
}
}
if (!foundGzip) {
return false;
}
// Compressed content length is unknown so mark it as such.
response.setContentLength(-1);
// Configure the content encoding for compressed content
responseHeaders.setValue("Content-Encoding").setString("gzip");
return true;
}
이제 다시 prepareResponse 메서드로 돌아와 하단에 있는 압축 코드를 살펴보겠습니다.
if (useCompression) {
outputBuffer.addActiveFilter(outputFilters[Constants.GZIP_FILTER]);
}
public void addActiveFilter(OutputFilter filter) {
if (lastActiveFilter == -1) {
filter.setBuffer(outputStreamOutputBuffer);
} else {
for (int i = 0; i <= lastActiveFilter; i++) {
if (activeFilters[i] == filter)
return;
}
filter.setBuffer(activeFilters[lastActiveFilter]);
}
activeFilters[++lastActiveFilter] = filter;
filter.setResponse(response);
}
만약, 다른 압축 필터를 추가하고 싶다면 OutputFilter를 구현하는 형태로 구현이 가능합니다.
여기까지 Gzip이 적용되는 과정을 살펴봤습니다. 이제 적용 이후에 얼마나 개선이 되는지를 확인해보겠습니다.
압축 적용 후 결과 분석
실제로 웹의 네트워크의 DevTools(개발자 도구)에서 Big request rows를 체크하여 확인하면 압축되기 이전에 사이즈와 압축 이후의 사이즈를 두 개를 비교하여 확인해 볼 수 있습니다.
흐린 색상의 사이즈가 압축 이전 사이즈이며 밝은 색상의 사이즈가 압축 이후의 사이즈입니다. 데이터 절감율은 다음과 같이 확인 할 수 있습니다.
$$
{1 - \frac{\text{압축 후 크기}}{\text{압축 전 크기}}} \times 100
$$
이를 대입해보면 이는 87.69% 전송량이 감소했으며, 비율로 보자면 8.13 배가 줄어들었습니다. 이는 데이터 전송 비용의 데이터도 약 8배 수준으로 줄어든다는 바를 시사하기 때문에 해당 Region의 데이터 전송 비용은 8배가 줄어들 것이라고 예측해 볼 수 있습니다.
회고
이전까지는 개발하면서 주로 DB 비용, EC2 비용 등 인프라 비용에 집중했었지만, 이번 경험을 통해 네트워크 비용 절감도 중요한 최적화 대상임을 알게 되었습니다. 특히, 이번 작업을 통해
- Loki와 Grafana를 활용한 비용 분석 방법을 익혔고,
- Spring Boot의 HTTP 압축 설정을 활용하여 간단한 설정 변경만으로도 비용을 절감할 수 있음을 배웠습니다.
환율이 상승하는 지금, 혹시 우리 서버에서도 데이터가 불필요하게 압축되지 않은 채 전송되고 있지는 않은지 점검해보는 것은 어떨까요? 이번 기회를 통해 여러분도 클라우드 비용을 절감해보시길 바랍니다!
'Backend > Spring' 카테고리의 다른 글
막내 개발자의 사내 분산 락 라이브러리 도입이야기 (1) (2) | 2024.11.10 |
---|---|
Spring Security 5 -> Spring Security 6 에서의 Session 변경점 (0) | 2023.08.20 |
Spring Security OAuth2 주요 용어와 인증 방식 (0) | 2023.08.14 |
Invalid character found in method name 에러 (2) | 2023.05.07 |
Spring AOP (0) | 2023.04.26 |