Post

[HTTP의 기초를 찾아서 - 4편] 캐시와 쿠키, 그리고 실무에서 만나는 헤더

응답을 저장해 두고 다시 쓰는 캐시, 상태를 기억하지 않는 HTTP에서 상태를 유지하게 해 주는 쿠키와 세션, 그리고 CORS·프록시·보안 관련 헤더를 정리했습니다.

[HTTP의 기초를 찾아서 - 4편] 캐시와 쿠키, 그리고 실무에서 만나는 헤더

이 글은 혼자 공부하는 네트워크 책과 널널한 개발자님의 외워서 끝내는 네트워크 핵심이론 - 기초 강의를 참고해서 작성한 글입니다.

헤더의 종류와 콘텐츠 협상은 이전 편인 3편에서 정리했습니다. 아직 읽지 않으셨다면 먼저 읽고 오시면 좋습니다.

3편에서는 헤더 하나하나가 어떤 정보를 담는지 살펴보았습니다. 이번 편에서는 여러 헤더가 함께 움직여서 해내는 일을 살펴보겠습니다. 다음 순서로 정리해 보겠습니다.

  1. 응답을 저장해 두고 다시 쓰는 캐시
  2. 상태를 기억하지 않는 HTTP에서 상태를 유지하게 해 주는 쿠키와 세션
  3. 실무에서 자주 만나는 CORS, 프록시, 보안 관련 헤더

캐시

캐시란?

클라이언트가 이미지를 보려고 서버에 GET 요청을 보냈고 응답으로 10MB짜리 이미지를 받았다고 해 보겠습니다. 같은 이미지를 두세 번 더 요청하면 어떻게 될까요? 매번 10MB를 다시 받는 것은 낭비입니다.

캐시(Cache)는 받은 정보의 사본을 임시로 저장해 두는 기술입니다. 불필요한 대역폭 낭비와 응답 지연을 막으려고 씁니다. 사본을 저장하는 일 자체는 캐싱(Caching)이라고 합니다. 처음 받은 응답을 저장해 두었다가 같은 요청이 생기면 서버에서 다시 받지 않고 저장해 둔 사본을 씁니다.

같은 10MB 이미지를 세 번 요청할 때 캐시가 없으면 매번 서버에서 10MB를 받아 모두 30MB를 받고, 캐시가 있으면 첫 응답만 받아 사본을 저장한 뒤 나머지 두 번은 요청 없이 사본을 써서 10MB만 받는 것을 비교한 그림 캐시가 있으면 첫 응답만 받고, 같은 요청에는 저장해 둔 사본을 쓴다

캐시는 저장되는 위치에 따라 브라우저 캐시와 공유 캐시로 나뉩니다.

  • 브라우저 캐시(Private Cache): 웹 브라우저 안에 저장되고 그 사용자만 씁니다.
  • 공유 캐시(Shared Cache): 프록시 서버나 CDN처럼 클라이언트와 서버 사이에 있는 중간 서버에 저장되고 여러 사용자가 함께 씁니다.

캐시 신선도와 유효 기간

캐시를 쓴다면 사본을 저장한 뒤에 원본이 바뀌는 상황에 늘 대비해야 합니다. 사본이 최신 원본과 얼마나 같은지를 캐시 신선도(Freshness)라고 합니다.

클라이언트는 자신이 가진 사본이 최신인지 어떻게 알까요? 가장 기본적인 방법은 사본에 유효 기간을 정해 두는 것입니다. 유효 기간 안에는 사본을 그대로 쓰고, 기간이 지나면 서버에 다시 요청합니다.

서버는 응답 메시지의 Expires 헤더나 Cache-Control 헤더의 max-age 값으로 유효 기간을 정해 줍니다.

