## 책 정보
[밑바닥부터 시작하는 웹 브라우저](https://www.hanbit.co.kr/store/books/look.php?p_code=B6818199506)
> [!NOTE]
> 코드를 계속 수정하면서 기록했기 때문에 초반의 코드와 후반의 코드가 좀 다를 수 있습니다.
## 서버에 연결하기
URL : Uniform Resource Locator, 자원을 식별하는 통일된 접근 방법 기술 규약
![[2119ef67-9294-4f58-8b63-42544f22d927.png]]
- 스킴 : 어떻게 정보를 얻을 수 있는지를 설명
- 호스트 : 어디에서 정보를 얻을 수 있는지를 설명
- 경로 : 무슨 정보를 얻을 것인지를 설명
이 외에도 선택 값인 포트, 쿼리, 프래그먼트 같은 부분도 있다.
URL이 주어지면 => 브라우저는 웹페이지를 다운로드한다.
1. 브라우저 -> 운영체제 : 호스트 이름에 해당하는 서버와 연결해달라
2. 운영체제 : DNS와 통신해 호스트 이름을 93.184.216.34 같은 IP주소로 변환함
3. 운영체제 : 라우팅 테이블을 이용해 해당 IP 주소와 통신하기에 가장 적합한 장치를 결정. 장치 드라이버로 신호를 보냄
4. 라우터 : 신호를 감지해서 메시지를 전달할 최적의 라우터에게 전송
5. ... 라우터간 전달 반복 ... 후 목적지에 도착
6. 메시지가 서버에 도착해서 해당 서버와의 연결이 이루어진다.
telnet으로 서버와의 연결을 확인할 수 있다 (mac에서는 `nc -v` 를 사용해도 되고, telnet을 설치해도 된다.)
```shell
nc -v example.org 80
```
![[ad8e6e7d-7d2a-4df7-9e8a-98589a1c476f.png]]
```shell
brew install telnet
telnet example.org 80
```
![[674e786e-aff6-446d-80ed-c3c10007850a.png]]
서버와 연결했으므로 이제 example.org와 메시지를 주고받을 수 있다!
> [!note]
> URL 문법은 [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986) 에 정의되어 있고 첫번째 저자는 팀 버너스 리이다 (또...!)
## 정보 요청하기
서버와 연결했으므로 /index.html처럼 호스트 이름 뒤에 오는 URL의 경로에 해당하는 정보를 요청할 수 있다.
![[170893b5-1f84-43e4-8999-52b038305ef4.png]]
실습 환경에서는 HTTP 1.0 버전만을 사용한다.
[Evolution of HTTP — HTTP/0.9, HTTP/1.0, HTTP/1.1, Keep-Alive, Upgrade, and HTTPS \| by Thilina Ashen Gamage \| Platform Engineer \| Medium](https://medium.com/platform-engineer/evolution-of-http-69cfe6531ba0)
요청 첫 번째 줄 이후에는 줄마다 이름과 값으로 이루어진 헤더가 자리한다. 헤더 종류는 더 많지만 우선은 Host만 사용한다.
마지막 헤더가 끝나면 빈 줄을 하나 넣어주는데, 이는 호스트에게 헤더가 끝났음을 알린다. telnet에서 요청할 때에도 위의 두 줄을 입력한 뒤에 enter를 두번 입력해야 example.org에서 응답을 받을 수 있다.
![[d2f13e8f-df94-4b76-81dc-837e460ac311.png]]
## 서버의 응답
서버의 응답 첫 줄은 이렇게 생겼다.
![[ee5d096c-e22a-4709-8abc-dcc4870cd554.png]]
호스트가 HTTP/1.0으로 응답하고 요청에 대해 OK로 응답했음을 알려준다. 다른 응답 코드로는 아래와 같은 것들이 있다.
- 100번대: 정보 메시지
- 200번대: 성공
- 300번대: 후속 작업(일반적으로 리다이렉트) 필요
- 400번대: 잘못된 요청
- 500번대: 서버가 요청을 제대로 처리하지 못함
첫번째 줄 뒤로는 서버에 헤더를 보낸다.
요청에 따라 다르지만 이런 헤더들을 보낸다.
```
Content-Type: text/html
ETag: "bc2473a18e003bdb249eba5ce893033f:1760028122.592274"
Last-Modified: Thu, 09 Oct 2025 16:42:02 GMT
Cache-Control: max-age=86000
Date: Mon, 24 Nov 2025 14:24:39 GMT
Content-Length: 513
Connection: close
X-N: S
```
많은 정보들이 있지만 일단은 넘어갑니다.
헤더 뒤에는 빈 줄이 있고, 이어서 HTML 코드가 전달된다. 이를 서버 응답의 바디라고 하고, 브라우저는 Content-Type 헤더를 통해서 이어지는 Body가 html임을 짐작한다. 여기까지 본 브라우저와 서버의 요청 응답 과정을 그림으로 정리하면 다음과 같다.
![[53504968-6c69-4990-9505-58dc6636cea2.png]]
이젠 telnet 대신 파이썬으로 이 과정을 직접 작성해봅시다.
## 파이썬을 통한 텔넷
```python
import socket
class URL:
def __init__(self, url: str):
# 스킴 분리 - 스킴은 '://' 앞부분이다
self.scheme, url = url.split("://", 1)
assert self.scheme == "http" # 실습 브라우저는 http만 지원하기 때문에 확인
# 호스트와 경로 분리 - 호스트는 다음에 오는 '/' 앞부분이다
if "/" not in url:
url = url + "/"
self.host, self.path = url.split("/", 1)
#url으로 요청을 보내기 위한 메서드
def request(self):
# 호스트에 연결하기 (소켓을 이용합니다.)
# 다른 컴퓨터와 연결할 때 필요한 요소
# 1. 주소 패밀리 (AF로 시작) : 다른 컴퓨터를 찾는 방법을 알려준다.
# 우리는 AF_INET을 쓴다.
# 2. 소켓 타입 (SOCK으로 시작) : 어떤 방식으로 데이터를 주고 받을지 알려준다.
# 우리는 SOCK_STREAM을 쓴다.(임의의 양의 데이터를 전송할 수 있는 타입)
# 3. 프로토콜 (IPPROTO로 시작) : 어떤 프로토콜을 사용할지 알려준다.
# 우리는 IPPROTO_TCP를 쓴다.(TCP 프로토콜)
# (최신 버전의 HTTP는 QUIC 프로토콜을 사용함)
sock = socket.socket(
family=socket.AF_INET,
type=socket.SOCK_STREAM,
proto=socket.IPPROTO_TCP
)
# 다른 컴퓨터에 연결하라고 지시
sock.connect((self.host, 80))
# 👏 연결 끝.
```
## 요청과 응답
```python
# 요청(request) 만들기
request = "GET {} HTTP/1.0\r\n".format(self.path)
request += "Host: {}\r\n".format(self.host)
request += "\r\n" # 헤더와 바디 구분을 위한 빈 줄
# 요청(request) 보내기
sock.send(request.encode("utf8"))
```
중요한 점...!
- 줄바꿈에는 `\r\n`을 사용한다.
- 마지막에 한번 더 `\r\n`을 보내야 한다. 이걸 빼먹으면 서버는 계속 요청의 끝을 기다리고 우리도 응답이 오기를 기다리게 된다.
encode는 텍스트를 바이트로 변환한다.
![[ee7a6102-081f-46e0-b927-69d62760ddbe.png]]
서버의 응답을 읽을 때에는 소켓의 read 함수를 사용하는데, 데이터가 도착할 때까지 수집하는 루프가 필요하다. 계속해서 읽는 makefile이라는 헬퍼 함수를 사용한다. (파이썬이 아니라면 루프에서 직접 socket.read를 호출해줘야 한다.)
```python
# 응답(response) 받기
response = sock.makefile("r", encoding="utf8", newline="\r\n")
# 응답 파싱
# 상태줄 파싱
statusline = response.readline()
version, status, explanation = statusline.split(" ", 2)
# 서버 응답 HTTP 버전 확인은 생략하지만, 사실은 확인하는 것이 좋다
# 헤더 줄 파싱
response_headers = {}
while True:
line = response.readline()
if line == "\r\n":
break
header, value = line.split(":", 1)
# 헤더는 대소문자 구분하지 않으므로 소문자로 통일
response_headers[header.casefold()] = value.strip()
# 중요한 헤더가 있는지를 확인한다.
assert "transfer-encoding" not in response_headers
assert "content-encoding" not in response_headers
# 바디 읽기
body = response.read()
# 연결 닫기
sock.close()
return body
```
## HTML 표시하기
HTML에는 태그와 텍스트가 있다.
- 태그 : 콘텐츠의 정보
- 텍스트 : 실제 콘텐츠
```html
<title>log.yoouyeon</title>
```
대부분의 태그는 시작 태그와 끝 태그로 구성되어 있다. `<>` 안에는 태그 이름이 있고, 공백으로 나누어진 어트리뷰트가 있을 수 있다. 태그의 쌍이 되는 끝 태그에는 `/`을 붙이는데, 끝 태그에는 어트리뷰트가 들어가지 못한다.
```python
# 태그를 제외한 텍스트를 출력하는 메서드
def show(body):
in_tag = False
for c in body:
if c == "<":
in_tag = True
elif c == ">":
in_tag = False
elif not in_tag:
print(c, end="")
```
이제 [[1. 웹페이지 다운로드#파이썬을 통한 텔넷|request]]와 show를 묶어서 웹페이지를 로드할 수 있게 되었다.
```shell
python3 browser.py http://example.org
```
![[4a0ed61b-cf07-4d7d-86f0-2f6f6f832bcb.png]]
```shell
python3 browser.py http://browser.engineering/http.html
```
## 암호화된 연결
지금까지 구현한 브라우저는 http 스킴만 지원하지만, 많은 웹사이트가 https 스킴으로 전환하고 있다. http와 https 스킴의 중요한 차이는 https가 더 안전하다는 것이다.
https는 HTTP over TLS를 의미하는데, https 스킴은 브라우저와 호스트 간의 모든 통신이 암호화된다는 점을 제외하면 일반적인 http 스킴과 동일하다.
![[3a87c2ff-17e9-4f09-8751-44ccd098c47d.png]]
출처: [Downloading Web Pages \| Web Browser Engineering](http://browser.engineering/http.html)
다행히도 파이썬의 ssl 라이브러리가 이 세부사항을 구현하고 있기 때문에 파이썬으로는 기본적인 암호화된 연결을 만드는 것이 간단하다.
```python
# ssl 적용
import ssl
ctx = ssl.create_default_context()
sock = ctx.wrap_socket(sock, server_hostname=host)
```
실제 우리 브라우저에 적용할 때에는 스킴을 확인하고, https일 때에만 소켓을 SSL로 감싸줘야 한다.
```python
def __init__(self, url: str):
# ...
assert self.scheme in ["http", "https"] # 지원하는 스킴에 https 추가
# 스킴 확인, 포트 결정
if self.scheme == "http":
self.port = 80
elif self.scheme == "https":
self.port = 443
# ...
def request(self):
# ...
# https인 경우에는 SSL로 소켓 감싸기
if self.scheme == "https":
import ssl
ctx = ssl.create_default_context()
sock = ctx.wrap_socket(sock, server_hostname=self.host)
```
추가로 URL에 사용자 지정 포트를 사용할 수 있도록 추가해봅시다.
![[542d7fe8-c6f4-4550-8519-4a02fc7b56ab.png]]
```python
def __init__(self, url:str):
# 만약에 사용자 지정 포트가 있다면 그걸 사용한다.
if ":" in self.host:
self.host, port = self.host.split(":", 1)
self.port = int(port)
```
사용자 지정 포트는 디버깅에 유용하다. 파이썬에는 컴퓨터의 파일을 웹 서비스로 만들 수 있는 간단한 웹 서버가 내장되어 있다.
```shell
python3 -m http.server 8000 -d /some/directory
```
![[d17a0600-c5e9-428f-a0d8-76313d127891.png]]
이제 브라우저는 어떤 웹 페이지든 실행할 수 있다!
http://broswer.engineering/examples/example1-simple.html
## 요약
- URL은 `스킴://호스트:포트/경로` 로 파싱한다.
- sockets와 ssl라이브러리를 이용해서 호스트에 연결한다.
- Host 헤더도 요청에 함께 포함한다.
- HTTP 응답을 상태 표시줄 / 헤더 / 바디로 분할한다.
- 바디에서 태그를 제외한 텍스트를 출력한다.
## 연습 문제
### 1-1 HTTP/1.1
헤더를 좀 더 쉽게 추가할 수 있도록 딕셔너리로 선언해줬다.
```python
headers = {
"Host": self.host,
"Connection": "close", # 요청 후 연결 닫기 (연습문제용)
"User-Agent": "SimpleBrowser/0.1" # 사용자 에이전트 헤더 추가 (연습문제용)
}
# ...
for header, value in headers.items():
request += "{}: {}\r\n".format(header, value)
```
### 1-2 파일 URL
로컬 파일을 읽을 수 있도록 file 스킴을 지원하기
```python
def __init__(self, url: str):
# ...
assert self.scheme in ["http", "https", "file"] # 지원하는 스킴에 file 추가
# ...
elif self.scheme == "file":
self.port = None
self.host = None
self.path = url
# ...
def request(self):
# file 스킴인 경우 로컬 파일 읽기
if self.scheme == "file":
with open(self.path, "r", encoding="utf8") as f:
return f.read()
# ...
```
![[f66224c4-c9c8-4899-a5d2-77b3c69488b2.png]]
URL이 없다면 특정한 로컬 파일이 열리도록
```python
class URL:
# 기본 로컬 파일 경로
INDEX_PATH = "/Users/jyeon/playground/web-browser/index.html"
def __init__(self, url: str):
# URL이 없는 경우에는 index.html을 열기
if url == "":
url = "file://" + self.INDEX_PATH
# ...
```
![[38a378df-0e86-4618-9d7e-671b3613fa69.png]]
### 1-3 data: 스킴
data 스킴은 `://` 이 아닌 `:`으로 스킴을 구분해야 한다. url을 파싱하는 `__init__` 메서드를 싹 다 수정했다 ㅠㅠ
```python
def __init__(self, url: str):
# URL이 없는 경우에는 index.html을 열기
if url == "":
url = "file://" + self.INDEX_PATH
# 스킴 분리 (http, https, file, data)
# 1. 첫 번째 ':' 앞부분이 스킴
self.scheme, remaining = url.split(":", 1)
# 2. 스킴이 'data'인 경우에는 나머지 전체가 경로
if self.scheme == "data":
self.port = None
self.host = None
self.path = remaining
# 3. 스킴이 'file'인 경우에는 '://' 다음부터 경로
elif self.scheme == "file":
assert remaining.startswith("//")
self.port = None
self.host = None
self.path = remaining[2:]
# 4. 스킴이 'http' 또는 'https'인 경우에는 '://' 다음부터 호스트와 경로
elif self.scheme in ["http", "https"]:
assert remaining.startswith("//")
url = remaining[2:]
# 호스트와 경로 분리 - 호스트는 다음에 오는 '/' 앞부분이다
if "/" not in url:
url = url + "/"
self.host, url = url.split("/", 1)
self.path = "/" + url
# 스킴 확인, 포트 결정
if self.scheme == "http":
self.port = 80
elif self.scheme == "https":
self.port = 443
# 만약에 사용자 지정 포트가 있다면 그걸 사용한다.
if ":" in self.host:
self.host, port = self.host.split(":", 1)
self.port = int(port)
else:
raise Exception("지원하지 않는 스킴입니다: {}".format(self.scheme))
```
### 1-4 HTML 엔티티 (특수코드)
```python
# 태그를 제외한 텍스트를 출력하는 메서드
def show(body):
ENTITIES = {
"lt": "<",
"gt": ">",
}
in_tag = False
for c in body:
#...
# 엔티티 처리
elif c == "&":
entity = ""
while True:
c = next(body)
if c == ";":
break
entity += c
if entity in ENTITIES:
print(ENTITIES[entity], end="")
else:
print("&" + entity + ";", end="")
# ...
```
### 1-5 view-source 스킴
`view-source`인 경우에는 source를 받아올 url 정보까지 함께 저장해야 한다. URL에 `inner_url` attribute를 새로 만들어줬다. 그리고 `__init__` 메서드에서 재귀적으로 파싱해주면 준비는 완료
```python
# 4. 스킴이 view-source인 경우에는 ":" 다음부터 다시 파싱
elif self.scheme == "view-source":
# 다시 URL 파싱
inner_url = URL(remaining)
self.inner_url = inner_url
```
그리고 `load` 함수를 수정해줘야 한다. 원래는 그냥 바로 요청을 보내서 바디를 받아오는 식이었지만, `view-source` 스킴에서는 내부 url을 이용해서 요청을 보내줘야 한다. 그렇게 요청을 보낸 뒤에는 태그나 엔티티를 처리하는 `show` 메서드 대신 그대로 출력해주는 내장 출력 메서드 `print`를 이용해서 출력해줬다.
```python
# 웹페이지 로드하기
def load(url: URL):
if url.scheme == "view-source":
assert hasattr(url, "inner_url")
body = url.inner_url.request()
print(body)
else:
body = url.request()
show(body)
```
### 1-6 Keep-alive
Keep-alive를 구현하기 위해서는 소켓을 재사용해야 한다. 소켓을 그대로 열어두고 있다가, 동일한 서버에 대한 후속 요청이 들어왔을 때 그 소켓을 재사용한다.
소켓을 바로 닫지 않고 유지해야 한다 → 여러 소켓을 저장해두고 관리하는 매커니즘 필요
소켓 저장 매커니즘 만들기
```python
# 소켓 저장용 딕셔너리 (호스트:포트 -> 소켓)
SOCKETS = {}
class URL:
def request(self):
# ...
# 열려 있는 소켓이 있다면 재사용하기
if (self.host, self.port) in SOCKETS:
sock = SOCKETS[(self.host, self.port)]
else:
sock = socket.socket(
family=socket.AF_INET,
type=socket.SOCK_STREAM,
proto=socket.IPPROTO_TCP
)
# ...
```
content-length가 존재하면 바디에서 그만큼만 읽도록 로직 수정
```python
def request(self):
# 바디 읽기
# content-length 헤더가 있으면 그만큼만, 없으면 끝까지 읽는다.
body = ""
if "content-length" in response_headers:
length = int(response_headers["content-length"])
body = response.read(length)
else:
body = response.read()
```
소켓 닫는 로직 추가
```python
def request(self):
# 소켓 닫기 로직
# Connection: close라면 소켓 닫기
if response_headers.get("connection", "") == "close":
sock.close()
# 저장되어 있던 소켓도 제거한다.
if (self.host, self.port) in SOCKETS:
del SOCKETS[(self.host, self.port)]
# Connection: close가 아닌데, content-length 헤더가 없다면 소켓 닫기
elif "content-length" not in response_headers:
sock.close()
# 저장되어 있던 소켓도 제거한다.
if (self.host, self.port) in SOCKETS:
del SOCKETS[(self.host, self.port)]
else:
# 소켓을 계속 열어둔다.
SOCKETS[(self.host, self.port)] = sock
```
### 1-7 리다이렉트
request 메서드에서 응답 헤더를 확인할 때 300번대 응답 코드라면 새로운 요청으로 분기해줘야 한다. 300번대 응답 코드라면 Location 헤더의 값을 확인해줘야 한다. 처리해야 할 응답코드 목록 : [RFC 7231 - Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content](https://datatracker.ietf.org/doc/html/rfc7231#section-6.4)
그전에 경로가 스킴과 호스트를 제외한 `/` 로 시작하는 상대경로일 수도 있기 때문에 URL resolve 함수가 필요하다. 그건 168 페이지 참고해서 만듦
```python
# 리다이렉트 확인
if status in ["300", "301", "302", "303", "305", "307"]:
assert "location" in response_headers
location = response_headers["location"]
response.close()
# 리다이렉트 시 무조건 소켓 닫기
sock.close()
if (self.host, self.port) in SOCKETS:
del SOCKETS[(self.host, self.port)]
return self.resolve(location)
```
응답이 URL 객체로 오는 경우에는 다시 load 메서드 안에서 재귀요청을 한다. 무한루프를 막기 위해서 limit도 함께 체크해준다.
```python
# 웹페이지 로드하기
def load(url: URL, limit=0):
# 리다이렉트 한도 검사
REDIRECT_LIMIT = 5
if limit > REDIRECT_LIMIT:
raise Exception("리다이렉트 한도 초과")
if url.scheme == "view-source":
assert hasattr(url, "inner_url")
response = url.inner_url.request()
print(response.get("body", ""))
else:
response = url.request()
if isinstance(response, URL):
# 리다이렉트된 URL 처리
return load(response, limit + 1)
show(response.get("body", ""))
```
~~리다이렉트 된 페이지가 너무 큰 탓인지 body를 읽는 데 시간이 너무 오래 걸리는데 (한 1분?) 원인은 아마도 응답을 바이너리 모드로 읽지 않아서 content-length 값이 제대로 반영되지 않는 탓인 것 같다. (notebooklm 피셜)
나중에 이미지 처리할 때 바이너리로 읽도록 수정해줄 기회가 있을 것 같아서 우선은,, 그냥 뒀다.~~ [[1. 웹페이지 다운로드#1-9 압축]]에서 바이너리 처리를 추가해서 문제는 해결됨
![[da7ba885-c48c-490d-be28-261226250f1c.png]]
![[f863fcab-9808-498b-adb1-3bc5bbba6db3.png]]
![[6cbb0a6a-d782-4d6c-9c31-85ea9225eb46.png]]
> [!QUOTE]
> 이 시점에서 코드가 너무너무 지저분해져서 리팩토링을 싹 했다.
### 1-8 캐싱
캐시 저장소를 전역에 두고, request 메서드 안에 캐싱 로직을 추가한다.
우선 실제 요청을 보내기 전에 캐싱된 응답이 있는지 확인한다.
```python
def request(self):
# 캐시 확인
cached_response = self._check_cache()
if cached_response is not None:
return cached_response
# ...
def _cache_key(self):
return (self.scheme, self.host, self.port, self.path)
```
캐시 확인 로직에서 캐시 유효성 검사까지 함께 해준다.
```python
def _check_cache(self):
key = self._cache_key()
cached = CACHE_STORE.get(key, None)
if cached is None:
return None
saved_time = cached["saved_time"]
max_age = cached["max_age"]
# max-age가 유효하지 않다면 캐시 삭제
if not max_age or max_age <= 0:
CACHE_STORE.pop(key, None) # 키가 없더라도 에러가 발생하지 않음
return None
# 기간 만료 검사
if time.time() - saved_time > max_age:
CACHE_STORE.pop(key, None) # 키가 없더라도 에러가 발생하지 않음
return None
print("캐시에서 로드됨:", self)
return cached["response"]
```
캐시 저장 로직에서 no-store 처리와 max-age 파싱이 필요하다.`Cache-Control: public, max-age=3600` 같이 여러 디렉티브가 나올 수 있기 때문에 그 부분 고려해서 파싱해야 함
```python
def _store_cache(self, response):
headers = response.get("headers", {})
cache_control = headers.get("cache-control", None)
if cache_control is None:
return
key = self._cache_key()
if "no-store" in cache_control:
CACHE_STORE.pop(key, None)
return
# max-age 파싱 (여러 directive 대비)
directives = [d.strip() for d in cache_control.split(",")]
max_age = None
for directive in directives:
if directive.startswith("max-age="):
try:
max_age = int(directive.split("=", 1)[1])
except ValueError:
max_age = None
break
if max_age is None or max_age <= 0:
return
CACHE_STORE[key] = {
"response": response,
"max_age": max_age,
"saved_time": time.time()
}
print("캐시에 저장됨", self)
```
아쉽게도 우리의 캐시는 인메모리에 저장되어 있는데, 브라우저 실행이 계속되는게 아니라 한번 요청 뒤에 프로그램이 종료되기 때문에 캐싱 기능을 당장 확인해보긴 힘들 듯 하다... 아마 나중에 테스트 해볼 수 있지 않을까...
### 1-9 압축
우선 브라우저의 헤더에 [Accept-Encoding](https://developer.mozilla.org/ko/docs/Web/HTTP/Reference/Headers/Accept-Encoding): gzip을 포함해야 한다.
```python
def _build_request(self):
headers = {
"Host": self.host,
"Connection": "keep-alive",
"User-Agent": "SimpleBrowser/0.1",
"Accept-Encoding": "gzip",
}
# ...
```
makefile에 rb를 전달해서 원시 바이트로 작업해야 한다. 기존의 텍스트 처리와 관련된 encoding이나 newline 인자는 제거해야 한다.
```python
response = sock.makefile("rb")
```
makefile을 "rb"로 변경했기 때문에 기존 텍스트 문자열로 처리하던 부분에서 명시적인 디코딩이 필요하다.
```python
def _parse_status_line(self, response):
statusLine = response.readline().decode("utf8")
# ...
def _parse_headers(self, response):
headers = {}
while True:
line = response.readline().decode("utf8")
# ...
```
body를 읽는 메서드에서는 content-encoding 값이 gzip인 경우에는 압축 해제해주는 로직을 추가했다.
```python
def _read_body(self, response, headers):
# ...
# 압축 해제
if "content-encoding" in headers and \
headers["content-encoding"].casefold() == "gzip":
body_bytes = gzip.decompress(body_bytes)
return body_bytes
```
[chunked 인코딩](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Transfer-Encoding#chunked)에 대한 지원도 추가해야 한다. 💦 청크 인코딩이 있다면 청크 단위 우선으로 읽는다.
```python
def _read_body(self, response, headers):
body_bytes = b""
# 청크 인코딩이 있다면 청크 단위 우선으로 읽기
if "transfer-encoding" in headers and \
headers["transfer-encoding"] == "chunked":
body_bytes = self._read_chunked_body(response)
# ...
def _read_chunked_body(self, response):
# 청크 단위로 읽기
body_bytes = b""
while True:
line = response.readline()
if not line:
break
chunk_size_str = line.strip()
if not chunk_size_str:
break
try:
chunk_size = int(chunk_size_str, 16)
except ValueError:
break
if chunk_size == 0:
break
chunk_data = response.read(chunk_size)
body_bytes += chunk_data
response.readline() # 청크 끝의 CRLF 읽기
# 0 크기 청크 뒤에 오는 트레일러 헤더 처리
# 0 청크 뒤에 빈 줄이 나올 때까지 헤더를 읽어 트레일러를 소진
while response.readline() != b"\r\n":
pass
return body_bytes
```
request 메서드 응답의 일관성을 위해서 file 스킴과 data 스킴의 인코딩도 명시적으로 바꾸어줬다.
```python
def request(self):
if self.scheme == "file":
with open(self.path, "r", encoding="utf8") as f:
return {"body": f.read().encode("utf8")} # 일관성을 위해서 인코딩 통일
if self.scheme == "data":
self.mime_type, body_str = self.path.split(",", 1)
return {"body": body_str.encode("utf8")} # 일관성을 위해서 인코딩 통일
# ...
```
이렇게 하면 request의 응답으로 바이트 타입의 바디가 오게 되므로 load에서 문자열로 변환하는 작업이 필요하다.
```python
# 웹페이지 로드하기
def load(url: URL, limit=0):
# ...
else:
response = url.request()
# ...
body_text = response.get("body", b"").decode("utf8", "replace")
show(body_text)
```
![[4807d17b-f2ed-493a-ab75-57ede9e94d35.png]]
잘 온다.
> [!QUOTE]- 1장 끝
> 본문은 되게 쉬운데, 연습문제가 좀 어렵다
> 그리고 42 Webserv 과제하던 생각이 나서 뭔가 재밌어졌다.
> 그때 C++말고 파이썬으로 했었으면 훨씬 덜 힘들었을 것 같다. 🥹