압축 요청 헤더 지원 여부에 따른 HTTP Client 이중화
고남현 @gnh1201@hackers.pub
본 글은 .NET 기준으로 작성되었으나, 내용은 HTTP 표준을 다루기 때문에 사실상 모든 프로그래밍 환경에 적용될 수 있는 내용인 점 참고하여 주시기 바랍니다. :)
압축 전송의 필요성
이전 글에서 .NET (Windows) 기반의 앱에서 컴파일된 라이브러리를 필요 시마다 네트워크(CDN)에서 동적 로딩하는 기능의 구현에 대해 다루었다.
컴파일된 라이브러리라는 개념은 Windows OS에만 존재하는 것이 아니라 Linux, Mac 등의 *nix OS 계열에도 존재하는 컴퓨터 공학의 공통 개념이니 설명은 생략하겠다.
프로그래밍 언어와 플랫폼 상관없이, 컴파일이 필요한 언어인지 아닌지 여부를 떠나 라이브러리를 부르는 속도는 무조건 빠를수록 좋다.
내 경우 원래 로컬에서 불러오던 라이브러리를 네트워크에 올리면서, 전송이라는 변수가 생겼기 때문에 압축을 통해 단 1ms라도 더 로드 시간을 줄이는 것이 안정성 확보를 위해 필요하다는 판단이 들었다.
압축 요청 헤더(Accept-Encoding 헤더 기반)란?
클라이언트는 서버에게 콘텐츠를 압축해서 응답을 보내줄 것을 Accept-Encoding 헤더로 요청할 수 있다. 아래와 같은 내용을 헤더에 추가하여 요청하게 된다.
Accept-Encoding: gzip, deflate, br
클라이언트가 선호하는 복수의 압축 알고리즘을 보내면, 서버는 요청된 알고리즘 중 가장 적절한 알고리즘을 선택하여 응답한다. 어떤 알고리즘을 선택했는지는 Content-Encoding 헤더를 통해 알려준다.
Content-Encoding: gzip
그럼 클라이언트는 서버가 어떤 알고리즘을 선택했는지 알 수 있고 이에 맞추어 압축된 데이터를 전송받은 뒤 복호화를 수행한다.
압축 요청 헤더의 정상작동 여부는 실제로 복불복이다.
압축 요청 헤더를 사용할 때 주의할 점은, HTTP 기반의 파일 단위 전송 서비스의 Accept-Encoding 헤더를 처리하는 방법과 규칙이 서비스별로 모두 다 다르다는 점이다.
가령, 어떤 서비스는 모든 파일 형식에 대한 압축 요청에 응하지만, 어떤 서비스는 미리 정해놓은 특정 파일 포맷의 압축 요청에만 응하게 되어있다. 아예 압축 요청을 무시하기도 한다. 심지어는, 같은 클라우드 업체 내에서 운영되는 서비스들도 각각 압축 요청에 응하는 정책이 다 다르다.
이 글에서 다루는 HTTP Client 이중화(.NET 기준)은 이런 상황에서도 압축 전송을 보장하기 위해 고민한 내용이다.
HTTP Client 이중화 (.NET 기준)
서론이 길었지만, HTTP Client를 이중화하는 방법은 간단하다. 옵션이 다른 두개의 클라이언트 인스턴스를 만들어서 활용하는 것이다. 다음은 그 예시이다.
private static readonly HttpClientHandler LegacyHttpHandler = new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.None
};
private static readonly HttpClientHandler HttpHandler = new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};
private static readonly HttpClient LegacyHttp = CreateClient(LegacyHttpHandler); // No the Accept-Encoding (e.g., gzip, deflate) header
private static readonly HttpClient Http = CreateClient(HttpHandler); // With the Accept-Encoding (e.g., gzip, deflate) header
HTTP Client 이중화 후 실제 처리 과정
내 경우는 모든 상황에서 압축 전송을 보장하기 위해 다음과 같은 방법을 사용하였다.
- 미리 압축된 파일을 원본 파일과 함께(가령, data.raw이면 data.raw.gz를 함께) 동일한 위치에 둔다.
- 미리 압축된 파일(예: data.raw.gz)이 존재하면 그 파일을 내려받는다.
- 존재하지 않는다면, 압축 요청 헤더(
Accept-Encoding헤더 기반)를 포함한 요청을 서버에 보내 자료를 내려받는다. 서버가 압축 요청을 수락했는지 여부와 상관없이 파일이 실제 존재한다면 내려받는데에는 문제가 없다.
이 과정을 코드로 표현하면 다음과 같다.
private static void DownloadFile(string url, string dest)
{
HttpResponseMessage res = null;
try
{
string gzUrl = url + ".gz";
bool isDll = url.EndsWith(".raw", StringComparison.OrdinalIgnoreCase); // *.raw.gz
bool downloaded = false;
// 미리 압축된 파일 전송을 시도
if (isDll && TryDownloadCompressedFile(gzUrl, dest))
{
Logger?.Info("Downloaded and decompressed file to: {0}", dest);
downloaded = true;
}
// 미리 압축된 파일이 존재하지 않으면, 압축 요청 헤더(Accept-Encoding 헤더 기반)를 이용해 시도
if (!downloaded)
{
Logger?.Info("Downloading file from: {0}", url);
res = Http.GetAsync(url).GetAwaiter().GetResult();
res.EnsureSuccessStatusCode();
using (Stream s = res.Content.ReadAsStreamAsync().GetAwaiter().GetResult())
using (var fs = new FileStream(dest, FileMode.Create, FileAccess.Write))
{
s.CopyTo(fs);
}
Logger?.Info("Downloaded file to: {0}", dest);
}
// 다운로드 된 파일이 존재하는지 확인
if (!File.Exists(dest))
{
throw new FileNotFoundException("File not found after download", dest);
}
}
catch (HttpRequestException ex)
{
Logger?.Error("Network or I/O error downloading {0}: {1}", url, ex.Message);
throw;
}
catch (Exception ex)
{
Logger?.Error("Unexpected error downloading {0}: {1}", url, ex.Message);
throw;
}
finally
{
res?.Dispose();
}
}
private static bool TryDownloadCompressedFile(string gzUrl, string dest)
{
string tempFile = dest + ".tmp";
try
{
using (var res = LegacyHttp.GetAsync(gzUrl).GetAwaiter().GetResult())
{
if (res.StatusCode == HttpStatusCode.NotFound)
{
Logger?.Info("No gzipped variant at {0}; falling back to uncompressed URL.", gzUrl);
return false;
}
res.EnsureSuccessStatusCode();
using (Stream s = res.Content.ReadAsStreamAsync().GetAwaiter().GetResult())
using (var gz = new GZipStream(s, CompressionMode.Decompress))
using (var fs = new FileStream(tempFile, FileMode.Create, FileAccess.Write))
{
gz.CopyTo(fs);
}
if (File.Exists(dest))
File.Delete(dest);
File.Move(tempFile, dest);
return true;
}
}
catch (HttpRequestException ex)
{
Logger?.Warn("Network or I/O error downloading compressed file from {0}: {1}", gzUrl, ex.Message);
throw;
}
catch (Exception ex)
{
Logger?.Error("Unexpected error downloading compressed file from {0}: {1}", gzUrl, ex.Message);
throw;
}
finally
{
if (File.Exists(tempFile))
{
try
{
File.Delete(tempFile);
}
catch (Exception ex)
{
Logger?.Info("Failed to delete temporary file {0}: {1}", tempFile, ex.Message);
}
}
}
}
본 내용을 적용한 후 흐름 및 사례
모든 내용을 적용하면 다음과 같은 흐름이 된다.
- 다운로드 시도: https://example.cdn.tld/data.raw.gz
- 다운로드에 실패했을 시 압축 요청 헤더(Accept-Encoding 헤더 기반)과 함께 요청: https://example.cdn.tld/data.raw
- 다운로드가 완료되면 구현된 압축 해제 과정에 따라 진행.
이것이 실제 적용된 사례는 AssemblyLoader.cs (gnh1201/welsonjs)에서 확인이 가능하다.