1
2
3
4
5
HTTP/1.1 200 OK
Date: Mon, 05 Feb 2024 12:00:00 GMT
Content-Type: text/plain
Content-Length: 100
Expires: Tue, 06 Feb 2024 12:00:00 GMT
1
2
3
4
5
HTTP/1.1 200 OK
Date: Mon, 05 Feb 2024 12:00:00 GMT
Content-Type: text/plain
Content-Length: 100
Cache-Control: max-age=1200
  • Expires는 만료 시각을 적습니다. 첫 번째 응답은 2024년 2월 6일 12시까지 유효합니다.
  • max-age는 응답이 만들어진 뒤로 유효한 시간을 초 단위로 적습니다. 두 번째 응답은 1200초, 즉 20분 동안 유효합니다.

두 헤더가 함께 있으면 max-age가 우선합니다. Expires는 서버와 클라이언트의 시계가 어긋나면 엉뚱하게 동작할 수 있어서 요즘은 주로 max-age를 씁니다.

Cache-Control의 주요 지시어

Cache-Control에는 max-age 말고도 캐시 동작을 정하는 여러 지시어(Directive)를 쉼표로 이어 적을 수 있습니다.

1
2
Cache-Control: private, max-age=600
Cache-Control: no-store
지시어의미
max-age=초이 시간 동안은 사본을 그대로 써도 된다
no-cache저장해도 되지만, 쓰기 전에 매번 서버에 최신인지 확인해야 한다
no-store아예 저장하지 않는다
private브라우저 캐시에만 저장한다 (공유 캐시에는 저장하지 않는다)
public공유 캐시에 저장해도 된다
must-revalidate유효 기간이 지나면 반드시 서버에 확인한 뒤에 쓴다

no-cache는 “캐시하지 마라”가 아닙니다. 이름과 달리 no-cache는 사본을 저장하되 쓸 때마다 서버에 확인하라는 뜻입니다. 아예 저장하지 않게 하려면 no-store를 써야 합니다. 개인 정보나 결제 정보처럼 민감한 응답에는 no-store를, 사용자마다 내용이 다른 응답에는 최소한 private를 붙여서 공유 캐시에 저장되지 않게 해야 합니다.

유효 기간이 지났다면: 재검증

사본을 쓰다가 유효 기간이 지났다고 해 보겠습니다. 그러면 서버에 자원을 다시 요청해야 합니다. 그런데 유효 기간은 지났어도 그동안 원본이 바뀌지 않았다면 어떨까요? 서버가 같은 자원을 다시 보내 줄 필요가 없습니다.

캐시가 만료되었더라도 사본이 여전히 최신이라면 클라이언트는 같은 자원을 다시 받을 필요가 없습니다.

유효 기간이 지나면 클라이언트는 사본이 여전히 최신인지 서버에 먼저 확인합니다. 이를 재검증(Revalidation)이라고 합니다.

첫 요청에 서버가 max-age=60과 ETag를 담아 200 OK로 응답하고, 60초 안에는 브라우저가 요청 없이 사본을 쓰며, 60초가 지나면 If-None-Match로 조건부 요청을 보내 304 Not Modified를 받고 사본을 계속 쓰는 흐름 유효 기간 안에는 요청 없이 사본을 쓰고, 기간이 지나면 서버에 바뀌었는지 묻는다

이렇게 조건을 붙여 보내는 요청을 조건부 요청(Conditional Request)이라고 하며, 확인하는 방법은 두 가지입니다.

  • 날짜를 기준으로 묻는 방법
  • 엔티티 태그(ETag)를 기준으로 묻는 방법

날짜 기반: Last-Modified와 If-Modified-Since

서버는 처음 응답할 때 Last-Modified 헤더에 자원이 마지막으로 바뀐 시각을 담아 보냅니다.

1
2
3
HTTP/1.1 200 OK
Last-Modified: Fri, 23 Aug 2024 09:00:00 GMT
Cache-Control: max-age=3600

캐시가 만료되면 클라이언트는 이 시각을 If-Modified-Since 헤더에 담아 다시 요청합니다. 원본이 이 시각 이후로 바뀌었을 때만 새 자원을 보내 달라는 요청입니다.

1
2
3
GET /index.html HTTP/1.1
Host: www.example.com
If-Modified-Since: Fri, 23 Aug 2024 09:00:00 GMT

