본문 바로가기

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

[HTTP] HTTP/1.0 의미해석(1)

HTTP는 웹브라우저와 웹서버가 통신하는 절차와 형식을 규정한 것이다. HTTP는 웹브라우저로 웹페이지를 표시할 때 서버로부터 정보를 받아오는 약속이지만, 그 범위를 넘어서 번역 API나 데이터 저장 API 등 다양한 서비스의 인터페이스로도 사용되면서 인터넷의 기초가 되었다.

  - 1990년: HTTP/0.9

  - 1996년: HTTP/1.0

  - 1997년: HTTP/1.1

  - 2005년: HTTP/2

이름 정식 명칭 역할/의미
IETF The Internet Engineering Task Force 인터넷의 상호 접속성을 향상시키는 것을 목적으로 만들어진 임의 단체
RFC Request For Comments IETF가 만든 규약 문서
IANA Internet Assigned Numbers Authority 포트 번호와 파일 타입(Content-Type) 등 웹에 관한 데이터베이스를 관리하는 단체
W3C World Wide Web Consortium 웹 관련 표준화를 하는 비영리 단체
WHATWG Web Hypertext Application Technology Working Group 웹 관련 규격을 논의하는 단체.

다양한 프로토콜이 RFC로 정의되었다. 새로운 웹 기느은 우선 브라우저 벤더나 서버 벤더가 독자적으로 구현하는 경우가 많지만, 이후에 상호접속성을 유지하고자 공통화 프로세스에 들어간다. 서버와 브라우저의 조합이 무엇이 되었든 동작하고 싶을 것이다. 인터넷이 서로 연결되는 것도, 메일이 도달하는 것도 모두 RFC로 정해진 규칙을 따라 인터넷 세계의 시스템이 만들어졌기 때문이다.

 

RFC는 IETF라는 조직이 중심이 되어 유지 관리하는 통신의 상호접속성 유지를 위한 공통화된 사양서 모음이다. 'Request for Comments'라는 이름인데 인터넷의 근간이 된 네트워크는 미국의 국방 예산으로 만들어져, 사양을 외부에 공개할 수 없었다. 그래서 품질 향상을 위한 의견을 전 세계로부터 폭넓게 수집한다는 명목으로 사양을 공개했던 흔적이 RFC라는 명칭으로 남아있게 되었다.

 

RFC에는 다양한 종류가 있고, 개개의 RFC는 RFC + 숫자로 표기한다. 이미 정의된 포맷을 새로운 RFC에서 참조하기도 한다. RFC에 문제가 있을때는 새로운 버전의 RFC로 갱신되기도 하고, 완전히 새로운 버전이 완성되면 폐기(obsolete)되는 일도 있다.

 

통신 프로토콜(알고리즘)이 아닌 파일 타입(데이터) 같은 공통 정보는 IAN에서 관리한다.  그리고 통신 규격이 아니라 브라우저에 특화된 기능의 책정은 IETF에서 W3C로 이관되었다. 구체적으로는 HTML의 사양 정책이나 server-sent events, 웹소켓처럼 자바스크립트 API를 동반하는 통신 프로콜이 대부분 W3C로 이관되었다. 정확한 사양을 알아야 하는 경우는 이런 정보원부터 조사를 시작하게 된다.

 

 

HTTP의 기본 네 요소는 다음과 같다.

  - 메서드와 경로

  - 헤더

  - 바디

  - 스테이터스 코드

 

웹 브라우저는 이 상자에 데이터를 넣어 송신하거나 혹은 서버의 응답으로 보내온 상자에서 데이터를 꺼내 서버와 통신을 한다. 웹의 고도화에 따라 다양한 기능이 추가됐지만, 특히 헤더라는 시스템 안에서 많은 기능이 실현되었다. 브라우저가 기본 요소들을 어떻게 응용하고 기본 기능을 실현하는지 살펴본다.

1. 단순한 폼 전송(x-www-form-urlencoded)

HTTP/1.0의 바디 수신은 특별히 어렵지 않다. 클라이언트가 지정한 콘텐츠가 그대로 저장될 뿐이다. 기본적으로 한번 HTTP가 응답할때마다 한 파일밖에 반환하지 못하기 때문이다. 즉 응답의 본체를 지정한 바이트 수만큼 읽어오면 그만이다. HTTP/1.1에는 범위 액세스라는 특수한 요청 방법이 생겼다.

 

폼을 사용한 POST 전송에는 몇가지 방식이 있다. 우선 가장 단순한 전송 방식은 다음과 같다.

<form method="POST">
    <input name="title">
    <input name="author">
    <input name="submit">
</form>

일반적인 웹에서 볼 수 있는 폼이다. method에는 POST가 설정돼있다. 다음처럼 curl 커맨드를 사용하면 폼과 같은 형식으로 전송할 수 있다.

$ curl --http1.0 -d title="The Art of Community" -d author="Jono Bacon" http://localhost:18888

curl 커맨드의 -d 옵션을 사용해 폼으로 전송할 데이터를 설정할 수 있다. curl 커맨드는 -d 옵션이 지정되면 브라우저와 똑같이 헤더로 Content-Type:application/x-www-form-urlencoded를 설정한다. 이때 바디는 다음과 같은 형식이 된다. 키와 값이 '='로 연결되고, 각 항목이 &으로 연결된 문자열이다.

 

실제로는 이 커맨드가 생성하는 바디는 브라우저의 웹 폼에서 전송한 것과는 약간 차이가 있다. -d 옵션으로 보낼 경우 지정된 문자열을 그대로 연결한다. 구분 문자인 &와 =이 있어도 그대로 연결해버리므로, 읽는 쪽에서 원래 데이터 세트로 복원할 수 없다.

 

브라우저는 RFC 1866에서 책정한 변환 포맷에 따라 변환을 실시하게 된다. 이 포맷에서는 알파벳, 수치, 별표, 하이픈, 마침표, 언더스코어의 여섯 종류 문자 외에는 변환이 필요하다. 공백은 +로 바뀌게 된다.

title=Head First PHP & MySQL&author=Lynn Beighley, Michael Morrison
title=Head+First+PHP%26+MySQL&author=Lynn+Beighley%2C+Michael+Morrison

이 방식에서는 이름과 값 안에 포함되는 =와 &은 각각 %3D와 %26으로 변환된다. 실제 구분 문자는 변환되지 않으므로 읽는 쪽에서는 바르게 구분할 수 있다. curl에는 이와 가까운 기능을 하는 --data-urlencode가 있다. 이를 -d 대신에 사용해서 변환할 수 있는데, 이때 RFC 3986에서 정의된 방법으로 변환된다. RFC 1866과 다루는 문자 종류가 다소 다르며, 공백이 +가 아니라 %20이 된다. (자바스크립트의 변환용 함수 encodeURLComponent()는 이보다 더 오래된 RFC 2396 형식으로 변환한다)

 

