본문 바로가기

서버운영 (TA, ADMIN)/네트워크

[HTTP] 프록시 & 캐시

프록시

프록시는 HTTP 등의 통신을 중계한다. 때로는 중계만 하는게 아니라 각종 부가 기능을 구현한 경우도 있다. 회사나 대학에서 구성원 중 누군가가 접근한 콘텐츠는 다른 구성원도 접근할 가능성이 크다. 이럴 경우, 캐시 기능이 있는 프록시를 조직의 네트워크 출입구에 설치하면, 콘텐츠를 저장한 웹서버의 부담은 줄이고 각 사용자가 페이지를 빠르게 열람할 수 있게 하는 효과가 있다.

 

또한 프록시는 네트워크를 보호하는 방화벽 역할도 한다. 저속 통신 회선용으로 데이터르 압축해 속도를 높이는 필터나 콘텐츠 필터링 등에도 프록시가 이용된다.

 

프록시 구조는 단순해서 GET 등의 메서드 다음에 오는 경로명 형식만 바뀐다. 메서드 뒤의 경로명은 보통 /helloword처럼 슬래시로 시작되는 유닉스 형식의 경로명이 되지만, 프록시를 설정하면 스키마도 추가돼, http://나 https://로 시작되는 URL 형식이 된다. HTTP/1.1부터 등장한 Host 헤더도 최종적으로 요청을 받는 서버명 그대로이다. 실제로 요청을 보내는 곳은 프록시 서버가 된다.

 

아래 테스트 예제는 프록시용 통신도 그 자리에서 응답해버리지만, 원래는 중계할 곳으로 요청을 리디렉트하고 결과를 클라이언트에 반환한다.

GET /helloworld
Host: localhost:18888
GET http://example.com/helloworld
Host: example.com

 

프록시 서버가 악용되지 않도록 인증을 이용해 보호하는 경우가 있다. 이런 경우는 Proxy-Authenticate 헤더에 인증 방식에 맞는 값이 들어가게 된다. 중계되는 프록시는 중간의 호스트 IP 주소를 특정 헤더에 기록해 간다. 이전부터 사용해오던 것은 X-Forwarded-For 헤더이다. 이 헤더는 사실상의 표준으로서 많은 프록시 서버에서 이용됐지만, 2014년에 RFC 7239로 표준화되면서 Forwarded 헤더가 도입됐다.

X-Forwarded-For: client, proxy1, proxy2

 

프록시를 설정하려면 -x/--proxy 옵션을 사용한다. 프록시 인증용 유저명과 패스워드는 -U/--proxy-user 옵션을 이용한다.

curl --http1.0 -x http://localhost:18888 -U user:pass http://example.com/helloworld

 

--proxy-basic, --proxy-digest 등의 옵션으로 프록시 인증 방식을 변경할 수도 있다. 프록시와 비슷한 것으로는 게이트웨이가 있다. HTTP/1.0에서는 다음과 같이 정의되어 있다.

 

프록시

 : 통신 내용을 이해한다. 필요에 따라서 콘텐츠를 수정하거나 서버 대신 응답한다.

 

게이트웨이

 : 통신 내용을 그대로 전송한다. 내용의 수정도 불허한다. 클라이언트에서는 중간에 존재하는 것을 알아채서는 안된다.

 

HTTPS 통신의 프록시 지원은 HTTP/1.1에서 추가된 CONNECT 메서드를 이용한다.


캐시

웹사이트의 콘텐츠가 점점 풍부해지면서 한 페이지를 표시하는 데도 수십개의 파일이 필요해졌고, 전체 용량도 메가바이트 단위로 늘어났다. 이렇게  늘어난 파일을 접속할 때마다 다시 다운로드해야 한다면, 아무리 회선이 빨라졌다고 해도 전부 표시하기까지 시간이 꽤 걸리게된다. 그래서 콘텐츠가 변경되지 않았을땐 로컬에 저장된 파일을 재사용함으로써 다운로드 횟수를 줄이고 성능을 높이는 '캐시' 메커니즘이 등장하게 되었다.

 

