뭔가 웹 브라우저나 curl을 통해서 어딘가에 요청을 보낸다면 그 응답이 돌아오는 것이 http라는 것인데, 사실 알고 보면 신기한 것은 아니다. 물론 DNS를 타고 요청을 보낼 곳을 알아낸다거나, mtu만큼 데이터가 잘라져서 보내지고 합쳐진다거나, L7부터 L1까지 가는 여정을 빼고 본다면. anffhs 뺀다기 보다는 잘 추상화된 것은 건드리지 않고 순수하게 요청과 응답, 이 두개로만 본다면 신기한 것이 아니라는 거다.

결국 통신이라는 것은 서로간에 인터페이싱을 진행하는 것이다. 인터페이싱은 사전에 정의된 대로 주고받도록 하는 약속이다. 그리고 이 약속은 L1~L7까지 모든 레이어가 다 자신이 수행하여야 할 약속을 그대로 이행한다. 그리고 http도 마찬가지다.

http의 요청과 응답은 편지와 같다. 기본적인 골격이 정해져 있는 편지지가 있어서, 여기에 여러 내용을 규격에 맞게 넣어 두기만 하면 그게 바로 요청이 된다는 것이다.

images-001

http rest api를 작성할 때 주로 사용하는 테스트 툴로 Postman이라는 프로그램이 있는데, 여기에서 여러 형태의 요청을 만들 수 있다. 여기서 보면 GET, POST등을 정하는 Method, 그리고 요청을 어디에 보낼지를 정하는 URL을 적는 란이 있으며 Header라던가, Query Params를 지정할 수 있다. 그러면 이것들은 실제로 어떻게 요청의 목적지에 전해지는가?

우선 편지지의 전체 내용부터 빠르게 보자.

METHOD PATH HTTP_VERSION
KEY: VALUE

BODY

이 부분을 채우면 그게 요청이다. 그래서 만약 google.com에 요청을 보낸다고 하면 위 편지지가,

GET / HTTP/1.1
Host: google.com

이렇게 만들고 google.com에 요청을 보내면(BODY는 보내지 않아도 된다.) 되는 것이다. 그래서 이 내용을 전달할 수 있는 어떤 프로그램이 있다면 그게 HTTP 클라이언트가 된다. 극단적으로는 telnet으로 보내는 것도 가능하다.

아래는 로컬에 띄운 간단한 웹 서버에 telnet으로 HTTP 요청을 보낸 내용이다.

$ telnet localhost 1313
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
GET / HTTP/1.1 

HTTP/1.1 400 Bad Request: missing required Host header
Content-Type: text/plain; charset=utf-8
Connection: close

위 출력의 7라인부터는 응답인데, 응답도 보면 요청과 유사하게, 어떤 규칙이 있는 것을 알 수 있다.

HTTP_VERSION STATUS_CODE REASON_PHRASE
KEY: VALUE

BODY

이 포맷과 위 7라인부터의 응답을 비교해 보자.

그래서 결국 http 요청이라는 것은 편지지를 만들어 보내는 것이고, 웹 서버는 이 편지지를 파싱해서 여러 정보를 얻은 후 필요한 곳에 데이터를 보내 처리하도록 하는 것, 그리고 응답은 이 처리 결과를 다시 조합하는 것이다.

request, 요청

위에서 적은 요청의 편지지, 본문을 풀어서 다시 적으면 다음과 같이 나누어 적을 수 있다.

REQUEST_LINE
HTTP_HEADERS

MESSAGE_BODY

줄 바꿈은 그냥 바꾼 것이 아니라 정말 의미가 있다. CRLF, 줄 바꿈을 통해 각 라인을 구분하며 특히 헤더 다음에 오는 빈 줄도 헤더와 바디를 구분하기 위한 구분자로 사용한다.

REQUEST_LINE

REQUEST_LINE은 다음과 같은 구조를 가진다.

METHOD PATH HTTP_VERSION

METHOD

METHOD는 우리가 아는 GET/POST 등을 그대로 적으면 된다. 어라? 그럼 어떤 메소드를 사용할 수 있는 것일까? 그건 HTTP/1.1 기준 다음의 rfc7231을 확인하면 되고 그 메소드는 다음과 같다.

  • GET
  • HEAD
  • POST
  • PUT
  • DELETE
  • CONNECT
  • OPTIONS
  • TRACE