이 요청을 받은 서버는 상황에 따라 셋 중 하나로 응답합니다.

상황응답
자원이 바뀌었다200 OK와 새 자원 (새 Last-Modified 포함)
자원이 그대로다304 Not Modified (본문 없음)
자원이 삭제되었다404 Not Found

304 Not Modified는 2편에서 본 대로 “가지고 있는 사본을 그대로 쓰라”는 뜻입니다. 본문 없이 헤더만 오기 때문에 전송량이 크게 줄어듭니다. 클라이언트는 사본을 계속 쓰면서 응답에 담긴 새 유효 기간으로 사본의 정보를 갱신합니다.

엔티티 태그 기반: ETag와 If-None-Match

두 번째 방법에 쓰는 ETag(Entity Tag)는 자원의 버전을 식별하는 값입니다. 여기서 버전이 바뀐다는 것은 자원에 유의미한 변경이 생겼다는 뜻입니다. 자원이 바뀔 때마다 ETag 값도 바뀝니다.

서버는 처음 응답할 때 ETag 헤더에 이 값을 담아 보냅니다.

1
2
3
HTTP/1.1 200 OK
ETag: "abc"
Cache-Control: max-age=3600

캐시가 만료되면 클라이언트는 받아 둔 ETag 값을 If-None-Match 헤더에 담아 다시 요청합니다.

1
2
3
GET /index.html HTTP/1.1
Host: www.example.com
If-None-Match: "abc"

“내가 가진 index.html의 ETag는 abc야. 지금 자원의 ETag가 이것과 다를 때만 새 자원을 보내 줘”라는 뜻입니다.

서버의 응답도 날짜 기반과 같은 세 가지입니다.

상황응답
자원이 바뀌었다 (ETag가 다르다)200 OK와 새 자원, 새 ETag
자원이 그대로다 (ETag가 같다)304 Not Modified (본문 없음)
자원이 삭제되었다404 Not Found

날짜 기반 방식은 초 단위까지만 비교합니다. 내용은 그대로인데 수정 시각만 바뀌어도 자원이 바뀐 것으로 봅니다. ETag는 내용을 기준으로 값을 만들 수 있어서 더 정확합니다. 두 헤더가 함께 오면 서버가 If-None-Match를 우선해서 판단하는 것도 이 때문입니다.

실무에서 겪은 일 사내 저작도구에서 이미지를 바꿨는데 브라우저에는 계속 이전 이미지가 보인 적이 있습니다. 그때 동료분이 ETag와 Cache-Control 헤더를 수정해서 해결했던 기억이 납니다.

이미지를 바꿔도 URL이 같으면 브라우저는 유효 기간 동안 서버에 묻지 않고 사본을 씁니다. Cache-Control: no-cache와 ETag를 함께 쓰면 브라우저가 매번 서버에 확인하므로 이미지가 그대로면 304를 받아 사본을 쓰고 바뀌었으면 새 이미지를 받습니다.

쿠키와 세션

쿠키란?

HTTP는 기본적으로 상태를 기억하지 않는 무상태(Stateless) 프로토콜입니다. “로그인 상태 유지” 같은 기능은 어떻게 만들 수 있을까요? 이런 기능은 서버가 클라이언트의 상태를 알아야만 만들 수 있습니다. 그 역할을 하는 것이 쿠키(Cookie)입니다.

쿠키는 서버가 만들어서 클라이언트에 저장해 두는 작은 데이터입니다. 상태를 기억하지 않는 HTTP의 특성을 보완해서 서버가 클라이언트의 상태를 알 수 있게 해 줍니다. 기본적으로 이름=값 쌍으로 이루어져 있고 적용 범위나 만료 기간 같은 여러 속성을 붙일 수 있습니다.

쿠키는 다음 순서로 주고받습니다.

  1. 서버가 쿠키를 만들어 응답 메시지의 Set-Cookie 헤더에 담아 보냅니다.
  2. 브라우저는 받은 쿠키를 저장해 둡니다.
  3. 이후 같은 서버로 요청을 보낼 때 저장해 둔 쿠키를 요청 메시지의 Cookie 헤더에 담아 자동으로 보냅니다.