현재는 캐시 관련 규약이 상당히 복잡해졌지만, 최초버전은 단순했다. 버전을 따라서 캐시시스템을 이해해보자. GET과 HEAD 메서드 이외는 기본적으로 캐시되지 않는다.

 

1) 갱신 일자에 따른 캐시(Last-Modified)

우선 HTTP/1.0에서의 캐시를 설명한다. 당시는 정적 콘텐츠 위주라서 콘텐츠가 갱신됐는지만 비교하면 충분했다. 웹서버는 대개 다음과 같은 헤더를 응답에 포함한다. 날짜는 RFC 1123이라는 형식으로 기술되고 타임 존에는 GMT(Greenwich Mean Time, GMT)를 설정한다. 

Last-Modified: Wed, 08 Jun 2016 15:23:45 GMT

 

웹 브라우저가 캐시된 URL을 다시 읽을 때는 서버에서 반환된 일시를 그대로 If-Modified-Since 헤더에 넣어 요청한다.

If-Modified-Since: Wed, 08 Jun 2016 15:23:45 GMT

 

웹서버는 요청에 포함된 If-Modified-Since의 일시와 서버의 콘텐츠의 일시를 비교한다. 변경됐으면 Status Code 200 OK를 반환하고 콘텐츠를 응답 바디에 실어서 보낸다. 변경되지 않았으면, Status Code 304 Not Modified를 반환하고 바디를 응답에 포함하지 않는다.

2) Expires

다양한 회로를 거치고 서버에서 처리하는 시간까지 더해지면 실제 통신 시간은 더욱 길어질 수 밖에 없다. 갱신 일시를 이용하는 캐시의 경우 캐시의 유효성을 확인하기 위해 통신이 더 발생하게 된다. 그런데 이 통신 자체를 없애는 방법이 HTTP/1.0에 도입되었다. 바로 Expires 헤더를 이용하는 방법이다.

 

Expires 헤더에는 날짜와 시간이 들어간다. 클라이언트는 지정한 기한 내라면 캐시가 '신선'하다고 판단해 강제로 캐시를 이요한다. 다시 말해 요청을 아예 전송하지 않는 것이다. 캐시의 유효 기한이 지났으면 캐시가 신선하지 않다(stale)고 판단한다.

Expires: Fir, 05 Aug 2016 00:52:00 GMT

'서버 접속'은 전술한 Last-Modified 헤더를 이용한 캐시 로직이 들어간다.

Expires라는 이름이 좀 혼동을 주는데, '3초 후 컨텐츠 유효 기간이 끝난다'라고 설정했어도 3초 후에 마음대로 리로드하지는 않는다. 설정된 날짜와 시간은 어디까지나 접속을 할지 말지 판단할때만 사용한다. 또한 방문 이력을 조작하는 경우는 기한이 지난 오래된 컨텐츠를 그대로 이용할 수도 있다.

 

Expires를 사용하면 서버에 변경 사항이 있는지 묻지 않게 되므로 SNS 실시간페이지 등에 사용할때는 주의해야 한다. 지정한 기간 이내의 변경 사항은 모두 무시되어 새로운 컨텐츠를 전혀 볼 수 없기 때문이다. 스타일시트 등 좀처럼 갱신되지 않는 정적 콘텐츠에 사용하는 것이 바람직하다.

 

HTTP/1.0의 RFC에는 없지만 HTTP/1.1을 정의한 RFC 2068에서는 변경할 일이 없는 콘텐츠라도 최대 1년의 캐시 수명을 설정에 대한 가이드라인이 추가되었다.

3) Pragma: no-cache

