안녕하세요! 프론트엔드 개발자 여러분, 오늘도 힘차게 달려볼까요? 🚀
이번 시간에는 HTTP 통신을 훨씬 더 스마트하고 효율적으로 만들어주는 HTTP 조건부 요청(HTTP conditional requests)에 대해 알아볼 거예요. 단순히 데이터를 달라고 떼쓰는 게 아니라, "내가 가진 데이터가 옛날 거면 새로 주고, 아니면 그냥 쓰게 해줘!"라고 서버와 똑똑하게 대화하는 방법이죠.
공식 문서의 모든 내용을 빠짐없이, 그리고 실무에서 어떻게 쓰이는지 제 팁까지 듬뿍 담아 친절하게 설명해 드릴 테니 잘 따라와 주세요!
HTTP에는 조건부 요청(conditional requests)이라는 아주 훌륭한 개념이 있습니다. 이 방식에서는 서버에 요청을 보낼 때, 영향을 받는 리소스(데이터)를 검증자(validator)와 비교하여 요청의 결과나 심지어 성공 여부까지 제어할 수 있어요.
이러한 조건부 요청은 브라우저에 이미 저장되어 있는 캐시(cached)된 콘텐츠를 검증할 때 아주 유용합니다. 브라우저가 이미 가지고 있는 복사본과 서버의 최신 원본이 다를 때만 데이터를 가져오도록 보장해 주거든요. (데이터 낭비를 막아주죠!)
또한 다운로드가 중간에 끊겼다가 다시 이어받을 때(resuming) 문서가 손상되지 않았는지 무결성을 확인하거나, 서버에 문서를 업로드하고 수정할 때 다른 사람의 업데이트 내역을 실수로 덮어씌워 날려버리는 일(lost updates)을 방지할 때도 매우 유용합니다.
💡 강사의 부연 설명!
"조건부 요청"은 실무 프론트엔드 성능 최적화와 에러 방지의 핵심입니다! 매번 무거운 이미지를 다운로드하면 사이트가 느려지겠죠? 이때 조건부 요청을 쓰면 "이 이미지 바뀐 적 있어? 없으면 나 그냥 내 캐시(임시 저장소)에 있는 거 쓸게!"라고 물어볼 수 있답니다.
HTTP 조건부 요청은 특정 헤더(header)들의 값에 따라 실행 방식이 달라지는 요청을 말합니다. 이 헤더들은 일종의 '전제 조건(precondition)'을 정의하는데, 이 조건이 맞는지 틀린지에 따라 서버의 응답 결과가 완전히 달라지게 됩니다.
어떤 전제 조건 헤더를 사용했는지, 그리고 어떤 HTTP 메서드(method)를 사용했는지에 따라 동작이 다음과 같이 정의됩니다:
GET과 같이 주로 문서를 가져오려고 시도하는 안전한(safe) 메서드의 경우:PUT과 같이 보통 문서를 업로드하는 데 쓰이는 안전하지 않은(unsafe) 메서드의 경우:💡 강사의 실무 TIP!
여기서 말하는 '안전한 메서드'는 서버의 데이터를 변경하지 않고 읽기만 하는GET,HEAD같은 메서드를 말해요. 반면 데이터를 수정, 삭제, 생성하는PUT,POST,DELETE는 상태를 변화시키기 때문에 '안전하지 않은 메서드'라고 부릅니다.
모든 조건부 헤더들은 서버에 저장된 리소스가 특정 버전과 일치하는지 확인하려고 시도합니다. 이걸 해내려면, 조건부 요청이 리소스의 '버전'을 가리킬 수 있어야 하죠. 원본 파일과 브라우저의 파일을 바이트(byte) 단위로 일일이 비교하는 건 너무 비효율적이고 우리가 항상 원하는 방식도 아닙니다. 대신, 요청은 해당 버전을 설명하는 '값'을 전송하게 됩니다. 이런 값을 바로 검증자(validators)라고 부르며, 크게 두 가지 종류가 있어요:
동일한 리소스의 버전들을 비교하는 건 생각보다 조금 까다롭습니다. 상황에 따라 두 가지 종류의 동등성 검사(equality checks) 가 존재하거든요:
검증의 종류는 어떤 검증자(Last-Modified나 ETag)를 사용하는지와는 독립적입니다. Last-Modified와 ETag 모두 이 두 가지 타입의 검증을 다 지원할 수 있어요. (물론 서버 쪽에서 이걸 구현하는 복잡도는 다르겠지만요.) HTTP는 기본적으로 강한 검증을 사용하며, 언제 약한 검증을 사용할 수 있는지 명시하고 있습니다.
강한 검증은 두 리소스가 바이트 하나하나까지 완벽하게 동일하다는 것을 보장합니다. 이는 일부 조건부 헤더에서는 필수사항이며, 나머지 헤더에서도 기본값으로 사용됩니다. 강한 검증은 매우 엄격해서 서버 레벨에서 보장하기가 꽤 어려울 수 있지만, 대신 언제나 데이터 손실이 없음을 완벽히 보장해 줍니다. (때로는 성능을 조금 희생하더라도 말이죠.)
Last-Modified 날짜만 가지고 강한 검증을 위한 완벽한 고유 식별자를 만드는 건 상당히 어렵습니다. 그래서 강한 검증을 할 때는 보통 해당 리소스의 MD5 해시(또는 파생된 값)를 이용해 만든 ETag를 자주 사용합니다.
참고 (Note):
파일의 콘텐츠 인코딩 방식(예: 압축)이 바뀌면 ETag 값도 바뀌어야 합니다. 그래서 일부 서버(예: 리버스 프록시)들은 원본 서버에서 온 응답을 압축할 때 ETag를 수정하기도 해요.
예를 들어 Apache 서버는 기본적으로 ETag 끝에 압축 방식의 이름(-gzip)을 덧붙이는데, 이는DeflateAlterETag지시어를 사용해 설정을 바꿀 수 있습니다.
약한 검증은 강한 검증과 다릅니다. 두 버전의 문서가 포함하고 있는 '콘텐츠의 의미'만 동등하다면 둘을 완벽히 동일한 것으로 간주합니다. 예를 들어 푸터(footer)에 적힌 날짜만 살짝 다르거나, 광고판 내용만 다른 두 페이지는 약한 검증 하에서는 동일한(identical) 것으로 여겨집니다. 반면 강한 검증을 쓰면 바이트가 다르니 다른(different) 문서로 취급되겠죠.
약한 검증을 사용하는 ETag 시스템을 구축하면 캐시 성능을 극적으로 최적화하는 데 매우 유용합니다. 하지만 페이지의 어떤 요소가 중요하고 어떤 요소는 무시해도 되는지 알아야 하기 때문에 구현이 꽤 복잡할 수 있습니다.
💡 강사의 부연 설명!
"ETag"는 파일의 지문(Fingerprint)이라고 생각하면 정말 쉬워요. 서버가 파일을 줄 때 "이 파일 지문은W/"12345"야!" 라고 알려주면, 브라우저가 나중에 "저W/"12345"파일 또 필요해. 혹시 지문 바뀌었어?" 하고 묻는 거죠. (앞에W/가 붙어 있으면 Weak(약한) 검증을 뜻합니다!)
여러 가지 HTTP 헤더들이 바로 이 조건부 요청을 이끌어내는 조건부 헤더 역할을 합니다. 그 주인공들은 다음과 같습니다:
If-MatchETag가 이 헤더에 나열된 ETag 중 하나와 일치할 때만 성공(Succeeds)합니다. 강한 검증(Strong validation)을 수행합니다.If-None-MatchETag가 이 헤더에 나열된 모든 ETag와 다를 때만 성공합니다. 약한 검증(Weak validation)을 수행합니다.If-Modified-SinceLast-Modified 날짜가 이 헤더에 주어진 날짜보다 더 최신일 때(더 최근에 수정되었을 때)만 성공합니다.If-Unmodified-SinceLast-Modified 날짜가 이 헤더에 주어진 날짜와 같거나 더 오래되었을 때(그 이후로 수정되지 않았을 때)만 성공합니다.If-RangeIf-Match나 If-Unmodified-Since와 비슷하지만, 단 하나의 ETag나 날짜만 가질 수 있습니다. 만약 이 조건이 실패하면, 부분 범위 요청(range request) 자체가 취소되고 206 Partial Content 응답 대신 전체 리소스가 담긴 200 OK 응답이 전송됩니다.이 조건부 요청들이 실무에서 어떻게 쓰이는지 구체적인 사례를 살펴봅시다!
조건부 요청이 가장 흔하게 쓰이는 곳이 바로 캐시를 업데이트할 때입니다. 캐시가 비어 있거나 아예 캐시를 사용하지 않을 때, 브라우저가 리소스를 요청하면 서버는 파일과 함께 200 OK 상태 코드를 돌려줍니다.
이때 서버는 리소스와 함께 헤더에 검증자(validators)를 담아서 보냅니다. 위 그림에서는 Last-Modified와 ETag가 모두 전송되었지만, 둘 중 하나만 보내도 상관없습니다. 브라우저는 리소스와 함께 이 검증자들을 캐시에 잘 저장해 두었다가, 나중에 캐시가 오래되어 만료(stale)되었을 때 조건부 요청을 정교하게 만드는 데 사용합니다.
캐시가 아직 싱싱할(만료되지 않았을) 때는 브라우저가 서버에 아예 요청조차 보내지 않고 캐시된 걸 바로 씁니다. 하지만 보통 Cache-Control 헤더에 의해 제어되는 이 캐시 수명이 다해서 '오래된(stale)' 상태가 되면, 클라이언트는 캐시된 값을 무턱대고 쓰지 않고 서버에 조건부 요청(conditional request) 을 날립니다. 이때 저장해 두었던 검증자 값이 If-Modified-Since와 If-None-Match 헤더의 파라미터로 쏙 들어가게 되죠.
만약 서버를 확인해 보니 리소스가 바뀌지 않았다면, 서버는 쿨하게 304 Not Modified (수정되지 않음) 응답을 보냅니다. 이 대답을 들은 브라우저는 "오, 아직 쓸만하네!" 하면서 캐시를 다시 신선한 상태로 갱신하고, 기존에 캐시 되어 있던 리소스를 그대로 화면에 그립니다. 비록 서버를 한번 갔다 오는 통신(round-trip)이 발생하긴 했지만, 무거운 파일을 네트워크를 통해 처음부터 다시 다 다운로드하는 것보다 훨씬 빠르고 효율적이죠.
반대로 만약 리소스가 진짜로 업데이트되었다면, 서버는 조건부 요청이 아니었던 것처럼 새로운 버전의 리소스와 함께 평범한 200 OK 응답을 보냅니다. 그럼 클라이언트는 이 새 리소스를 사용하고, 캐시에 새롭게 저장하죠.
💡 강사의 실무 TIP!
크롬 개발자 도구의 Network 탭을 열고 여러분이 만든 사이트를 새로고침 해보세요. 이미지나 CSS 파일 옆에304라는 숫자가 보이나요? 프론트엔드 개발자가 볼 수 있는 가장 아름다운 숫자 중 하나입니다! 우리 사이트가 불필요한 데이터를 아끼고 아주 빠릿빠릿하게 동작하고 있다는 증거거든요! 웹 개발자가 따로 코드를 짜지 않아도 브라우저가 알아서 투명하게 이 과정을 관리해 줍니다.
파일의 일부분만 다운로드하는 기능은, 이전에 받다 끊긴 파일 전송을 이어서 받을 수 있게 해주는 HTTP의 훌륭한 기능입니다. 이미 받아놓은 데이터는 유지하니까 대역폭과 시간을 엄청나게 아낄 수 있죠.
서버가 부분 다운로드를 지원한다면 응답 헤더에 Accept-Ranges를 보내서 이를 알려줍니다. 이걸 본 클라이언트는 나중에 다운로드가 끊겼을 때, 누락된 부분의 범위를 나타내는 Range 헤더를 보내서 다운로드를 재개할 수 있습니다.
원리는 간단해 보이지만 한 가지 잠재적인 문제가 있어요. 만약 다운로드가 끊겨있던 그 시간 동안, 서버에 있는 원본 파일이 새로운 버전으로 수정되었다면 어떡하죠? 새로 받아온 조각(범위)들은 이전 버전의 파일 조각과 섞이게 될 테고, 결국 다운로드가 완료되어도 파일이 엉망으로 꼬여서 손상(corrupted)될 것입니다.
이 대참사를 막기 위해 조건부 요청이 출동합니다! 범위를 요청할 때 두 가지 방법을 쓸 수 있어요.
첫 번째는 좀 더 유연한 방식으로 If-Unmodified-Since와 If-Match를 사용하는 겁니다. 만약 파일이 변경되어서 이 조건이 실패하면, 서버는 에러를 반환합니다. 그러면 클라이언트는 기존 조각을 버리고 파일을 처음부터 아예 새로 다운로드하게 되죠.
이 방법도 잘 작동하긴 하지만, 파일이 변경되었을 때 에러를 주고받는 불필요한 요청/응답 과정(round-trip)이 한 번 더 생깁니다. 성능이 살짝 깎이겠죠?
그래서 HTTP에는 이 시나리오를 깔끔하게 피하기 위한 전용 헤더인 If-Range 가 있습니다.
If-Range는 훨씬 효율적입니다. 조건이 맞으면 조각만(206 Partial Content) 돌려주고, 조건이 틀리면(파일이 수정됐으면) 귀찮은 에러 없이 곧바로 전체 파일(200 OK)을 새로 내려줍니다. 조건에 ETag를 딱 한 개만 쓸 수 있다는 제약이 있어 유연성은 살짝 떨어지지만, 굳이 여기서 더 많은 유연성이 필요한 경우는 거의 없답니다.
웹 애플리케이션에서 원격 서버의 문서를 업데이트하는 건 아주 일상적인 작업입니다. 위키(Wiki)나 CMS, 소스 컨트롤 시스템(Git 등)을 생각해 보세요.
클라이언트는 보통 파일을 읽어와서 로컬에서 수정한 다음, PUT 메서드를 써서 서버로 쑥 밀어 넣습니다.
하지만 불행히도 동시성(concurrency), 즉 여러 명이 동시에 수정하는 상황을 고려하면 이야기가 복잡해집니다.
첫 번째 사람이 문서를 받아서 열심히 수정하고 있는 동안, 두 번째 사람이 똑같은 문서를 가져가서 자기도 수정을 시작합니다. 정말 최악의 시나리오는 그다음에 벌어져요. 두 사람이 각자 수정한 내용을 서버로 보낼(push) 때, 먼저 수정한 사람의 피땀어린 내용이 두 번째 사람의 데이터에 의해 허무하게 덮어씌워져 날아가 버립니다. 두 번째 사람은 첫 번째 사람이 내용을 바꿨다는 사실을 전혀 몰랐거든요!
누구의 수정본이 살아남을지는 그들이 얼마나 빨리 커밋하느냐에 달린 복불복입니다. 인터넷 속도, 서버 성능, 심지어 타자 치는 속도에 따라 승자가 매번 바뀌죠. 우리는 이런 끔찍한 현상을 경쟁 상태(race condition)라고 부릅니다. 잡아내기도, 디버깅하기도 아주 어려운 골칫덩어리죠.
한쪽 클라이언트를 억울하게 만들지 않고서는 이 문제를 피할 길이 없습니다. 하지만 그렇다고 데이터가 허무하게 날아가는 걸 방치할 순 없죠. 우리는 결과가 예측 가능하길 바라고, 적어도 데이터가 거절당한 사람에게 "네 수정본이 거절당했어!"라고 알려주길 원합니다.
이때 조건부 요청을 사용하면 대부분의 위키나 소스 컨트롤 시스템이 채택하고 있는 낙관적 잠금(optimistic locking) 알고리즘을 구현할 수 있습니다.
개념은 이렇습니다: 모든 클라이언트가 리소스를 마음껏 가져가서 자유롭게 수정하게 둡니다. 하지만 서버에 업데이트를 밀어 넣을 때, 가장 먼저 제출한 사람의 업데이트만 성공시킵니다. 그 이후에 도착한 제출본들은 이미 낡은(obsolete) 버전을 바탕으로 수정한 것이므로 서버가 자비 없이 거절(reject)해버리는 거죠!
이건 If-Match나 If-Unmodified-Since 헤더를 사용해서 구현합니다. 클라이언트가 보내온 ETag가 서버의 원본 파일과 다르거나, 파일을 받아간 이후 서버의 원본 파일이 누군가에 의해 수정되었다면, 서버는 412 Precondition Failed (전제조건 실패) 에러를 뱉으며 변경을 거절합니다.
이 에러를 받은 클라이언트(프론트엔드)는 이제 사용자에게 대처를 맡기면 됩니다. "누군가 먼저 문서를 수정했어요! 최신 버전을 다시 불러올까요?" 하고 알림을 띄우거나, Git처럼 두 버전의 차이점(diff) 을 화면에 보여주면서 사용자가 어떤 변경 사항을 살릴지 직접 결정하게 도와줄 수 있죠.
새로운 리소스를 서버에 '처음' 업로드하는 상황도 앞서 말한 업데이트 상황의 극단적인 버전(edge case)이라고 볼 수 있습니다. 이것 역시 두 명의 클라이언트가 하필 정확히 똑같은 시간에 동일한 이름의 파일을 올리려고 하면 똑같은 경쟁 상태(Race condition)가 발생하거든요.
이를 막기 위해, 아무 값이나 다 일치한다는 뜻을 가진 특별한 값 *를 If-None-Match 헤더에 넣어서 조건부 요청을 보낼 수 있습니다.
이렇게 하면 "서버에 이런 파일이 단 하나도 존재하지 않을 때만!" 업로드가 성공하게 됩니다.
If-None-Match: * 기능은 HTTP/1.1 규격을 준수하는 서버에서만 제대로 작동합니다. 만약 여러분의 서버가 이 규격을 잘 따르는지 확신할 수 없다면, 파일 업로드를 하기 전에 먼저 HEAD 요청을 찔러봐서 서버에 그 파일이 존재하는지 1차로 확인하는 작업이 필요할 수 있습니다.
조건부 요청은 HTTP의 핵심 중의 핵심 기능이며, 효율적이고 복잡한 애플리케이션을 만들 수 있게 해주는 원동력입니다.
캐싱이나 다운로드 재개 같은 기능은 프론트엔드 개발자가 특별히 코드를 짜지 않아도, 웹마스터가 서버 설정(정확한 ETag 발급 등)만 잘 해두면 브라우저가 알아서 척척 기대한 대로 조건부 요청을 날려줍니다. (물론 서버 환경에 따라 ETag 설정이 까다로울 순 있지만요.)
반면, 낙관적 잠금(Locking) 같은 메커니즘은 정반대입니다. 이건 서버 관리자보다 프론트엔드 개발자가 직접 적절한 헤더를 달아서 똑똑하게 요청을 보내는 코드를 작성해야 합니다.
어떤 경우든 조건부 요청이 현대의 웹을 지탱하는 가장 근본적이고 위대한 기능 중 하나라는 사실은 변함이 없습니다!
조금 더 깊이 파고 싶으시다면 아래 링크들을 확인해 보세요. (원본 MDN 문서 링크입니다.)
304 Not ModifiedIf-None-Matchmod_deflate.c 코드 (압축 중 ETag를 변환하는 방식을 볼 수 있어요!)이 페이지는 MDN 기여자들에 의해 2025년 7월 4일에 마지막으로 수정되었습니다.