1
2
3
4
5
6
7
8
<응답 메시지>
Set-Cookie: 이름=값
Set-Cookie: 이름=값; 속성1
Set-Cookie: 이름=값; 속성1; 속성2

<요청 메시지>
Cookie: 이름=값
Cookie: 이름=값; 이름=값    ← 쿠키가 여러 개일 때

Set-Cookie는 쿠키 하나당 한 줄씩 보내고 Cookie는 여러 쿠키를 ;로 이어 한 줄에 담습니다. 쿠키는 브라우저가 저장하고 관리합니다. 크롬이라면 개발자 도구의 Application 탭에서 사이트별로 저장된 쿠키가 보입니다.

쿠키를 보낼 범위: Domain과 Path

쿠키는 아무 서버에나 보내지 않습니다. 어느 도메인과 경로로 가는 요청에 담을지가 정해져 있습니다.

Domain 속성은 쿠키를 보낼 도메인을 정합니다.

1
Set-Cookie: name=iamkanguk97; Domain=example.com

Domain을 적으면 그 도메인과 하위 도메인(www.example.com, api.example.com 등)으로 가는 요청에 모두 쿠키를 담습니다.

Domain을 생략하면 쿠키를 만든 호스트와 이름이 정확히 같은 곳으로 가는 요청에만 담습니다. 예를 들어 api.example.com이 Domain 없이 만든 쿠키는 api.example.com으로만 가고, 상위 도메인인 example.com이나 형제 도메인인 www.example.com, 하위 도메인인 v2.api.example.com으로는 가지 않습니다.

Domain에는 자기 사이트와 관련 없는 도메인을 적을 수 없습니다. 예를 들어 example.com은 naver.com용 쿠키를 만들지 못합니다.

같은 도메인이라도 경로마다 다른 쿠키를 쓰고 싶을 때가 있습니다. 예를 들어 www.example.com/lectures 아래에서 쓸 쿠키와 www.example.com/books 아래에서 쓸 쿠키를 나누고 싶을 수 있습니다. 이때는 Path 속성으로 쿠키를 보낼 경로를 정합니다.

1
Set-Cookie: name=iamkanguk97; Path=/lectures

위 쿠키는 /lectures나 /lectures/1처럼 /lectures와 그 하위 경로로 가는 요청에만 담깁니다.

쿠키의 유효 기간: Expires와 Max-Age

쿠키에는 보통 유효 기간이 있습니다. 개발자 도구의 쿠키 목록에 보이는 Expires / Max-Age 열이 바로 이 값입니다.

1
2
Set-Cookie: sessionId=abc123; Expires=Fri, 23 Aug 2024 09:00:00 GMT
Set-Cookie: sessionId=abc123; Max-Age=3600
  • Expires는 만료 시각을 적습니다.
  • Max-Age는 지금부터 몇 초 동안 유효한지 적습니다. 두 속성이 함께 있으면 Max-Age가 우선합니다.

둘 다 없으면 브라우저를 닫을 때 사라지는 세션 쿠키가 됩니다. 반대로 기간을 정해 둔 쿠키는 브라우저를 닫아도 남아 있어서 영속 쿠키라고 부릅니다. 쿠키를 지우고 싶을 때는 Max-Age=0처럼 이미 끝난 기간을 보내면 됩니다. 로그아웃을 구현할 때 이 방법을 씁니다.

쿠키의 보안: Secure, HttpOnly, SameSite

쿠키의 대표적인 약점은 보안입니다. 쿠키는 쉽게 노출되거나 조작될 수 있어서 개인 정보처럼 민감한 정보를 쿠키에 담아 주고받거나 저장하는 것은 바람직하지 않습니다. 이 약점을 보완하려고 다음 속성을 붙입니다.

1
Set-Cookie: sessionId=abc123; Secure; HttpOnly; SameSite=Lax

Secure

