GET
서버 상태를 바꾸지 않고 리소스를 조회한다. 안전하고 캐싱에 가장 친화적이다.
목록 조회, 상세 조회, 검색 조건이 URL query로 표현될 때분류: Layer 1 - 백엔드 기초 | 작성일: 2026-03-21
HTTP(HyperText Transfer Protocol)는 클라이언트와 서버가 리소스에 대한 요청(Request)과 응답(Response)을 주고받기 위해 정한 애플리케이션 계층 프로토콜이다.
정식 L1 도입부인 L1 백엔드 & API 입구에서 요청·응답부터 통신 방식 선택까지의 지도를 확인했다면, 이 문서는 그 첫 기준점인 “HTTP 요청 하나가 어떤 의미를 갖는가”를 독립적으로 다룬다. 뒤의 API 설계, 인증/인가, 웹 보안, WebSocket/gRPC 문서는 모두 이 의미를 전제로 한다. 처음 읽을 때는 명령어를 외우기보다 요청의 구성요소, 메서드의 의미, 상태 코드의 실패 신호, 헤더와 캐시의 약속을 잡는 데 집중한다. Keep-Alive timeout 수치, HTTP/2·3 전송 최적화, CDN/프록시 운영 세부는 첫 회독에서 전부 익히지 않아도 된다. 처음에는 “왜 같은 요청이라도 메서드·상태 코드·헤더가 달라지면 시스템 전체의 의미가 바뀌는가”를 잡고, 전송 버전과 튜닝값은 2회독에서 확인한다.
읽기 순서는 단순하다. 이 문서에서는 HTTP 요청 하나의 의미를 잡고, 다음 API Design 문서에서는 그 의미를 안정적인 클라이언트-서버 계약으로 만드는 법을 본다. 그다음 Auth 문서에서는 같은 HTTP 표면 위에서 신원 확인과 권한 판단이 어떻게 분리되는지 읽으면 된다.
백엔드와 플랫폼 업무에서 대부분의 장애 보고는 HTTP 표면으로 나타난다. 사용자는 “API가 안 된다”고 말하고, 모니터링은 401, 403, 502, 504 같은 숫자를 보여주며, 브라우저는 CORS 에러처럼 실제 요청이 서버까지 가지 않은 상황도 네트워크 실패처럼 보이게 만든다.
HTTP를 안다는 것은 모든 내부 구현을 안다는 뜻이 아니다. 다음 질문에 답할 수 있다는 뜻이다.
TCP(Transmission Control Protocol)는 바이트 스트림을 안정적으로 전달해 주지만, 그 바이트가 “사용자 목록 조회”인지 “결제 생성”인지 “캐시된 응답을 다시 써도 되는지”는 말해주지 않는다. TLS(Transport Layer Security)도 전송 중 데이터의 암호화·무결성과 서버 신원 검증을 담당할 뿐, 요청의 의도와 응답의 의미를 정하지 않는다.
HTTP는 이 빈칸을 채운다. 메서드는 의도, URL은 대상 리소스, 헤더는 메타데이터, 상태 코드는 결과를 표현한다. 그래서 HTTP는 단순 전송 계층 위에 “웹 애플리케이션이 서로 오해하지 않기 위한 의미 체계”를 얹은 프로토콜이라고 볼 수 있다.
실제 요청에서 DNS(Domain Name System)는 A·AAAA 같은 주소 레코드를 해석해 호스트 이름을 IP 주소에 연결한다. 아래 예시처럼 TLS가 Edge LB에서 종료되면 클라이언트와 Edge 사이의 보호는 그 지점에서 끝나며, Edge 이후 구간은 별도 TLS 연결이나 내부 네트워크 정책으로 보호해야 한다. LB(load balancer)의 L4 분산은 IP·포트와 TCP 연결 단위로 뒷단을 고르고, L7 라우팅은 HTTP host·path·header를 읽어 요청별 목적지를 고른다. 이 계층들은 HTTP 메시지의 의미를 정하지 않지만 요청이 앱에 도달하고 응답이 돌아오는 경로를 만든다.
sequenceDiagram participant Client as Browser or Client participant DNS participant Edge as Edge LB / TLS termination participant App as API Server participant DB Client->>DNS: hostname 조회 DNS-->>Client: IP 주소 반환 Client->>Edge: TCP 연결 수립 Client->>Edge: TLS handshake Client->>Edge: HTTP request Edge->>App: HTTP request 전달 App->>DB: 필요 시 데이터 조회 DB-->>App: rows App-->>Edge: HTTP status + headers + body Edge-->>Client: HTTP response
위 그림에서 DNS, TCP, TLS는 HTTP 바깥의 계층이다. 하지만 실제 장애는 이 경계가 섞여 보인다. 예를 들어 TLS 인증서 오류는 HTTP 상태 코드가 나오기 전 실패이고, 502는 HTTP 상태 코드지만 보통 프록시와 앱 서버 사이의 연결 문제가 원인이다.
HTTP 메시지는 크게 시작 줄(start line), 헤더(headers), 빈 줄, 바디(body)로 나뉜다. 요청과 응답의 모양은 다르지만 역할은 대칭적이다.
GET /users/1 HTTP/1.1Host: api.example.comAccept: application/jsonAuthorization: Bearer eyJ...HTTP/1.1 200 OKContent-Type: application/jsonCache-Control: private, no-storeX-Request-Id: req-7f3a
{ "id": 1, "name": "Ada" }| 구성요소 | 요청에서의 의미 | 응답에서의 의미 |
|---|---|---|
| 시작 줄 | GET /users/1 HTTP/1.1처럼 메서드, 경로, 버전을 담는다 | HTTP/1.1 200 OK처럼 버전과 상태 코드를 담는다 |
| 헤더 | 인증, 본문 형식, 캐시 조건, 추적 ID 같은 메타데이터를 담는다 | 응답 형식, 캐시 정책, 쿠키 설정, 추적 ID를 담는다 |
| 바디 | 생성/수정 요청의 데이터가 들어간다 | 조회 결과, 오류 상세, 생성된 리소스 표현이 들어간다 |
작은 관찰 예시는 다음과 같다.
curl -v https://httpbin.org/get> GET /get HTTP/1.1> Host: httpbin.org> User-Agent: curl/8.5.0> Accept: */*>< HTTP/1.1 200 OK< Content-Type: application/json< Content-Length: 340이 출력에서 >는 클라이언트가 보낸 요청, <는 서버가 돌려준 응답이다. 디버깅할 때 첫 단계는 “내가 보낸 메서드, 경로, 헤더가 의도와 맞는가”와 “서버가 상태 코드와 헤더로 무엇을 말했는가”를 나누어 읽는 것이다.
HTTP 요청을 설계하거나 읽을 때 자주 헷갈리는 지점은 “이 정보가 URL에 있어야 하는가, 헤더에 있어야 하는가, body에 있어야 하는가”다. 절대 규칙은 아니지만 첫 판단 기준은 있다.
| 위치 | 담기 좋은 정보 | 예시 | 피해야 할 것 |
|---|---|---|---|
| path | 리소스의 정체성 | /users/1, /orders/42/items | 사용자마다 달라지는 인증 토큰 |
| query | 조회 조건, 정렬, 페이지 | ?page=2&sort=createdAt | 너무 큰 JSON 객체, 민감정보 |
| header | 요청 해석에 필요한 메타데이터 | Authorization, Accept-Language, If-None-Match | 비즈니스 데이터 전체 |
| body | 생성/수정할 표현이나 복잡한 명령 입력 | { "name": "Ada" } | 캐시 키로 쉽게 드러나야 하는 단순 조회 조건 |
Worked example로 검색 API를 보자.
GET /products?keyword=keyboard&page=2Accept: application/json이 요청은 “상품 목록이라는 리소스를 keyword와 page 조건으로 조회”한다. 조회 조건이 짧고 URL로 표현 가능하므로 GET과 query가 자연스럽다.
POST /products/searchContent-Type: application/json
{ "keyword": "keyboard", "filters": { "price": { "min": 10000, "max": 50000 }, "brands": ["A", "B", "C"] }}두 번째 요청은 복잡한 검색 조건을 body에 담는다. 의미는 조회에 가깝지만 URL 길이, 구조화된 조건, 보안 로그 노출을 고려해 POST를 선택할 수 있다. 이때 POST라고 해서 반드시 “생성”만 뜻하는 것은 아니다. HTTP 메서드는 CRUD 이름표가 아니라 요청 의도와 전송 제약을 표현하는 약속이다.
HTTP 메서드는 서버에게 “무엇을 하려는지”를 알려주는 동사다. 메서드 선택은 코드 스타일 문제가 아니라 캐시, 재시도, 중복 실행 위험과 연결된다.
서버 상태를 바꾸지 않고 리소스를 조회한다. 안전하고 캐싱에 가장 친화적이다.
목록 조회, 상세 조회, 검색 조건이 URL query로 표현될 때서버에 새 처리를 요청한다. 생성뿐 아니라 결제, 작업 실행처럼 결과가 매번 달라질 수 있는 명령에도 쓴다.
회원가입, 결제 요청, 복잡한 검색 body가 필요할 때리소스 전체 표현을 교체한다. 같은 요청을 여러 번 보내도 최종 상태가 같도록 설계한다.
클라이언트가 프로필 전체의 새 상태를 알고 있을 때리소스 일부 필드만 변경한다. 부분 변경을 표현하지만 패치 연산 자체가 항상 멱등인 것은 아니다.
이름만 변경, 알림 설정 하나만 토글할 때리소스 삭제를 요청한다. 삭제 후 최종 상태는 같아야 하지만 응답 코드는 첫 요청과 재시도가 다를 수 있다.
세션 제거, 임시 리소스 삭제처럼 제거 의도가 명확할 때두 단어는 HTTP 메서드를 이해하는 핵심이다.
GET은 조회가 목적이므로 안전해야 한다.PUT /users/1로 같은 전체 프로필을 여러 번 저장하면 최종 상태는 같다.| 메서드 | 안전한가 | 멱등인가 | 재시도 감각 |
|---|---|---|---|
| GET | 예 | 예 | 네트워크 실패 시 비교적 재시도하기 쉽다 |
| POST | 아니오 | 보통 아니오 | 결제, 주문 생성은 중복 실행 위험이 있어 Idempotency-Key가 필요할 수 있다 |
| PUT | 아니오 | 예 | 같은 전체 상태를 다시 저장하는 재시도에 적합하다 |
| PATCH | 아니오 | 설계에 따라 다름 | count += 1 같은 패치는 재시도하면 값이 달라질 수 있다 |
| DELETE | 아니오 | 예 | 첫 번째는 204, 두 번째는 404가 와도 최종 상태는 삭제됨으로 같다 |
반례를 하나 보자. POST /payments가 네트워크 타임아웃으로 응답을 못 받았다고 해서 같은 요청을 그대로 다시 보내면 결제가 두 번 생성될 수 있다. 이때 Idempotency-Key: pay-20260407-001 같은 키를 함께 보내면 서버가 “같은 의도의 재시도”로 인식해 중복 생성을 막을 수 있다.
다음 세 상황은 초보자가 자주 섞는 예시다.
| 상황 | 자연스러운 선택 | 이유 |
|---|---|---|
| 사용자 프로필 전체를 저장한다 | PUT /users/1/profile | 클라이언트가 전체 새 표현을 알고 있고, 같은 요청을 반복해도 최종 상태가 같다 |
| 알림 수신 여부 하나만 바꾼다 | PATCH /users/1/settings | 리소스 일부 필드만 바뀐다 |
| 주문 결제를 시작한다 | POST /orders/42/payments | 외부 결제 승인처럼 부수 효과가 있고 결과가 매번 달라질 수 있다 |
PATCH의 반례도 중요하다.
PATCH /counters/like
{ "op": "increment", "by": 1 }이 요청은 일부 변경이지만 멱등이 아니다. 네트워크 실패 후 같은 요청을 재시도하면 좋아요 수가 1이 아니라 2 증가할 수 있다.
PATCH /users/1/settings
{ "emailNotification": false }이 요청은 부분 변경이면서 사실상 멱등으로 설계할 수 있다. 같은 값을 여러 번 저장해도 최종 상태가 false로 같기 때문이다. 그래서 “PATCH는 무조건 재시도 위험”이 아니라, 패치 문서가 상태를 지정하는지 연산을 지정하는지까지 봐야 한다.
상태 코드는 서버가 요청 결과를 세 자리 숫자로 요약한 것이다. 모든 코드를 외우기보다 첫 자리와 대표 코드를 디버깅 방향으로 연결한다.
| 범위 | 의미 | 먼저 볼 것 |
|---|---|---|
2xx | 성공 | 바디가 있는지, 생성인지, 바디 없는 성공인지 |
3xx | 리다이렉션 또는 캐시 재검증 | Location, ETag, Cache-Control 헤더 |
4xx | 클라이언트 요청 문제 | 요청 형식, 인증, 권한, 존재하지 않는 리소스 |
5xx | 서버 또는 중간 서버 문제 | 앱 예외, 프록시, 로드밸런서, 타임아웃 |
대표 코드는 다음 정도를 먼저 잡으면 된다.
| 코드 | 의미 | 실패 신호로 읽는 법 |
|---|---|---|
200 OK | 성공, 보통 바디 있음 | 바디 내용이 맞는지도 확인해야 한다 |
201 Created | 생성 성공 | Location 헤더나 생성된 리소스 ID를 같이 본다 |
204 No Content | 성공, 바디 없음 | DELETE 성공, 토글 성공처럼 결과 바디가 필요 없을 때 자연스럽다 |
304 Not Modified | 캐시 재사용 가능 | 서버가 바디를 다시 보내지 않아도 된다는 뜻이다 |
400 Bad Request | 요청 형식 오류 | JSON 형식, 필수 필드, validation 실패를 본다 |
401 Unauthorized | 인증 실패 | 토큰 없음, 만료, 서명 오류를 먼저 본다 |
403 Forbidden | 인가 실패 | 신원은 확인됐지만 역할, 소유권, 정책이 막은 것이다 |
404 Not Found | 리소스 없음 | 경로 오타와 실제 리소스 존재 여부를 나눠 본다 |
429 Too Many Requests | 요청량 제한 | Retry-After 헤더나 rate limit 정책을 본다 |
500 Internal Server Error | 앱 내부 오류 | 서버 로그와 예외 trace를 본다 |
502 Bad Gateway | 프록시가 뒷단의 잘못된 응답을 받음 | 앱 크래시, 연결 종료, keep-alive 불일치를 의심한다 |
503 Service Unavailable | 일시적 처리 불가 | 점검, 과부하, readiness 실패를 본다 |
504 Gateway Timeout | 프록시가 뒷단 응답을 기다리다 시간 초과 | 슬로우 쿼리, 외부 API 지연, timeout 설정을 본다 |
퀴즈
힌트: HTTP 상태 코드는 디버깅 출발점을 좁히는 신호다.
401은 신원을 증명하지 못한 상태라 토큰 존재와 만료를 먼저 보고, 403은 신원은 확인됐지만 권한이 부족한 상태라 역할과 정책을 봐야 한다.
502와 504는 앱 서버가 직접 만든 오류라기보다 중간 서버가 관찰한 실패인 경우가 많다. 로드밸런서, 리버스 프록시, CDN 같은 컴포넌트가 앞에 있으면 클라이언트는 앱이 낸 응답과 중간 서버가 낸 응답을 모두 HTTP 상태 코드로 받는다.
502 Bad Gateway: 뒷단 서버가 연결을 끊었거나, 잘못된 응답을 보냈거나, 재시작 중이라 프록시가 정상 HTTP 응답을 받지 못한 신호다.504 Gateway Timeout: 프록시는 뒷단에 요청을 보냈지만 정해진 시간 안에 응답을 못 받은 신호다.간단한 판단 기준은 “이상한 응답 또는 연결 종료면 502, 너무 오래 기다린 끝이면 504”다. 502가 재시도하면 곧바로 성공하는 패턴이면 keep-alive timeout 불일치나 배포 중 연결 종료를 의심하고, 504가 특정 API에서만 반복되면 DB 쿼리나 외부 API 지연을 먼저 의심한다.
헤더는 요청이나 응답의 본문을 해석하기 위한 메타데이터다. HTTP를 공부할 때 헤더를 “부가 정보”로만 보면 디버깅이 어려워진다. 인증, 캐시, 형식 협상, CORS, 추적은 모두 헤더로 표현된다.
| 헤더 | 방향 | 역할 |
|---|---|---|
Content-Type | 요청/응답 | 바디가 JSON인지, HTML인지, 폼 데이터인지 알려준다 |
Accept | 요청 | 클라이언트가 받고 싶은 응답 형식을 알려준다 |
Authorization | 요청 | bearer token 같은 인증 정보를 명시적으로 전달한다 |
Cookie | 요청 | 브라우저가 조건에 맞는 쿠키를 자동 첨부한다 |
Set-Cookie | 응답 | 서버가 브라우저에 쿠키 저장 규칙을 내려준다 |
Cache-Control | 요청/응답 | 캐시 보관, 재검증, 공유 가능 여부를 정한다 |
ETag | 응답 | 리소스 버전 식별자다 |
If-None-Match | 요청 | 클라이언트가 가진 ETag와 비교해 달라고 요청한다 |
X-Request-Id | 요청/응답 | 로그와 추적 시스템에서 같은 요청을 찾게 해준다 |
둘 다 인증과 연결될 수 있지만 동작 방식이 다르다.
| 구분 | Cookie | Authorization 헤더 |
|---|---|---|
| 누가 붙이나 | 브라우저가 도메인, 경로, SameSite 조건에 맞으면 자동 첨부한다 | 클라이언트 코드가 명시적으로 붙인다 |
| 저장 위치 감각 | 브라우저 쿠키 저장소 | 앱 메모리, secure storage, 서버 간 secret 관리 |
| 서버가 내려주는 방식 | Set-Cookie 응답 헤더 | 보통 로그인 응답 바디나 별도 토큰 발급 응답 |
| 주요 위험 | CSRF, SameSite 설정 오류, 쿠키 범위 과대 | 토큰 노출, 만료 처리 누락, 재시도 중 헤더 누락 |
| 잘 맞는 곳 | 브라우저 중심 세션, HttpOnly 쿠키 기반 로그인 | 모바일 앱, 서버 간 호출, 명시적 API 클라이언트 |
쿠키가 자동으로 붙는다는 성질은 편리하지만, 브라우저 보안 정책과 강하게 묶인다. 반대로 Authorization: Bearer ...는 자동 첨부가 아니므로 CORS preflight 대상이 되기 쉽고, 클라이언트가 모든 요청에 헤더를 넣어야 한다.
용어
HTTP 서버가 이전 요청을 기억하지 않는다는 뜻이다. 그래서 인증, 추적 ID, 멱등성 키처럼 다음 판단에 필요한 정보는 매 요청마다 다시 전달해야 한다.
실무에서는 Authorization 헤더, Cookie, Idempotency-Key, X-Request-Id가 이 한계를 보완한다.Content-Type과 Accept는 모두 “형식”과 관련 있지만 방향이 다르다.
Content-Type: 지금 보내는 body가 어떤 형식인지 말한다.Accept: 응답으로 어떤 형식을 받고 싶은지 말한다.POST /usersContent-Type: application/jsonAccept: application/json
{ "name": "Ada" }이 요청은 “내가 보내는 body는 JSON이고, 응답도 JSON이면 좋겠다”는 뜻이다. 서버가 JSON body만 받는데 클라이언트가 Content-Type: text/plain으로 보내면 415 Unsupported Media Type이 자연스럽다. 서버가 JSON 응답만 만들 수 있는데 클라이언트가 Accept: text/csv만 허용하면 406 Not Acceptable을 고려할 수 있다.
실무에서는 400으로 뭉뚱그려 처리하는 API도 많지만, 학습 관점에서는 실패 원인을 나눠 보는 것이 중요하다. JSON 문법이 깨졌으면 body parsing 문제이고, Content-Type이 틀렸으면 형식 협상 문제이며, 필수 필드가 없으면 validation 문제다. 모두 4xx지만 고쳐야 할 위치가 다르다.
X-Request-Id, Traceparent, Correlation-Id 같은 헤더는 HTTP 자체의 필수 요소는 아니지만, 분산 시스템에서 요청 하나를 따라가기 위해 자주 쓴다.
Client -> API GatewayX-Request-Id: req-20260407-0001
API Gateway -> AppX-Request-Id: req-20260407-0001
App -> Payment APIX-Request-Id: req-20260407-0001이 값이 유지되면 사용자가 본 504를 API Gateway 로그, 앱 로그, 외부 결제 API 로그에서 같은 요청으로 묶을 수 있다. 반대로 중간 계층이 추적 헤더를 새로 만들거나 제거하면 “어느 요청이 어디서 느려졌는가”를 사람 손으로 맞춰야 한다. 상태 코드가 결과라면 추적 헤더는 원인 위치를 찾는 실마리다.
HTTP 캐시는 “응답을 저장해도 되는가”, “얼마나 오래 그대로 써도 되는가”, “서버에 다시 확인해야 하는가”를 헤더로 정한다. 캐시는 성능 기능이지만, 잘못 설정하면 사용자가 오래된 데이터나 다른 사용자의 데이터를 볼 수 있다.
Cache-Control은 캐시 정책의 중심 헤더다.
| directive | 의미 | 자주 쓰는 상황 |
|---|---|---|
max-age=300 | 300초 동안 신선하다고 본다 | 공개 이미지, 버전이 붙은 정적 리소스 |
no-cache | 저장은 가능하지만 쓰기 전에 서버 재검증이 필요하다 | 변경 가능성이 있지만 재검증으로 절약하고 싶을 때 |
no-store | 저장하지 말아야 한다 | 개인정보, 인증된 사용자별 응답 |
private | 사용자 개인 캐시에만 저장 가능하다 | 로그인 사용자별 응답 |
public | 공유 캐시도 저장할 수 있다 | CDN에 올려도 되는 공개 응답 |
반례가 중요하다. GET /users/me가 200 OK로 잘 응답해도 Cache-Control: public이 붙으면 CDN이나 공유 캐시가 사용자별 응답을 섞어 저장할 수 있다. 인증이 붙은 응답은 기본적으로 private, no-store 또는 그에 준하는 정책을 먼저 검토한다.
ETag는 리소스 버전 식별자다. 클라이언트가 이미 가진 버전과 서버의 현재 버전이 같으면 서버는 바디를 다시 보내지 않고 304 Not Modified만 돌려줄 수 있다.
flowchart TD
A["첫 GET /users/1"] --> B["200 OK + ETag v1 + body"]
B --> C["다음 요청: If-None-Match v1"]
C --> D{"서버 리소스가 바뀌었나?"}
D -->|아니오| E["304 Not Modified - body 없음"]
D -->|예| F["200 OK + ETag v2 + 새 body"] 작은 수치 감각을 붙이면 캐시의 의미가 선명해진다. 100KB짜리 응답을 1분에 1,000번 재검증한다고 하자. 매번 200으로 바디를 보내면 약 100MB를 전송한다. 같은 리소스가 바뀌지 않아 304로 헤더만 오가면 바디 전송은 거의 사라진다. 단, 304도 네트워크 왕복은 필요하므로 완전한 무료는 아니다.
첫 요청GET /users/1-> 200 OK-> ETag: "user-1-v3"-> { "id": 1, "name": "Ada" }
재검증 요청GET /users/1If-None-Match: "user-1-v3"-> 304 Not Modified-> body 없음캐시는 실패해도 500을 만들지 않을 때가 많다. 그래서 상태 코드만 보는 모니터링은 놓치기 쉽다.
| 증상 | 의미 | 먼저 볼 개념 |
|---|---|---|
| 사용자가 옛 데이터를 보는데 서버 5xx가 없다 | ETag 비교나 재검증 헤더 전달이 깨졌을 수 있다 | ETag, If-None-Match, CDN 헤더 전달 |
| 사용자별 응답이 다른 사용자에게 보인다 | 공유 캐시에 개인 응답이 저장됐을 수 있다 | Cache-Control: private, no-store |
304가 와야 할 상황에서 계속 200과 큰 바디가 온다 | 캐시 절약은 실패했지만 기능 오류로는 보이지 않을 수 있다 | ETag 생성 규칙, 프록시 헤더 제거 |
캐시 정책은 성능과 정확성의 균형이다. 첫 설계에서는 응답을 세 그룹으로 나누면 판단이 쉬워진다.
| 응답 종류 | 예시 | 기본 정책 감각 | 이유 |
|---|---|---|---|
| 버전이 붙은 공개 정적 파일 | /assets/app.a1b2.js | public, max-age=31536000, immutable | 파일명이 바뀌면 새 리소스이므로 오래 저장해도 된다 |
| 공개 API 조회 | /products/1 | 짧은 max-age 또는 ETag 재검증 | 자주 바뀌지 않지만 최신성 요구가 있을 수 있다 |
| 로그인 사용자별 API | /users/me | private, no-store 또는 매우 보수적 캐시 | 사용자별 데이터가 공유 캐시에 섞이면 안 된다 |
| 결제/주문 결과 | /payments/42 | 보수적 캐시, 재검증 우선 | 재무 상태는 오래된 응답이 큰 사고로 이어질 수 있다 |
정량 감각도 필요하다. 공개 상품 상세가 50KB이고 초당 100번 조회된다면 원본 서버는 초당 5MB, 1분이면 300MB의 body를 보낸다. 짧은 CDN 캐시나 ETag 재검증만 제대로 동작해도 원본 부하와 지연이 크게 줄어든다. 반대로 사용자별 5KB 응답을 잘못 공유 캐시하면 전송량 절감보다 데이터 노출 위험이 훨씬 크다.
캐시 설계의 좋은 질문은 “얼마나 빨라지는가”보다 먼저 “오래된 응답을 보아도 되는가”다. 오래된 응답이 가격 표시처럼 잠깐 허용될 수 있는지, 결제 완료 여부처럼 허용되면 안 되는지를 먼저 나누면 max-age와 재검증 전략이 따라온다.
HTTP는 애플리케이션 계층 의미 체계이고, 실제 전송은 TCP/TLS 같은 아래 계층에 기대는 경우가 많다. 성능 문제를 볼 때는 “HTTP 메시지 의미”와 “연결을 어떻게 재사용하는가”를 분리해서 봐야 한다.
TCP 연결을 새로 맺을 때는 3-way handshake가 필요하고, HTTPS라면 TLS handshake도 추가된다. 이 왕복 비용은 같은 서버에 많은 요청을 보낼 때 누적된다. Keep-Alive는 한 번 맺은 연결을 여러 HTTP 요청에 재사용해 이 비용을 줄인다.
같은 서버에 10회 연속 요청하는 상황
Without Keep-Alive:요청마다 TCP + TLS handshake 반복평균 응답 시간 예시: 첫 요청과 이후 요청이 모두 약 250ms
With Keep-Alive:첫 요청에서 연결 수립, 이후 요청은 연결 재사용평균 응답 시간 예시: 첫 요청 이후 약 50ms이 숫자는 환경마다 달라지지만 판단 방향은 같다. 같은 origin에 짧은 요청을 많이 보낸다면 연결 재사용이 중요하다. 반대로 열린 연결을 너무 오래 유지하면 파일 디스크립터와 메모리를 점유하므로 idle timeout도 같이 봐야 한다.
시나리오
ALB 앞단의 Node.js/NestJS API에서 재시도하면 대부분 성공하는 502가 보인다. 앱 로그에는 명확한 예외가 없고, 장애가 특정 API 로직에만 묶이지 않는다.
먼저 로드밸런서 idle timeout과 앱 서버 keepAliveTimeout의 상대값을 비교한다. 프록시가 연결을 재사용하려는 순간 앱이 먼저 닫으면 502로 보일 수 있다.대표적인 수치 경계는 Node.js 서버의 기본 keep-alive timeout이 짧고, AWS ALB의 기본 idle timeout은 60초라는 점이다. 앱 서버가 5초 후 연결을 닫는데 ALB가 60초 동안 살아 있다고 믿고 재사용하면, 프록시는 뒷단이 연결을 닫은 순간을 502로 관찰할 수 있다. 여기서 중요한 것은 특정 명령어가 아니라 “양쪽 timeout의 상대 순서”다.
HTTP 버전은 같은 의미 체계를 더 효율적으로 싣기 위한 전송 방식의 차이로 먼저 이해한다.
| 버전 | 핵심 변화 | 첫 독서에서 잡을 경계 |
|---|---|---|
| HTTP/1.1 | 하나의 연결에서 요청/응답을 순서대로 처리하는 모델이 기본이다 | Keep-Alive로 연결을 재사용하지만 동시성은 제한적이다 |
| HTTP/2 | 하나의 TCP 연결 안에서 여러 스트림을 멀티플렉싱하고 헤더를 압축한다 | 스트림은 나뉘지만 TCP 패킷 손실의 영향은 공유할 수 있다 |
| HTTP/3 | QUIC 위에서 스트림을 독립적으로 다루고 연결 마이그레이션을 지원한다 | 보통 앱 서버 코드보다 CDN/엣지 계층에서 먼저 만난다 |
작은 JSON API 응답을 같은 origin으로 많이 주고받는 서비스는 HTTP/2의 멀티플렉싱과 헤더 압축 이득을 체감하기 쉽다. 반대로 대용량 동영상 파일처럼 한 요청이 전송량 대부분을 차지하면 버전 차이보다 CDN, range request, 캐시 정책이 더 큰 변수가 될 수 있다. HTTP/3도 앱 코드를 바꾸는 문제라기보다 모바일 네트워크 전환, 패킷 손실, 엣지 지원 여부를 함께 보는 선택이다.
HTTP/1.1 연결 흐름Client -> request 1 -> ServerClient <- response 1 <- ServerClient -> request 2 -> ServerClient <- response 2 <- Server
HTTP/2 멀티플렉싱 흐름Client -> request 1, stream 1 -> ServerClient -> request 2, stream 2 -> ServerClient <- response 2, stream 2 <- ServerClient <- response 1, stream 1 <- Server백엔드 입문 단계에서는 “NestJS 같은 앱 서버가 항상 HTTP/3를 직접 처리해야 한다”고 생각하지 않아도 된다. 흔한 구조는 브라우저나 모바일 앱과 CDN/로드밸런서 사이에서 HTTP/2 또는 HTTP/3를 쓰고, 엣지와 앱 서버 사이에서는 HTTP/1.1이나 HTTP/2를 쓰는 방식이다. 그래서 HTTP/2/3의 첫 질문은 “어느 구간에서 어떤 버전이 끝나는가”다.
Keep-Alive는 항상 켜면 좋은 스위치가 아니라 연결 비용과 연결 보유 비용의 교환이다.
| 상황 | 연결 재사용 이점 | 경계 |
|---|---|---|
| 같은 API 서버에 짧은 요청을 반복 | handshake 비용을 여러 요청에 분산한다 | idle timeout과 최대 연결 수를 본다 |
| 요청이 드물고 서버 리소스가 작음 | 이점이 작을 수 있다 | 열린 연결이 파일 디스크립터를 점유한다 |
| 서버 간 호출이 burst로 몰림 | 커넥션 풀이 지연을 줄일 수 있다 | 너무 큰 풀은 뒷단을 압박한다 |
| 프록시와 앱 timeout이 다름 | 재사용 중 502가 날 수 있다 | 양쪽 timeout의 상대값을 맞춘다 |
수치 반례를 보자. 파일 디스크립터 한도가 1,024인 작은 컨테이너에서 idle 연결 900개를 오래 붙잡고 있으면, 실제 처리 중인 요청이 많지 않아도 새 연결을 받을 여유가 작아진다. Keep-Alive는 요청 지연을 줄이는 장치지만, 연결 수 관측 없이 timeout만 길게 늘리면 다른 실패를 만든다.
HTTP에서 배운 원리는 WebSocket, gRPC, 메시지 큐를 볼 때도 기준점이 된다.
| HTTP에서 배운 질문 | gRPC에서의 연결 | WebSocket에서의 연결 |
|---|---|---|
| 상태는 어디에 있는가? | Unary RPC는 호출 단위로 독립적이다 | 연결 이후에는 애플리케이션이 상태 복원을 책임진다 |
| 다중화 단위는 무엇인가? | HTTP/2 스트림으로 여러 RPC를 한 채널에 싣는다 | 기본 WebSocket은 연결당 하나의 양방향 채널이다 |
| 연결 비용은 어떻게 나누는가? | gRPC 채널을 재사용해야 한다 | 연결 유지와 재연결 전략이 중요하다 |
| 메타데이터는 어디에 있는가? | HTTP/2 metadata가 헤더 역할을 한다 | 핸드셰이크 이후에는 메시지 설계에 따라 다르다 |
이 절은 심화 토픽의 예고편이다. 자세한 비교는 뒤의 WebSocket & gRPC 문서에서 다루고, 여기서는 “HTTP가 기준점이 된다”는 정도만 잡으면 된다.
CORS(Cross-Origin Resource Sharing)는 서버 간 HTTP 통신의 일반 규칙이 아니라, 브라우저가 웹 페이지를 대신해 요청을 보낼 때 적용하는 보안 정책이다. 같은 HTTP 요청이라도 curl, 서버 코드, 브라우저 fetch에서 결과가 다르게 보일 수 있는 이유가 여기에 있다.
핵심 용어는 세 가지다.
https://app.example.com과 https://api.example.com은 host가 다르므로 다른 origin이다.OPTIONS 요청으로 허용 여부를 묻는 과정이다.Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers 등으로 허용 범위를 말한다.브라우저 fetchOrigin: https://app.example.comAuthorization: Bearer ...
preflight OPTIONS /usersAccess-Control-Request-Method: GETAccess-Control-Request-Headers: authorization
서버 응답에 허용 헤더가 없으면:브라우저가 실제 GET을 보내지 않거나 응답을 JS에 노출하지 않는다.중요한 경계는 “CORS 에러가 서버가 죽었다는 뜻은 아니다”라는 점이다. 백엔드 로그에 OPTIONS만 있고 실제 GET이 없다면, HTTP 요청 자체가 브라우저 정책 단계에서 막힌 것이다.
Preflight는 성능에도 영향을 준다. Access-Control-Max-Age가 없으면 브라우저는 짧은 시간 뒤 다시 preflight를 보낼 수 있다. 예를 들어 10초마다 polling하는 API에서 preflight 캐시가 5초라면 실제 요청만큼 OPTIONS 요청도 반복되어 체감 요청 수가 거의 두 배가 된다. 다만 이 값은 브라우저별 상한이 있으므로, 숫자를 외우기보다 “preflight도 HTTP 요청이고 캐시할 수 있다”는 원리를 잡는다.
서버 개발자가 curl로 확인했을 때는 성공하지만 브라우저에서만 실패하는 상황을 보자.
curl -H "Authorization: Bearer token" https://api.example.com/users-> 200 OK
browser fetch("https://api.example.com/users", { headers: { Authorization: "Bearer token" }})-> CORS policy error이 둘은 같은 HTTP 서버를 향하지만 같은 조건이 아니다. 브라우저 요청에는 Origin이 있고, Authorization 같은 비단순 헤더가 있으면 preflight가 먼저 간다. 서버가 실제 GET /users는 잘 처리해도 OPTIONS /users에 CORS 허용 헤더를 주지 않으면 브라우저는 실제 요청을 보내지 않거나 응답을 JavaScript에 넘기지 않는다.
또 다른 반례는 쿠키다.
fetch("https://api.example.com/me", { credentials: "include"})credentials 설정과 쿠키의 SameSite/Secure 속성은 브라우저가 쿠키를
요청에 첨부할 수 있는지를 결정한다. 반면 구체적인
Access-Control-Allow-Origin(ACAO)과
Access-Control-Allow-Credentials: true(ACAC)는 credential이 포함된
cross-origin 응답을 JavaScript에 노출해도 되는지를 주로 통제한다.
특히 preflight가 없는 단순 credential 요청은 조건에 맞는 쿠키가 이미
전송되고 서버의 상태 변경까지 끝난 뒤, ACAO나 ACAC가 없어서 응답만
JavaScript에 노출되지 않을 수 있다. 따라서 CORS는 CSRF 방어가 아니며,
상태 변경 요청에는 SameSite 정책, CSRF 토큰, Origin 검증 같은 별도 방어가
필요하다. credential 응답에는 와일드카드 ACAO를 사용할 수 없다는 점도 함께
기억한다.
| 관찰 | HTTP 관점 번역 | 먼저 볼 것 |
|---|---|---|
| 서버 로그에 OPTIONS만 있다 | preflight에서 멈췄다 | allowed method/header/origin |
| GET은 200인데 브라우저 콘솔은 CORS 에러 | 응답을 JS에 노출하지 않았다 | Access-Control-Allow-Origin |
| 쿠키 로그인만 브라우저에서 풀린다 | 쿠키 첨부 조건이 안 맞는다 | credentials, SameSite, Secure |
| curl은 항상 성공한다 | curl은 브라우저 CORS 정책을 강제하지 않는다 | 브라우저 Network 탭의 preflight |
운영 런북을 외우기 전에 증상, 의미, 먼저 볼 HTTP 개념을 연결한다. 다음 표는 긴 명령어 대신 첫 판단을 좁히기 위한 지도다.
| 증상 | 의미 | 먼저 확인할 개념 |
|---|---|---|
401이 난다 | 요청자가 신원을 증명하지 못했다 | Authorization, Cookie, 토큰 만료 |
403이 난다 | 신원은 알지만 접근 정책이 막았다 | role, scope, 소유권, RBAC/IAM 정책 |
400이 난다 | 요청 형식이나 필드가 서버 계약과 맞지 않는다 | Content-Type, JSON shape, validation |
429가 난다 | 요청량 제한에 걸렸다 | rate limit window, Retry-After |
배포 직후 502가 늘어난다 | 프록시가 뒷단의 정상 응답을 못 받았다 | 앱 기동 상태, readiness, keep-alive timeout |
특정 API만 504가 난다 | 뒷단 처리가 timeout 안에 끝나지 않았다 | DB 쿼리, 외부 API, timeout budget |
| 브라우저는 CORS 에러, 서버 로그는 조용하다 | 브라우저가 실제 요청을 막았을 수 있다 | preflight, allowed origin/header/method |
200 OK인데 사용자가 옛 데이터를 본다 | 상태 코드는 성공이지만 캐시 의미가 틀렸을 수 있다 | Cache-Control, ETag, CDN |
| curl은 성공, 브라우저만 실패한다 | HTTP 서버보다는 브라우저 정책 차이일 수 있다 | CORS, Cookie SameSite, credentials |
HTTP 실패는 항상 5xx로 나타나지 않는다.
200 OK 또는 304 Not Modified가 오지만, 사용자가 기대한 최신 데이터가 아니다. ETag 생성 기준, CDN의 헤더 전달, Cache-Control을 본다.Authorization을 특정 요청에서 빠뜨리면 서버는 정상적으로 401을 돌려준다. 앱 입장에서는 장애가 아니라 요청 계약 위반이다.이런 실패를 줄이려면 상태 코드만 보지 말고 요청/응답 헤더와 body의 의미를 같이 기록해야 한다. X-Request-Id 같은 추적 헤더는 HTTP 계층과 앱 로그를 연결하는 최소 장치다.
다음 세 사례는 긴 운영 절차 없이도 HTTP 개념으로 첫 방향을 잡을 수 있다.
사례 A: 로그인 API는 성공했는데 다음 API가 401이다
POST /login -> 200 OKGET /me -> 401 Unauthorized먼저 “로그인은 성공했지만 다음 요청에 신원 정보가 전달됐는가”를 본다. 토큰 방식이면 Authorization 헤더가 빠졌는지, 쿠키 방식이면 Set-Cookie가 저장됐는지와 다음 요청의 Cookie 헤더가 붙었는지 본다. 이 문제를 권한 부족인 403으로 처리하면 role 정책을 뒤지느라 시간을 잃는다.
사례 B: 상품 상세가 가끔 오래된 가격을 보여준다
GET /products/1 -> 200 OKCache-Control: public, max-age=60010분 동안 공개 캐시가 가격을 재사용할 수 있다는 뜻이다. 가격 최신성이 중요하다면 짧은 max-age와 ETag 재검증이 더 맞을 수 있다. 반대로 버전이 붙은 이미지라면 긴 max-age가 자연스럽다. 같은 200 OK라도 응답의 시간 의미가 다르다.
사례 C: 배포 직후 502가 2분간 증가했다가 사라진다
Client -> ALB -> App응답: 502 Bad Gateway재시도: 200 OK앱 코드의 특정 예외보다 뒷단 연결과 준비 상태를 먼저 의심한다. 컨테이너가 아직 readiness를 통과하지 못했거나, 이전 연결을 프록시가 재사용하는 동안 앱이 재시작했을 수 있다. 여기서 핵심 개념은 502가 “앱이 비즈니스 오류를 돌려줬다”가 아니라 “중간 서버가 정상 응답을 받지 못했다”는 신호라는 점이다.
아래 명령은 런북이 아니라 개념 확인용이다. 실제 운영 복구는 팀의 배포 환경과 권한 모델을 따라야 한다.
curl -v https://httpbin.org/get확인할 것:- 요청 시작 줄: GET /get HTTP/1.1- 요청 헤더: Host, User-Agent, Accept- 응답 시작 줄: HTTP/1.1 200 OK- 응답 헤더: Content-Type, Content-Lengthcurl -I https://api.example.com/users/1curl -I -H 'If-None-Match: "v1-abc123"' https://api.example.com/users/1정상 관찰:- 변경 없음: 304 Not Modified, body 없음- 변경 있음: 200 OK, 새 ETag, 새 body
실패 신호:- ETag가 매 요청 불필요하게 바뀌면 캐시 효율이 떨어진다.- 인증된 사용자 응답에 public cache가 붙으면 데이터 노출 위험이 생긴다.curl -X OPTIONS https://api.example.com/users \ -H "Origin: https://app.example.com" \ -H "Access-Control-Request-Method: GET" \ -H "Access-Control-Request-Headers: authorization" \ -i확인할 것:- Access-Control-Allow-Origin이 요청 Origin과 맞는가- Access-Control-Allow-Headers에 authorization이 있는가- Access-Control-Max-Age가 preflight 반복 비용을 줄일 만큼 설정됐는가curl -o /dev/null -s -w "HTTP Code: %{http_code}\nTotal: %{time_total}s\n" \ https://api.example.com/health해석:- 200 + 짧은 Total: HTTP 표면은 정상- 504 + 긴 Total: 프록시가 뒷단 응답을 기다리다 포기했을 가능성- 502 + 짧은 Total: 뒷단 연결 종료, 배포 중 재시작, keep-alive 불일치 가능성| 개념 A | 개념 B | 차이점 |
|---|---|---|
| 401 Unauthorized | 403 Forbidden | 401은 신원 확인 실패, 403은 신원 확인 후 권한 부족 |
| PUT | PATCH | PUT은 전체 교체, PATCH는 일부 변경 |
| 안전성 | 멱등성 | 안전성은 상태를 안 바꾸는가, 멱등성은 여러 번 보내도 최종 상태가 같은가 |
| HTTP | HTTPS | HTTPS는 HTTP 메시지를 TLS로 보호해 전송한다 |
| 500 | 502 | 500은 앱 내부 오류, 502는 중간 서버가 뒷단의 정상 응답을 받지 못한 것 |
| 502 | 504 | 502는 잘못된 응답/연결 종료, 504는 응답 시간 초과 |
| 200 OK | 304 Not Modified | 200은 새 표현을 보내고, 304는 클라이언트가 가진 표현을 재사용하라는 뜻 |
| Cookie | Authorization | Cookie는 브라우저 자동 첨부, Authorization은 클라이언트가 명시적으로 첨부 |
| CORS | 서버 간 접근 제어 | CORS는 브라우저가 강제하는 출처 정책이지 모든 HTTP 클라이언트의 공통 차단 규칙이 아니다 |
Content-Type | Accept | Content-Type은 보낸 body 형식, Accept는 받고 싶은 응답 형식이다 |
401, 403, 429, 5xx를 계약 문서와 연결해 해석한다.200만 보는 것이 아니라 readiness, timeout, 프록시 상태 코드를 함께 본다.REST, Content Negotiation, Cookie SameSite, Session, JWT, CORS preflight, ETag, Cache-Control, Idempotency-Key, Retry-After, Keep-Alive, HTTP/2 stream, HTTP/3 QUIC, WebSocket Upgrade, gRPC metadata
Cache-Control, ETag, 재검증, shared cache 규칙.요청이 실패했다├── 4xx: 요청자가 고칠 가능성이 크다│ ├── 400: 요청 형식/데이터가 잘못됐다│ ├── 401: 신원 증명이 안 됐다│ ├── 403: 신원은 알지만 권한이 없다│ ├── 404: 대상 리소스가 없다│ └── 429: 너무 많이 요청했다└── 5xx: 서버나 중간 계층을 봐야 한다 ├── 500: 앱 내부 오류 ├── 502: 프록시가 뒷단 정상 응답을 못 받음 ├── 503: 일시적 처리 불가 └── 504: 뒷단 응답 시간 초과