$ curl --http1.0 --data-urlencode title="The Art of Community" --data-urlencode author="Jono Bacon" http://localhost:18888

 

다만 어떤 변환 방법을 써도 같은 알고리즘으로 복원할 수 있으므로 문제가 되지 않는다. 어느 방식이든 'URL 인코딩'으로 부르며 하나로 취급하는 경우가 대부분이다.

2. 폼을 이용한 파일 전송

HTML의 폼에서는 옵션으로 멀티파트 폼 형식이라는 인코딩 타입을 선택할 수 있다. 단순 폼 전송에 비해 복잡하지만, 옵션을 사용해서 파일을 보낼수 있다. RFC 1867에 정의되어 있다.

<form action="POST" enctype="multipart/form-data">
</form>

보통 HTTP 응답은 한번에 한 파일씩 반환하므로, 빈 줄을 찾아 그곳부터 Content-Length로 지정된 바이트 수만큼 읽기만 하면 데이터를 통째로 가져올 수 있다. 파일의 경계를 신경쓸 필요가 없다. 하지만 멀티파트를 이용하는 경우는 한 번의 요청으로 복수의 파일을 전송할 수 있으므로 받는 쪽에서 파일을 나눠야 한다. 다음 코드는 구글 크롬 브라우저의 멀티파트 폼 형태로 출력했을때의 헤더이다. Content-Type은 확실히 multipart/form-data이지만, 또 하나의 속성이 부여되어 있다. 이것은 경계 문자열이다. 경계 문자열은 각 브라우저가 독자적인 포맷으로 랜덤하게 만들어낸다.

Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryyOYfbccgoID172j7

바디는 다음과 같이 되어 있다. 경계 문자열로 두 개의 블록으로 나뉜것을 알 수 있다. 또한 맨 끝에는 경계 문자열 +--라고 되어 있는 줄이 있다. 각각의 블록 내부도 HTTP와 같은 구성으로, 헤더+빈 줄+콘텐츠로 되어 있다. 헤더에는 Content-Disposition이라는 항목이 포함된다. Disposition은 기질, 성질이란 뜻으로, 대체로 Content-Type과 같은 것이다. 여기서는 항목의 이름을 붙이고 폼의 데이터라고 선언했다.

----WebKitFormBoundaryyOYfbccgoID172j7
Content-Disposition: form-data; name="title"

The Art of Community
----WebKitFormBoundaryyOYfbccgoID172j7
Content-Disposition: form-data; name="author"

Jono Bacon
----WebKitFormBoundaryyOYfbccgoID172j7--

이것만 보면 구분 문자가 화려해진 x-www-form-urlencoded 형식과 다를바 없지만, 파일을 전송해보면 다르다. 흔히 있는 파일 선택 입력을 추가해본다.

<input name="attachment-file" type="file">

이 폼을 전송하면 다음과 같은 결과가 표시된다. x-www-form-urlencoded는 이름에 대해서 그 콘텐츠라는 1:1 정보밖에 가질 수 없지만, multipart/form-data는 항목마다 추가 메타 정보를 태그로 가질 수 있다. 표시된 결과를 보면 파일을 전송할 때 이름, 파일명(test.txt), 파일 종류(text/plain), 그리고 파일 내용이라는 세가지 정보가 전송되는 것을 알 수 있다. 파일을 전송하고 싶었는데, enctype에 multipart/form-data를 지정하지 않아서 실패한 경험이 있을 수 있다. x-www-form-urlencoded에서는 파일 전송에 필요한 정보를 모두 보낼수가 없어, 파일이름만 전송해버리기 때문이다. RFC에서 x-www-form-urlencoded를 사용한 파일 전송 시의 동작은 정의되지 않았다.

----WebKitFormBoundaryX139fhEFk4BdHACC
Content-Disposition: form-data; name="attachment-file"; filename="text.txt"
Content-Type: text/plain

hello world

----WebKitFormBoundaryX139fhEFk4BdHACC--

-d 대신에 -F를 사용하는 것만으로 curl 커맨드는 enctype="multipart/form-data"가 설정된 폼과 같은 형식으로 송신한다. -d와 -F를 섞어 쓸 수는 없다. 파일 전송은 @를 붙여 파일 이름을 지정하면, 그 내용을 읽어와서 첨부한다. 아래와 같이 전송할 파일명과 파일 형식을 수동으로 설정할 수도 있다. type과 filename은 동시에 설정할 수 있다.

#파일 내용을 test.txt에서 취득. 파일명은 포컬 파일명과 같다. 형식도 자동 설정.
$ curl --http1.0 -F attachment-file@test.txt http://localhost:18888

#파일 내용을 test.txt에서 취득. 형식은 수동으로 지정.
$ curl --http1.0 -F "attachment-file@test.txt;type=text/html" http://localhost:18888

#파일 내용을 test.txt에서 취득. 파일명은ㅇ 지정한 파일명을 이용.
$ curl --http1.0 -F "attachment-file@test.txt;filename=sample.txt" http://localhost:18888

이 @파일명 형식은 -d의 x-www-form-urlencoded에서도 사용할 수 있다. 이 경우 파일명은 전송되지 않은 채 파일 내용이 전개되어 전송된다. -F일때 파일 첨부가 아니라 내용만 보내고 싶을때는 -F "attachment-file=< 파일명"을 이용한다.

 

다른 폼 전송 인코딩 text/plain

폼의 enctype은 아무것도 설정하지 않으면 www-form-urlencoded, 파일을 전송하고 싶을때는 multipart/form-data를 설정한다. 이밖에도 text/plain을 사용할 수도 있다. www-form-urlencoded에 가깝지만 변환을 하지 않으며, 개행으로 구분해 값을 전송한다.

title=The Art of Community
author=Jono Bacon

curl 커맨드로 개행을 구분해 복수의 파라미터를 넘기는 방법은 없다. 이와 같은 형식으로 보내려면 보낼 내용과 같은 텍스트 파일을 미리 준비하고, -d "@파일명" 옵션으로 전송한다. -H "Content-Type: text/plain"도 필요하다. 이 인코딩을 권장되지는 않는다. 서비스 쪽의 보안이 약할때 보안 문제를 일으키는 경우가 있기 때문이다.

3. Content Negotiation

서버와 클라이언트는 따로 개발되어 운용되므로 양쪽이 기대하는 형식이나 설정이 항상 일치한다고 할 수는 없다. 통신 방법을 최적화하고자 하나의 요청 안에서 서버와 클라이언트가 서로 최고의 설정을 공유하는 시스템이 콘텐트 니고시에이션이다. 콘텐트 니고시에이션에는 헤더를 이용한다. 니고시에이션할 대상과 니고시에이션에 사용하는 헤더는 아래 네가지이다.