그런데… 여기 있는 것을 안 적으면 어찌 되는가? 그냥 보내는 사람이 아무거나 적어서 보내도 포맷이 그냥 METHOD에 맞춰져서 작성되었다면…

$ telnet 172.17.0.2 80 
Trying 172.17.0.2...
Connected to 172.17.0.2.
Escape character is '^]'.
GET / HTTP/1.1
Host: 172.17.0.2

HTTP/1.1 200 OK
Server: nginx/1.21.1
Date: Fri, 03 Sep 2021 17:57:10 GMT
Content-Type: text/html
Content-Length: 612
Last-Modified: Tue, 06 Jul 2021 14:59:17 GMT
Connection: keep-alive
ETag: "60e46fc5-264"
Accept-Ranges: bytes

<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
    body {
        width: 35em;
        margin: 0 auto;
        font-family: Tahoma, Verdana, Arial, sans-serif;
    }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

$ telnet 172.17.0.2 80 
Trying 172.17.0.2...
Connected to 172.17.0.2.

NEWMETHOD / HTTP/1.1
Host: 172.17.0.2 

HTTP/1.1 405 Not Allowed
Server: nginx/1.21.1
Date: Fri, 03 Sep 2021 17:57:47 GMT
Content-Type: text/html
Content-Length: 157
Connection: keep-alive

<html>
<head><title>405 Not Allowed</title></head>
<body>
<center><h1>405 Not Allowed</h1></center>
<hr><center>nginx/1.21.1</center>
</body>
</html>

간단히 nginx를 실행해서 NEWMETHOD라는 메소드로 요청을 보냈더니 위와 같은 결과가 나왔다. GET은 성공인데 NEWMETHOD는 HTTP 405가 출력되었다. nginx로그를 보면…

$ docker logs nginx
...
172.17.0.1 - - [03/Sep/2021:17:57:10 +0000] "GET / HTTP/1.1" 200 612 "-" "-" "-"
172.17.0.1 - - [03/Sep/2021:17:57:47 +0000] "NEWMETHOD / HTTP/1.1" 405 157 "-" "-" "-"
...

이를 보면 알 수 있는 것은 GET으로 보내든 NEWMETHOD로 보내든 요청은 nginx에 무사히 도달했다는 것이다. 즉 메소드는 받아주는 곳에서 처리만 할 수 있다면 보내는 쪽에서 아무렇게나 보내도 괜찮다는 뜻이다. 물론 RFC를 비롯해서 여러 곳에서 제안하는 일반적인 메소드가 있긴 하다는 거지만.

PATH

PATH는 별다른 내용은 없고, 그냥 요청을 받은 웹 서버가, 자신이 가지고 있는 자원을 구분할 수 있도록 하는 구분자라고 생각하면 된다. url 쿼리 파라미터의 경우 이 Path에 함께 넣어서 처리할 수 있다.

$ telnet localhost 10500                                                                                                          (labs-mapdatalake@cdg1)
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
GET /admin/notifications?limit=1 HTTP/1.1
Host: localhost:10500

HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Totalcount: 9
Date: Sat, 04 Sep 2021 08:33:29 GMT
Content-Length: 164

[{"id":"88cae662-1f2c-456a-a330-cadcb1d86fbc"}]

GET /admin/notifications?limit=2 HTTP/1.1
Host: localhost:10500

HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Totalcount: 9
Date: Sat, 04 Sep 2021 08:35:36 GMT
Content-Length: 326

[{"id":"88cae662-1f2c-456a-a330-cadcb1d86fbc"},{"id":"691aefbc-276f-40f0-8b87-f4f70b09ca47"}]

HTTP_VERSION

HTTP_VERSION, http 버전은 0.9, 1.0, 1.1, 2.0등 다양하게 있지만 여기서는 HTTP/1.1만 적는다. 굉장히 생략한 채로 이야기하면 HTTP 1.1 기준으로 설명해도 크게 상관은 없다. 그래도 HTTP/2.0에 대한 내용은 나중에 따로 적어 보려고 한다. 이게 또 적으려면 길게 적어야 하기 때문에…