Secure를 붙인 쿠키는 HTTPS로 보내는 요청에만 담깁니다. 암호화되지 않은 HTTP 통신에서는 중간에서 누군가 쿠키를 엿볼 수 있기 때문입니다.

HttpOnly

HttpOnly를 붙인 쿠키는 HTTP 요청과 응답으로만 주고받고, 자바스크립트로는 읽을 수 없습니다.

쿠키는 HTTP 통신뿐 아니라 자바스크립트의 document.cookie로도 읽을 수 있습니다. 공격자가 웹 페이지에 악성 스크립트를 심는 공격을 XSS(Cross-Site Scripting)라고 하는데, 이 공격에 당하면 심어진 스크립트가 쿠키를 읽어 공격자에게 보낼 수 있습니다. HttpOnly를 붙이면 자바스크립트가 쿠키에 접근하지 못하므로 이런 탈취를 막습니다. 로그인 정보를 담은 쿠키에는 꼭 붙여야 합니다.

SameSite

다른 사이트에서 시작된 요청에도 쿠키를 담을지는 SameSite 속성으로 정합니다.

쿠키는 기본적으로 요청이 어디서 시작되었든 해당 도메인으로 가는 요청이면 자동으로 담깁니다. 이 점을 노린 공격이 CSRF(Cross-Site Request Forgery)입니다. 사용자가 은행 사이트에 로그인된 상태로 공격자의 사이트에 들어가면 그 사이트가 몰래 은행 사이트로 송금 요청을 보내는 식입니다. 이 요청에도 사용자의 쿠키가 담기기 때문에 은행 서버는 사용자가 직접 보낸 요청으로 착각합니다.

SameSite는 이런 공격을 막는 데 쓰입니다.

값다른 사이트에서 시작된 요청에 쿠키를
Strict담지 않는다
Lax링크를 눌러 페이지를 이동할 때처럼 페이지 이동 GET 요청에만 담는다
None항상 담는다 (Secure를 함께 붙여야 한다)

크롬을 비롯한 일부 브라우저는 SameSite를 적지 않으면 Lax로 처리합니다. 여기서 말하는 사이트는 뒤에서 볼 출처보다 넓은 개념입니다. www.example.com과 api.example.com은 출처는 다르지만 같은 사이트로 봅니다.

웹 스토리지: LocalStorage와 SessionStorage

쿠키 말고도 클라이언트의 상태를 유지하는 방법이 있습니다. 브라우저에 키-값 형태로 정보를 저장하는 웹 스토리지(Web Storage)입니다.

웹 스토리지는 브라우저가 관리하는 저장 공간으로, 쿠키와는 저장 크기와 서버 전송 방식이 다릅니다.

  • 쿠키보다 훨씬 큰 데이터를 저장할 수 있습니다. 쿠키는 하나에 4KB 정도지만 웹 스토리지는 보통 출처마다 5MB 정도를 쓸 수 있습니다.
  • 쿠키는 요청마다 서버로 자동 전송되지만, 웹 스토리지의 정보는 서버로 자동 전송되지 않습니다. 필요하면 자바스크립트로 꺼내서 요청에 직접 담아야 합니다.

웹 스토리지는 정보가 유지되는 기간에 따라 세션 스토리지와 로컬 스토리지로 나뉩니다.

  • 세션 스토리지(SessionStorage): 브라우저 탭이 열려 있는 동안만 유지되고 탭을 닫으면 사라집니다. 같은 사이트라도 탭마다 따로 저장됩니다.
  • 로컬 스토리지(LocalStorage): 사용자 PC의 디스크에 저장되므로 직접 지우지 않는 한 브라우저를 닫거나 컴퓨터를 꺼도 계속 남아 있습니다.
구분쿠키세션 스토리지로컬 스토리지
크기약 4KB약 5MB약 5MB
서버로 자동 전송OXX
유지 기간Expires·Max-Age로 정함탭을 닫을 때까지지울 때까지
자바스크립트로 접근가능 (HttpOnly면 불가)가능가능