프록시를 이용하여 캐시 기능을 적용할 수도 있는데, 이때 클라이언트가 프록시 서버에 지시할 수도 있다. 지시를 포함한 요청 헤더가 들어갈 곳으로서 HTTP/1.0부터 Pragma 헤더가 정의되었다. Pragma 헤더에 포함할 수 있는 페이로드로 유일하게 HTTP 사양으로 정의된 것이 no-cache이다.

 

no-cache는 '요청한 컨텐츠가 이미 저장돼 있어도, 원래 서버(오리진 서버)에서 가져오라'고 프록시 서버에 지시하는 것이다. no-cache는 HTTP/1.1에 이르러 Cache-Control로 통합됐지만, 1.1 이후에도 하위 호환성 유지를 위해 남아있다.

 

캐시 메커니즘에는 Pragma: no-cahce처럼 클라이언트에서 지시하는 것이나 프록시에 대해 지시하는 것도 몇가지 있지만, 그다지 적극적으로 사용되진 않는다. HTTP는 스테이트리스한 프로토콜로 설계됐고, REST는 '클라이언트가 콘텐츠의 의미 등을 사전 지식으로 갖지 않는 것'을 목표로 한다. 클라이언트가 정보의 수명과 품질을 일일이 관리하는 상태는 부자연스럽다.

 

게다가 프록시가 어느 정도 지시를 이해하고 기대한 대로 동작할지 보증할 수도 없다. 중간에서 프록시가 하나라도 no-cache를 무시하면 기대한 대로 동작하지 않는다.

 

HTTP/2가 등장한 이후로는 보안 접속 비율이 증가했다. 보안 통신에서는 프록시가 통신 내용을 감시할 수 없고 중계만 할 수 있다. 프록시의 캐시를 외부에서 적극적으로 관리하는 의미가 이제 없다고도 말할 수 있다.

4) ETag 추가

날짜와 시간을 이용한 캐시 비교만으로 해결할 수 없을때도 있다. 동적으로 바뀌는 요소가 늘어날수록 어떤 날짜를 근거로 캐시의 유효성을 판단해야 하는지 판단하기 어려워진다. 따라서 하나의 수치로 귀착시키는 데 지혜를 모아야 한다.

 

그럴때 사용할 수 있는 것이 RFC 2068의 HTTP/1.1에서 추가된 ETag(entity tag)이다. ETag는 순차적인 갱신일시가 아니라 파일의 해시 값으로 비교한다. 일시를 이용해 확인할 때처럼 서버는 응답에 ETag 헤더를 부여한다. 두번째 이후 다운로드시 클라이언트는 If-None-Match 헤더에 다운로드된 캐시에 들어있던 ETag 값을 추가해 요청한다. 서버는 보내려는 파일의 ETag와 비교해서 같으면 304 Not Modified로 응답한다. 여기까지는 HTTP/1.0에도 있었던 캐시 제어 구조이다.

 

ETag는 서버가 자유롭게 결정해서 반환할 수 있다. 예를 들어 아마존 S3의 경우 콘텐츠 파일의 해시 값이 사용되는 것 같다. 일시 외의 갱신 정보를 고려한 해시 값을 서버가 생성할 수 있다. ETag는 갱신 일시와 선택적으로 사용할 수 있다. 아파치 2.3.15 이후 nginx, h20 등의 서버가 정적 파일에 부여하는 ETag는 갱신일시-파일크기(갱신 일시의 유닉스 시간-파일 크기의 바이트를 각각 16진수로 연결) 형식으로 되어 있다. 프로그램에서 동적으로 생성할 여지는 남아있지만, 정적 파일의 경우 Last-Modified와 같다.

 

예전 아파치에서는 갱신 일시와 크기 이외에 inode 번호도 이용했다. inode는 디스크상의 콘텐츠를 나타내는 인덱스 값으로, 동일한 드라이브 안에서는 고유한 값이 된다. 그러나 서버를 여러 대 병렬시킨 경우에는 같은 콘텐츠인데도 ID가 달라져 ETag가 바뀌므로, 캐시가 낭비되는 일이 있었다. 게다가 이런 문제는 잠재적인 공격 기회를 허용하므로 기본 설정이 변경되었다. 

 

