조건부 요청
고친 사람 github-actions[bot]
조건부 요청은 서버에게 조건을 먼저 따져 보고 일하라고 시킵니다. 조건이 어긋나면 서버는 요청을 수행하지 않고 짧은 응답만 돌려줍니다. 이미 손에 있는 내용을 다시 받는 일과, 남이 먼저 고쳐 둔 내용을 덮어쓰는 일을 이렇게 막습니다.
쉽고 빠른 이해
조건부 요청은 「이럴 때만 해 주세요」라는 단서를 붙여 보내는 요청입니다. 브라우저가 어제 받아 둔 이미지를 오늘도 그냥 써도 되는지 서버에 물을 때 이 요청을 씁니다.
단서를 안 붙이면 두 가지가 곤란합니다. 안 바뀐 내용을 매번 전부 다시 받습니다. 그리고 두 사람이 같은 것을 고칠 때 나중에 도착한 요청이 먼저 들어온 수정을 소리 없이 지웁니다.
어떻게 도는가:
- 서버가 내용을 보내면서 그 내용의 버전을 가리키는 짧은 표식을 함께 줍니다
- 다음 요청에 그 표식을 단서로 적어 보냅니다
- 서버는 표식을 지금 버전과 맞춰 보고, 어긋나면 요청을 수행하지 않습니다
대가가 있습니다. 조건이 맞아떨어져도 서버까지 한 번은 다녀와야 합니다. 서버는 버전마다 표식을 만들어 들고 있어야 합니다.
상세
도서관에서 빌려 읽던 책의 새 쇄가 나왔는지 확인한다고 해 봅시다. 사서에게 「제가 든 것이 지난 쇄인데 그게 아직 최신이면 굳이 꺼내 주지 마세요」라고 말해 두면, 사서는 쇄가 바뀐 경우에만 새 책을 가져옵니다.
조건부 요청은 요청에 전제조건을 붙여, 그 조건이 성립할 때만 서버가 요청을 수행하게 하는 요청입니다. 전제조건은 「서버가 지금 들고 있는 것이 내가 아는 것과 같은가」를 묻는 한 줄입니다. 조건이 어긋나면 서버는 본문을 만들지도 보내지도 않고 어긋났다는 답만 돌려줍니다.
이 방식은 HTTP(HyperText Transfer Protocol, 하이퍼텍스트 전송 규약)에서 주로 만납니다. 브라우저가 어제 받아 둔 이미지를 다시 쓰기 전에 「그 사이 안 바뀌었으면 보내지 마세요」라고 붙여 보내는 요청이 그런 예입니다.
조건이 가리키는 표식
조건을 붙이려면 양쪽이 같은 것을 가리킬 표식이 있어야 합니다. 이 소절은 그 표식이 무엇이고 어디서 오는지를 봅니다.
검증자는 서버가 지금 들고 있는 내용이 어느 버전인지 가리키는 짧은 값입니다. 내용이 고쳐지면 버전이 바뀌고 검증자도 따라 바뀝니다. 서버는 내용을 내려보낼 때 이 값을 응답에 함께 실어 줍니다.
검증자는 두 가지 꼴로 옵니다. 하나는 내용을 마지막으로 고친 시각입니다. 다른 하나는 엔티티 태그로, 서버가 버전마다 붙이는 짧은 식별 문자열입니다.
엔티티 태그는 값 자체에 뜻이 없습니다. 값을 뜯어봐야 알 수 있는 정보가 없고, 이전 값과 같은지 다른지만 비교하면 됩니다. 그래서 서버는 내용의 해시를 쓰든 버전 번호를 쓰든 마음대로 정할 수 있습니다.
전제조건은 그 검증자를 조건으로 적어 둔 한 줄입니다. 클라이언트는 앞서 받아 둔 검증자를 요청에 실어 보내고, 서버는 그 값을 지금 버전의 검증자와 맞춰 봅니다.
내려받기를 줄이는 쓰임
읽기 요청에는 「내가 든 버전과 같으면 보내지 마세요」라는 조건을 붙입니다. 조건이 성립하면 서버는 본문 없이 「안 바뀌었다」는 짧은 응답만 돌려줍니다. 그 응답의 상태 코드가 304 Not Modified입니다.
캐시는 이 답을 받고 보관해 둔 사본을 계속 씁니다. 사본이 아직 맞는지 원본에 다시 물어보는 이 과정을 재검증이라고 합니다. 오가는 양은 헤더 몇 줄로 줄지만 사본은 손대지 않고 그대로 쓸 수 있습니다.
버전이 달라졌으면 서버는 평소처럼 새 내용을 본문에 실어 보냅니다. 캐시는 사본을 새것으로 갈아 끼우고, 함께 온 새 검증자를 다음 물음에 쓸 값으로 보관합니다.
sequenceDiagram
participant 캐시
participant 서버
캐시->>서버: 내 검증자는 이것이다 · 같으면 보내지 마라
Note over 서버: 지금 버전의 검증자와 맞춰 본다
서버-->>캐시: 같다 · 본문 없는 짧은 응답
Note over 캐시,서버: 다르면 대신 새 본문이 실려 온다
그림에서 오가는 것은 검증자 한 줄과 짧은 답뿐입니다. 본문이 클수록 아끼는 양이 커집니다.
덮어쓰기를 막는 쓰임
쓰기 요청에서는 조건의 방향이 뒤집힙니다. 「내가 읽은 버전 그대로일 때만 고쳐 주세요」라고 붙입니다. 그 사이 남이 먼저 고쳐 놓았다면 버전이 달라졌으므로 서버는 요청을 수행하지 않습니다.
이때 돌아오는 상태 코드가 412 Precondition Failed입니다. 클라이언트는 남의 수정이 먼저 들어갔다는 것을 알고 최신 내용을 다시 읽어 옵니다. 조건 없이 보냈다면 나중 요청이 앞선 수정을 소리 없이 지웠을 것입니다. 이렇게 사라지는 수정을 갱신 손실이라고 부릅니다.
여기서 중요한 것은 아무것도 잠그지 않았다는 점입니다. 미리 잠가 두는 대신, 고치는 순간에 버전이 그대로인지만 확인합니다. 충돌이 드물다고 보고 일단 진행한 뒤 어긋날 때만 되돌리는 이 방식을 낙관적 잠금이라고 합니다.
조건을 적는 요청 헤더
조건은 요청 헤더에 한 줄로 적습니다. 어느 헤더를 고르느냐에 따라 맞춰 보는 대상과 어긋났을 때의 답이 갈립니다.
| 헤더 | 언제 수행하나 | 어긋나면 |
|---|---|---|
| If-None-Match | 내가 든 엔티티 태그와 다를 때 | 304 Not Modified |
| If-Modified-Since | 이 시각 뒤에 고쳐졌을 때 | 304 Not Modified |
| If-Match | 내가 든 엔티티 태그와 같을 때 | 412 Precondition Failed |
| If-Unmodified-Since | 이 시각 뒤에 안 고쳐졌을 때 | 412 Precondition Failed |
| If-Range | 내가 든 버전이 그대로일 때 | 본문 전체를 보낸다 |
표의 앞 둘은 읽기 쪽, 가운데 둘은 쓰기 쪽입니다. 맨 아래는 범위 요청에 붙어, 이어받기 도중 원본이 바뀌었으면 조각을 이어 붙이지 말고 처음부터 받게 합니다.
읽기와 쓰기가 실제로 어떻게 갈리는지는 한 쌍으로 놓고 보면 짧습니다.
GET /photo.png
If-None-Match: "a1b2" // 내가 든 버전
→ 304 Not Modified // 본문 없음
PUT /doc
If-Match: "a1b2" // 이 버전일 때만
→ 412 Precondition Failed // 그새 바뀌었다
같은 검증자 "a1b2" 를 쓰는데 붙인 헤더가 달라서 답이 갈렸습니다. 읽기는 「같으니 안 보낸다」로 끝났고, 쓰기는 「달라졌으니 안 고친다」로 끝났습니다.
조건부 요청이 지는 대가
첫째, 조건이 맞아떨어져도 서버까지 한 번은 다녀와야 합니다. 아낀 것은 본문이지 왕복이 아닙니다. 아예 묻지 않고 사본을 쓰는 쪽은 신선도가 따로 맡습니다.
둘째, 서버가 검증자를 만들어 들고 있어야 합니다. 내용이 같은데 검증자만 달라지는 경우도 생깁니다. 서버 여럿이 각자 다른 방식으로 태그를 만들면 그런 일이 잦아지고, 그때마다 본문이 헛되이 다시 옵니다.
셋째, 시각으로 재는 검증자는 눈금이 굵습니다. 같은 눈금 안에서 두 번 고쳐지면 뒤의 수정을 못 알아봅니다. 엔티티 태그가 이 문제를 비켜 가는 쪽입니다.
넷째, 조건을 붙이는 쪽은 클라이언트입니다. 안 붙이고 보낸 요청에는 아무 보호가 걸리지 않으므로, 서버 혼자서는 갱신 손실을 막을 수 없습니다.
관련 항목
조건부 요청이 조건으로 삼는 값
검증자 · 엔티티 태그 · ETag · Last-Modified · 강한 검증자 · 약한 검증자 · 불투명 검증자
조건을 적는 요청 헤더 필드
If-None-Match · If-Match · If-Modified-Since · If-Unmodified-Since · If-Range · 전제조건
조건이 갈린 뒤 돌아오는 상태 코드
304 Not Modified · 412 Precondition Failed · 200 OK · 206 Partial Content · 상태 코드
조건부 요청을 불러 쓰는 캐시 절차
재검증 · 검증 · 캐싱 · 신선도 · 무효화 · 낡은 데이터 · 캐시 키
이 요청이 실려 오는 프로토콜과 메서드
HTTP · RFC 9110 · RFC 9111 · GET · PUT · 요청 메서드 · 범위 요청 · 헤더
같은 문제를 푸는 다른 수단
다른 이름: conditional request · 조건부요청