HTTP — 요청과 응답은 그냥 텍스트다
Article
1강에서 만든 서버는 이렇게 생겼었다.
String first = in.readLine();
System.out.println("첫 줄: " + first);
한 줄만 읽었다. 그리고 그 줄에 뭐라고 적혀 있든 무시하고 무조건 "안녕"을 돌려줬다.
- 첫 줄: GET / HTTP/1.1
그런데 curl 이 정말 한 줄만 보냈을까. 아니다. readLine() 은 한 줄만 읽었을 뿐이고, 나머지는 읽지 않은 채 버려졌다.
이번 강의는 버린 것들을 전부 주워서 본다. 그러고 나면 HTTP 가 무엇인지 알게 된다. 미리 말해두면, 별거 없다.
전부 찍어본다
1강의 서버를 조금 고친다. 한 줄만 읽지 말고, 빈 줄이 나올 때까지 계속 읽어서 전부 찍는다.
읽는 함수를 직접 만든다. 왜 BufferedReader 를 안 쓰는지는 조금 뒤에 알게 된다.
static String readLine(InputStream in) throws IOException {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
int b;
while ((b = in.read()) != -1) {
if (b == '\r') { in.read(); break; }
if (b == '\n') break;
buf.write(b);
}
return buf.toString("UTF-8");
}
한 바이트씩 읽다가 줄바꿈을 만나면 멈춘다. 그리고 본체다.
Socket client = server.accept();
InputStream in = client.getInputStream();
String start = readLine(in);
System.out.println("[시작줄] " + start);
int len = 0;
String line;
while (!(line = readLine(in)).isEmpty()) {
System.out.println("[헤더 ] " + line);
if (line.toLowerCase().startsWith("content-length:")) {
len = Integer.parseInt(line.substring(15).trim());
}
}
System.out.println("[빈 줄 ] 여기까지가 머리다");
if (len > 0) {
byte[] body = in.readNBytes(len);
System.out.println("[본문 ] " + new String(body, "UTF-8"));
} else {
System.out.println("[본문 ] 없음");
}
띄워놓고 요청을 보낸다.
curl http://localhost:8080/books
- [시작줄] GET /books HTTP/1.1
- [헤더 ] Host: localhost:8080
- [헤더 ] User-Agent: curl/8.15.0
- [헤더 ] Accept: */*
- [빈 줄 ] 여기까지가 머리다
- [본문 ] 없음
이게 전부다. HTTP 요청은 글자 몇 줄이다. 암호도 아니고 이진 데이터도 아니다. 메모장에 그대로 쳐도 되는 텍스트다.
요청은 네 부분으로 되어 있다
방금 본 것을 정리하면 이렇다.
- 01
시작줄 — 딱 한 줄
무엇을, 어디에, 어떤 규격으로. 세 덩어리가 빈칸으로 나뉘어 있다.
GET /books HTTP/1.1 - 02
헤더 — 0줄 이상
이름: 값꼴로 부가 정보를 적는다. 몇 줄이든 상관없다.Host: localhost:8080 User-Agent: curl/8.15.0 - 03
빈 줄 — 정확히 한 줄
머리가 끝났다는 신호다. 이게 없으면 서버는 헤더가 더 올 줄 알고 계속 기다린다.
- 04
본문 — 있을 수도, 없을 수도
보낼 게 있으면 여기 담는다. GET 요청에는 대개 없다.
시작줄을 쪼갠다
GET /books HTTP/1.1 은 빈칸을 기준으로 세 덩어리다.
| 덩어리 | 이름 | 뜻 |
|---|---|---|
GET | 메서드 | 무엇을 하려는가 — 가져가겠다 |
/books | 경로 | 어디에 대고 하려는가 |
HTTP/1.1 | 버전 | 어떤 규격으로 말하는가 |
1강에서 우리 서버는 / 로 오든 /books 로 오든 똑같이 "안녕"을 돌려줬다. 경로를 읽지 않았으니까. 경로마다 다르게 대접하는 일이 결국 우리가 만들 API 의 전부고, 그걸 9강에서 @RestController 로 한다.
헤더는 부가 정보다
시작줄 다음 줄부터 빈 줄까지가 헤더다. 형식은 언제나 이름: 값 이다.
| 헤더 | 뜻 |
|---|---|
Host: localhost:8080 | 어느 집을 찾아왔는지. 한 서버가 여러 도메인을 받을 수 있어서 필요하다 |
User-Agent: curl/8.15.0 | 나는 이런 프로그램이다. 브라우저면 브라우저 이름이 적힌다 |
Accept: */* | 아무 형식이나 받겠다. application/json 만 받겠다고 적을 수도 있다 |
헤더는 얼마든지 늘릴 수 있고, 서버가 모르는 헤더는 그냥 무시된다.
빈 줄이 하는 일
헤더는 몇 줄인지 미리 정해져 있지 않다. 그래서 "헤더 끝"을 알리는 표시가 필요하다. 그게 빈 줄이다.
우리 코드가 정확히 그렇게 생겼다.
while (!(line = readLine(in)).isEmpty()) { // 빈 줄이 나오면 멈춘다
빈 줄을 만나기 전까지는 전부 헤더고, 만난 다음부터는 본문이다. 그래서 응답을 만들 때도 헤더와 본문 사이에 \r\n\r\n 을 넣었던 것이다. 앞의 \r\n 은 마지막 헤더 줄을 끝내고, 뒤의 \r\n 이 빈 줄이다.
본문 — POST 로 보내본다
GET 요청에는 본문이 없었다. 이번엔 보낼 것을 담아 보낸다.
curl -X POST http://localhost:8080/books \
-H 'Content-Type: application/json' \
-d '{"title":"자바","price":20000}'
- [시작줄] POST /books HTTP/1.1
- [헤더 ] Host: localhost:8080
- [헤더 ] User-Agent: curl/8.15.0
- [헤더 ] Accept: */*
- [헤더 ] Content-Type: application/json
- [헤더 ] Content-Length: 32
- [빈 줄 ] 여기까지가 머리다
- [본문 ] {"title":"자바","price":20000} <- 32 바이트
두 헤더가 새로 붙었다. curl 이 알아서 계산해 넣은 것이다.
Content-Type— 본문이 어떤 형식인지. 여기서는 JSON 이다Content-Length— 본문이 몇 바이트인지
Content-Length 가 왜 필요한가. 본문에는 "여기가 끝"을 알리는 빈 줄 같은 표시가 없다. 본문 안에 줄바꿈이 들어갈 수도 있으니 그런 표시를 쓸 수 없다. 그래서 길이를 미리 알려준다. 서버는 그 숫자만큼만 읽고 멈춘다.
우리 코드가 그렇게 되어 있다.
byte[] body = in.readNBytes(len); // 딱 len 바이트만 읽는다
32 바이트인데 글자는 28개다
방금 보낸 본문을 세어보자.
{"title":"자바","price":20000}
글자는 28개다. 그런데 Content-Length 는 32 라고 적혀 있었다.
Content-Length 는 글자 수가 아니라 바이트 수를 센다. 자 와 바 는 UTF-8 에서 한 글자당 3바이트다. 영문·숫자·기호 26개가 26바이트, 한글 두 글자가 6바이트. 합쳐서 32다.
1강에서 "안녕" 을 돌려주며 Content-Length: 6 이라고 적었던 것도 같은 이유다. 두 글자인데 6바이트였다.
숫자를 틀리면 어떻게 되는지 직접 해봤다. 본문은 받았다(9바이트)로 고정하고 Content-Length 만 거짓말시켰다.
| 적어 보낸 값 | 클라이언트가 받은 것 | curl 종료코드 |
|---|---|---|
9 (정직) | 받았다 | 0 — 정상 |
20 (실제보다 크게) | 받았다 + 에러 | 18 — end of response with 11 bytes missing |
6 (실제보다 작게) | 받았 — 조용히 잘린다 | 0 — 정상으로 취급 |
응답도 똑같이 생겼다
여기가 이 강의에서 가장 중요한 대목이다. 응답은 요청과 구조가 같다. 첫 줄만 다르다.
요청
클라이언트 → 서버
- 시작줄 —
GET /books HTTP/1.1 - 헤더 —
Host: ... - 빈 줄
- 본문 — 있을 수도, 없을 수도
응답
서버 → 클라이언트
- 상태줄 —
HTTP/1.1 200 OK - 헤더 —
Content-Type: ... - 빈 줄
- 본문 — 있을 수도, 없을 수도
1강에서 응답을 손으로 조립할 때 이렇게 썼던 것을 다시 보자.
String head = "HTTP/1.1 200 OK\r\n"
+ "Content-Type: text/plain; charset=utf-8\r\n"
+ "Content-Length: " + bodyBytes.length + "\r\n"
+ "\r\n";
상태줄 한 줄, 헤더 두 줄, 그리고 빈 줄. 우리는 그때 이미 HTTP 를 손으로 쓰고 있었다. 뜻을 몰랐을 뿐이다.
상태줄 HTTP/1.1 200 OK 의 200 이 상태코드다. 이 숫자가 무슨 약속인지는 3강에서 본다.
curl -v 로 양쪽을 한눈에
curl 에 -v 를 붙이면 주고받은 글자를 전부 보여준다.
curl -v http://localhost:8080/books
- > GET /books HTTP/1.1
- > Host: localhost:8080
- > User-Agent: curl/8.15.0
- > Accept: */*
- >
- < HTTP/1.1 200 OK
- < Content-Type: text/plain; charset=utf-8
- < Content-Length: 9
- <
- 받았다
응답의 Content-Length: 9 도 확인해두자. 받았다 는 세 글자, 9바이트다.
그래서 HTTP 는 어렵지 않다
- 글자를 보낸다GET /books HTTP/1.1 …
- 서버가 읽는다시작줄 · 헤더 · 본문
- 글자를 돌려준다HTTP/1.1 200 OK …
- 연결을 끊는다
주소창에 주소를 치는 것도, 앱이 서버에서 데이터를 받아오는 것도 전부 이 왕복이다. https 는 이 글자들을 암호화해서 보낼 뿐, 구조는 똑같다.
정리하면
HTTP 요청과 응답은 시작줄 · 헤더 · 빈 줄 · 본문으로 된 텍스트다.
- 01
요청의 첫 줄은 세 덩어리다
메서드 · 경로 · 버전. 1강에서 읽고 버렸던 그 줄이다.
GET /books HTTP/1.1 - 02
빈 줄이 머리의 끝을 알린다
헤더가 몇 줄인지 미리 알 수 없으므로 끝을 알리는 표시가 필요하다.
- 03
본문의 끝은 Content-Length 로 안다
글자 수가 아니라 바이트 수다. 실제보다 작게 적으면 에러도 없이 조용히 잘린다.
byte[] body = in.readNBytes(len); - 04
응답은 요청과 구조가 같다
첫 줄만 상태줄로 바뀐다. 헤더 · 빈 줄 · 본문은 그대로다.
HTTP/1.1 200 OK - 05
서버는 방금 누가 왔는지 기억하지 않는다
요청 하나하나가 독립이다.
이번 강의에서 두 개를 미뤄뒀다.
시작줄의 GET 자리에 무엇을 쓸 수 있는지, 그리고 상태줄의 200 이 무슨 뜻인지. 둘 다 약속이다. 누가 강제하지 않지만 모두가 지키기로 한 약속이고, 그 약속을 지키는 API 를 REST 답다고 부른다.
3강에서 그 약속을 본다. 우리 서버가 지금 무엇을 어기고 있는지도 함께 본다. 지금 우리 서버는 없는 책을 달라고 해도 200 OK 라고 답한다.