그 밖에도 자식 프로세스의 ID가 멀티파트의 MIME 바운더리에서 유출될 수 있는 문제도 수정되었다.

 

5) Cache-Control (1)

ETag와 같은 시기에 HTTP/1.1에서 추가된 것이 Cache-Control 헤더이다. 서버는 Cache-Control 헤더로 더 유연한 캐시 제어를 지시할 수 있다. Cache-Control은 Expires보다 우선해서 처리된다. 먼저 서버가 응답으로 보내는 헤더는 대체로 아래와 같은 키를 사용할 수 있다.

 public: 같은 컴퓨터를 사용하는 복수의 사용자간 캐시 재사용을 허가한다.
 private: 같은 컴퓨터를 사용하는 다른 사용자 간 캐시를 재사용하지 않는다. 같은 URL에서 사용자마다 다른 콘텐츠가 돌아오는 경우에 이용한다.
 max-age=n: 캐시의 신선도를 초단위로 설정. 86400을 지정하면 하루동안 캐시가 유효하고 서버에 문의하지 않고 캐시를 이용한다. 그 이후는 서버에 문의한 뒤 304 Not Modified가 반환됐을때만 캐시를 이용한다.
 s-maxage=n: max-age와 같으나 공유 캐시에 대한 설정값이다.
 no-cache: 캐시가 유효한지 매번 문의한다. max-age=0과 거의 같다.
 no-store: 캐시하지 않는다.

no-cache는 Pragma: no-cache와 똑같이 캐시하지 않는 것은 아니고, 시간을 보고 서버에 접속하지 않은채 콘텐츠를 재이용하는 것을 그만둘 뿐이다. 갱신 일자와 ETag를 사용하며, 서버가 304를 반환했을때 이용하는 캐시는 유효하다. 캐시하지 않는 것은 no-store이다.

 

캐시와 개인 정보 보호 관계도 주의해야 한다. Cache-Control은 리로드를 억제하는 시스템이고, 개인 정보 보호 목적으로 사용할 수 없다. private는 같은 URL이 유저마다 다른 결과를 줄 경우에 이상한 결과가 되지 않도록 지시하는 것이다. 보안 접속이 아니면 통신 경로에서 내용이 보입니다. no-store도 캐시 서버가 저장하지 않을 뿐 캐시 서버가 통신 내용 감시를 억제하는 기능은 없습니다.

 

콤마로 구분해 복수 지정이 가능하지만, 내용면에서 다음과 같이 조합한다.

 private, public 중 하나. 혹은 설정하지 않는다 (기본은 private)
 max-age, s-maxage, no-cache, no-store 중 하나

 

캐시를 피해 매번 서버에서 읽게 하고 싶을때, no-cache 헤더를 쓰지않고 랜덤 값으로 된 쿼리를 말미에 붙이는(?random=15431) 방식이 가끔 보이지만, no-cache가 깔끔하다.

 

'서버에 접속' 부분에는 앞서 설명한 날짜와 ETag를 이용한 캐시로직이 들어간다. s-maxage는 max-age와 대상이 다를뿐이므로 생략한다.

도식화된 캐시 메커니즘에서 클라이언트가 어떻게 각 헤더의 설정값으로 동작을 변경하는지 대략적인 이미지를 살펴보았는데, 항상 맞다는 보증은 할 수 없다. 모순된 설정을 동시에 할 경우(no-cache와 max-age 등)의 우선순위까지는 RFC에 적혀있지 않다.

6) Cache-Control (2)

