CS · 기본
비전공자를 위한 컴퓨터공학 지도 - 전공 4년을 개발자 눈으로 따라잡기
네트워크 - 요청 하나가 서버에 닿기까지
계층 구조, IP·포트·TCP·HTTP, DNS, 상태 코드, 완성 코드는 HTTP 요청·응답 문자열을 직접 해석하는 작은 파서
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서 프로그램이 실행되는 자리와 운영체제가 자원을 나누는 방식을 살펴보았다. 이번에는 프로그램이 다른 컴퓨터의 프로그램과 대화하는 길을 따라간다. 화면에서 버튼을 눌렀는데 결과가 나타나지 않을 때, 원인은 이름을 주소로 바꾸는 과정에도, 연결을 만드는 과정에도, 서버가 보낸 응답에도 있을 수 있다. 네트워크를 배우는 목적은 이 구간들을 구별하고 확인할 근거를 찾는 데 있다.
전공 수업에서는 통신 기능을 여러 층으로 나누어 설명하는 방식을 계층 구조라고 부른다. 여기서는 계층 이름을 외우는 데서 멈추지 않고, 학습 계획을 조회하는 요청이 어느 정보를 담고 이동하는지 살펴본다. 실제 인터넷 연결 대신 정해진 문자열을 사용하므로 실행 결과는 항상 같다.
- 주소, 포트, 통신 규약이 각각 어떤 문제를 해결하는지 설명한다.
- 이름 조회, 연결, 요청, 응답을 서로 다른 단계로 구별한다.
- HTTP 요청과 응답에서 시작 줄, 헤더, 본문을 찾아낸다.
- 상태 코드와 본문 길이를 확인하는 작은 파서를 작성한다.
- 네트워크 면접 질문을 장애를 좁히는 순서와 연결한다.
문제 상황
다른 전공을 공부하다 개발 업무를 시작한 지수가 과목별 학습 계획을 보여 주는 서비스를 만든다고 하자. 사용자가 ‘네트워크’ 과목을 누르면 프로그램은 서버에 저장된 계획을 요청한다. 어느 날 화면에는 ‘조회하지 못했다’는 문장만 나타난다. 지수는 서버가 꺼졌다고 생각했지만, 기록에는 404라는 응답 코드가 남아 있다.
404를 받았다는 사실과 연결 자체가 실패했다는 사실은 다르다. HTTP 응답을 받았다면 어떤 서버가 요청을 해석하고 응답을 보냈다는 근거가 있다. 다만 그 서버가 의도한 서버인지, 중간에서 응답한 서버인지는 추가로 확인해야 한다. 화면에 같은 실패 문장이 보이더라도 조사할 위치는 달라진다.
이번 실험에서는 이름을 주소로 바꾸는 작은 표와 요청·응답 문자열을 준비한다. 문자열에서 통신에 필요한 정보를 꺼내는 프로그램을 파서라고 한다. 전공 수업에서는 이러한 작업을 구문 분석이라고 부른다. 완성 코드는 인터넷에 접속하지 않으며, 실제 DNS나 TCP를 구현하지도 않는다. 대신 각 단계의 입력과 결과를 고정된 자료로 확인하고, HTTP 메시지를 직접 해석한다.
계층은 문제를 나누는 지도다
서로 다른 프로그램이 약속된 형식과 순서로 대화하려면 통신 규약(protocol)이 필요하다. HTTP는 요청과 응답의 의미를 정하고, TCP는 프로그램 사이에서 바이트가 순서대로 전달되는 통로를 제공한다. IP는 여러 네트워크를 거쳐 목적지로 데이터를 보내는 데 쓰인다. 각 규약이 맡은 일이 다르므로 하나를 안다고 나머지가 해결되지는 않는다.
전공 수업에서는 인터넷 통신을 응용 계층, 전송 계층, 인터넷 계층, 링크 계층으로 나누어 설명하기도 한다. 층을 더 잘게 구분하는 일곱 계층 모형도 있지만, 이 장에서는 다음 네 층으로 요청의 이동을 살펴본다.
| 계층 | 이 장의 예 | 확인할 질문 |
|---|---|---|
| 응용 | HTTP, DNS | 무엇을 요청하고 어떤 답을 받는가 |
| 전송 | TCP | 어느 프로그램과 어떤 방식으로 주고받는가 |
| 인터넷 | IP | 목적지 주소가 무엇인가 |
| 링크 | 현재 연결된 구간의 전달 | 다음 장비까지 어떻게 보내는가 |
보내는 쪽에서는 윗층의 데이터를 아랫층이 운반할 내용으로 담는다. 전공 수업에서는 이를 캡슐화라고 부른다. 받는 쪽에서는 각 층이 자신에게 필요한 정보를 읽고 윗층으로 내용을 넘긴다. HTTP의 본문을 읽는 코드가 전선의 신호까지 직접 다루지 않아도 되는 이유다.
계층은 장애를 조사하는 데도 쓰인다. 이름 조회에 실패했다면 먼저 HTTP 상태 코드를 찾을 수 없다. 연결을 만들지 못했다면 서버의 본문 처리 코드부터 고쳐도 원인을 해결하지 못할 수 있다. 반대로 응답에서 404를 읽었다면 그 응답을 만든 경로와 요청 대상을 확인할 근거가 생긴다.
실무의 규약은 반드시 하나의 조합으로만 쓰이지 않는다. 이 장에서 다루는 HTTP/1.1은 주로 TCP 위에서 사용한다. 다른 HTTP 버전에는 다른 전달 방식도 있으므로 ‘HTTP는 언제나 TCP다’라고 일반화하지 않는다. 이번 코드는 범위를 HTTP/1.1 텍스트 메시지로 한정한다.
이름에서 주소로, 주소에서 프로그램으로
IP 주소는 네트워크에서 목적지를 식별하는 데 쓰는 주소다. 이번 예제에서는 192.0.2.10을 사용한다. 설명과 문서에 사용하도록 마련된 주소 범위에 속하며, 실제 접속 대상으로 사용하지 않는다. 코드에 나오는 plan.example도 예시용 이름이다.
사람이 쓰는 이름을 주소 등의 정보로 찾아주는 체계를 도메인 이름 시스템(DNS)이라고 한다. 전공 수업에서는 이름 해석이라는 표현도 쓴다. 실제 DNS는 여러 서버와 저장된 조회 결과를 활용한다. 이번 코드의 사전은 그중 ‘이름을 넣으면 주소가 나온다’는 입출력만 나타낸다. DNS 요청을 보내거나 DNS 응답을 해석하는 코드는 아니다.
주소만으로는 어느 프로그램에 데이터를 전달할지 충분히 표현하지 못한다. 한 컴퓨터에서 여러 통신 프로그램이 실행될 수 있기 때문이다. 포트는 전송 계층에서 통신 대상을 구별하는 번호다. 예제의 목적지는 TCP를 사용하는 192.0.2.10의 8080번 포트다. 주소와 포트의 조합을 통신 끝점(endpoint)이라고 부른다.
포트 번호만 보고 프로그램의 정체를 확정할 수는 없다. 서버 프로그램이 어느 포트에서 요청을 기다리도록 설정했는지 확인해야 한다. 또한 실제 TCP 연결은 양쪽 주소와 포트로 구별한다. 이번 실험은 목적지만 출력하지만, 실제로는 요청을 보내는 쪽에도 주소와 포트가 있다.
전송 제어 프로토콜(TCP)은 연결된 양쪽에 순서 있는 바이트 흐름을 제공한다. 바이트는 데이터를 세는 작은 단위로, 한 바이트는 여덟 비트다. TCP는 전달 중 빠진 데이터를 다시 보내고 순서를 맞추는 기능을 갖는다. 그러나 상대의 작업이 성공했는지까지 보장하지는 않는다. 연결이 끊어졌다면 요청이 처리되었는지 별도로 판단해야 할 수도 있다.
여기서 중요한 성질은 바이트의 순서를 유지해도 응용 메시지의 경계는 유지하지 않는다는 점이다. 보내는 프로그램이 한 번에 쓴 HTTP 메시지를 받는 프로그램이 여러 번 나누어 읽을 수 있다. 반대로 두 메시지가 한 번의 읽기 결과에 함께 들어올 수도 있다. 전공 수업에서는 이를 바이트 스트림이라고 부른다.
코드는 하나의 요청을 세 조각으로 잘랐다가 다시 붙인다. 이것은 TCP의 재전송이나 순서 복구를 구현하는 실험이 아니다. 프로그램이 받은 조각의 경계와 HTTP 메시지의 경계가 같지 않을 수 있다는 사실을 확인하는 실험이다. 실제 통신 프로그램은 아직 부족한 데이터를 모으고, 완성된 메시지만 해석해야 한다.
HTTP는 경계와 의미를 함께 읽는다
하이퍼텍스트 전송 프로토콜(HTTP)은 요청과 응답의 형식과 의미를 정한다. 이번에 다루는 HTTP/1.1 메시지는 시작 줄, 헤더, 빈 줄, 본문으로 나뉜다. 헤더는 본문 길이나 요청 대상의 호스트처럼 메시지를 해석하는 데 필요한 부가 정보다.
요청 시작 줄의 GET /plan HTTP/1.1에서 GET은 수행하려는 동작인 메서드다. /plan은 요청 대상이며 HTTP/1.1은 사용한 규약 버전이다. 응답 시작 줄의 HTTP/1.1 200 OK에서는 200이 상태 코드이고 OK는 이유 구절이다. 프로그램은 이유 구절의 문구보다 숫자 코드로 처리 방향을 판단한다.
줄 끝에는 복귀 문자와 줄바꿈 문자를 이어 쓴다. 이를 CRLF라고 부르며 Python 문자열에서는 \r\n으로 적는다. 헤더가 끝나는 곳에는 빈 줄이 있으므로, 전체 경계는 \r\n\r\n이다. 본문에도 같은 문자열이 들어갈 수 있어 처음 나타난 경계만 나누어야 한다.
Content-Length는 본문의 바이트 수다. Python의 len("계획")은 글자 수인 2지만, 문자를 바이트로 바꾸는 문자 인코딩인 UTF-8을 적용하면 여섯 바이트다. 문자열 길이를 그대로 넣으면 받는 쪽이 본문을 덜 읽거나 다음 데이터를 기다릴 수 있다. 이번 코드에서는 모든 입력 문자열이 UTF-8로 바뀐다는 조건을 두고 길이를 검사한다.
헤더 이름은 대소문자를 구별하지 않는다. 따라서 Content-Length와 content-length는 같은 이름으로 처리한다. 다만 서로 다른 규칙으로 길이를 해석하는 프로그램들이 섞이면 메시지 경계를 다르게 판단할 수 있다. 예제 파서는 범위를 좁혀 중복 헤더와 Transfer-Encoding을 거부한다. 모든 메시지에 Content-Length가 있어야 한다는 조건도 둔다.
| 범주 | 뜻 | 예 | 확인할 내용 |
|---|---|---|---|
| 1xx | 처리 중의 정보 | 100 | 뒤에 이어질 응답 |
| 2xx | 요청을 성공적으로 처리함 | 200 | 본문의 내용과 형식 |
| 3xx | 추가 동작이 필요함 | 301 | 이동 위치나 저장된 표현의 사용 |
| 4xx | 요청에 관한 오류 | 404 | 대상 경로와 접근 조건 |
| 5xx | 서버의 처리 오류 | 500 | 서버의 처리 기록 |
404는 요청한 대상을 찾지 못했거나 그 존재를 공개하지 않으려는 경우에도 사용한다. 500은 서버 내부에서 예상하지 못한 문제가 발생했음을 나타내지만 구체적인 원인까지 알려 주지는 않는다. 상태 코드는 조사 방향을 주는 신호다. 요청과 서버 기록을 함께 읽어야 실제 원인을 좁힐 수 있다.
완성 코드는 단일 메시지가 전부 준비된 상황만 다룬다. 요청에서는 GET과 POST를 허용하고 Host를 요구한다. 응답에서는 200부터 599까지 읽되 본문 규칙이 다른 204와 304는 지원 범위에서 제외한다. 정보 응답, HEAD 요청에 대한 응답, 연결 전환, 청크 단위 전송, 여러 메시지의 연속 처리도 다루지 않는다. 이는 HTTP 전체의 제한이 아니라 학습용 파서가 명시한 입력 조건이다.
규약의 세부 조건을 더 확인할 때는 HTTP 의미에 관한 규약과 HTTP/1.1 메시지 형식에 관한 규약을 사실 확인 자료로 사용할 수 있다. 아래 문장과 코드는 이 장의 실험을 위해 새로 구성한 것이다.
완성 코드
다음 내용을 main.py로 저장한다. 외부 패키지, 파일 입력, 네트워크 연결이 필요하지 않다. 잘못된 입력은 이유를 담은 ValueError로 거부한다. 이 예외는 입력값이 함수가 정한 조건에 맞지 않음을 나타낸다.
import re
HEADER_NAME = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
def parse_message(raw):
head, boundary, body = raw.partition("\r\n\r\n")
if not boundary:
raise ValueError("헤더 종료 빈 줄이 없다")
lines = head.split("\r\n")
if any("\r" in line or "\n" in line for line in lines):
raise ValueError("헤더의 줄 끝이 CRLF가 아니다")
start = lines[0]
headers = {}
for line in lines[1:]:
name, colon, value = line.partition(":")
if not colon or not HEADER_NAME.fullmatch(name):
raise ValueError("헤더 이름 형식이 잘못되었다")
value = value.strip(" \t")
if any(ord(char) < 32 and char != "\t" for char in value):
raise ValueError("헤더 값에 제어 문자가 있다")
if any(ord(char) > 126 for char in value):
raise ValueError("이 파서는 ASCII 헤더 값만 지원한다")
key = name.lower()
if key in headers:
raise ValueError("중복 헤더는 지원하지 않는다")
headers[key] = value
if "transfer-encoding" in headers:
raise ValueError("Transfer-Encoding은 지원하지 않는다")
length_text = headers.get("content-length", "")
if not re.fullmatch(r"[0-9]+", length_text):
raise ValueError("Content-Length에 바이트 수가 필요하다")
if int(length_text) != len(body.encode("utf-8")):
raise ValueError("Content-Length와 본문 바이트 수가 다르다")
if start.startswith("HTTP/"):
match = re.fullmatch(r"HTTP/1\.1 ([2-5][0-9]{2}) ([ -~]*)", start)
if match is None:
raise ValueError("응답 시작 줄 형식이 잘못되었다")
status = int(match.group(1))
if status in (204, 304):
raise ValueError("204와 304의 본문 규칙은 지원하지 않는다")
first = ("response", status)
else:
match = re.fullmatch(r"(GET|POST) (/[^ \t\r\n]*) HTTP/1\.1", start)
if match is None:
raise ValueError("요청 시작 줄 형식이 잘못되었다")
if not headers.get("host"):
raise ValueError("요청에 Host가 필요하다")
first = ("request", match.group(1), match.group(2))
return first, headers, body
def main():
dns_table = {"plan.example": "192.0.2.10"}
host = "plan.example"
port = 8080
print(f"이름 조회: {host} -> {dns_table[host]}")
print(f"목적지: TCP {dns_table[host]}:{port}")
request = (
"GET /plan HTTP/1.1\r\n"
"Host: plan.example:8080\r\n"
"Content-Length: 0\r\n"
"\r\n"
)
pieces = [request[:9], request[9:32], request[32:]]
restored = "".join(pieces)
print(f"수신 조각: {len(pieces)}개, 복원 일치: {restored == request}")
first, headers, body = parse_message(restored)
print(f"요청: {first[1]} {first[2]}, Host={headers['host']}")
print(f"요청 본문: {len(body.encode('utf-8'))}바이트")
for status, reason, text in (
(200, "OK", "계획"),
(404, "Not Found", "없음"),
):
size = len(text.encode("utf-8"))
response = (
f"HTTP/1.1 {status} {reason}\r\n"
"Content-Type: text/plain; charset=utf-8\r\n"
f"Content-Length: {size}\r\n"
"\r\n"
f"{text}"
)
first, headers, body = parse_message(response)
print(f"응답: {first[1]}, 본문={body}, 길이={headers['content-length']}")
bad_response = (
"HTTP/1.1 200 OK\r\n"
"Content-Length: 2\r\n"
"\r\n"
"계획"
)
try:
parse_message(bad_response)
except ValueError as error:
print(f"거부: {error}")
if __name__ == "__main__":
main()
줄별 해설
import re는 문자열 형식을 검사하는 표준 라이브러리를 불러온다. re가 다루는 정규 표현식은 허용할 문자와 반복 규칙을 적는 표기법이다. HEADER_NAME의 패턴은 HTTP 헤더 이름에 사용할 수 있는 문자들을 검사한다. fullmatch는 문자열 전체가 그 규칙에 맞는지 확인한다.
parse_message의 첫 줄은 partition으로 처음 나온 빈 줄을 찾는다. 결과는 경계 앞, 경계 자체, 경계 뒤의 세 부분이다. 경계를 찾지 못하면 가운데 값이 빈 문자열이 된다. 본문이 빈 문자열인 정상 요청과 경계가 없는 입력을 구별하려고 boundary를 따로 검사한다.
head.split("\r\n")은 시작 줄과 각 헤더 줄을 분리한다. 분리한 줄에 복귀 문자나 줄바꿈 문자가 남아 있다면 이번 파서가 기대한 줄 끝 형식이 아니다. 이어서 첫 줄은 start에 두고, 나머지 줄은 헤더로 해석한다.
각 헤더 줄도 partition(":")으로 첫 콜론에서만 나눈다. Host 값의 plan.example:8080처럼 값 자체에 콜론이 있을 수 있기 때문이다. 이름 앞뒤의 공백은 지우지 않는다. 이름에 공백이 들어간 잘못된 입력을 조용히 받아들이지 않으려는 선택이다. 값 양끝의 공백과 탭만 제거한다.
ord는 문자에 대응하는 정수 값을 돌려준다. 이 검사는 헤더 값에 제어 문자가 들어 있는지 확인하고, 이 파서에서 지원하지 않는 문자도 거부한다. 소문자로 바꾼 이름을 사전의 키로 사용하므로 Host와 host를 동일하게 찾는다. 같은 이름이 다시 나오면 덮어쓰지 않고 거부한다.
길이 검사 앞에서는 Transfer-Encoding을 거부한다. 이번 파서는 본문을 다른 단위로 나누어 전송하는 형식을 해석하지 않기 때문이다. Content-Length는 0부터 9까지의 숫자만 허용한 뒤 정수로 바꾼다. 본문은 encode("utf-8")로 바이트로 바꾸고 그 길이를 비교한다.
이 길이 비교가 성공했다고 실제 서버에 사용할 수 있는 파서가 되는 것은 아니다. 실제 수신 코드는 먼저 바이트를 모으고 메시지 경계를 찾아야 한다. 여기서는 이미 문자열로 준비된 한 메시지를 검사하므로, 본문이 덜 도착한 경우와 뒤에 다른 메시지가 붙은 경우를 모두 길이 불일치로 거부한다.
시작 줄이 HTTP/로 시작하면 응답으로 분류한다. 응답 패턴은 버전, 세 자리 상태 코드, 이유 구절을 나눈다. 상태 코드는 숫자로 바꾸어 반환한다. 204와 304는 이 실험의 범위 밖이므로 명시적으로 거부한다. 범위를 알리지 않은 채 일반 응답처럼 처리하는 것보다 제한을 드러내는 편이 낫다.
그 밖의 시작 줄은 요청으로 해석한다. 요청 패턴은 GET 또는 POST, 슬래시로 시작하는 대상, HTTP/1.1을 요구한다. Host가 비어 있거나 없으면 거부한다. 반환하는 first는 종류별 정보를 묶은 튜플이다. 요청은 종류·메서드·대상, 응답은 종류·상태 코드가 들어 있다.
main의 dns_table은 이름 조회 결과를 고정한다. 실제 주소를 알아낸 것이 아니라 예제의 대응표를 조회한 것이다. pieces는 요청 문자열을 임의의 세 위치에서 나누고 join으로 복원한다. 프로그램이 받은 조각을 모은 다음 메시지 해석을 시작한다는 관계를 보여 준다.
두 응답은 같은 반복문에서 만들어진다. ‘계획’과 ‘없음’은 모두 UTF-8에서 여섯 바이트라서 길이는 같지만, 상태 코드와 의미는 다르다. 마지막 입력은 글자 수 2를 길이로 적어 두었다. try와 except는 이 잘못된 입력을 거부하는 과정을 출력하고 프로그램을 정상적으로 끝낸다.
실행 결과
main.py가 있는 디렉터리에서 다음 명령을 실행한다. 입력이나 네트워크 상태에 의존하지 않으므로 매번 같은 결과가 나온다.
python3 main.py
이름 조회: plan.example -> 192.0.2.10
목적지: TCP 192.0.2.10:8080
수신 조각: 3개, 복원 일치: True
요청: GET /plan, Host=plan.example:8080
요청 본문: 0바이트
응답: 200, 본문=계획, 길이=6
응답: 404, 본문=없음, 길이=6
거부: Content-Length와 본문 바이트 수가 다르다
처음 두 줄은 이름과 통신 목적지를 구별한다. 세 번째 줄은 수신 조각 세 개를 합치면 원래 요청이 된다는 사실을 보여 준다. 이어지는 줄은 메시지에서 꺼낸 값이다. 마지막 거부는 오류를 방치한 결과가 아니라 파서가 입력 조건을 검사한 결과다.
이 출력만으로 DNS 통신이나 TCP 연결에 성공했다고 말할 수는 없다. 실제로 확인한 것은 대응표 조회, 조각 결합, HTTP 형식 해석과 길이 검사다. 실험이 확인한 사실의 범위를 설명하는 태도도 네트워크 문제 해결에 필요하다.
실무에서 자주 틀리는 것
본문의 글자 수를 전송 길이로 쓴다
문자열의 길이와 전송할 바이트의 길이는 다를 수 있다. 다음 잘못된 코드는 이번 본문에 2를 기록한다.
body = "계획"
content_length = len(body)
전송에 사용할 인코딩을 적용한 뒤 길이를 센다. 완성 코드의 마지막 입력은 이 차이를 검사한다.
body = "계획"
content_length = len(body.encode("utf-8"))
빈 줄이 나타날 때마다 메시지를 나눈다
본문에도 빈 줄이 있을 수 있다. 다음 코드는 본문에 같은 경계가 들어가면 결과가 두 개보다 많아져 대입에 실패한다.
head, body = raw.split("\r\n\r\n")
첫 경계만 분리하고 경계가 존재하는지도 확인한다. 이 수정은 여러 메시지를 연속 처리하는 기능까지 제공하지는 않는다.
head, boundary, body = raw.partition("\r\n\r\n")
if not boundary:
raise ValueError("헤더 종료 빈 줄이 없다")
한 번 읽은 데이터가 요청 하나라고 생각한다
아래 조각들은 완성 코드의 request를 나눈 것이다. 첫 조각만 파서에 넘기면 헤더 종료 빈 줄이 없다는 오류가 난다.
pieces = [request[:9], request[9:32], request[32:]]
parsed = parse_message(pieces[0])
이번처럼 조각 세 개가 메시지 전부라는 조건을 알고 있다면 합친 뒤 해석한다. 실제 연결에서는 본문 길이까지 확인하며 필요한 바이트를 모으고, 남은 바이트는 다음 메시지를 위해 보관해야 한다.
pieces = [request[:9], request[9:32], request[32:]]
parsed = parse_message("".join(pieces))
오류 응답을 연결 실패로 기록한다
응답 상태와 연결 실패를 같은 이름으로 기록하면 조사할 단서를 잃는다. 다음 코드는 404를 연결 실패라고 잘못 부른다.
status = 404
if status != 200:
print("연결 실패")
응답을 받았다면 받은 코드 자체를 남긴다. 연결 과정에서 발생한 실패는 실제 통신 코드가 별도로 기록해야 한다. 이 예제에는 연결 동작이 없으므로 연결 실패를 만들어 출력하지 않는다.
status = 404
if 400 <= status < 500:
print(f"요청 관련 오류 응답: {status}")
elif 500 <= status < 600:
print(f"서버 오류 응답: {status}")
한눈에 보기
| 핵심 | 실무에서 쓰는 곳 | 면접에서 묻는 방식 | 코드의 확인 지점 |
|---|---|---|---|
| 계층 구조 | 장애 구간을 나누어 조사한다 | 연결 실패와 HTTP 오류는 어떻게 다른가 | 목적지 출력과 응답 해석을 구별한다 |
| DNS·IP·포트 | 이름과 실제 접속 대상을 확인한다 | 주소가 있는데 포트는 왜 필요한가 | 이름 대응표와 8080번 포트를 출력한다 |
| TCP의 바이트 흐름 | 수신 데이터를 모아 메시지를 만든다 | 한 번 읽으면 요청 하나가 오는가 | 세 조각을 합쳐 같은 요청을 만든다 |
| HTTP 메시지 경계 | 헤더와 본문을 올바르게 나눈다 | Content-Length는 무엇의 길이인가 | UTF-8 본문 길이를 검사한다 |
| 상태 코드 | 실패의 조사 방향을 정한다 | 404와 500을 어떻게 구별하는가 | 같은 길이의 서로 다른 응답을 읽는다 |
면접에서는 용어 정의 뒤에 관찰할 근거를 덧붙이면 설명이 구체적이 된다. “TCP는 바이트 흐름을 제공하므로 읽기 한 번을 HTTP 메시지 하나로 가정하지 않는다. 헤더 경계와 본문 길이를 확인한다”처럼 성질과 코드의 선택을 연결한다. “404가 왔으니 서버가 꺼졌다”는 설명 대신 “어떤 서버가 응답했는지와 요청 경로를 확인한다”고 답할 수 있다.
더 깊게 볼 서재의 책으로는 제임스 쿠로세와 키스 로스의 Computer Networking: A Top-Down Approach, 래리 피터슨과 브루스 데이비의 Computer Networks: A Systems Approach가 있다. 첫 책에서는 응용 통신에서 아래 계층으로 이어지는 설명을, 두 번째 책에서는 계층 사이의 설계 관계를 중심으로 읽을 수 있다. 처음부터 모든 내용을 따라가기보다 이번 실험에서 생긴 질문에 해당하는 부분을 찾아 읽는다.
학습 순서를 정할 때는 우선 메시지와 바이트의 차이를 설명할 수 있는지 점검한다. 다음으로 이름 조회와 연결, 응답 처리를 구별한다. 그 뒤에 실제 통신 프로그램의 수신과 실패 처리를 공부하면 된다. 다음 장에서는 이렇게 서버에 도착한 요청이 데이터를 읽고 바꾸는 문제로 관심을 옮긴다.
연습 문제
- 완성 코드에서 응답 본문 ‘계획’을 ‘계획표’로 바꾸라. Content-Length와 출력이 어떻게 바뀌는지 실행 전에 예측하고 확인하라.
- 요청의 Content-Length를 content-length로 바꾸라. 이어서 Content-Length와 content-length를 같은 메시지에 모두 넣어 보라. 두 경우의 차이를 설명하라.
- 본문을 "첫 줄\r\n\r\n둘째 줄"로 바꾼 응답을 만들라. 길이를 올바르게 계산한 뒤 parse_message가 본문 전체를 보존하는지 비교식으로 확인하라.
- 200 응답과 404 응답에 같은 본문을 넣어 해석하라. 본문만으로 성공 여부를 판단하기 어려운 이유와, 404 응답만으로 DNS 통신 성공을 증명할 수 없는 이유를 설명하라.
정답과 해설
첫 번째 문제에서 ‘계획표’는 세 글자이며 UTF-8에서는 아홉 바이트다. 완성 코드는 size를 본문에서 계산하므로 숫자를 직접 고칠 필요가 없다. 바꾼 200 응답의 출력은 다음과 같다.
응답: 200, 본문=계획표, 길이=9
두 번째 문제에서 이름만 소문자로 바꾸면 기존 출력이 유지된다. 파서가 이름을 소문자로 통일하기 때문이다. 두 이름을 함께 넣으면 같은 키가 두 번 나타나 ‘중복 헤더는 지원하지 않는다’는 ValueError가 발생한다. 실제 HTTP에서 모든 중복 헤더가 금지되는 것은 아니다. 이 파서는 헤더별 결합 규칙을 구현하지 않아 모두 거부한다.
세 번째 문제는 다음 코드로 확인할 수 있다. 한글 여섯 글자가 열여덟 바이트이고, 공백 두 개와 CRLF 두 쌍이 여섯 바이트여서 전체 본문은 스물네 바이트다. 마지막 비교식은 True가 된다. partition이 첫 경계만 분리했으므로 본문 안의 빈 줄은 남는다.
text = "첫 줄\r\n\r\n둘째 줄"
raw = (
"HTTP/1.1 200 OK\r\n"
f"Content-Length: {len(text.encode('utf-8'))}\r\n"
"\r\n"
f"{text}"
)
first, headers, body = parse_message(raw)
print(len(body.encode("utf-8")))
print(body == text)
네 번째 문제에서는 같은 본문이라도 반환된 first의 상태 코드는 각각 200과 404다. 본문은 사람이 읽을 설명이나 다른 형식의 데이터를 담을 수 있으므로 요청 결과는 상태 코드와 본문의 약속을 함께 읽어 판단한다. 이 실험에서는 문자열을 직접 만들었고 DNS 통신을 수행하지 않았다. 실제 통신에서도 이미 저장된 주소를 쓰거나 주소를 직접 지정할 수 있으므로, HTTP 응답 하나만으로 그 요청 직전에 DNS 통신이 있었다고 단정할 수 없다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.