HTTP_HEADERS

HTTP 헤더는 요청에 대한 추가적인 정보를 함께 담으려고 사용한다. 걍 본문에 넣으면 되는 것 아님? 이라 생각할 수 있는데 이게 본문하고는 분명히 다르다. 본문은 말 그대로 본문이며, 이런 추가 정보를 넣는다고 하면 같은 내용인데도 매 요청마다 본문이 달라지는 이상한 상황이… 아무튼 그래서 이 헤더라는 것으로 추가 정보를 넣는다.

헤더의 구조

헤더는 구조가 굉장히 단순하다. 그냥 키: 값로 연결해둔 것이 헤더다. 그리고 헤더는 여러 라인에 걸쳐서 넣을 수도 있다. MESSAGE_BODY와 헤더 사이에는 CRLF 두 개를 넣기로 했으므로 이걸 기준으로 헤더의 끝을 알 수 있으며, REQUEST_LINE은 한 줄로 정해져 있으므로 헤더의 시작도 알 수 있다. REQUEST_LINE 다음 줄부터 빈 줄이 나올때까지 헤더인 것이다.

헤더 자체는 크게 특이한 것이 없다. 단순 키와 값이 매핑된 형태이므로 그대로 적으면 된다. 여기서 까다로운 것은 well-known 헤더가 매우 많다는 것이다. 특히나 메소드 혹은 응답 코드, 다양한 상황에서 출현해야 하는 헤더들이 많기 때문에, 클라이언트나 서버를 바닥부터 구현할 때는 이런게 조금 힘든 것 같다.

MESSAGE_BODY

MESSAGE_BODY, 바디는 무엇인가? 필요한 텍스트를 적으면 그게 바디가 된다. 서버와 바디의 포맷을 논의하였다면, 어떤 형태로 보내도 상관없는 것이다. 만약 이 바디에 json형태의 데이터를 보냈다면, 받는 쪽에서는 그걸 json으로 사용할 수 있다. 그러나 이게 말처럼 단순하게 처리되지는 않고 조금 더 자세하게 이를 정리해 보자.

HEADER와 BODY

그러면 보낸 쪽에서 json을 보냈다고 생각해 보자. 받는 쪽에서는 이게 어떻게 json임을 아는가? 물론 json validation을 통해 들어온 BODY가 json인지를 확인할 수는 있다. 하지만 다양한 BODY 타입, 예를 들어 XML, JSON, markdown, 기타 등등이 모두 들어오는 케이스라고 하면 이것들을 어떻게 구분하느냐는 것이다. 들어올 가능성이 있는 모든 타입을 검증하도록 할 수는 있는데, 매 요청마다 이런 과정을 거치는 것은 조금 비효율적인 것 같다.

그래서 본문의 형식이 어떤 것인지를 서버에서 쉽게 파악할 수 있도록 HEADER와 이를 결합한다. 즉 json 타입을 보낼 때는 헤더에 이 본문의 타입이 json임을 표시하는 것이다. 이게 헤더 Content-Type이다.

Content-Type: type/subtype

type/subtype을 통틀어 MIME 혹은 Media type이라고 부르는데, 어쨌든 가능한 리스트는 이 페이지를 참고하는 것이 좋겠다. type의 경우는 크게 multipart와 나머지를 카테코라이징하는 discrete가 있다. subtype의 경우 type의 하위 카테고리가 되므로 종류가 매우 많은데, type의 경우는

The following information is related to MIME Media-Types:

The "media-types" directory contains a subdirectory for each content
type and each of those directories contains a file for each content
subtype.

                               |-application-
                               |-audio-------
                               |-image-------
                 |-media-types-|-message-----
                               |-model-------
                               |-multipart---
                               |-text--------
                               |-video-------

이것이 다인 것 같다. 그래서 json의 경우 application/json의 형태를 가지고, 또 json도 여러 종류의 media type이 있는 것을 알 수 있다. 요청을 받은 서버는 Content-Type: application/json이라는 헤더를 받았다면 본문이 json인지를 알 수 있을 것이다.

