학습 자료

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 요청은 글자 몇 줄이다. 암호도 아니고 이진 데이터도 아니다. 메모장에 그대로 쳐도 되는 텍스트다.

요청은 네 부분으로 되어 있다

방금 본 것을 정리하면 이렇다.

HTTP 요청의 구조
  1. 01

    시작줄 — 딱 한 줄

    무엇을, 어디에, 어떤 규격으로. 세 덩어리가 빈칸으로 나뉘어 있다.

    GET /books HTTP/1.1
  2. 02

    헤더 — 0줄 이상

    이름: 값 꼴로 부가 정보를 적는다. 몇 줄이든 상관없다.

    Host: localhost:8080
    User-Agent: curl/8.15.0
  3. 03

    빈 줄 — 정확히 한 줄

    머리가 끝났다는 신호다. 이게 없으면 서버는 헤더가 더 올 줄 알고 계속 기다린다.

  4. 04

    본문 — 있을 수도, 없을 수도

    보낼 게 있으면 여기 담는다. GET 요청에는 대개 없다.

시작줄을 쪼갠다

GET /books HTTP/1.1 은 빈칸을 기준으로 세 덩어리다.

시작줄의 세 덩어리
덩어리이름
GET메서드무엇을 하려는가 — 가져가겠다
/books경로어디에 대고 하려는가
HTTP/1.1버전어떤 규격으로 말하는가
메서드에 어떤 것들이 있고 각각 무슨 약속인지는 3강에서 본다.

1강에서 우리 서버는 / 로 오든 /books 로 오든 똑같이 "안녕"을 돌려줬다. 경로를 읽지 않았으니까. 경로마다 다르게 대접하는 일이 결국 우리가 만들 API 의 전부고, 그걸 9강에서 @RestController 로 한다.

헤더는 부가 정보다

시작줄 다음 줄부터 빈 줄까지가 헤더다. 형식은 언제나 이름: 값 이다.

curl 이 자동으로 붙여준 헤더 셋
헤더
Host: localhost:8080어느 집을 찾아왔는지. 한 서버가 여러 도메인을 받을 수 있어서 필요하다
User-Agent: curl/8.15.0나는 이런 프로그램이다. 브라우저면 브라우저 이름이 적힌다
Accept: */*아무 형식이나 받겠다. application/json 만 받겠다고 적을 수도 있다
우리가 시킨 적 없는데 curl 이 알아서 붙였다. 브라우저도 마찬가지로 잔뜩 붙인다.

헤더는 얼마든지 늘릴 수 있고, 서버가 모르는 헤더는 그냥 무시된다.

빈 줄이 하는 일

헤더는 몇 줄인지 미리 정해져 있지 않다. 그래서 "헤더 끝"을 알리는 표시가 필요하다. 그게 빈 줄이다.

우리 코드가 정확히 그렇게 생겼다.

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 만 거짓말시켰다.

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 OK200 이 상태코드다. 이 숫자가 무슨 약속인지는 3강에서 본다.

curl -v 로 양쪽을 한눈에

curl-v 를 붙이면 주고받은 글자를 전부 보여준다.

curl -v http://localhost:8080/books
curl -v
  • > 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 는 어렵지 않다

결국 이 왕복이 전부다
  1. 글자를 보낸다GET /books HTTP/1.1 …
  2. 서버가 읽는다시작줄 · 헤더 · 본문
  3. 글자를 돌려준다HTTP/1.1 200 OK …
  4. 연결을 끊는다
그리고 서버는 방금 누가 다녀갔는지 기억하지 않는다.

주소창에 주소를 치는 것도, 앱이 서버에서 데이터를 받아오는 것도 전부 이 왕복이다. https 는 이 글자들을 암호화해서 보낼 뿐, 구조는 똑같다.

정리하면

HTTP 요청과 응답은 시작줄 · 헤더 · 빈 줄 · 본문으로 된 텍스트다.

다시 짚어보기
  1. 01

    요청의 첫 줄은 세 덩어리다

    메서드 · 경로 · 버전. 1강에서 읽고 버렸던 그 줄이다.

    GET /books HTTP/1.1
  2. 02

    빈 줄이 머리의 끝을 알린다

    헤더가 몇 줄인지 미리 알 수 없으므로 끝을 알리는 표시가 필요하다.

  3. 03

    본문의 끝은 Content-Length 로 안다

    글자 수가 아니라 바이트 수다. 실제보다 작게 적으면 에러도 없이 조용히 잘린다.

    byte[] body = in.readNBytes(len);
  4. 04

    응답은 요청과 구조가 같다

    첫 줄만 상태줄로 바뀐다. 헤더 · 빈 줄 · 본문은 그대로다.

    HTTP/1.1 200 OK
  5. 05

    서버는 방금 누가 왔는지 기억하지 않는다

    요청 하나하나가 독립이다.

이번 강의에서 두 개를 미뤄뒀다.

시작줄의 GET 자리에 무엇을 쓸 수 있는지, 그리고 상태줄의 200 이 무슨 뜻인지. 둘 다 약속이다. 누가 강제하지 않지만 모두가 지키기로 한 약속이고, 그 약속을 지키는 API 를 REST 답다고 부른다.

3강에서 그 약속을 본다. 우리 서버가 지금 무엇을 어기고 있는지도 함께 본다. 지금 우리 서버는 없는 책을 달라고 해도 200 OK 라고 답한다.

HTTP — 요청과 응답은 그냥 텍스트다 — 디코드랩(DCODELAB)