## 책 정보 [밑바닥부터 시작하는 웹 브라우저](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++말고 파이썬으로 했었으면 훨씬 덜 힘들었을 것 같다. 🥹