HTTP와 API는 서비스 사이의 계약이다 — 멱등성, 상태코드, 타임아웃
백엔드 지식 지도 5편. HTTP 메서드 의미론, 5xx의 화자, 스트리밍 타임아웃, CORS와 리다이렉트 함정을 실무 관점에서 정리한다.
Series
백엔드 지식 지도HTTP를 단순한 요청/응답 포맷으로 보면 중요한 것을 놓친다.
HTTP는 서비스와 서비스 사이의 계약이다. 메서드, 상태코드, 헤더, 캐시, 리다이렉트 규칙은 모두 “이 요청을 어떻게 다뤄야 하는가”를 말한다.
이 의미론을 대충 알면 언젠가 이런 문제를 만난다.
- 왜 같은 요청이 두 번 저장됐지?
- 왜 POST가 리다이렉트 뒤에 GET으로 바뀌었지?
- 왜 서버는 처리했는데 클라이언트는 실패로 봤지?
- 왜 SSE 연결은 살아 있는데 아무 데이터도 안 오지?
- 왜 서버 대 서버 호출인데 CORS를 찾고 있지?
HTTP를 제대로 안다는 것은 상태코드 표를 외우는 것이 아니다. 실패했을 때 다시 보내도 되는지, 누가 실패를 말하고 있는지, 어디서 timeout을 걸어야 하는지 판단하는 것이다.
헤더를 먼저 읽어야 한다
HTTP 요청과 응답은 시작줄, 헤더, 본문으로 이뤄진다.
많은 사람은 본문부터 본다. JSON payload가 비즈니스 데이터를 담고 있기 때문이다. 하지만 운영에서는 헤더가 먼저일 때가 많다.
헤더에는 라우팅, 인증, 캐시, 압축, 콘텐츠 협상, 추적 정보가 들어 있다.
Authorization
Cookie
Content-Type
Accept
Cache-Control
ETag
X-Request-Id
Retry-After본문이 “무엇을 요청했는가”라면, 헤더는 “이 요청을 어떤 조건으로 처리해야 하는가”에 가깝다.
메서드 의미론은 재시도 규칙이다
HTTP 메서드에서 가장 중요한 구분은 safe, idempotent, cacheable이다.
GET과 HEAD는 safe다. 상태를 바꾸지 않는 요청이어야 한다.
GET, HEAD, PUT, DELETE는 idempotent다. 같은 요청을 여러 번 보내도 결과가 같아야 한다.
POST는 기본적으로 멱등이 아니다.
이 구분은 문서용 분류가 아니다. 재시도 가능 여부를 결정한다.
GET이 timeout 났다면 보통 다시 보내도 된다. 물론 서버가 GET에서 상태를 바꾸는 나쁜 API를 만들었다면 깨지겠지만, 그건 API가 HTTP 계약을 어긴 것이다.
반면 POST는 조심해야 한다. 결제 생성, 주문 생성, 포인트 지급 같은 POST가 timeout 났을 때 다시 보내면 중복 처리가 생길 수 있다.
핵심 문장은 이것이다.
응답을 못 받은 것과 처리되지 않은 것은 다르다.클라이언트가 응답을 못 받았더라도 서버는 이미 처리했을 수 있다. 그래서 POST를 안전하게 재시도하려면 멱등키가 필요하다.
5xx는 누가 말했는지를 봐야 한다
500번대 상태코드는 모두 서버 쪽 실패처럼 보인다. 하지만 실무에서는 “누가 그 말을 했는가”가 중요하다.
| 코드 | 주로 말하는 쪽 | 의미 | 재시도 |
|---|---|---|---|
| 500 | 앱 | 앱 코드가 터졌다 | 보통 의미 없음 |
| 502 | 프록시 | upstream에 못 붙었거나 응답이 깨졌다 | 멱등 요청이면 가능 |
| 503 | 앱 또는 프록시 | 지금 받을 수 없다 | Retry-After를 보고 가능 |
| 504 | 프록시 | upstream이 시간 안에 응답하지 않았다 | POST는 위험 |
| 429 | 서버 | rate limit | Retry-After를 지켜야 함 |
특히 504가 중요하다.
504는 “서버가 처리하지 않았다”가 아니다. 프록시가 upstream 응답을 정해진 시간 안에 못 받았다는 뜻이다. 서버는 여전히 처리 중일 수 있다.
따라서 GET이면 재시도해도 비교적 안전하다. POST라면 멱등키가 있어야 한다.
502도 마찬가지다. upstream에 못 붙었을 수도 있지만, 응답이 깨졌다는 것은 요청이 어디까지 갔는지 애매할 수 있다는 뜻이다. 자동 재시도는 메서드와 멱등성을 함께 봐야 한다.
timeout은 connect, read, total로 나눠야 한다
HTTP 클라이언트 timeout을 하나의 숫자로만 두면 문제가 생긴다.
실제로는 최소 세 가지가 다르다.
- connect timeout: 연결이 붙기까지
- read timeout: 다음 바이트를 받기까지
- total timeout: 전체 요청이 끝나기까지
일반 요청에서는 connect와 read timeout만으로도 어느 정도 방어가 된다. 하지만 스트리밍에서는 다르다.
SSE나 chunked response에서는 서버가 헤더를 먼저 보내고 본문을 조금씩 보낼 수 있다. 이때 서버가 아주 느리게, 그러나 꾸준히 바이트를 보내면 read timeout은 계속 갱신된다. 호출은 끝나지 않는데 timeout도 안 난다.
그래서 스트리밍에서는 total timeout이나 별도의 단계 예산이 필요하다.
LLM streaming API를 쓸 때 이 문제가 자주 나온다. TCP 연결은 살아 있고, HTTP 상태도 200이고, 가끔 토큰도 온다. 그런데 전체 작업은 사용자 기대 시간을 훨씬 넘는다.
“다음 바이트” 기준 timeout과 “전체 요청” 기준 timeout은 다르다.
CORS는 서버 대 서버 문제가 아니다
서버 간 HTTP 호출이 실패했는데 “CORS 문제 같다”는 말을 자주 듣는다.
대부분 틀렸다.
CORS는 브라우저가 자기 자신에게 거는 제약이다. 어떤 웹 페이지가 다른 origin의 리소스를 읽어도 되는지 브라우저가 확인하는 규칙이다.
백엔드 서버에서 curl, httpx, requests, axios로 다른 서버를 호출할 때는 CORS가 적용되지 않는다.
CORS 오류는 브라우저 콘솔에서 난다. 서버 로그나 백엔드 HTTP 클라이언트에서 난 연결 실패는 네트워크, 인증, DNS, 프록시, TLS, 방화벽 문제다.
이 구분만 해도 디버깅 시간이 줄어든다.
리다이렉트는 API에서 위험할 수 있다
301, 302, 307, 308은 모두 리다이렉트지만 중요한 차이가 있다.
307과 308은 메서드와 본문을 보존한다. POST로 보냈다면 POST로 따라간다.
반면 301과 302는 역사적으로 많은 클라이언트가 GET으로 바꿔 따라간다. 명세와 구현의 역사가 섞여 생긴 실무 함정이다.
그래서 API 경로에 302를 걸면 위험하다.
POST /api/orders
302 Location: /login
GET /login원래 의도는 인증 페이지로 보내는 것이었을 수 있다. 하지만 API 클라이언트 입장에서는 POST 본문이 사라지고 GET 요청으로 바뀐다.
메서드를 보존해야 한다면 307 또는 308을 써야 한다. 더 나은 방법은 API에서 브라우저용 리다이렉트를 피하고, 명확한 401/403과 JSON 오류를 돌려주는 것이다.
캐시는 헤더로 제어된다
의도치 않은 캐싱은 대개 헤더를 안 줘서 생긴다.
Cache-Control, ETag, Last-Modified, If-None-Match 같은 헤더가 캐시 수명과 재검증을 정한다. 브라우저뿐 아니라 중간 프록시와 CDN도 이 헤더를 본다.
개인화 응답, 권한이 걸린 응답, 자주 바뀌는 API 응답은 캐시 정책을 명시해야 한다.
반대로 정적 자산은 강하게 캐시하고 파일명에 해시를 넣는 식으로 무효화를 설계한다.
캐시는 “빠르게 만들기”보다 “언제 낡은 값을 허용할 것인가”의 문제다.
하위호환 계약은 필드 추가와 의미 변경을 구분한다
API는 한 번 배포하면 여러 클라이언트가 동시에 본다. 모바일 앱, 외부 파트너, 내부 서비스, 배치 잡이 모두 다른 속도로 업데이트된다.
그래서 하위호환이 중요하다.
응답 필드 추가는 대체로 안전하다. 클라이언트가 모르는 필드를 무시하도록 짜여 있다면 문제가 없다.
하지만 필드 삭제, 타입 변경, 의미 변경은 위험하다.
price: number → string
status: "DONE"의 의미 변경
필수 필드 제거이런 변경은 새 API 버전을 만들거나, 확장-수축처럼 단계적으로 옮겨야 한다.
API도 데이터베이스 스키마처럼 굴러간다. 옛 클라이언트와 새 서버가 동시에 존재하는 시간을 설계해야 한다.
마무리
HTTP는 단순한 통신 형식이 아니다.
서비스 사이의 계약이다. 메서드는 재시도 가능성을 말하고, 상태코드는 실패의 화자를 말하고, 헤더는 인증과 캐시와 협상을 말한다. timeout은 connect, read, total로 나눠야 하고, 스트리밍에서는 total 예산이 특히 중요하다. CORS는 브라우저 문제이며, API 리다이렉트는 메서드 보존을 조심해야 한다.
HTTP를 제대로 안다는 것은 결국 이 질문에 답하는 것이다.
이 요청을 다시 보내도 되는가?
다음 편에서는 코드가 실제로 도는 자리, OS와 프로세스를 본다.
핵심 질문은 이것이다.
왜 앱 로그에는 아무것도 없는데 프로세스는 죽었는가.