목차
브라우저 개발자도구의 Network 탭을 처음 열면 줄이 수십 개 뜬다. 그 한 줄 한 줄이 요청 하나와 응답 하나의 짝이다.
주소창에 한 줄 쳤을 뿐인데
https://example.com/products/7 을 열면 화면에 상품 하나가 뜬다. 브라우저와 서버 사이에 무슨 일이 있었나.
핵심은 두 가지다. 하나, 언제나 클라이언트가 먼저 묻고 서버가 답한다. 서버가 먼저 말을 거는 일은 없다. 둘, 한 왕복에 하나씩만 오간다. 페이지 하나를 그리려고 왕복이 수십 번 일어난다.
그리고 그 왕복에서 실제로 선을 타고 흐르는 건 놀랍게도 그냥 글자다. HTTP 메시지는 사람이 읽을 수 있는 텍스트로 정의돼 있다.
“텍스트다”는 HTTP/1.1까지의 이야기다. HTTP/2부터는 같은 내용을 사람이 못 읽는 이진 형식으로 나른다. 다만 담기는 내용(메서드·경로·헤더·바디)은 그대로라서, 개발자도구는 어느 버전이든 아래와 같은 모양으로 보여준다. 왜 형식이 바뀌었는지는 연결 글에서 다룬다.
요청 메시지는 네 덩어리다
브라우저가 보낸 요청을 글자 그대로 펼치면 이렇게 생겼다.
GET /products/7 HTTP/1.1
Host: example.com
Accept: text/html
User-Agent: Mozilla/5.0
네 덩어리로 나뉜다.
| 덩어리 | 예 | 무슨 뜻인가 |
|---|---|---|
| 메서드 | GET | 무엇을 하려는지 |
| 경로 | /products/7 | 어느 자원에 |
| 버전 | HTTP/1.1 | 어떤 규칙으로 말할지 |
| 헤더 | Host: ... | 이 요청에 대한 부가 설명 |
그리고 헤더 뒤에 빈 줄이 하나 온다. 이게 “헤더 끝, 여기부터 바디”라는 신호다. 위 요청은 빈 줄 뒤에 아무것도 없다. GET은 보통 보낼 내용이 없기 때문이다.
바디가 있는 요청은 이렇게 생긴다.
POST /products HTTP/1.1
Host: example.com
Content-Type: application/json
Content-Length: 37
{"name": "키보드", "price": 79000}Content-Type은 “바디가 무슨 형식인지”, Content-Length는 “몇 바이트인지”를 알려준다. 받는 쪽은 이 둘이 없으면 바디를 어디까지 읽어야 하는지도, 읽고 나서 뭘로 해석해야 하는지도 모른다.
응답도 같은 모양이다
응답은 요청과 다른 물건처럼 보이지만, 뜯어보면 구조가 거의 같다.
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 46
{"id": 7, "name": "키보드", "price": 79000}다른 건 첫 줄뿐이다. 요청은 첫 줄에 “무엇을 하고 싶다”를 쓰고, 응답은 첫 줄에 **“그래서 어떻게 됐다”**를 쓴다.
이 대칭을 기억해두면 나중이 쉽다. 뒤에 나올 캐시도 압축도 인증도 전부 저 헤더 자리에 한 줄씩 붙는 것이지, 새로운 통신 방식이 아니다.
메서드는 무엇을 하려는지다
경로가 “어디”라면 메서드는 “무엇을”이다. 같은 /products/7이라도 GET이면 읽기고 DELETE면 삭제다.
| 메서드 | 하려는 일 |
|---|---|
GET | 읽는다 |
POST | 새로 만든다 (또는 그 밖의 처리를 시킨다) |
PUT | 통째로 바꾼다 |
PATCH | 일부만 바꾼다 |
DELETE | 지운다 |
여기서 초중급이 자주 놓치는 게 있다. 메서드는 약속이지 강제가 아니다. GET 핸들러 안에서 데이터를 지우는 코드를 짜도 HTTP는 막지 않는다. 다만 그렇게 하면 중간의 캐시나 프록시, 브라우저가 “GET은 읽기니까 마음대로 다시 보내도 되겠지”라고 판단해서 의도치 않게 삭제가 여러 번 일어날 수 있다. 약속을 어기는 쪽이 손해를 본다.
“여러 번 보내도 결과가 같은가”를 멱등성이라고 부른다. GET·PUT·DELETE는 멱등이고 POST는 아니다. 왜 그런지, 그리고 POST를 안전하게 재시도하려면 무엇이 필요한지는 멱등성 글이 따로 다룬다. 여기서는 “메서드마다 재시도해도 되는지가 다르다”까지만 알면 된다.
상태 코드는 앞자리로 갈린다
응답 첫 줄의 세 자리 숫자다. 외울 게 많아 보이지만 앞자리 하나만 보면 누구 잘못인지가 갈린다.
이 구분이 실제로 하는 일이 있다. 장애를 볼 때 4xx가 치솟으면 클라이언트나 API 계약을 의심하고, 5xx가 치솟으면 서버를 의심한다. 앞자리 하나로 수사 방향이 갈린다.
자주 보는 것만 추리면 이 정도다.
| 코드 | 뜻 | 흔한 상황 |
|---|---|---|
200 OK | 됐다 | 조회 성공 |
201 Created | 만들었다 | 생성 성공 |
204 No Content | 됐는데 줄 게 없다 | 삭제 성공 |
301 / 302 | 주소가 바뀌었다 | 리다이렉트 |
304 Not Modified | 안 바뀌었으니 갖고 있던 걸 써라 | 캐시 |
400 Bad Request | 요청이 잘못됐다 | 필수 값 누락 |
401 Unauthorized | 누군지 모르겠다 | 로그인 안 함 |
403 Forbidden | 누군지는 아는데 권한이 없다 | 남의 자원 접근 |
404 Not Found | 그런 건 없다 | 잘못된 경로 |
500 Internal Server Error | 서버가 터졌다 | 처리 중 예외 |
상태 코드는 고르는 게 어렵다
표를 외우는 건 쉽다. 어려운 건 내 상황이 어느 칸인지 정하는 일이다. 로그인을 안 한 것과 권한이 없는 것, 형식이 깨진 것과 값이 규칙에 안 맞는 것 사이에서 매번 갈린다.
이건 HTTP가 정해주는 게 아니라 API를 설계하면서 정하는 일이라, 상태 코드와 에러 응답이 갈림길별로 따로 다룬다.
하나만 미리 못 박는다. 제일 흔한 사고는 에러를 200으로 주는 것이다. 바디에 {"success": false}를 담고 상태 코드는 200을 준다. 사람 눈에는 멀쩡해 보이지만, 상태 코드만 보는 것들이 전부 속는다. 모니터링은 에러율 0%를 그리고, 재시도 로직은 실패를 성공으로 알고 넘어가고, 캐시는 에러 응답을 정상 응답으로 저장한다.
헤더가 실제로 하는 일
헤더는 “부가 정보”라고 뭉뚱그리기 쉬운데, 실제로는 협상의 도구다. 클라이언트가 “나는 이런 걸 받을 수 있다”고 말하면 서버가 그중에서 고른다.
Accept: application/json
Accept-Language: ko-KR
Accept-Encoding: gzip이렇게 보내면 서버는 JSON으로, 가능하면 한국어로, gzip으로 압축해서 답한다. 응답 헤더는 그 결과를 다시 알려준다.
Content-Type: application/json
Content-Language: ko-KR
Content-Encoding: gzipAccept-*가 요청 쪽의 희망사항이고 Content-*가 응답 쪽의 실제다. 짝을 이룬다는 걸 알면 헤더 이름을 외울 일이 줄어든다.
그리고 하나 더. Host 헤더는 뺄 수 없다. 서버 컴퓨터 하나에 사이트가 여러 개 올라가 있으면, 요청이 도착했을 때 어느 사이트를 달라는 건지 알 방법이 이 헤더뿐이기 때문이다.
요청은 서버 안에서 어디를 지나나
지금까지는 선을 타고 오가는 글자만 봤다. 그 글자가 서버에 도착한 뒤에는 어떻게 되나.
여기서 눈여겨볼 건 가운데 칸이다. 로그를 남기고, 인증을 확인하고, 압축을 거는 일은 핸들러마다 할 일이 아니라 모든 요청이 똑같이 겪는 일이다. 그래서 핸들러 앞뒤로 공통 처리 구간을 따로 세운다. 그 구간을 다루는 게 미들웨어 글이다.
이 구조를 알아두면 뒤가 편하다. 캐시 헤더를 붙이는 일도, HTTPS를 벗기는 일도, 압축하는 일도 대체로 핸들러가 아니라 이 길목에서 일어난다.
실무에서 이 왕복을 어디서 보나
개발자도구 말고도 이 메시지를 마주칠 자리가 많다.
- 서버 액세스 로그. 대부분
메서드 · 경로 · 상태코드 · 응답시간순으로 남는다. 이 글에서 본 그대로다. 장애가 나면 여기서 상태 코드 분포부터 본다. - curl.
curl -i https://example.com/api/products를 치면 응답 헤더와 바디가 그대로 나온다.-v를 붙이면 보낸 요청까지 보인다. 브라우저를 거치지 않아 브라우저가 몰래 붙이는 것들을 걷어낸 순수한 왕복을 볼 수 있다. - API 게이트웨이·로드밸런서. 요청이 앱에 닿기 전에 이런 것들을 지나면서 헤더가 붙는다. 원래 클라이언트 IP를 알려주는
X-Forwarded-For가 대표적이다. 앱이 보는 요청은 클라이언트가 보낸 것과 이미 다를 수 있다. - 에러 응답 형식. 상태 코드만으로는 “왜”를 못 담아서, 바디에 에러 코드와 메시지를 함께 담는 게 보통이다. 이때도 상태 코드는 정직하게 주고, 상세는 바디에 담는다. 위에서 본 “에러를 200으로 주지 마라”가 여기서 지켜진다.
정리
| 한 왕복 | 클라이언트가 묻고 서버가 답한다. 서버가 먼저 말 걸지 않는다 |
| 메시지 구조 | 시작줄 · 헤더 · 빈 줄 · 바디. 요청과 응답이 같은 모양 |
| 메서드 | 무엇을 하려는지. 약속이지 강제가 아니다 |
| 상태 코드 | 앞자리로 갈린다. 4xx는 보낸 쪽, 5xx는 받은 쪽 |
| 헤더 | 부가 정보가 아니라 협상. Accept-*와 Content-*가 짝 |
| 서버 안에서 | 공통 처리 구간을 지나 핸들러로 간다 |
한 왕복의 구조를 봤다. 그런데 이 왕복에는 이상한 성질이 하나 있다. 방금 로그인한 사람이 다음 요청을 보내도, 서버는 그가 누구인지 모른다. 요청 하나하나가 서로를 모른 채 도착하기 때문이다. 왜 그렇게 만들었고 그래서 무엇이 필요해졌는지가 다음 글이다.