요청 헤더 응답 니고시에이션 대상
Accept Content-Type 헤더 MIME 타입
Accept-Language Content-Language 헤더 / html 태그 표시 언어
Accept-Charset Content-Type 헤더 문자의 문자셋
Accpet-Encoding Content-Encoding 헤더 바디 압축
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8

우선 콤마로 항목을 나눈다.

  - image/webp

  - */*;q=0.8

q는 품질 계수라는 것으로 0에서 1까지의 수치로 설정한다. 기본은 1.0이고 이때는 q가 생략된다. 이 수치는 우선 순위를 나타낸다. 즉, 웹 서버가 WebP(구글이 권장하는 PNG보다 20% 파일 크기가 작아지는 이미지 형식)를 지원하면 WebP를, 그렇지 않으면 PNG 등 다른 포맷(우선 순위 0.8)을 서버에 보낼 것을 요구하고 있다.

 

서버는 요청에서 요구한 형식 중에서 파일을 반환한다. 우선 순위를 해석해 위에서부터 차례로 지원하는 포맷을 찾고, 일치하면 그 포맷으로 반환한다. 만약 서로 일치하는 형식이 없으면 서버가 406 Not Acceptable 오류를 반환한다.

 

표시 언어 설정

클라이언트가 지원하는 언어 종류를 나타낸다. 표시 언어도 기본은 같다. 영어로 우선 설정이 된 크롬은 다음 헤더를 각 요청에 부여한다.

Accept-Language: en-US,en;q=0.8,ko;q=0.6

en-US, en, ko라는 우선 순위로 요청을 보낸다. 언어 정보를 담는 상자로서 Content-Language 헤더가 있지만, 대부분 이 헤더는 사용하지 않는다. 다음과 같이 HTML 태그 안에서 반환하는 페이지를 많이 볼 수 있다.

<html lang="ko">

 

문자셋 결정

문자셋 설정도 아래와 같은 헤더를 송부한다.

Accept-Charset: windows-949,utf-8;q=0.7,*;q=0.3

그러나 어떤 모던 브러우저도 Accept-Charset를 송신하지 않는다. 아마도 브라우저가 모든 문자셋 인코더를 내장하고 있어, 미리 니고시에이션할 필요가 없어졌기 때문으로 보인다. 문자셋은 MIME 타입 세트로 Content-Type 헤더에 실려 통지된다.

Content-Type: text/html; charset=UTF-8

HTML의 경우 문서 안에 쓸 수도 있다. 이 방식은 RFC 1866의 HTML/2.0으로 이미 이 용할 수 있다. HTML을 로컬에 저장했다가 다시 표시하는 경우도 많으므로, 이 방식도 함께 많이 사용한다.

<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">

HTML의 <meta http-equiv> 태그는 HTTP 헤더와 똑같은 지시를 문서 내부에 삽입해서 반환하는 상자이다. HTML5에서는 다음과 같이 표기할 수도 있다.

<meta charset="UTF-8">

사용할 수 있는 문자셋은 IAN에서 관리된다.

http://www.iana.org/assignments/character-sets/character-sets.html 

 

UTF-8에서는 하이픈을 쓰고 Shitft_JIS에는 언더스코어로 쓰는 등 일관성이 없어 혼동하기 쉽다.

 

압축을 이용한 통신 속도 향상

콘텐츠 압축은 전송 속도 향상을 위한 것으로, 1992년 사양에서도 이미 정의되어 있었다.

 

콘텐츠 내용에 따라 다르지만, 현재 일반적으로 사용되는 압축 알고리즘을 적용하면 텍스트 파일은 1/10 크기로 압축된다. 같은 기호가 반복해서 나오는 JSON이라면 1/20 정도로 압축할 수 있다. 통신에 걸리는 시간보다 압축과 해제가 짧은 시간에 이루어지므로, 압축을 함으로써 웹페이지를 표시할때 걸리는 전체적인 처리 시간을 줄일 수 있다. 다시 말해 브라우저 사용자에게는 전송 속도가 향상된 것처럼 보인다.

 

콘텐츠 압축은 전송 속도 향상뿐만 아니라 이용 요금에도 영향을 미친다. 데이터 사용량에 따라 종량제 과금이 이루어지는 경우, 콘텐츠를 압축하면 비용 부담이 줄어들게 된다. 더구나 모바일 단말은 전파 송수신에 전력을 많이 소비하므로, 전력 소비가 줄어드는 효과도 기대할 수 있다.

 

콘텐츠 압축 니고시에이션은 모두 HTTP의 헤더 안에서 완료한다. 우선 클라이언트가 수용 가능한 압축 방식을 헤더에서 지정한다. 여기에서는 deflate와 gzip 두가지를 지정했다.

Accept-Encoding: deflate, gzip

curl 커맨드에서 --compressed 옵션을 지정하면, -H 옵션으로 위 헤더를 기술한 것과 같다.

curl --http1.0 --compressed http://localhost:18888

서버는 전송받은 목록 중 지원하는 방식이 있으면, 응답할 때 그 방식으로 압축하거나 미리 압축된 콘텐츠를 반환한다. 서버가 gzip을 지원하면, 조금 전에 받은 요청에 대한 응답으로 다음과 같은 헤더가 부여된다. 콘텐츠의 데이터양을 나타내는 Content-Length 헤더는 압축된 파일 크기이다.

Content-Encoding: gzip

구글은 gzip보다 효율이 더 좋은 새로운 압축 포맷 브로틀리(Brotli)를 공개했다. 현시점에서 파이어폭스, 크롬, 엣지 브라우저가 지원하고, 역시 같은 방식으로 이용할 수 있다. 인코딩으로 받아들일 수 있는 포맷으로 br을 지정해 요청을 보내고, 서버도 지원할 경우 브로틀리 압축으로 고속화가 이루어진다. 클라이언트가 지원하지 않으면 br은 전송되지 않고, 서버가 지원하지 않으면 양쪽에서 다 지원하는 다른 인코딩(아마도 gzip)으로 대체된다. 이처럼 HTTP 헤더라는 틀을 이용해 1 왕복의 짧은 요청과 응답 속에서 하위 호환성을 유지하면서도 서로 최적의 통신을 할 수 있게 시스템을 정비할 수 있다.

 

클라이언트에서 서버로 업로드할 때 또한 압축을 이용하는 방법이 논의되고 있다. 현제 제안된 방식은 한번의 통신으로 완결되진 않지만, 기본은 같다. 서버에서 클라이언트로 첫번째 웹페이지를 반환할때 Accept-Encoding 헤더를 부여하고, 그런 다음 클라이언트에서 무언가 업로드할 때 Content-Encoding을 부여한다. 요청, 응답 양쪽에서 똑같이 헤더 구조가 이용되므로 이처럼 간단하게 구현할 수 있다.

 

무압축을 뜻하는 identity도 사용할 수 있다. 웹브라우저에서 이용할 수 있는 주요 압축 알고리즘은 다음과 같다. 

이름 별명 알고리즘 IANA 등록
br   브로틀리 o
compress x-compress 유닉스에 탑재된 compress 커맨드 o
deflate   zlib 라이브러리에서 제공되는 압축 알고리즘(RFC 1951) o
exi   W3C Efficient XML Interchange o
gzip x-gzip GZIP 커맨드와 같은 압축 알고리즘(RFC 1952) o
idenity   무압축을 선언하는 예약어 o
pack200-gzip   자바용 네크워크 전송 알고리즘 o
sdch   Shared Dictionary Compressing for HTTP, 미리 교환한 사전을 이용하는 방법  

sdch는 크롬에 탑재된 공유 사전을 이용하는 알고리즘으로 전체 텍스트에서 중복된 부분을 대량으로 찾아내면 데이터양을 줄일 수 있다. 자주 사용되는 문구가 사전으로 만들어진다면 통신량을 대폭 줄일 수 있다. sdhc는 RFC 3284의 VCDIFF를 사용한다. 이런 공유사전 압축 방식은 HTTP/2의 헤더 부분 압축에도 사용된다.

 

압축을 이용한 통신량 절감에는 Content-Encoding으로 콘텐츠 크기를 줄이는 방법 말고도, Transfer-Encoding 헤더로 통신 경로를 압축하는 방법도 규격에 있지만, 그다지 사용되진 않는다.

4. 쿠키

쿠키란 웹사이트의 정보를 브라우저 쪽에 저장하는 작은 파일이다. 일반적으로 데이터베이스는 클라이언트가 데이터베이스 관리 시스템에 SQL을 발행해서 데이터를 저장하지만, 쿠키의 경우 거꾸로 서버가 클라이언트(브라우저)에 쿠키 저장을 지시한다.

 

쿠키고 HTTP 헤더를 기반으로 구현되었다. 서버에서는 다음과 같이 응답 헤더를 보낸다. 이 서버는 최종 액세스 날짜와 시간을 클라이언트에 저장하려고 한다.

Set-Cookie: LAST_ACCESS_DATE=Jul/31/2016
Set-Cookie: LAST_ACCESS_TIME=12:04

각각 '이름 = 값' 형식으로 회신했는데, 클라이언트는 이 값을 저장해둔다. 다음번에 방문할 때는 다음과 같은 형식으로 보낸다. 서버는 이 설정을 읽고, 클라이언트가 마지막으로 액세스한 시간을 알수 있게 된다.

Cookie: LAST_ACCESS_DATE=Jul/31/2016
Cookie: LAST_ACCESS_TIME=12:04

다음 코드는 첫방문인지 아닌지 판단해서 표시 내용을 바꾼다.

func handler(w http.ResponseWriter, r *http.Request) {
    w.Header().Add("Set-Cookie", "VISIT=TRUE")
    if _, ok := r.Header["Cookie"]; ok {
        // 쿠키가 있다는 것은 한번 다녀간 적이 있는 사람
        fmt.Fprintf(w, "<html><body>두번째 이후 </body></html>\n"
    } else {
        fmt.Fprintf(w, "<html><body>첫방문</body></html>\n"
    }
}

서버 프로그램이 볼땐 마치 데이터베이스처럼 외부에 데이터를 저장해두고, 클라이언트가 액세스할 때마다 데이터를 로드하는 것과 같다. HTTP는 stateless(언제 누가 요청해도 요청이 같으면 결과가 같음)를 기본으로 개발됐지만, 쿠키를 이용하면 (중단 지점부터 작업을 재개하는 등) 서버가 상태를 유지하는 statefull 처럼 보이게 서비스를 제공할 수 있다.

 

쿠키는 헤더를 바탕으로 만들어져 curl 커맨드를 사용할때도 헤더로서 받은 내용을 Cookie에 넣고 재전송함으로써 실현할 수 있지만, 쿠키를 위한 전용 옵션도 존재한다. -c/--cookie-jar 옵션으로 지정한 파일에 수신한 쿠키를 저장하고, -b/--cookie 옵션으로 지정한 파일에서 쿠키를 읽어와 전송한다. 브라우저처럼 동시에 송수신하려면 둘 다 지정한다. -b/--cookie 옵션은 파일에서 읽기만 하는게 아니라 개별 항목을 추가할 때도 사용할 수 있다.

$ curl --http1.0 -c cookie.txt -b cookie.txt -b "name=value" http://example.com/helloworld

 

쿠키의 잘못된 사용법

쿠키는 편리한 기능이지만, 몇가지 제약이 있어 적절하지 않은 사용법이 있다.

 

영속성 문제 - 쿠키는 어떤 상황에서도 확실하게 저장되는 것은 아니다. 비밀 모드 혹은 브라우저의 보안 설정에 따라 세션이 끝나면 초기화되거나 쿠키를 보관하라는 서버의 지시를 무시하기도 한다. 방문 기록 삭제 메뉴나 개발자 도구 등으로 삭제되는 겨우도 있다. 쿠키가 초기화되면 저장된 데이터는 사라진다. 그러므로 사라지더라도 문제가 없는 정보나 서버 정보로 복원할 수 있는 자료를 저장하는 용도에 적합하다.

 

용량 문제 - 쿠키의 최대 크기는 4킬로바이트 사양으로 정해져 있어 더 보낼 수는 없다. 쿠키는 헤더로서 항상 통신에 부가되므로 통신량이 늘어나는데, 통신량 증가는 요청과 응답 속도 모두에 영향을 미친다. 제한된 용량과 통신량 증가는 데이터베이스로 사용하는데 제약이 된다.

 

보안 문제 - secure 속성을 부여하면 HTTPS 프로토콜로 암호화된 통신에서만 쿠키가 전송되지만, HTTP 통신에서는 쿠키가 평문으로 전송된다. 매 요청시 쿠기가 송수신되는데, 보여선 곤란한 비밀번호 등이 포함되면 노출될 위험성이 있다. 암호화된다고 해도 사용자가 자유롭게 접근할 수 있는 것도 문제이다. 원리상 사용자가 쿠키를 수정할 수도 있으므로, 시스템에서 필요한 ID나 수정되면 오작동으로 이어지는 민감한 정보를 넣는데도 적합하지 않다. 정보를 넣을때는 서명이나 암호화 처리가 필요하다.

 

쿠키에 제약을 준다

클라이언트는 서버가 보낸 쿠키를 로컬 스토리지에 저장하고, 같은 URL로 접속할때 저장된 쿠키를 읽고 요청 헤더에 넣는다. 쿠키는 특정 서비스를 이용하는 코튼으로 이용될 때가 많아 쿠키가 필요하지 않은 서버에 전송하는것은 보안이 위험해질 뿐이다. 그러므로 쿠키 보낼 곳을 제어하거나 쿠키의 수명을 설정하는 등 쿠키를 제한하는 속성이 몇가지 정의되어 있다. HTTP 클라이언트는 이 속성을 해석해 쿠키 전송을 제어할 책임이 있다.

 

속성은 세미콜론으로 구분해 얼마든지 나열할 수 있다. 속성은 대문자와 ㅅ문자를 구별하지 않으므로 모두 소문자로 써도 유효하다. 다음은 RFC 6265에 따라 표기된 내용이다.

Set-Cookie: SID=31d4d96e407aad42; Path=/; Secure; HttpOnly
Set-Cookie: lang=en-US; Path=/; Domain=example.com

 - Expires, Max-Age 속성: 쿠키의 수명을 설정한다. Max-Age는 초단위로 지정. 현재 시각에서 지정된 초수를 더한 시간에서 무효가 된다. Expires는 Wed, 09 Jun 2021 10:18:14 GMT 같은 형식의 문자열을 해석한다.

 - Domain 속성: 클라이언트에서 쿠키를 전송할 대상 서버. 생략하면 쿠키를 발행한 서버가 된다.

 - Path 속성: 클라이언트에서 쿠키를 전송할 대상 서버의 경로. 생략하면 쿠키를 발행한 서버가 된다.

 - Secure 속성: https로 프로토콜을 사용한 보안 접속일 때만 클라이언트에서 서버로 쿠키를 전송한다. 쿠키는 URL을 키로 전송을 결정하므로, DNS 해킹으로 URL을 사칭하면 의도치 않은 서버에 쿠키를 전송할 위험이 있다. DNS 해킹은 기기를 조작하지 않고도 무료 와이파이 서비스 등으로 속여 간단히 할 수 있다. Secure 속성을 붙이면 http 접속일때는 브라우저가 경고를 하고 접속하지 않아 정보 유출을 막게 된다.

 - HttpOnly 속성: 쿠키를 자바스크립트로 다룰 수도 있지만, 이 속성을 붙이면 자바스크립트 엔진으로부터 쿠키를 감출 수 있다. 크로스 사이트 스크립팅 등 악의적인 자바스크립트가 실행되는 보안 위험에 대한 방어가 된다.

 - SameSite 속성: 이 속성은 RFC에는 존재하지 않는다. 크롬 브라우저 버전 51에서 도입한 속성으로, 같은 오리진(출처)의 도메인에 전송하게 된다.

5. 인증과 세션

인증에는 몇가지 방식이 있다. 유저명과 패스워드를 매번 클라이언트에서 보내는 방식 두가지를 먼저 소개한다.

 

BASIC 인증과 Digest 인증

가장 간단한 것이 BASIC 인증이다. BASIC 인증은 유저명과 패스워드를 BASE64로 인코딩한 것이다. BASE64 인코딩은 가역변환이므로 서버로부터 복원해 원래 유저명과 패스워드를 추출할 수 있다. 추출된 정보를 서버의 데이터베이스와 비교해서 정상 사용자인지 검증한다. 단 SSL/TLS 통신을 사용하지 않은 상태에서 통신이 감청되면 손쉽게 로그인 정보가 유출될 수 있다.

base64(유저명 + ":" + 패스워드)

curl 커맨드로 BASIC 인증을 할 경우, -u/--user 옵션으로 유저명과 패스워드를 보낸다. --basic 이라고 해서 BASIC 인증을 사용한다고 명시할 수도 있지만, 기본 인증 방식이 BASIC 이므로 생략해도 별다른 문제는 없다.

$ curl --http1.0 --basic -u user:pass http://localhost:18888

이 옵션을 붙이면 다음과 같은 헤더가 부여된다.

Authorization: "Basic dXNlcjpwYXNz"

 

이보다 더 강력한 방식이 Digest 인증이다. Digest 인증은 해시 함수(A->B는 쉽게 계산할 수 있지만, B->A는 쉽게 계산할 수 없다)를 이용한다. 방대한 계산 리소스를 사용하면 출력 B가 나오는 A 후보를 몇개 찾아낼 수 있지만, 단시간에 문자열을 복원하기는 쉽지 않다. 브라우저가 보호된 영역에 접속하려고 하면, 401 Unauthorized라는 스테이터스 코드로 응답이 돌아온다. 이때 아래와 같은 헤더가 부여된다.

WWW-Authenticate: Digest realm="영역명", nonce="1234567890", algorithm=MD5, qop="auth"

realm은 보호되는 영역의 이름으로, 인증창에 표시된다. nonce는 서버가 매번 생성하는 랜덤한 데이터이다. qop는 보호 수준을 나타낸다. 클라이언트는 이곳에서 주어진 값과 무작위로 생성한 cnonce를 바탕으로 다음처럼 계산해서 response를 구한다.

A1 = 유저명 ":" realm ":" 패스워드
A2 = HTTP 메서드 ":" 콘텐츠 URI
response = MD5( MD5(A1) ":" nonce ":" nc ":" cnonce ":" qop ":" MD5(A2) )

nc는 특정 nonce값을 사용해 전송한 횟수이다. qop가 없을 때는 생략한다. 8자리 16진수로 표현한다. 같은 nc 값이 다시 사용된 것을 알 수 있으므로, 서버가 리플레이 공격을 탐지할 수도 있다.

 

클라이언트에서는 생성한 cnonce와 계산으로 구한 response를 부여해 한데 모으고, 다음과 같은 헤더를 덧붙여 재요청을 보내게된다.

Authorization: Digest username="유저명", realm="영역명",
    nonce="1234567890", uri="/secret.html", algorithm=MD5,
    qop=auth, nc=00000001, cnonce="0987654321",
    response="9d47a3f8b2d5c"

서버 측에서도 이 헤더에 있는 정보와 서버에 저장된 유저명, 패스워드로 같은 계산을 실시한다. 재발송된 요청과 동일한 response가 계산되면 사용자가 정확하게 유저명과 패스워드를 입력했음을 보증할 수 있다. 이로써 유저명과 패스워드 자체를 요청에 포함하지 않고서도 서버에서 사용자를 올바르게 인증할 수 있게된다.

 

curl에서는 다음과 같이 --digest와 -u/--user 옵션으로 Digest 인증을 사용할 수 있지만, 테스트 서버는 401을 반환하지 않으므로 접속이 그대로 종료된다. 동작을 확인할 경우에는 401 Unauthorized을 반환하도록 한다. /digest라는 패스에서 Authorization 헤더가 없을때 401을 반환하도록 테스트 서버에 핸들러 함수를 추가한다.

import (
    // import 섹션에 아래 2줄 추가
    "io/ioutil"
    "github.com/k0kubun/pp"
)

func handlerDigest(w http.ReponseWriter, r *http.Request) {
    pp.Printf("URL: %s\n", r.URL.String())
    pp.Printf("Query: %v\n", r.URL.Query())
    pp.Printf("Proto: %s\n", r.Proto)
    pp.Printf("Method: %s\n", r.Method)
    pp.Printf("Header: %v\n", r.Header)
    defer r.Body.Close()
    body, _ := ioutil.ReadAll(r.Body)
    fmt.Printf("--body--\n%s\n", string(body))
    if _, ok := r.Header["Authorization"]; !ok {
        w.Header().Add("WWW-Authenticate", `Digest realm="Secret Zone",
                        nonce="TgLc25U2BQA=f510a2780473e18e6587be702c2e67fe2b04afd",
                        algorithm=MD5, qop="auth"`)
        w.WriterHeader(http.StatusUnauthorized)
    } else {
        fmt.Fprint(w, "<html><body>secret page</body></html>\n")
    }
}

main 함수의 핸들러 등록 부분에 지금 만든 함수를 등록한다.

:
http.HandleFunc("/", handler)
http.HandleFunc("/digest", handlerDigest)
:

서드파티 라이브러리나 준표준 라이브러리는 따로 다운로드해야만 사용할 수 있다. go get 명령어로 가져올 수 있다.

$ go get golang.org/x/net/idna

다음 커맨드로 조금 전과는 다른 응답이 반환되는 것을 확인할 수 있다. -v를 붙여 보면, 한번 401로 거부된 후에 다시 보낸다는 것을 알 수 있다.

$ curl --http1.0 --digest -u user:pass http://localhost:18888/digest

 

쿠키를 사용한 세션 관리

BASIC 인증과 Digest 인증 모두 많이 사용되지는 않는다. 그 이유는 다음과 같다.

 - 특정 폴더 아래를 보여주지 않는 방식으로만 사용할 수 있어, 톱페이지에 사용자 고유 정보를 제공할 수 없다. 톱페이지에 사용자 고유 정보를 제공하려면 톱페이지도 보호할 필요가 있어, 톱페이지 접속과 동시에 로그인 창을 표시해야 한다. 처음 방문하는 사용자에게 친절한 톱페이지는 아니다.

 - 요청할 때마다 유저명과 패스워드를 보내고 계산해서 인증할 필요가 있다. 특히 Digest 인증 방식은 계산량도 많다.

 - 명시적인 로그오프를 할 수 없다.

  - 로그인한 단말을 식별할 수 없다. 게임 등 동시 로그인을 막고 싶은 서비스나 구글처럼 미등록 단말로 로그인할때 보안 경고를 등록된 메일로 보내는 기능이 있는 웹서비스도 있다.

 

최근 가장 많이 사용되는 방식은 폼을 이용한 로그인과 쿠키를 이용한 세션관리 조합이다. 이 방식으로는 처음에 설명한 폼과 쿠키를 이용하는 단순한 구조가 된다.

 

클라이언트는 폼으로 ID와 비밀번호를 전송한다. Digest 인증과 달리, 유저 ID와 패스워드를 직접 송신하므로 SSL/TLS이 필수이다. 서버 측에서는 유저 ID와 패스워드를 직접 송신하므로 SSL/TLS이 필수이다. 서버 측에서는 유저 ID와 패스워드로 인증하고 문제가 없으면 세션 토큰을 발행한다. 서버는 세션 토큰을 관계형 데이터베이스나 키 밸류형 데이터베이스에 저장해둔다. 토큰은 쿠키로 클라이언트에 되돌아간다. 두번째 이후 접속에서는 쿠키를 재전송해서 로그인된 클라이언트임을 서버가 알 수 있다.

 

클라이언트의 동작은 폼 전송과 쿠키라는 기술의 조합이다. 웹서비스에 따라서는 사이트간 요청위조 대책으로 랜덤키를 보내는 경우도 있으므로, 랜덤키도 잊지않고 전송해야 한다.

 

서명된 쿠키를 이용한 세션 데이터 저장

쿠키는 통신량을 증가시키므로 조심해야하지만, 원래의 용도대로 스토리지로서 사용할 수 있다. 

 

웹 애플리케이션 프레임워크는 영속화 데이터를 읽고 쓰는 OR 매퍼 등의 시스템과 함께 휘발성 높은 데이터를 다루는 세션 스토리지 기능을 갖추고 있다. 예전 세션 스토리지는 관계형 데이터베이스에 전용 테이블을 만들고, 전술한 세션 관리에서 작성한 ID를 키로 삼아 서버측에서 데이터를 관리했다.

 

하지만 통신 속도가 빨라지고 웹사이트 자체의 데이터양도 많이 늘어나면서, 쿠키의 데이터양 증가는 걱정할 필요가 없어졌다. 그래서 쿠키를 사용한 데이터 관리 시스템도 널리 사용되기 시작했다.

 

루비 온 레일즈의 기본 세션 스토리지는 쿠키를 이용해 데이터를 저장하며, 장고도 1.4부터 지원하기 시작했다. 이 시스템에서는 변조되지 않도록 클라이언트에 전자 서명된 데이터를 보낸다. 클라이언트가 서버로 쿠키를 재전송하면 서버는 서명을 확인한다. 서명하는 것도 서명을 확인하는 것도 서버에서 하므로, 클라이언트는 열쇠를 갖지 않는다. 공개 키와 비밀 키 모두 서버에 있다.

 

이 시스템의 장점은 서버 측에서 데이터저장 시스템을 준비할 필요가 없다는 점이다. 서버를 상세하게 기능 단위로 나누는 마이크로서비스라도 세션 스토리지 암호화 방식을 공통화해두면 따로 데이터스토어를 세우지 않고 세션 데이터를 읽고 쓸 수 있게 된다.

 

클라이언트 입장에서 보면 서버에 액세스해서 조작한 결과가 쿠키로 저장된다. 쿠키를 갖고 있는 한 임시 데이터가 유지된다. 다만 전통적인 Memcached라든지 관계형 데이터베이스를 이용하는 세션 스토리와 달리, 같은 사용자라도 스마트폰과 컴퓨터로 각각 접속한 경우 데이터가 공유되지 않는다.

6. 프록시

프록시는 HTTP 등의 통신을 중계한다. 때로는 중계만 하는게 아니라 각종 부가 기능을 구현한 경우도 있다. 예를 들어 구성원 중 누군가가 접근한 콘텐츠는 다른 구성원도 접근할 가능성이 크다. 그럴때 캐시 기능이 있는 프록시를 조직의 네트워크 출입구에 설치하면, 콘텐츠를 저장한 웹서버의 부담은 줄이고 각 사용자가 페이지를 빠르게 열람할 수 있게하는 효과가 있다. 또한 프록시는 외부 공격으로부터 네트워크를 보호하는 방화벽 역할도 한다. 저속 통신 회선용 데이터를 압축해 속도를 높이는 필터나 콘텐츠 필터링 등에도 프록시가 이용된다.

 

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

 

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

 

프록시 서버가 악용되지 않도록 인증을 이용해 보호하는 경우가 있다. 이런 경우는 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 메서드를 이용한다.

7. 캐시

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

 

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

HTTP/1.0에서의 캐시는 당시에 정적 콘텐츠 위주라서 콘텐츠가 갱신됐는지만 비교하면 충분했다. 웹 서버는 대개 다음과 같은 헤더를 응답에 포함한다. 날짜는 RFC 1123이라는 형식으로 기술되고 타임존에는 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의 일시와 서버의 콘텐츠의 일시를 비교한다. 변경됐으면 정상 스테이터스 코드 200 OK를 반환하고 콘텐츠를 응답 바디에 실어서 보낸다. 변경되지 않았으면, 스테이터스 코드 304 Not Modified를 반환하고 바디를 응답에 포함하지 않는다.

 

Expires

빛의 속도에는 물리적 한계가 있고, 석영유리의 굴절률 또한 고려하면 전혀 손실이 없다고 해도 지구 반대편을 돌아오는데는 0.2초 가량 걸린다. 게다가 다양한 회로를 거치고 서버에서 처리하는 시간까지 더해지면 실제 통신시간은 더욱 길어진다. 갱신 일시를 이용하는 캐시의 경우 캐시의 유효성을 확인하기 위해 통신이 발생한다. 그런데 이런 통신 자체를 없애는 방법이 HTTP/1.0에 도입되었고, Expires 헤더를 이용하는 방법이다.

 

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

 

Expires라는 이름이 혼동을 줄수 있는데, '3초 후 콘텐츠 유효 기간이 끝난다'라고 설정했어도 3초 후에 마음대로 리로드하지는 않는다. 여기에 설정된 날짜와 시간은 어디까지나 접속을 할지 말지 판단할때만 사용한다. Expries를 사용하면 서버에 변경사항이 있는지 묻지 않게 되므로 톱페이지 등에 사용할때는 주의해야 한다. 지정한 기간 이내의 변경 사항은 모두 무시되어 새로운 컨텐츠를 전혀 볼수 없기 때문이다. 스타일시트 등 좀처럼 갱신되지 앟는 정적 콘텐츠에 사용하는 것이 바람직하다.

 

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

 

Pragma: no-cache

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

 

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

 

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

 

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

 

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

 

ETag 추가

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

 

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

 

ETag는 서버가 자유롭게 결정해서 반환할 수 있다. 일시 외의 갱신 정보를 고려한 해시 값을 서버가 생성할 수 있다. ETag는 갱신 일시와 선택적으로 사용할 수 있다. 아파치 2.3.15 이후 nginx, h2o 등의 서버가 정적 파일에 부여하는 ETag는 갱신 일시-파일크기 형식으로 되어 있다. 프로그램에서 동적으로 생성할 여지는 남아 있지만, 정적 파일의 경우 Last-Modified와 같다.

 

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

 

Cache-Control (1)

ETag와 같은 시기에 HTTP/1.1에서 추가된 것이 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-cahce와 똑같이 캐시하지 안흔 것은 아니고, 시간을 보고 서버에 접속하지 않은 채 콘텐츠를 재이용하는 것을 그만둘 뿐이다. 갱신 일자와 ETag를 이용하며, 서버가 304를 반환했을때 이용하는 캐시는 유효하다. 캐시하지 않는것은 no-store이다.

 

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

 

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

  - private, public 중 하나. 혹은 설정하지 않는다

  - max-age, s-maxage, no-cache, no-store 중 하나

 

모순된 설정을 동시에 할 경우(no-cache와 max-age 등)의 우선순위까지는 RFC에 적혀있지 않다.

https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching 

 

Cache-Control (2)

사용할 일이 많지 않은 프록시에 대한 캐시 관련 요청으로, Cache-Control 헤더를 요청 헤더에 포함함으로써 프록시에 지시할 수도 있다. 서버에서 프록시로 보내는 응답 헤더에 사용할 수 있는 지시도 있다.

 

클라이언트 측에서 요청 헤더에서 사용할 수 있는 설정값은 다음과 같다.

헤더 설명
no-cache: Pragma no-cache와 같다.
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와 같지만, 공유 캐시에만 요청한다.

 

Vary

ETag는 같은 URL이라도 개인마다 결과가 달라질 수 있다. 같은 URL이라도 클라이언트에 따라 반환 결과가 다름을 나타내는 헤더가 Vary이다.

 

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

Vary: User-Agent, Accept-Language

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

8. 리퍼러

리퍼러(헤더명: Referer)는 사용자가 어느 경로로 웹사이트에 도달했는지 서버가 파악할 수 있도록 클라이언트가 서버에 보내느 헤더이다. 클라이언트가 http://www.example.com/link.html의 링크를 클릭해서 다른 사이트로 이동할 때, 링크가 있는 페이지의 URL을 목적지 사이트의 서버에 아래와 같은 형식으로 전송하게 된다. 웹페이지가 이미지나 스크립트를 가져올 경우는 리소스를 요청할 때 리소스를 이용하는 HTML 파일의 URL이 리퍼러로서 전송된다.

Referer: http://www.example.com/link.html

만약 북마크에서 선택하거나 주소창에서 키보드로 직접 입력했을때는 Referer 태그를 전송하지 않거나 Referer:aboud:blank를 전송한다.

 

예를 들어 검색 엔진은 검색 결과를 '?q=키워드' 형식의 URL로 표시한다. 브라우저가 이 URL을 리퍼러로서 전송하면, 서버는 어떤 검색 키워드로 웹사이트에 도달했는지 알 수 있다. 웹서비스는 리퍼러 정보를 수집함으로써 어떤 페이지가 자신의 서비스에 링크를 걸었는지도 알 수 있다.

 

웹서비스 설계자는 개인 정보가 GET 파라미터로 표시되게 만들어선 안된다. GET 파라미터는 리퍼러를 통해 외부 서비스로 전송되므로 바로 개인 정보 유출로 이어지기 때문이다. 리퍼러를 보내지 않도록 웹 브라우저를 설정할 수도 있다.

 

사용자의 통신 내용을 비밀로 하는 HTTPS가 HTTP/1.1에서 추가됐지만, 보호된 통신 내용이 보호되지 않은 통신 경로로 유출되는 것을 막고자 클라이언트가 리퍼러 전송을 제한하는 규약이 RFC 2616으로 제정되었다. 액세스 출발지 및 액세스 목적지의 스키마 조합과 리퍼러 전송 유무 관계는 다음과 같다.

액세스 출발지 액세스 목적지 전송하는가?
HTTPS HTTPS 한다.
HTTPS HTTP 하지 않는다.
HTTP HTTPS 한다.
HTTP HTTP 한다.

리퍼러 정책은 다음 중 한가지 방법으로 설정할 수도 있다.

  - Referrer-Policy 헤더

  - <meta name="referrer" content="설정값">

  - <a> 태그 등 몇가지 요소의 referrerpolicy 속성 및 rel="noreferrer" 속성

 

리퍼러 정책으로서 설정할수 있는 값은 다음과 같다.

헤더 설명
no-referrer 전혀 보내지 않는다.
no-referrer-when-downgrade 현재 기본 동작과 마찬가지로 HTTPS -> HTTP일때는 전송하지 않는다.
same-origin 동일 도메인 내의 링크에 대해서만 리퍼러를 전송한다.
origin 상세 페이지가 아니라 톱페이지에서 링크된 것으로 해 도메인 이름만 전송한다.
strict-origin origin과 같지만 HTTPS->HTTP일 때는 전송하지 않는다.
origin-when-crossorigin 같은 도메인 내에서는 완전 리퍼러를, 다른 도메인에는 도메인 이름만 전송한다.
strict-origin-when-crossorigin origin-when-crossorigin과 같지만 HTTPS -> HTTP일 때는 송신하지 않는다.
unsafe-url 항상 전송한다.

아래와 같이 Content-Security-Policy 헤더로 지정할 수도 있다.

Content-Security-Policy: referrer origin

Content-Security-Policy 헤더는 많은 보안 설정을 한꺼번에 변경할 수 있는 헤더이다.

9. 검색 엔진용 콘텐츠 접근 제어

인터넷은 브라우저를 이용해 문서를 열람하는 구조로 출발했지만, 점차 검색 엔진이 정보를 수집하는 자동 순회 프로그램이 많이 운용되게 됐다. 자동 순회 프로그램은 '크롤러', '로봇', '봇', '스파이더' 같은 이름으로 불린다. 정확히 자동 순회 프로그램은 '봇'이지만, 대부분 검색 엔진에서 정보를 수집(크롤)하는 용도로 운용되므로 거의 같은 뜻으로 사용된다.

 

이 크롤러의 접근을 제어하는 방법은 주로 다음 두가지가 널리 사용된다.

  - robots.txt

  - 사이트맵

 

robots.txt는 정식 RFC는 아니다. 그러나 사실상 표준으로 널리 인지되고 있고 HTML4 사양에서도 설명되고 있다. 2006년에는 구글의 캐시가 저작권을 침해한다는 이유로 열린 소송사건이 있다. 작가이자 변호사였던 블레이크 필드가 구글에 낸 소송이었는데, 결과적으로 구글의 주장이 인정되었다. 판결에 결정적인 것이 robots.txt 였다. 원고인 필드는 robots.txt로 크롤러의 접속을 금지하는 방법을 알고 있었지만 그 방법을 쓰지 않았기 때문에 재판에서 저작권 침해를 주장할 수 없었다. 구글이 정의한 공정 이용 등이 인정이 된 판례이다.

 

http://www.robotstxt.org/norobots-rfc.txt 

http://www.robotstxt.org/faq/legal.html 

 

크롤러 제작사 사이에 계약서를 쓰진 않지만, robots.txt를 설치하면 웹서비스 제공자가 명확히 의사를 표명한 것으로 봐야 하므로 크롤러는 이를 지켜야 한다. 일본 법률도 robots.txt와 메타 태그에 따른 읫표시를 존중해야 한다고 되어있으므로 일본 법에도 기준이 되고 있다.

 

사이트맵

사이트맵은 웹사이트에 포함된 페이지 목록과 메타데이터를 제공하는 XML 파일로, 2005년에 구글이 개발해 야후나 마이크로소프트에서도 이용하게 됐다. robots.txt가 블랙리스트처럼 사용된다면, 사이트맵은 화이트리스트처럼 사용된다. 크롤러는 링크를 따라가면서 페이지를 찾아내는데, 동적 페이지의 링크처럼 크롤러가 페이지를 찾을 수 없는 경우라도 사이트맵으로 보완할 수 있다.

 

사이트맵 사이트에는 기본 설정 항목이 정의되어 있지만, 해석하는 검색 엔진마다 다른 기능이 추가되기도 한다.

<?xml version="1.0" encoding="utf-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
            http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
    <url>
        <loc>http://example.com/</loc>
        <lastmod>2006-11-18</lastmod>
    </url>
</urlset>

<url> 태그를 등록하고 싶은 페이지 수만큼 작성한다. <loc>은 절대 URL이다. XML 형식이 가장 많이 사용되지만 단순히 URL이 나열된 텍스트 파일이나 RSS, 아톰 같은 블로그의 업데이트 정보 통지에 쓰이는 형식도 사이트맵으로 사용할 수 있다.

 

사이트맵은 robots.txt에 쓸 수도 있다. 또한 각 검색 엔진에 XML 파일을 업로드 하는 방법도 있다.

Sitemap: http://www.example.org/sitemap.xml

구글의 경우는 사이트맵을 사용해 웹사이트의 메타데이터를 검색 엔진에 전달할 수 있다.

  - 웹사이트에 포함되는 이미지의 경로, 설명, 라이선스, 물리적인 위치

  - 웹사이트에 포함되는 비디오 섬네일, 타이틀, 재생 시간, 연령 적합성 등급이나 재생 수

  - 웹사이트에 포함되는 뉴스의 타이틀, 공개일, 카테고리, 뉴스에서 다루는 기업의 증권코드

 

HTTP는 효율적으로 계층화되어 있다. 통신의 데이터 상자 부분은 변화지 않으므로, 규격에서 제안된 새로운 기능이 구현되지 않아도 호환성을 유지하기 쉽도록 되어있다. 또한 압축 방식 선택 등 브라우저가 규격화되지 않은 방식을 새로 지원해도 가능하다면 사용할 수 있다. 토대가 되는 문법과 그 문법을 바탕으로 한 헤더의 의미 해석(시맨틱스)이 분리되어 있으므로 상위 호환성과 하위 호환성이 모두 유지된다.