웹 스토리지는 언제나 자바스크립트로 읽을 수 있어서 XSS 공격에 당하면 저장된 값이 그대로 노출됩니다. 민감한 정보를 담을 때는 이 점을 고려해야 합니다.

세션 기반 인증

HTTP는 무상태 프로토콜이라서 같은 클라이언트가 여러 번 요청을 보내도 서버는 기본적으로 각 요청을 별개의 요청으로 봅니다. 그렇다면 클라이언트는 요청을 보낼 때마다 아이디와 비밀번호를 보내서 매번 인증을 거쳐야 할까요? 그렇지 않습니다. 쿠키와 세션(Session)을 함께 쓰면 됩니다.

  1. 클라이언트가 아이디와 비밀번호 같은 인증 정보를 서버에 보냅니다.
  2. 인증 정보가 올바르면 서버는 세션 ID를 만들고 이 ID와 사용자 정보를 서버 쪽 저장소(메모리, 데이터베이스, Redis 등)에 저장합니다.
  3. 서버는 Set-Cookie 헤더로 세션 ID를 클라이언트에 보냅니다.
  4. 클라이언트는 이후 요청마다 Cookie 헤더에 세션 ID를 담아 보냅니다.
  5. 서버는 받은 세션 ID를 저장소에서 찾아 어떤 사용자인지 확인합니다.

로그인 요청에 서버가 세션 ID abc123과 사용자 1을 세션 저장소에 저장하고 Set-Cookie로 세션 ID를 보내며, 이후 요청에는 브라우저가 Cookie 헤더로 세션 ID를 보내고 서버가 저장소에서 사용자를 찾아 응답하는 흐름 쿠키에는 세션 ID만 담고, 사용자 정보는 서버의 세션 저장소에서 찾는다

쿠키에는 의미 없는 세션 ID만 담기고 실제 사용자 정보는 서버에 있으므로, 쿠키가 노출되더라도 개인 정보까지 곧바로 드러나지는 않습니다. 하지만 세션 ID를 탈취당하면 다른 사람이 그 사용자인 척 요청을 보낼 수 있습니다. 세션 ID를 담는 쿠키에는 앞에서 본 Secure, HttpOnly, SameSite 속성을 함께 붙여야 합니다.

참고로 세션 대신 토큰을 쓰는 방법도 있습니다. 3편에서 본 Authorization: Bearer <토큰>처럼 서버가 발급한 토큰을 요청마다 헤더에 담아 보내는 방식입니다.

실무에서 자주 만나는 헤더

지금까지 책에서 다루는 헤더를 정리했습니다. 실제로 웹 서비스를 개발하다 보면 이 밖에도 자주 마주치는 헤더가 있는데 대표적인 세 묶음을 살펴보겠습니다.

CORS 관련 헤더

출처와 동일 출처 정책

출처(Origin)는 URL의 스킴, 호스트, 포트를 합친 것입니다. https://www.example.com과 https://api.example.com은 호스트가 다르므로 다른 출처이고, http://localhost:3000과 http://localhost:8080도 포트가 다르므로 다른 출처입니다.