이미 Pragma: no-cache 부분에서 '별로 사용할 일이 없다'라고 설명한 프록시에 대한 캐시 관련 요청이지만, Cache-Control 헤더를 요청 헤더에 포함함으로써 프록시에 지시할 수 있다. 서버에서 프록시로 보내는 응답 헤더에 사용할 수 있는 지시도 있다.

 

아래는 클라이언트 측에서 요청 헤더에 사용할 수 있는 설정값이다.

 no-cache: Pragma: no-cahce와 같다.
 no-store: 응답의 no-store와 같고, 프록시 서버에 캐시를 삭제하도록 요청한다.
 max-age: 프록시에 저장된 캐시가 최초로 저장되고 나서 지정 시간 이상 캐시는 사용하지 않도록 프록시에 요청한다.
 max-stale: 지정한 시간만큼 유지 시간이 지났어도 클라이언트는 지정한 시간 동안은 저장된 캐시를 재사용하라고 프록시에 요청한다. 연장 시간은 생략할 수 있고, 그런 경우 영원히 유요하다는 의미가 된다.
 min-fresh: 캐시의 수명이 지정된 시간 이상 남아 있을때, 캐시를 보내도 좋다고 프록시에 요청한다. 즉 적어도 지정된 시간만큼은 신선해야 한다.
 no-transform: 프록시가 콘텐츠를 변형하지 않도록 프록시에 요청한다.
 only-if-cached: 캐시된 경우에만 응답을 반환하고, 캐시된 콘텐츠가 없을땐 504 Gateway Timeout 오류 메시지를 반환하도록 프록시에 요청한다. 이 헤더가 설정되면 처음을 제외하고 오리진 서버에 전혀 액세스하지 않는다.

응답 헤더에서 서버가 프록시에 보내는 캐시 컨트롤 지시에는 다음과 같은 것이 있다. 물론 이미 전항의 서버에서 클라이언트로 보내는 지시에서 소개한 명령은 모두 프록시에도 유효하다.

 no-transform: 프록시가 콘텐츠를 변경하는 것을 제어한다.
 must-revalidate: no-cache와 비슷하지만 프록시 서버에 보내는 지시가 된다. 프록시 서버가 서버에 문의했을때 서버의 응답이 없으면, 프록시 서버가 클라이언트에 504 Gateway Timeout이 반환되기를 기대한다.
 proxy-revalidate: must-revalidate와 같지만, 공유 캐시에만 요청한다.

7) Vary

ETag 설명에서 같은 URL이라도 개인마다 결과가 달라지는 경우가 있다고 했다. 같은 URL이라도 클라이언트에 따라 반환 결과가 다름을 나타내는 헤더가 Vary이다.

 

예를 들어, 사용자의 브라우저가 스마트폰용 브라우저일 때는 모바일용 페이지가 표시되고, 사용하는 언어에 따라 내용이 바뀌는 경우를 들 수 있다. 이처럼 표시가 바뀌는 이유에 해당하는 헤더명을 Vary에 나열함으로써 잘못된 콘텐츠의 캐시로 사용되지 않게 한다.

Vary: User-Agent, Accept-Language

 

로그인이 필요한 사이트라면 쿠키도 지시하게 된다. Vary 헤더는 검색 엔진용 힌트로도 사용된다. 브라우저 종류에 따라 콘텐츠가 바뀔수 있다는 것은 모바일 버전은 다르게 보일 수도 있다고 판단할 수 있는 재료가 된다. 그리고 영어 버전, 한국어 버전 등 언어별로 바르게 인덱스를 만드는 힌트도 된다.

 

모바일 브라우저인지 판정하는 방법은 주로 두가지이다. 첫번째는 이미 소개한 User-Agent이다. 서버에서는 이 정보를 바탕으로 콘텐츠를 나눠 내보낼 수 있다. 다만 유저 에이전트 이름은 관례적인 것이지 정규화된 정보는 아니다. User-Agent '스니핑'으로 불리기도 하는 것처럼 추측이므로 판정이 틀릴 수도 있다.