이런 식으로 요청에 매핑되는 헤더를 추가해서 서버에 부가 정보를 전달할 수 있다.

Form은 어떻게 보내는가?

HTTP Request의 여러 케이스를 살펴봤는데, 잘 보면 REQUEST_LINE, HTTP_HEADERS, BODY 이렇게 세 줄이 있다고 치면 도대체 form 데이터 같은 경우는 어떻게 보내는가? 하는 것이다. 일단 저 포맷대로라면 form을 위한 자리는 없으므로 헤더 혹은 body에 들어가야 하는데, 헤더는 다양한 추가 정보를 부여하는 자리라고 한다면 결국 form도 body에 들어가야 한다. 그러면 form을 어떻게 body에 보내는가?

images-002

<form> 태그로 감싼 본문을 보면 <input> 태그에 namevalue가 있는 것을 볼 수 있다. submit을 통해 이를 서버에 전달할 수 있는데, 그럼 이건 어떻게 body에 들어가는지를 살펴보려고 한다. 여기서부터는 curl을 사용해서 확인해 보자.

$ curl http://w3.org/ --form "hi=there" --form "hello=world" --trace-ascii /dev/stdout
== Info:   Trying 128.30.52.100:80...
== Info: TCP_NODELAY set
== Info:   Trying 2603:400a:ffff:804:801e:34:0:64:80...
== Info: TCP_NODELAY set
== Info: Immediate connect fail for 2603:400a:ffff:804:801e:34:0:64: 네트워크가 접근 불가능합니다
== Info: Connected to w3.org (128.30.52.100) port 80 (#0)
=> Send header, 178 bytes (0xb2)
0000: POST / HTTP/1.1
0011: Host: w3.org
001f: User-Agent: curl/7.68.0
0038: Accept: */*
0045: Content-Length: 241
005a: Content-Type: multipart/form-data; boundary=--------------------
009a: ----fa163b04654700e7
00b0: 
=> Send data, 241 bytes (0xf1)
0000: --------------------------fa163b04654700e7
002c: Content-Disposition: form-data; name="hi"
0057: 
0059: there
0060: --------------------------fa163b04654700e7
008c: Content-Disposition: form-data; name="hello"
00ba: 
00bc: world
00c3: --------------------------fa163b04654700e7--
== Info: We are completely uploaded and fine
== Info: Mark bundle as not supporting multiuse
<= Recv header, 32 bytes (0x20)
0000: HTTP/1.1 301 Moved Permanently
<= Recv header, 19 bytes (0x13)
0000: content-length: 0
<= Recv header, 30 bytes (0x1e)
0000: location: http://www.w3.org/
<= Recv header, 19 bytes (0x13)
0000: connection: close
<= Recv header, 2 bytes (0x2)
0000: 
== Info: Closing connection 0

Content-Disposition이라는 헤더가 보이는데, 이는 body를 특별한 기준으로 나눈다는 뜻이다. 그리고 위 예에서 기준은 --------------------------fa163b04654700e7가 된다. form의 키로서 사용했던 nameContent-Disposition 헤더의 ;이후 나오는 부가 정보로 포함되었고, 이후 빈 줄 다음 value가 출현한다. 따라서 서버는 헤더에 명시된 boundary값으로 body를 파싱하여 form 데이터를 모두 가져올 수 있다. 그리고 마지막 0x00c3부분을 보면 fa163b04654700e7 이후 --가 포함된 것이 보이는데, 이는 form 데이터의 끝을 말한다.

curl 요청을 매번 보내면 boundary가 계속 변경되는 것을 알 수 있는데, 매 요청마다 랜덤한 값으로 정해진다. 이 값은 랜덤하지 않아도 상관없다. 바디를 나누는 기준이므로 유저가 직접 입력하여 사용할 수도 있는 것이다. 단지 curl에서 편의를 위해 이를 랜덤으로 지정했을 뿐.

결국 그래서… 위 application/json과 마찬가지지만, 결국 헤더에 body를 파싱할 수 있는 부가 정보가 있는 것이다. 폼이라고 해서 특별한 것은 아니고 그냥 BODY에 정해진 규격대로 데이터를 잘 넣는다는 것.

파일 업로드?

파일 업로드는 form을 통해서 진행할 수 있는데 위 내용으로 생각해 보면 결국 똑같은 boundary가 있고 이 포맷대로 파싱할 수 있도록 요청의 본문에 파일의 내용을 적당히 집어넣으면 될 것이다. 실제로 curl을 통해 해 보면 아래와 같다.

$ cat test.txt
testfile, testdata, testtest
$ curl http://localhost:10500/ --form 'file=@./test.txt' --trace-ascii /dev/stdout
== Info:   Trying 127.0.0.1:10500...
== Info: TCP_NODELAY set
== Info: Connected to localhost (127.0.0.1) port 10500 (#0)
=> Send header, 187 bytes (0xbb)
0000: POST / HTTP/1.1
0011: Host: localhost:10500
0028: User-Agent: curl/7.68.0
0041: Accept: */*
004e: Content-Length: 215
0063: Content-Type: multipart/form-data; boundary=--------------------
00a3: ----f1692b43cef53a67
00b9: 
=> Send data, 215 bytes (0xd7)
0000: --------------------------f1692b43cef53a67
002c: Content-Disposition: form-data; name="file"; filename="test.txt"
006e: Content-Type: text/plain
0088: 
008a: testfile, testdata, testtest.
00a9: --------------------------f1692b43cef53a67--
== Info: We are completely uploaded and fine
== Info: Mark bundle as not supporting multiuse

그냥 파일 내용을 읽어서 본문에 그대로 전달하면 된다. 그럼 파일 이름은 어떻게 보존하는가? 그건 Content-Dispositionfilename으로 이름을 전달한다. 서버는 본문의 내용을 filename=test.txt로 해서 잘 저장하면 되는 것이다. 용량이 매우 큰 파일은 어떨까?

$ ls -la test.txt
-rw-rw-r-- 1 cublr cublr 15283  9월  4 17:56 test.txt
$ curl http://localhost:10500/ -H "Expect:" --form 'file=@./test.txt' --trace-ascii /dev/stdout
== Info:   Trying 127.0.0.1:10500...
== Info: TCP_NODELAY set
== Info: Connected to localhost (127.0.0.1) port 10500 (#0)
=> Send header, 189 bytes (0xbd)
0000: POST / HTTP/1.1
0011: Host: localhost:10500
0028: User-Agent: curl/7.68.0
0041: Accept: */*
004e: Content-Length: 15469
0065: Content-Type: multipart/form-data; boundary=--------------------
00a5: ----3f4e5780dde92ab8
00bb: 
=> Send data, 15469 bytes (0x3c6d)
0000: --------------------------3f4e5780dde92ab8
002c: Content-Disposition: form-data; name="file"; filename="test.txt"
006e: Content-Type: text/plain
0088: 
008a: testfile, testdata, testtest testfile, testdata, testtest testfi
00ca: le, testdata, testtest testfile, testdata, testtest testfile, te
010a: stdata, testtest testfile, testdata, testtest testfile, testdata
...
...
...
3b8a: test testfile, testdata, testtest testfile, testdata, testtest t
3bca: estfile, testdata, testtest testfile, testdata, testtest testfil
3c0a: e, testdata, testtest testfile, testdata, testtest.
3c3f: --------------------------3f4e5780dde92ab8--
== Info: We are completely uploaded and fine
== Info: Mark bundle as not supporting multiuse

요청 헤더에 Expect: 를 줬는데 이는 추후에 설명하기로 하고, form을 사용한 파일 업로드라는 것은 이런 식으로 파일의 내용을 본문에 넣는 것이 다다.

헤더 Expect

x-www-form-urlencode?

위에서 form을 살펴봤는데, 일반적으로 GET에서는 BODY를 싣지 않으므로 GET요청에서의 폼을 어떻게 처리하느냐가 문제가 된다.

  • POST: 쿼리 스트링이 (원칙적으로는)필요없음, 왜냐하면 BODY에 값을 넣으면 되니까
  • GET: BODY가 없는 요청이므로 form 데이터를 전송할 수 없다. form 데이터도 body로 가야 하니까, url에 이를 인코딩해서 넣는다.