브라우저는 보안을 위해 동일 출처 정책(Same-Origin Policy)을 따릅니다. 웹 페이지의 자바스크립트가 다른 출처로 요청을 보내면 그 응답을 읽지 못하게 막는 정책입니다. 그런데 다른 출처의 자원이 필요한 경우는 아주 흔합니다. 프론트엔드(http://localhost:3000)와 API 서버(http://localhost:8080)를 따로 띄워 개발할 때가 그렇습니다.

이럴 때 쓰는 방식이 CORS(Cross-Origin Resource Sharing)입니다. 서버는 “이 출처에서 온 요청은 허용한다”는 허락을 헤더에 담아 브라우저에 알려 줍니다.

Origin과 Access-Control-Allow-Origin

브라우저는 다른 출처로 요청을 보낼 때 Origin 헤더에 요청을 보낸 페이지의 출처를 담습니다. 서버는 허용하는 출처를 응답의 Access-Control-Allow-Origin 헤더에 담아 돌려줍니다.

1
2
3
4
5
6
7
8
<요청 메시지>
GET /users HTTP/1.1
Host: localhost:8080
Origin: http://localhost:3000

<응답 메시지>
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000

브라우저는 Access-Control-Allow-Origin 값이 페이지의 출처와 같거나 모든 출처를 허용하는 *일 때만 자바스크립트가 응답을 읽도록 허락합니다. 그렇지 않으면 응답을 막고 콘솔에 CORS 에러를 띄웁니다.

사전 요청에 쓰는 헤더

2편에서 보았듯이 브라우저는 어떤 요청을 보내기 전에 OPTIONS 메서드로 사전 요청(Preflight Request)을 먼저 보내 허락을 구하기도 합니다. Content-Type: application/json인 POST 요청이나 Authorization 헤더를 붙인 요청이 사전 요청을 거치는 대표적인 예입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
<사전 요청>
OPTIONS /users HTTP/1.1
Host: localhost:8080
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

<사전 요청에 대한 응답>
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
헤더방향의미
Access-Control-Request-Method요청실제 요청에 쓸 메서드
Access-Control-Request-Headers요청실제 요청에 담을 헤더
Access-Control-Allow-Methods응답허용하는 메서드
Access-Control-Allow-Headers응답허용하는 헤더
Access-Control-Max-Age응답사전 요청 결과를 캐시해 둘 시간(초)
Access-Control-Allow-Credentials응답쿠키 같은 인증 정보를 담은 요청도 허용할지

사전 요청이 통과해야 브라우저는 실제 POST 요청을 보냅니다.

쿠키를 담아 다른 출처로 요청을 보내려면 클라이언트는 fetch의 credentials: 'include'처럼 쿠키를 담겠다고 지정하고, 서버는 사전 요청의 응답과 실제 응답 모두에 Access-Control-Allow-Credentials: true를 보내야 합니다. 이 경우에는 Access-Control-Allow-Origin에 *를 쓸 수 없고 허용할 출처를 정확히 적어야 합니다.

CORS는 브라우저가 지키는 규칙입니다. curl이나 Postman으로 보낸 요청, 서버끼리 주고받는 요청에서는 CORS 에러가 나지 않습니다. 브라우저에서만 CORS 에러가 난다면 요청 코드보다 서버가 보내는 응답 헤더 설정을 먼저 확인해 보세요.

프록시를 거칠 때 쓰는 헤더: X-Forwarded-For와 Forwarded

실제 서비스에서는 Nginx 같은 리버스 프록시나 로드 밸런서가 요청을 먼저 받아 애플리케이션 서버로 넘겨 주는 경우가 많습니다. 2편의 502 이야기에 나온 구조입니다. 애플리케이션 서버 입장에서 요청을 보낸 상대는 클라이언트가 아니라 프록시입니다. 따로 처리하지 않으면 모든 요청의 IP 주소가 프록시의 IP로 보이고 클라이언트가 HTTPS로 접속했는지도 알 수 없습니다.

그래서 프록시는 원래 요청의 정보를 헤더에 담아 함께 넘겨 줍니다.

1
2
3
X-Forwarded-For: 203.0.113.10, 198.51.100.7
X-Forwarded-Proto: https
X-Forwarded-Host: www.example.com
  • X-Forwarded-For: 원래 클라이언트의 IP 주소입니다. 프록시를 여러 번 거치면 거쳐 온 IP가 차례로 뒤에 붙으므로 맨 왼쪽이 원래 클라이언트입니다.
  • X-Forwarded-Proto: 클라이언트가 프록시에 접속할 때 쓴 프로토콜(http 또는 https)입니다.
  • X-Forwarded-Host: 클라이언트가 원래 요청한 Host 값입니다.

이름 앞의 X-는 원래 표준이 아닌 헤더라는 표시였습니다. 같은 정보를 표준으로 정리한 것이 Forwarded 헤더이지만, 실무에서는 아직 X-Forwarded-* 헤더를 더 많이 씁니다.

1
Forwarded: for=203.0.113.10;proto=https;host=www.example.com

X-Forwarded-For는 클라이언트가 위조할 수 있습니다. 클라이언트가 처음부터 이 헤더에 아무 IP나 넣어 보낼 수 있기 때문입니다. 프록시는 받은 값 뒤에 덧붙이기만 하므로 맨 왼쪽 값은 클라이언트가 적은 그대로 남습니다. 그래서 오른쪽부터 내가 운영하는 프록시가 붙여 준 값만 믿도록 서버를 설정해야 합니다. Express의 trust proxy가 이런 설정입니다.

보안 관련 응답 헤더

서버는 응답 헤더로 브라우저에게 “이 페이지는 이렇게 다뤄 달라”는 보안 규칙을 알려 주기도 합니다. 대표적인 헤더는 다음과 같습니다.

헤더예시하는 일
Strict-Transport-Securitymax-age=31536000; includeSubDomains정해진 기간 동안 이 사이트에는 HTTPS로만 접속하게 한다 (HSTS)
Content-Security-Policydefault-src 'self'스크립트, 이미지 같은 자원을 불러올 수 있는 출처를 제한해 XSS 피해를 줄인다 (CSP)
X-Content-Type-Optionsnosniff브라우저가 Content-Type을 무시하고 내용을 보고 타입을 짐작하지 못하게 한다
X-Frame-OptionsDENY다른 사이트가 이 페이지를 <iframe>으로 띄우지 못하게 해 클릭재킹을 막는다

클릭재킹(Clickjacking)은 공격자가 자기 사이트 위에 내 페이지를 투명하게 겹쳐 띄워 놓고 사용자를 속이는 공격입니다. 사용자는 다른 버튼을 누른다고 착각하지만 실제로는 내 페이지의 버튼을 누르게 됩니다. 요즘은 X-Frame-Options와 같은 기능을 Content-Security-Policy의 frame-ancestors 지시어로도 설정할 수 있습니다.

이런 헤더는 애플리케이션 코드에서 하나씩 넣기보다 Nginx 같은 웹 서버나 프레임워크의 보안 설정에서 한꺼번에 붙이는 경우가 많습니다.

정리하면

  • 캐시의 유효 기간은 Cache-Control: max-age나 Expires로 정합니다. no-cache는 매번 확인하라는 뜻이고 저장 자체를 막으려면 no-store를 씁니다.
  • 유효 기간이 지나면 If-Modified-Since나 If-None-Match로 재검증하고 바뀐 게 없으면 서버는 304 Not Modified로 응답합니다.
  • 쿠키는 Set-Cookie로 받고 Cookie로 보냅니다. Domain, Path로 범위를, Expires, Max-Age로 기간을 정하고, Secure, HttpOnly, SameSite로 보안을 보완합니다.
  • 세션 기반 인증은 서버가 사용자 정보를 저장해 두고 쿠키에는 세션 ID만 담아 주고받는 방식입니다.
  • 브라우저는 다른 출처로 보내는 요청에 Origin을 담고, 서버가 Access-Control-Allow-* 헤더로 허락해야 응답을 읽을 수 있습니다.
  • 프록시 뒤의 서버는 X-Forwarded-For, X-Forwarded-Proto로 원래 클라이언트의 IP와 프로토콜을 알 수 있습니다. 단, 내가 운영하는 프록시가 붙인 값만 믿어야 합니다.

지금까지 제가 생각하기에 꼭 알아야 하는 HTTP 기본 지식을 정리해 보았습니다. 이 정도는 최소한 알고 있어야 한다고 생각이 들더라구요.. 물론 굉장히 부족한 내용입니다. HTTP의 내용은 정말 많기도 하고, 깊이도 지금 수준이면 많이 얕은 수준이라고 생각합니다.

다음 포스팅이 아마 이 시리즈의 마지막이 될 것 같습니다. 마지막 편에서는 요즘 거의 필수가 된 HTTPS에 대해 정리해 보겠습니다. 감사합니다.