HTTP 서버 만들기
Echo 서버에서 비동기
초급 TS 학습자를 위한 웹 개발 기초
HTTP 프로토콜은 TCP 프로토콜 위에 위치합니다. TCP 자체가 어떻게 상세하게 동작하는지는 우리의 관심사가 아닙니다. 우리가 TCP에 대해 알아야 할 것은, 이것이 원시 바이트(raw bytes)를 전송하기 위한 양방향 채널이며 HTTP나 SSH와 같은 다른 애플리케이션 프로토콜을 위한 전송 수단(carrier)이라는 점입니다. 우리는 HTTP에 대해서만 고민하도록 하겠습니다.
1. HTTP 개요
TCP를 사용한 연결은 독립적으로 작동할 수 있지만, 많은 프로토콜은 요청-응답(request-response) 모델을 따릅니다. 클라이언트는 요청을 보내고, 서버는 응답을 보내며, 그 후 클라이언트는 동일한 연결을 사용하여 추가적인 요청과 응답을 주고받을 수 있습니다. 아래 표는 이 과정을 보여줍니다.
HTTP 요청 또는 응답은 헤더(header)와 그 뒤에 오는 선택적인 페이로드(payload)로 구성됩니다. 헤더는 요청의 URL이나 응답 코드를 포함하며, 그 뒤를 이어 헤더 필드 목록이 나열됩니다.
HTTP 예제
네트워크 프로토콜에 대한 감을 잡기 위해, 먼저 커맨드 라인에서 HTTP 요청을 만들어 보겠습니다. netcat 명령어를 실행해 보세요.
nc(netcat) 명령어는 목적지 호스트와 포트로 TCP 연결을 생성한 후, 해당 연결을 표준 입력(stdin)과 표준 출력(stdout)에 연결합니다. 이제 터미널에 입력하기 시작하면 데이터가 전송될 것입니다.
그러면 다음과 같은 응답을 받게 됩니다.
커맨드 라인에서 HTTP 요청을 만드는 것은 매우 쉽습니다. 이제 이 데이터를 보고 무엇을 의미하는지 파악해 봅시다.
요청의 첫 번째 줄인 GET / HTTP/1.0은 HTTP 메서드 GET, URI /, 그리고 HTTP 버전 1.0을 포함합니다. 이는 쉽게 파악할 수 있습니다. 그리고 응답의 첫 번째 줄인 HTTP/1.0 200 OK는 HTTP 버전과 응답 코드 200을 포함합니다.
첫 번째 줄 다음에는 Key: value 형식의 헤더 필드 목록이 이어집니다. 이 요청은 도메인 이름을 포함하는 Host라는 단일 헤더 필드를 가집니다. 응답에는 많은 필드가 포함되어 있으며, 그 기능들은 Host 필드만큼 명확하지는 않습니다. 많은 HTTP 헤더 필드는 선택 사항이며, 일부는 심지어 쓸모가 없기도 합니다. 이에 대해서는 이후에 필요한 내용을 위주로 다시 다뤄보도록 하겠습니다.
응답 헤더 다음에는 페이로드가 오는데, 이 예시에서는 HTML 문서입니다. 페이로드와 헤더는 빈 줄로 구분됩니다. GET 요청은 페이로드가 없으므로 빈 줄로 끝납니다.
이것은 커맨드 라인에서 직접 실행해 볼 수 있는 간단한 예제일 뿐입니다(향후에 필요하다면 HTTP 프로토콜에 대해서는 나중에 더 자세히 살펴볼 것입니다).
HTTP의 발전
HTTP/1.0: 프로토타입
위의 예제는 HTTP의 아주 오래된 버전인 HTTP/1.0을 사용합니다. HTTP/1.0은 단일 연결을 통한 다중 요청을 전혀 지원하지 않으며, 모든 요청마다 새로운 연결이 필요합니다. 일반적인 웹 페이지는 이미지, 스크립트, 스타일시트와 같은 많은 추가 리소스에 의존하기 때문에 이는 문제가 됩니다.
TCP 핸드셰이크(handshake)의 지연 시간은 HTTP/1.0에 큰 단점으로 작동합니다. HTTP/1.1은 이 문제를 해결하고 실용적인 프로토콜이 되었습니다.
nc 명령어에서 HTTP/1.0을 HTTP/1.1로 바꾸면 동일한 연결에서 여러 요청을 보낼 수 있습니다.
HTTP/1.1: 실용적이며 이해하기 쉬운 버전
HTTP/1.1은 이 문제를 해결하여 실용적인 프로토콜이 되었습니다. nc 명령어에서 HTTP/1.0을 HTTP/1.1로 바꾸면 동일한 연결에서 여러 요청을 보낼 수 있습니다. 우리 교안은 HTTP/1.1에 초점을 맞출 것인데, 이는 여전히 매우 대중적이고 이해하기 쉽기 때문입니다. 심지어 웹과 전혀 관련 없는 소프트웨어 시스템조차도 네트워크 프로토콜의 기반으로 HTTP를 채택했습니다. 백엔드 개발자가 “API”에 대해 이야기할 때, 내부 소프트웨어 서비스용이라 할지라도 HTTP 기반 API를 의미할 가능성이 높습니다.
왜 HTTP는 이렇게 인기가 많을까요? 한 가지 가능한 이유는 범용적인 요청-응답 프로토콜로 사용될 수 있기 때문입니다. 개발자들은 자신들만의 프로토콜을 만드는 대신 HTTP에 의존할 수 있습니다. 이 점이 HTTP를 네트워크 프로토콜 구축 방법을 배우기에 좋은 대상으로 만듭니다.
HTTP/2: 새로운 기능들
HTTP/1.1 이후에도 추가적인 발전이 있었습니다. HTTP/2는 SPDY와 관련이 있으며 HTTP의 다음 버전입니다. 헤더 압축과 같은 점진적인 개선 외에도 두 가지 새로운 기능을 가집니다.
- 서버 푸시 (Server push): 클라이언트가 요청하기 전에 서버가 리소스를 클라이언트로 보내는 기능
- 단일 TCP 연결을 통한 다중 요청의 멀티플렉싱 (Multiplexing): HOL(Head-of-Line) 블로킹 문제를 해결하기 위한 시도
이러한 새로운 기능들로 인해, HTTP/2는 더 이상 단순한 요청-응답 프로토콜이 아닙니다. 이것이 우리가 충분히 간단하고 이해하기 쉬운 HTTP/1.1부터 시작하는 이유입니다.
HTTP/3: 더 큰 포부
HTTP/3는 HTTP/2보다 훨씬 더 큰 변화를 담고 있습니다. TCP를 대체하고 대신 UDP를 사용합니다. 그래서 TCP의 기능 대부분을 다시 구현해야 하는데, 이 TCP 대안을 QUIC이라고 부릅니다. QUIC의 개발 동기는 사용자 공간(userspace)에서의 혼잡 제어(congestion control), 멀티플렉싱, 그리고 HOL 블로킹 문제 해결입니다.
이러한 새로운 기술에 대해 읽어보면 많은 것을 배울 수 있지만, 관련 개념과 전문 용어에 압도될 수 있습니다. 그러니 작고 간단한 것, 즉 HTTP/1.1 서버를 코딩하는 것부터 시작합시다.
2.TCP 서버 코딩하기
우리의 첫 단계는 소켓(socket) API에 익숙해지는 것이므로, 이 장에서는 간단한 TCP 서버를 코딩할 것입니다.
프로토콜 계층
네트워크 프로토콜은 여러 계층으로 나뉘며, 상위 계층은 하위 계층에 의존하고 각 계층은 서로 다른 기능을 제공합니다.
TCP 아래 계층은 IP 계층입니다. 각 IP 패킷은 세 가지 구성 요소를 가진 메시지입니다.
- 송신자 주소
- 수신자 주소
- 메시지 데이터
패킷 기반 통신 방식은 애플리케이션이 해결해야 할 많은 문제점을 안고 있어 쉽지 않습니다.
- 메시지 데이터가 단일 패킷의 용량을 초과하면 어떻게 할 것인가?
- 패킷이 손실되면 어떻게 할 것인가?
- 패킷 순서가 뒤바뀌면 어떻게 할 것인가?
문제를 단순화하기 위해, IP 패킷 위에 다음 계층인 TCP가 추가되었습니다. TCP는 다음을 제공합니다.
- 패킷 대신 바이트 스트림
- 신뢰성 있고 순서가 보장되는 전달
바이트 스트림은 단순히 순서가 있는 바이트의 연속입니다. 애플리케이션이 아닌 프로토콜이 이 바이트들을 해석하는 데 사용됩니다. 프로토콜은 파일 형식과 유사하지만, 전체 길이를 알 수 없고 데이터를 한 번에 순차적으로 읽는다는 차이점이 있습니다.
UDP는 TCP와 같은 계층에 있지만, 하위 계층처럼 여전히 패킷 기반입니다. UDP는 단지 IP 패킷 위에 포트 번호를 추가할 뿐입니다.
TCP 바이트 스트림 vs. UDP 패킷
- 핵심 차이점:
경계(boundaries) - UDP: 소켓에서 한 번 읽는 것은
상대방(peer)에서 한 번 쓰는 것과 일치 - TCP: 관계가 없으며, 데이터는 연속적인 바이트 흐름이며, TCP는 경계를 보존하는 메커니즘이 없음
- TCP 송신 버퍼: 전송 전에 데이터가 저장되는 곳입니다. 여러 번의 쓰기(write)는 한 번의 쓰기와 구별할 수 없습니다.
- 데이터는 하나 이상의 IP 패킷으로 캡슐화되며, IP 경계는 원래의 쓰기 경계와 아무런 관계가 없습니다.
- TCP 수신 버퍼: 데이터는 도착하는 대로 애플리케이션에서 사용할 수 있습니다.
소켓 프로그래밍에서 초보자가 저지르는 1순위 함정은 “TCP 패킷 이어붙이기 & 나누기”입니다. 왜냐하면 “TCP 패킷”이라는 것 자체가 없기 때문입니다. 바이트 스트림 내에 경계를 설정함으로써 TCP 데이터를 해석하기 위해서는 프로토콜이 필요합니다.
바이트 스트림 vs. 패킷: DNS 예제
바이트 스트림의 의미를 이해하는 데 도움을 주기 위해 DNS 프로토콜(도메인 이름 -> IP 주소 조회)을 예로 들어 보겠습니다. DNS는 UDP 상에서 실행되며, 클라이언트는 단일 요청 메시지를 보내고 서버는 단일 응답 메시지로 응답합니다. DNS 메시지는 UDP 패킷 안에 캡슐화됩니다.
큰 메시지를 사용할 수 없다는 점 때문에, DNS는 TCP 상에서도 실행되도록 설계되었습니다. 하지만 TCP는 “메시지”에 대해 아무것도 모르므로, TCP를 통해 DNS 메시지를 보낼 때는 각 DNS 메시지 앞에 2바이트 길이 필드가 추가되어 서버나 클라이언트가 바이트 스트림의 어느 부분이 어떤 메시지인지 알 수 있게 합니다. 이 2바이트 길이 필드는 TCP 상의 애플리케이션 프로토콜의 가장 간단한 예입니다. 이 프로토콜은 단일 TCP 바이트 스트림에 여러 애플리케이션 메시지(DNS)를 포함할 수 있게 합니다.
TCP 연결을 설정하려면 클라이언트와 서버가 있어야 합니다(동시 연결의 경우는 무시). 서버는 특정 주소(IP + 포트)에서 클라이언트를 기다리는데, 이 단계를 bind & listen이라고 합니다. 그러면 클라이언트는 그 주소로 연결할 수 있습니다. “연결” 작업은 3단계 핸드셰이크(SYN, SYN-ACK, ACK)를 포함하지만, 이는 OS가 투명하게 처리해주므로 우리의 관심사가 아닙니다. OS가 핸드셰이크를 완료한 후, 서버는 연결을 수락(accept)할 수 있습니다.
일단 설정되면, TCP 연결은 양방향 바이트 스트림으로 사용될 수 있으며, 각 방향에 대해 2개의 채널이 있습니다. 많은 프로토콜이 HTTP/1.1처럼 요청-응답 방식이지만, 여기서 한쪽은 요청/응답을 보내거나 응답/요청을 받고 있습니다. 하지만 TCP는 이러한 통신 방식에만 국한되지 않습니다. 각 피어는 동시에 보내고 받을 수 있으며(예를 들어 웹소켓), 이를 전이중(full-duplex) 통신이라고 합니다.
한쪽 피어는 FIN 플래그를 사용하여 더 이상 데이터를 보내지 않을 것임을 상대방에게 알리고, 상대방은 FIN에 대해 ACK로 응답합니다. 원격 애플리케이션은 채널에서 읽을 때 종료를 통지받습니다. 각 방향의 채널은 독립적으로 종료될 수 있으므로, 상대방도 동일한 핸드셰이크를 수행하여 연결을 완전히 닫습니다.
소켓 기본 연산 (Socket Primitives)
소켓 API는 언어와 라이브러리에 따라 다른 형태로 제공됩니다. 기본을 모른 채 API 문서에 뛰어들면 혼란스러워지기 쉽습니다.
리스닝 소켓과 연결 소켓
TCP 서버는 특정 주소(IP + 포트)에서 리스닝(listen)하며 해당 주소로부터 클라이언트 연결을 수락(accept)합니다. 리스닝 주소 역시 소켓 핸들(socket handle)로 표현됩니다. 그리고 새로운 클라이언트 연결을 수락하면, 그 TCP 연결의 소켓 핸들을 얻게 됩니다.
이제 두 가지 유형의 소켓 핸들이 있다는 것을 알게 되었습니다.
리스닝 소켓(Listening sockets): 주소에서 리스닝하여 얻습니다.연결 소켓(Connection sockets): 리스닝 소켓으로부터 클라이언트 연결을 수락하여 얻습니다.
전송 종료
전송(send)과 수신(receive)은 읽기(read)와 쓰기(write)라고도 불립니다. 쓰기 측면에서는, 상대방에게 더 이상 보낼 데이터가 없음을 알리는 방법이 있습니다.
- 소켓을
닫으면(closing)연결이 종료되고 TCP FIN이 전송 셧다운(shutdown)을 통해 상대방으로부터 데이터를 계속 수신하면서 자신의 전송만 종료(FIN 전송)할 수도 있음. 이를반-열림(half-open)연결이라 함
읽기 측면에서는, 상대방이 전송을 종료했는지(FIN 수신) 알 수 있는 방법이 있습니다. 전송의 끝은 종종 파일의 끝(End-Of-File, EOF)이라고 불립니다.
소켓 기본 연산 목록
요약하자면, 알아야 할 몇 가지 소켓 기본 연산이 있습니다.
- 리스닝 소켓(Listening sockets)
bind&listenacceptclose
- 연결 소켓(Connection sockets)
readwriteclose
Node.js의 소켓 API
소켓 API를 소개하기 위해 작은 실습을 해보겠습니다. 클라이언트로부터 데이터를 읽어 동일한 데이터를 다시 써주는 TCP 서버입니다.이를 에코 서버(echo server)라고 합니다.
1단계: 리스닝 소켓 생성하기
모든 네트워킹 관련 기능은 net 모듈에 있습니다.
net.createServer() 함수는 net.Server 타입의 리스닝 소켓을 생성합니다.net.Server는 주소에 바인딩하고 리스닝하기 위한 listen() 메서드를 가집니다.
2단계: 새 연결 수락하기
다음은 새 연결을 얻기 위한 accept 기본 연산입니다. 안타깝게도, 단순히 연결을 반환하는 accept() 함수는 없습니다. 여기서 JS의 I/O 처리에 대한 배경지식이 필요합니다. JS에서 I/O를 처리하는 두 가지 스타일이 있는데, 첫 번째는 콜백(callback)을 사용하는 것입니다. 무언가 작업이 수행되도록 요청하고 런타임에 콜백을 등록하면, 작업이 완료되었을 때 콜백이 호출됩니다.
위 코드에서 server.on('connection', newConn)은 newConn 콜백 함수를 등록합니다. 런타임은 자동으로 accept 연산을 수행하고 새 연결을 인자로 하여 콜백을 호출합니다.
3단계: 오류 처리하기
'connection' 인자는 ’이벤트(event)’라고 불리며, 콜백을 등록할 수 있는 대상입니다. 리스닝 소켓에는 다른 이벤트들도 있습니다. 예를 들어, 오류가 발생했을 때 호출되는 'error' 이벤트가 있습니다.
여기서는 단순히 예외를 던져 프로그램을 종료합니다.
4단계: 읽기와 쓰기
연결로부터 수신된 데이터 또한 콜백을 통해 전달됩니다.소켓에서 읽기와 관련된 이벤트는 'data' 이벤트와 'end' 이벤트입니다.'data' 이벤트는 상대방으로부터 데이터가 도착할 때마다 호출되며, 'end' 이벤트는 상대방이 전송을 종료했을 때 호출됩니다.
socket.write() 메서드는 상대방에게 데이터를 다시 보냅니다.
5단계: 연결 닫기
socket.end() 메서드는 전송을 종료하고 소켓을 닫습니다. 여기서는 데이터에 문자 ’q’가 포함되어 있을 때 socket.end()를 호출하여 이 시나리오를 쉽게 테스트할 수 있도록 합니다.
6단계: 테스트하기
에코 서버의 전체 코드는 다음과 같습니다.
import * as net from "net";
function newConn(socket: net.Socket): void {
console.log('new connection', socket.remoteAddress, socket.remotePort);
socket.on('end', () => {
// FIN received. The connection will be closed automatically.
console.log('EOF.');
});
socket.on('data', (data: Buffer) => {
console.log('data:', data);
socket.write(data); // echo back the data.
// actively closed the connection if the data contains 'q'
if (data.includes('q')) {
console.log('closing.');
socket.end(); // this will send FIN and close the connection.
}
});
}
let server = net.createServer();
server.on('error', (err: Error) => { throw err; });
server.on('connection', newConn);
server.listen({host: '127.0.0.1', port: 1234}); npx ts-node echo_server.ts를 실행하여 에코 서버를 시작하고, nc나 socat 명령어로 테스트합니다.
에코 서버가 정상적으로 동작하는지 확인하려면, 터미널에서 nc(netcat) 명령어를 사용하여 서버에 접속합니다.
- 먼저, 서버를 실행하세요.
- 터미널에서
nc명령어를 사용하여 서버에 접속합니다.
- 터미널에 입력한 데이터가 서버에서 다시 출력되는지 확인합니다. 이 때, 데이터에 ’q’가 포함되어 있으면 연결이 종료됩니다.
- 데이터에 ’q’가 포함되어 있으면 연결이 종료됩니다.
논의: Half-Open Connections
TCP 연결의 각 방향은 독립적으로 종료되며, 한 방향은 닫히고 다른 방향은 여전히 열려 있는 상태를 활용할 수 있습니다. 이러한 TCP의 단방향 사용을 TCP 하프 오픈(half-open)이라고 합니다. 예를 들어, 피어 A가 피어 B로의 연결을 하프 클로즈(half-closes)하면 다음과 같습니다.
- A는 더 이상 데이터를 보낼 수 없지만, 여전히 B로부터 데이터를 수신합니다.
- B는 EOF를 받지만, 여전히 A에게 데이터를 보낼 수 있습니다.
이 기능을 활용하는 애플리케이션은 많지 않습니다. 대부분의 애플리케이션은 EOF를 상대방에 의해 완전히 닫힌 것과 동일하게 취급하며, 즉시 소켓을 닫습니다. 이를 위한 소켓 기본 요소는 shutdown이라고 불립니다. Node.js의 소켓은 기본적으로 하프 오픈을 지원하지 않으며, 어느 한쪽이 EOF를 보내거나 받으면 자동으로 닫힙니다. TCP 하프 오픈을 지원하려면 추가 플래그가 필요합니다.
allowHalfOpen 플래그가 활성화되면, 연결을 닫는 것은 우리의 책임이 됩니다. 왜냐하면 socket.end()는 더 이상 연결을 닫지 않고 EOF만 보내기 때문입니다. 소켓을 수동으로 닫으려면 socket.destroy()를 사용하십시오.
논의: 이벤트 루프와 동시성
JS 코드는 이벤트 루프 내에서 실행됩니다. 에코 서버에서 무언가를 하려면 콜백이 필요합니다. 이것이 이벤트 루프가 작동하는 방식입니다. 이는 프로그래머에게는 보이지 않는 Node.js 런타임의 메커니즘입니다. 런타임은 다음과 유사하게 동작합니다.
런타임은 OS로부터 새로운 연결 도착, 소켓 읽기 준비 완료, 또는 타이머 만료와 같은 I/O 이벤트를 폴링(poll)합니다. 그런 다음 런타임은 이벤트에 반응하여 프로그래머가 이전에 등록한 콜백을 호출합니다. 모든 이벤트가 처리된 후 이 과정이 반복되므로, 이를 이벤트 루프(event loop)라고 부릅니다.
JS 코드와 런타임은 단일 OS 스레드를 공유합니다. 이벤트 루프는 단일 스레드입니다. 실행은 런타임 코드 또는 JS 코드(콜백 또는 메인 프로그램) 중 하나에서 이루어집니다. 이것이 작동하는 이유는 콜백이 반환되거나await될 때 제어권이 런타임으로 돌아가, 런타임이 이벤트를 발생시키고 다른 작업을 스케줄링할 수 있기 때문입니다. 이는 JS 코드를 실행할 때 이벤트 루프가 멈추기 때문에, 모든 JS 코드는 짧은 시간 안에 끝날 것으로 예상된다는 것을 의미합니다.
Node.js의 동시성은 이벤트 기반입니다.
이벤트 루프의 의미를 이해하는 데 도움이 되도록, 이제 동시성을 고려해 봅시다. 서버는 동시에 여러 연결을 가질 수 있으며, 각 연결은 이벤트를 발생시킬 수 있습니다. 이벤트 핸들러가 실행되는 동안, 단일 스레드 런타임은 해당 핸들러가 반환될 때까지 다른 연결을 위해 아무것도 할 수 없습니다. 이벤트를 처리하는 시간이 길어질수록 다른 모든 것이 지연됩니다.
논의: 비동기(Asynchronous) vs. 동기(Synchronous)
블로킹 & 논블로킹 I/O
이벤트 루프에 너무 오래 머무르는 것을 피하는 것이 매우 중요합니다. 그러한 문제를 일으키는 한 가지 방법은 CPU 집약적인 코드를 실행하는 것입니다. 이것은 다음과 같이 해결할 수 있습니다.
- 자발적으로 런타임에 제어권을 양보
- 멀티스레딩이나 멀티프로세싱을 통해 CPU 집약적인 코드를 이벤트 루프 밖으로 이동
이러한 주제는 이 책의 범위를 벗어나며, 우리의 주된 관심사는 I/O입니다. OS는 네트워크 I/O를 위해 블로킹 모드와 논블로킹 모드를 모두 제공합니다.
- 블로킹 모드에서는, 호출하는 OS 스레드가 결과가 준비될 때까지 블로킹
- 논블로킹 모드에서는, 결과가 준비되지 않았거나 (또는 준비되었을 때) OS가 즉시 반환하며, (이벤트 루프를 위해) 준비 상태를 통지받는 방법이 있음
Node.js 런타임은 논블로킹 모드를 사용합니다. 왜냐하면 블로킹 모드는 이벤트 기반 동시성과 호환되지 않기 때문입니다. 이벤트 루프에서 유일한 블로킹 작업은 할 일이 없을 때 OS에 더 많은 이벤트를 폴링하는 것입니다.
Node.js의 I/O는 비동기적입니다
I/O와 관련된 대부분의 Node.js 라이브러리 함수는 콜백 기반이거나 프로미스(Promise) 기반입니다. 프로미스는 콜백을 관리하는 또 다른 방법으로 볼 수 있습니다. 이것들은 또한 비동기적(asynchronous)이라고 설명되는데, 이는 결과가 콜백을 통해 전달된다는 것을 의미합니다. 이러한 API는 이벤트 루프를 블로킹하지 않습니다. 왜냐하면 JS 코드는 결과를 기다리지 않고 런타임으로 반환되며, 결과가 준비되면 런타임이 콜백을 호출하여 프로그램을 계속 진행시키기 때문입니다.
그 반대는 동기(synchronous) API이며, 이는 결과를 기다리기 위해 호출하는 OS 스레드를 블로킹합니다. 예를 들어, fs 모듈의 문서를 살펴보면 파일 API가 세 가지 유형 모두로 제공되는 것을 볼 수 있습니다.
동기 API는 이벤트 루프를 블로킹하기 때문에 네트워크 애플리케이션에서 사용해서는 안 되는 것입니다. 이것들은 이벤트 루프에 전혀 의존하지 않는 일부 간단한 사용 사례(스크립팅 등)를 위해 존재합니다.
네트워킹을 넘어서는 이벤트 기반 프로그래밍
I/O는 디스크 파일과 네트워킹 이상의 것입니다. GUI 시스템에서는 마우스와 키보드로부터의 사용자 입력 또한 I/O입니다. 그리고 이벤트 루프는 Node.js 런타임에만 국한되지 않습니다. 웹 브라우저와 다른 모든 GUI 애플리케이션도 내부적으로 이벤트 루프를 사용합니다. GUI 프로그래밍 경험을 네트워크 프로그래밍에, 또는 그 반대로 활용할 수 있습니다.
논의: 프로미스(Promise) 기반 I/O
앞서 언급했듯이, I/O 코드를 작성하는 또 다른 스타일이 있습니다. 대안적인 스타일은 콜백 대신 프로미스를 사용하는 것입니다. 프로미스 기반 API의 장점은 그것들을 await하고 결과를 얻을 수 있다는 점입니다. 따라서 프로그램을 여기저기 흩어진 작은 콜백들로 나누는 것을 피할 수 있습니다. accept 기본 요소에 대한 가상적인 프로미스 기반 API는 다음과 같습니다.
그리고 read와 write 기본 요소에 대한 가상적인 API는 다음과 같습니다.
위의 의사 코드는 동기적으로 보이지만, 이벤트 루프를 블로킹하지 않습니다. 우리 프로그램이 매우 단순하기 때문에 이 시점에서는 장점이 명확하지 않을 수 있습니다.
3. 프로미스(Promises)와 이벤트(Events)
프로미스 기반 스타일을 사용하여 에코 서버를 다시 구현할 것입니다. 이러한 결정에 대한 이유는 나중에 설명하겠습니다. 먼저 async/await와 JS의 Promise 타입에 익숙하지 않은 분들을 위해 간단히 살펴보겠습니다.
콜백(Callbacks) 사용하기
프로미스(Promises)와 await 사용하기
프로미스에 await를 사용하는 예시입니다. 애플리케이션 로직이 동일한 async 함수 내에서 계속됩니다. 애플리케이션 로직이 여러 함수로 나뉘지 않기 때문에, 코드의 응집성이 높습니다.
프로미스 생성하기
프로미스를 생성하는 예시입니다. 콜백 기반 API를 프로미스 기반으로 변환합니다.
콜백은 JS에서 피할 수 없습니다. 프로미스 객체를 생성할 때, 실행자(executor) 콜백이 인자로 전달되어 두 개의 콜백을 추가로 받습니다.
resolve():await구문이 값을 반환하게 합니다.reject():await구문이 예외를 던지게 합니다.
결과가 준비되거나 작업이 실패했을 때 둘 중 하나를 반드시 호출해야 합니다. 이는 실행자 함수 외부에서 발생할 수 있으므로, 이 콜백들을 어딘가에 저장해 두어야 할 수도 있습니다.
프로미스 관련 용어는 아래와 같습니다.
- 이행됨 (Fulfilled):
resolve()가 호출된 상태 - 거부됨 (Rejected):
reject()가 호출된 상태 - 처리됨 (Settled): 이행되거나 거부된 상태
- 대기 중 (Pending): 아직 처리되지 않은 상태
async와 await 이해하기
일반 함수는 return으로 런타임에 제어권을 넘김
JS 함수에는 일반 함수와 async 함수, 두 가지 종류가 있습니다. 일반 함수는 시작부터 (명시적이든 암묵적이든) return 할 때까지 실행됩니다. JS 런타임은 단일 스레드이며 이벤트 기반이므로, JS에서 블로킹(blocking) I/O를 수행할 수 없습니다. 대신 I/O 완료에 대한 콜백을 등록하고 JS 코드를 종료합니다. 런타임으로 돌아오면, 런타임은 이벤트를 폴링(poll)하고 콜백을 호출할 수 있는데, 이것이 바로 우리가 앞에서 이야기했던 이벤트 루프입니다!
async 함수는 await으로 런타임에 제어권을 넘김
초기에 Promise 타입은 단순히 콜백을 관리하는 방법이었습니다. 이는 너무 많은 중첩 함수 없이 여러 콜백을 연결(chaining)할 수 있게 해 주었습니다. 하지만 async 함수가 추가되었기 때문에, 우리는 프로미스의 이러한 사용법에 대해서는 신경 쓰지 않을 것입니다.
일반 함수와 달리, async 함수는 실행 도중에 런타임으로 돌아갈 수 있습니다. 이는 프로미스에 await 구문을 사용할 때 발생합니다. 그리고 프로미스가 처리(settled)되면, 프로미스의 결과와 함께 async 함수의 실행이 재개됩니다. 이는 콜백에 의해 중단되지 않고 동일한 함수 내에서 순차적인 I/O 코드를 작성할 수 있기 때문에 훨씬 뛰어난 코딩 경험을 제공합니다.
async 함수 호출은 새로운 태스크(Task)를 시작함
async 함수를 호출하면, 해당 async 함수가 반환하거나 예외를 던질 때 스스로 처리(settle)되는 프로미스가 생성됩니다. 일반 프로미스처럼 await할 수 있지만, 그렇게 하지 않더라도 async 함수는 여전히 런타임에 의해 스케줄링됩니다. 이는 멀티스레드 프로그래밍에서 스레드를 시작하는 것과 유사합니다. 하지만 모든 JS 코드는 단일 OS 스레드를 공유하므로, ’태스크(task)’라는 단어를 사용하는 것이 더 적절합니다.
다양한 환경에서 태스크를 시작하는 방법은 아래와 같습니다.
- JS에서는 프로미스를 기다리지 않음으로써 백그라운드 태스크를 시작합니다.
- Go에서는
go구문을 사용합니다. - Python의
async/await는 JS와 유사하지만, 언어 내장 기능이 아니므로 이벤트 루프를 직접 실행해야 합니다. - 이벤트 루프가 없는 환경에서는 사용자 수준 태스크 대신 OS 스레드를 시작합니다.
이벤트에서 프로미스로
net 모듈은 프로미스 기반 API를 제공하지 않으므로, API를 직접 구현해야 합니다.
1단계: 해결책 분석하기
soRead 함수는 소켓 데이터로 이행(resolve)되는 프로미스를 반환합니다. 이는 세 가지 이벤트에 의존합니다.
data이벤트는 프로미스를이행(resolve)시킵니다.- 소켓을 읽는 동안 EOF(End-Of-File)가 발생했는지도 알아야 합니다. 따라서
'end'이벤트도 프로미스를 이행시킵니다. EOF를 나타내는 일반적인 방법은 길이가 0인 데이터를 반환하는 것입니다. error이벤트도 있습니다. 이 이벤트가 발생했을 때 프로미스를거부(reject)해야 합니다. 그렇지 않으면 프로미스는 영원히 대기 상태에 머무르게 됩니다.
이러한 이벤트들로부터 프로미스를 이행(resolve)하거나 거부(reject)하려면, 프로미스는 어딘가에 저장되어야 합니다. 이를 위해 TCPConn 래퍼(wrapper) 객체를 만들 것입니다.
프로미스의 resolve와 reject 콜백은 TCPConn.reader 필드에 저장됩니다.
2단계: ‘data’ 이벤트 처리하기
이제 'data' 이벤트를 구현해 봅시다. 여기서 문제가 있습니다. 'data' 이벤트는 데이터가 도착할 때마다 발생하지만, 프로미스는 프로그램이 소켓에서 데이터를 읽으려고 할 때만 존재합니다. 따라서 'data' 이벤트가 발생할 준비가 되는 시점을 제어할 방법이 있어야 합니다.
이 지식을 바탕으로 soRead 함수를 구현합니다.
// net.Socket으로부터 래퍼 생성
function soInit(socket: net.Socket): TCPConn {
const conn: TCPConn = {
socket: socket, reader: null,
};
socket.on('data', (data: Buffer) => {
console.assert(conn.reader);
// 다음 읽기까지 'data' 이벤트를 일시 중지
conn.socket.pause();
// 현재 읽기 작업의 프로미스를 이행
conn.reader!.resolve(data);
conn.reader = null;
});
return conn;
}
function soRead(conn: TCPConn): Promise<Buffer> {
console.assert(!conn.reader); // 동시 호출 없음
return new Promise((resolve, reject) => {
// 프로미스 콜백 저장
conn.reader = {resolve: resolve, reject: reject};
// 그리고 'data' 이벤트를 재개하여 나중에 프로미스를 이행하도록 함
conn.socket.resume();
});
}'data' 이벤트는 우리가 소켓을 읽을 때까지 일시 중지되므로, 소켓은 생성된 후 기본적으로 일시 중지 상태여야 합니다. 이를 위한 플래그가 있습니다.
3단계: ‘end’와 ’error’ 이벤트 처리하기
'data' 이벤트와 달리 'end'와 'error' 이벤트는 일시 중지할 수 없으며 발생하는 즉시 방출됩니다. 우리는 이 이벤트들을 래퍼 객체에 저장하고 soRead에서 확인하는 방식으로 처리합니다.
만약 현재 reader 프로미스가 있다면, 그것을 이행하거나 거부합니다.
// net.Socket으로부터 래퍼 생성
function soInit(socket: net.Socket): TCPConn {
const conn: TCPConn = {
socket: socket, err: null, ended: false, reader: null,
};
socket.on('data', (data: Buffer) => {
// 생략
});
socket.on('end', () => {
// 이 또한 현재 읽기 작업을 이행
conn.ended = true;
if (conn.reader) {
conn.reader.resolve(Buffer.from('')); // EOF
conn.reader = null;
}
});
socket.on('error', (err: Error) => {
// 오류 또한 현재 읽기 작업으로 전달
conn.err = err;
if (conn.reader) {
conn.reader.reject(err);
conn.reader = null;
}
});
return conn;
}soRead가 호출되기 전에 발생한 이벤트들은 저장되었다가 확인됩니다.
// EOF 후에는 빈 'Buffer'를 반환
function soRead(conn: TCPConn): Promise<Buffer> {
console.assert(!conn.reader); // 동시 호출 없음
return new Promise((resolve, reject) => {
// 연결이 읽기 불가능한 상태라면, 즉시 프로미스를 완료
if (conn.err) {
reject(conn.err);
return;
}
if (conn.ended) {
resolve(Buffer.from('')); // EOF
return;
}
// 프로미스 콜백 저장
conn.reader = {resolve: resolve, reject: reject};
// 그리고 'data' 이벤트를 재개하여 나중에 프로미스를 이행하도록 함
conn.socket.resume();
});
}4단계: 소켓에 쓰기
socket.write 메서드는 쓰기 완료를 알리는 콜백을 받으므로, 프로미스로의 변환은 간단합니다.
Node.js 문서에는 이 작업을 위해 사용할 수 있는 'drain' 이벤트도 있습니다. Node.js 라이브러리는 종종 같은 일을 할 수 있는 여러 방법을 제공하므로, 하나를 선택하고 나머지는 무시하면 됩니다.
async와 await 사용하기
에코 서버 구현으로 돌아가 봅시다. 프로미스 기반 API에 await를 사용하기 위해, 새 연결을 위한 핸들러(newConn)는 async 함수가 됩니다.
await 구문은 거부(rejected)될 때 예외를 던질 수 있으므로, 우리 코드를 try-catch 블록으로 감쌌습니다. 실제 프로덕션 코드에서는 모든 예외를 잡는 핸들러를 사용하기보다 실제로 오류를 처리하기를 원할 것입니다.
이제 소켓을 사용하는 코드는 간단해졌습니다. 애플리케이션 로직을 방해하는 콜백이 없습니다. newConn async 함수는 어디에서도 await 되지 않는다는 점에 유의하십시오. 이 함수는 리스닝 소켓의 콜백으로 단순히 호출됩니다. 이는 여러 연결이 동시에 처리됨을 의미합니다.
import * as net from "net";
// Promise Based API for TCP Sockets
type TCPConn = {
// the JS socket object
socket: net.Socket;
// from the 'error' event
err: null | Error;
// from the 'end' event
ended: boolean;
// the callbacks of the promise of the current read
reader: null | {
resolve: (value: Buffer) => void,
reject: (value: Error) => void
};
};
// create a wrapper from net.Socket
function soInit(socket: net.Socket): TCPConn {
const conn: TCPConn = {
socket: socket, err: null, ended: false, reader: null,
};
// 'data' 이벤트 처리
socket.on('data', (data: Buffer) => {
console.assert(conn.reader);
// 다음 읽기까지 'data' 이벤트 일시 중지
conn.socket.pause();
// 현재 읽기 작업의 프로미스 이행
conn.reader!.resolve(data);
conn.reader = null;
});
// 'end' 이벤트 처리
socket.on('end', () => {
// 현재 읽기 작업도 이행
conn.ended = true;
if (conn.reader) {
conn.reader.resolve(Buffer.from('')); // EOF
conn.reader = null;
}
});
// 'error' 이벤트 처리
socket.on('error', (err: Error) => {
// 오류도 현재 읽기 작업으로 전달
conn.err = err;
if (conn.reader) {
conn.reader.reject(err);
conn.reader = null;
}
});
return conn
}
function soRead(conn: TCPConn): Promise<Buffer> {
console.assert(!conn.reader) // 동시 호출 없음
return new Promise((resolve, reject) => {
// 연결이 읽기 불가능한 상태라면, 즉시 프로미스 완료
if (conn.err) {
reject(conn.err);
return;
}
if (conn.ended) {
resolve(Buffer.from('')) // EOF
return;
}
// 프로미스 콜백 저장
conn.reader = { resolve: resolve, reject: reject };
// 'data' 이벤트 재개하여 나중에 프로미스 이행
conn.socket.resume();
});
}
function soWrite(conn: TCPConn, data: Buffer): Promise<void> {
console.assert(data.length > 0);
return new Promise((resolve, reject) => {
// 오류가 있다면, 즉시 거부
if (conn.err) {
reject(conn.err);
return;
}
conn.socket.write(data, (err?: Error) => {
if (err) {
reject(err)
} else {
resolve();
}
});
});
}
async function newConn(socket: net.Socket): Promise<void> {
console.log('new connection', socket.remoteAddress, socket.remotePort);
try {
await serveClient(socket);
} catch (exc) {
console.error('exception', exc);
} finally {
socket.destroy();
}
}
async function serveClient(socket: net.Socket): Promise<void> {
const conn: TCPConn = soInit(socket);
while (true) {
const data = await soRead(conn);
if (data.length === 0) {
console.log('end connection');
break;
}
console.log('writing data', data);
await soWrite(conn, data);
}
}
const server = net.createServer({
pauseOnConnect: true, // required by TCPConn
});
// 주소에 바인딩하고 리스닝
server.listen({ host: '127.0.0.1', port: 12345 })
// 연결 이벤트 발생 시 newConn 함수 실행
server.on('connection', newConn);
// 오류 이벤트 발생 시 오류 던지기
// server.on('error', (err: Error) => {throw err;});논의: 백프레셔(Backpressure)
소켓 쓰기 완료를 기다려야 하는가?
새로운 에코 서버에는 이제 socket.write()가 완료되기를 기다린다는 큰 차이점이 있습니다. 그런데 “쓰기 완료”란 무엇을 의미할까요. 그리고 왜 우리는 그것을 기다려야 할까요. 이 질문에 답하자면, socket.write()는 데이터가 OS에 제출되었을 때 완료됩니다. 하지만 “왜 데이터가 OS에 즉시 제출될 수 없는가?”라는 새로운 질문이 생깁니다. 이 질문은 사실 네트워크 프로그래밍 자체보다 더 깊은 곳까지 파고듭니다.
생산자는 소비자에 의해 병목 현상을 겪는다
비동기 통신이 있는 곳에는 어디든 생산자와 소비자를 연결하는 큐(queue)나 버퍼(buffer)가 있습니다. 우리 물리적 세계의 큐와 버퍼는 크기가 제한되어 있으며 무한한 양의 데이터를 담을 수 없습니다. 비동기 통신의 한 가지 문제는 생산자가 소비자가 소비하는 것보다 더 빨리 생산할 때 발생하는 일입니다. 큐나 버퍼가 오버플로우되는 것을 방지하는 메커니즘이 있어야 합니다. 이 메커니즘은 네트워크 애플리케이션에서 종종 백프레셔(backpressure)라고 불립니다.
TCP에서의 백프레셔: 흐름 제어(Flow Control)
TCP에서의 백프레셔는 흐름 제어(flow control)로 알려져 있습니다.
- 소비자의 TCP 스택은 들어오는 데이터를 수신 버퍼에 저장하여 애플리케이션이 소비할 수 있게 합니다.
- 생산자의 TCP 스택이 보낼 수 있는 데이터의 양은 생산자의 TCP 스택에 알려진
윈도우(window)에 의해 제한되며, 윈도우가 가득 차면 데이터 전송을 일시 중지합니다. - 소비자의 TCP 스택은 윈도우를 관리합니다. 애플리케이션이 수신 버퍼에서 데이터를 가져가면 윈도우를 앞으로 이동시키고 생산자의 TCP 스택에 데이터 전송 재개를 알립니다.
흐름 제어의 효과: TCP는 전송을 일시 중지하고 재개할 수 있으므로 소비자의 수신 버퍼 크기는 제한됩니다.
flow ctrl bounded!
|producer| ==> |send buf| ===========> |recv buf| ==> |consumer|
app OS TCP OS app
TCP 흐름 제어는 윈도우를 제어하는 또 다른 메커니즘인 TCP 혼잡 제어(congestion control)와 혼동되어서는 안 됩니다.
애플리케이션과 OS 사이의 백프레셔
이 멋진 메커니즘은 TCP뿐만 아니라 애플리케이션에도 구현되어야 합니다. 생산자 측에 집중해 봅시다. 애플리케이션은 데이터를 생산하여 OS에 제출하고, 데이터는 송신 버퍼로 가며, TCP 스택은 송신 버퍼에서 데이터를 소비하여 전송합니다.
write() may block!
|producer| ========> |send buf| =====> ...
app OS TCP
OS는 어떻게 송신 버퍼가 오버플로우되는 것을 막을까요. 간단합니다. 버퍼가 가득 차면 애플리케이션은 더 이상 데이터를 쓸 수 없습니다. 이제 애플리케이션은 과잉 생산을 스스로 조절할 책임이 있습니다. 왜냐하면 데이터는 어딘가로 가야 하지만 메모리는 유한하기 때문입니다. 애플리케이션이 블로킹 I/O를 수행하고 있다면, 송신 버퍼가 가득 찼을 때 호출이 블로킹되므로 백프레셔는 자연스럽게 처리됩니다. 하지만 이벤트 루프와 함께 JS로 코딩할 때는 그렇지 않습니다.
무제한 큐는 스스로를 망치는 위험한 기능(Footguns)이다
이제 “왜 쓰기 완료를 기다리는가?”라는 질문에 답할 수 있습니다. 애플리케이션이 기다리는 동안에는 생산할 수 없기 때문입니다. socket.write()는 송신 버퍼가 가득 차서 런타임이 OS에 더 이상 데이터를 제출할 수 없더라도 항상 성공합니다. 하지만 데이터는 어딘가로 가야 하므로, 런타임 내의 무제한 내부 큐로 들어가게 됩니다. 이는 무제한적인 메모리 사용을 유발할 수 있는, 스스로를 망치기 쉬운 위험한 기능(footgun)입니다.
write() unbounded! event loop
|producer| ======> |internal queue| =========> |send buf| =====> ...
app Node.js OS TCP
우리의 이전 에코 서버를 예로 들면, 서버는 생산자이자 소비자이며 클라이언트도 마찬가지입니다. 만약 클라이언트가 에코된 데이터를 소비하는 것보다 더 빨리 데이터를 생산한다면(또는 클라이언트가 데이터를 전혀 소비하지 않는다면), 서버가 쓰기 완료를 기다리지 않을 경우 서버의 메모리는 무한정 증가할 것입니다. 백프레셔는 생산자와 소비자를 연결하는 모든 시스템에 존재해야 합니다. 일반적으로 소프트웨어 시스템에서 무제한 큐를 찾아보는 것이 좋은데, 이는 백프레셔의 부재를 나타내는 신호이기 때문입니다.
논의: 이벤트와 순차적 실행
이전 버전과의 또 다른 차이점은 socket.pause()의 사용입니다. 이제 이것이 왜 필수적인지 이해할 수 있을 것입니다. 왜냐하면 이것이 백프레셔를 구현하는 데 사용되기 때문입니다.
'data' 이벤트를 일시 중지하는 또 다른 이유가 있습니다. 콜백 기반 코드에서 이벤트 핸들러가 반환되면, 런타임은 일시 중지되지 않은 경우 다음 'data' 이벤트를 발생시킬 수 있습니다. 문제는 이벤트 콜백의 완료가 이벤트 처리의 완료를 의미하지 않는다는 것입니다. 처리는 추가적인 콜백으로 계속될 수 있습니다. 그리고 데이터가 순서가 있는 바이트 시퀀스라는 점을 고려할 때, 이러한 처리의 뒤섞임(interleaved handling)은 문제를 일으킬 수 있습니다. 이 상황을 경쟁 상태(race condition)라고 하며, 동시성과 관련된 문제의 한 종류입니다. 이 상황에서는 원치 않는 동시성이 발생하게 됩니다.
결론: 프로미스 대 콜백
위의 논의에 따라, 우리가 왜 프로미스 기반 API로 전환했는지 설명할 수 있습니다. 여기에는 장점들이 있기 때문입니다.
- 프로미스와
async/await를 고수하면, 작업들이 순서대로 일어나기 때문에 위에서 설명한 종류의 경쟁 상태를 만들기가 더 어렵습니다. - 콜백 기반 코드는 코드 실행 순서를 파악하기 더 어려울 뿐만 아니라, 순서를 제어하기도 더 어렵습니다. 요컨대, 콜백은 읽기 더 어렵고 작성할 때 오류가 발생하기 쉽습니다.
- 프로미스 기반 스타일을 사용할 때는 백프레셔가 자연스럽게 존재합니다. 이는 (Node.js에서는 할 수 없는) 블로킹 I/O로 코딩하는 것과 유사합니다.
4. 간단한 네트워크 프로토콜
메시지 에코 서버
HTTP를 파싱하는 것은 상당히 많은 작업이 필요한 일이므로, 먼저 더 작은 단계부터 시작하겠습니다. 프로토콜의 가장 중요한 기능인 바이트 스트림(byte stream)을 메시지로 분할하는 과정을 설명하기 위해 더 간단한 프로토콜을 구현합니다.
우리가 만들 프로토콜은 \n(개행 문자)으로 구분된 메시지로 구성됩니다. 서버는 메시지를 읽고 동일한 프로토콜을 사용하여 응답을 다시 보냅니다.
- 클라이언트가 ’quit’을 보내면, ’Bye.’라고 응답하고 연결을 닫습니다.
- 그 외의 경우에는 메시지 앞에 ’Echo: ’라는 접두사를 붙여 그대로 반향(echo)합니다.
동적 버퍼
동적 버퍼의 필요성
메시지는 소켓에서 저절로 오지 않으므로, 우리는 들어오는 데이터를 버퍼에 저장한 다음, 그곳에서 메시지를 분할해야 합니다. Node.js의 Buffer 객체는 고정된 크기를 가진 이진 데이터 덩어리입니다. 데이터를 추가하여 크기를 늘릴 수 없습니다. 우리가 할 수 있는 것은 두 개의 버퍼를 연결(concatenate)하여 새로운 버퍼를 만드는 것입니다.
하지만, 이는 알고리즘 복잡도를 $O(n^2)$으로 만들 수 있는 안티패턴(anti-pattern)입니다.
새로운 데이터를 연결할 때마다 기존 데이터가 복사됩니다. 이러한 복사 비용을 분할 상환(amortize)하기 위해 동적 배열이 사용됩니다.
C++:std::vector,std::stringPython:bytearrayGo:slice
동적 버퍼 코딩하기
버퍼는 추가되는 데이터에 필요한 것보다 더 커야 합니다. 이 여유 공간(extra capacity)을 사용해 복사 비용을 분할 상환합니다. DynBuf 타입은 실제 데이터 길이를 저장합니다.
copy() 메서드의 구문은 src.copy(dst, dst_offset, src_offset)입니다. 이는 데이터를 추가하는 데 사용됩니다.
Buffer.alloc(cap)은 주어진 크기의 새 버퍼를 생성합니다. 이는 버퍼의 크기를 조절하는 데 사용됩니다. 분할 상환 비용이 $O(1)$이 되려면 새 버퍼는 기하급수적으로 커져야 합니다. 우리는 버퍼 용량을 위해 2의 거듭제곱(power of two)을 사용할 것입니다.
// append data to DynBuf
function bufPush(buf: DynBuf, data: Buffer): void {
const newLen = buf.length + data.length;
if (buf.data.length < newLen) {
// grow the capacity by the power of two
let cap = Math.max(buf.data.length, 32);
while (cap < newLen) {
cap *= 2;
}
const grown = Buffer.alloc(cap);
buf.data.copy(grown, 0, 0);
buf.data = grown;
}
data.copy(buf.data, buf.length, 0);
buf.length = newLen;
}메시지 프로토콜 구현하기
1단계: 서버 루프
상위 레벨에서 서버는 루프(loop) 형태로 동작해야 합니다.
- 들어오는 바이트 스트림에서 완전한 메시지 하나를 파싱하고 제거합니다.
- 버퍼에 데이터를 추가합니다.
- 메시지가 아직 불완전하다면 루프를 계속 진행합니다.
- 메시지를 처리합니다.
- 응답을 전송합니다.
async function serveClient(socket: net.Socket): Promise<void> {
const conn: TCPConn = soInit(socket);
const buf: DynBuf = {data: Buffer.alloc(0), length: 0};
while (true) {
// try to get 1 message from the buffer
const msg: null|Buffer = cutMessage(buf);
if (!msg) {
// need more data
const data: Buffer = await soRead(conn);
bufPush(buf, data);
// EOF?
if (data.length === 0) {
// omitted ...
return;
}
// got some data, try it again.
continue;
}
// omitted. process the message and send the response ...
} // loop for messages
}소켓 읽기(read)는 특정 메시지 경계와 관련이 없습니다. 우리가 하는 일은 버퍼가 완전한 메시지를 포함할 때까지 데이터를 계속 추가하는 것입니다. cutMessage() 함수는 메시지가 완전한지 여부를 알려줍니다.
- 불완전하면
null을 반환합니다. - 완전하면 메시지를 버퍼에서 제거하고 그 메시지를 반환합니다.
2단계: 메시지 분할하기
cutMessage() 함수는 구분자(delimiter) \n을 사용하여 메시지가 완전한지 검사합니다.
function cutMessage(buf: DynBuf): null|Buffer {
// messages are separated by '\n'
const idx = buf.data.subarray(0, buf.length).indexOf('\n');
if (idx < 0) {
return null; // not complete
}
// make a copy of the message and move the remaining data to the front
const msg = Buffer.from(buf.data.subarray(0, idx + 1));
bufPop(buf, idx + 1);
return msg;
}그런 다음 이 함수는 메시지 데이터의 복사본을 만드는데, 이는 해당 데이터가 버퍼에서 제거될 것이기 때문입니다.
buf.subarray()는 데이터를 복사하지 않고 서브배열(subarray)의 참조를 반환합니다.Buffer.from()은 소스(source)로부터 데이터를 복사하여 새 버퍼를 생성합니다.
메시지는 나머지 데이터를 앞으로 이동시킴으로써 제거됩니다.
buf.copyWithin(dst, src_start, src_end)는 버퍼 내에서 데이터를 복사하며, 소스와 대상 영역이 겹칠 수 있습니다. 이는 C 언어의 memmove()와 유사합니다. 버퍼를 다루는 이 방식은 그다지 최적화되어 있지 않으며, 이에 대해서는 나중에 더 다루겠습니다.
3단계: 요청 처리하기
요청을 처리하고 응답을 보내는 작업은 서버 루프 안에서 이루어집니다.
while (true) {
// try to get 1 message from the buffer
const msg = cutMessage(buf);
if (!msg) {
// omitted ...
continue;
}
// process the message and send the response
if (msg.equals(Buffer.from('quit\n'))) {
await soWrite(conn, Buffer.from('Bye.\n'));
socket.destroy();
return;
} else {
const reply = Buffer.concat([Buffer.from('Echo: '), msg]);
await soWrite(conn, reply);
}
} // loop for messages 이제 우리의 메시지 에코 서버가 완성되었습니다. socat 명령어로 테스트해 보세요.
논의: 파이프라인 요청
버퍼에서 메시지를 제거할 때, 우리는 남은 데이터를 앞으로 이동시켰습니다. 요청-응답 시나리오에서는 클라이언트가 다음 요청을 보내기 전에 응답을 기다리는데, 어떻게 버퍼에 데이터가 남아있을 수 있는지 궁금할 것입니다. 이는 한 가지 최적화를 지원하기 위함입니다.
파이프라이닝으로 지연 시간 줄이기
수많은 스크립트와 스타일시트를 포함하는 일반적인 최신 웹 페이지를 생각해 봅시다. 페이지를 로드하는 데는 많은 HTTP 요청이 필요하며, 각 요청은 최소한 한 번의 왕복 시간(Round-Trip Time, RTT)만큼 로드 시간을 증가시킵니다. 만약 우리가 응답을 하나씩 기다리지 않고 여러 요청을 한 번에 보낼 수 있다면, 로드 시간을 크게 줄일 수 있을 것입니다. 서버 측에서는 TCP 연결이 단지 바이트 스트림이기 때문에 그 차이를 알아채지 못해야 합니다.
이것을 파이프라인 요청(pipelined requests)이라고 합니다. 이는 요청-응답 프로토콜에서 RTT를 줄이는 일반적인 방법입니다. 바로 이 때문에 우리가 남은 버퍼 데이터를 유지했던 것입니다. 버퍼 안에는 1개 이상의 메시지가 있을 수 있기 때문입니다.
파이프라인 요청 지원하기
Redis나 NGINX와 같이 잘 구현된 많은 네트워크 서버에는 파이프라인 요청을 할 수 있지만, 일부 덜 일반적인 구현에서는 문제가 발생할 수 있습니다. 웹 브라우저는 버그가 있는 서버들 때문에 파이프라인 HTTP 요청을 사용하지 않고, 대신 여러 개의 동시 연결(concurrent connections)을 사용합니다.
하지만 TCP 데이터를 엄격하게 연속적인 바이트 스트림으로 취급한다면 파이프라인 메시지는 구별할 수 없어야 합니다. 왜냐하면 파서는 버퍼링된 데이터의 크기에 의존하지 않고, 단지 요소들을 하나씩 소비하기 때문입니다. 따라서 파이프라인 메시지는 프로토콜 파서의 정확성을 확인하는 방법이 될 수 있습니다. 만약 서버가 소켓 읽기를 “TCP 패킷”으로 취급한다면, 쉽게 실패할 것입니다.
다음 명령어로 파이프라인 시나리오를 테스트합니다.
서버는 아마도 단일 ‘data’ 이벤트에서 2개의 메시지를 수신할 것이며, 우리 서버는 이를 올바르게 처리했습니다.
파이프라이닝으로 인한 교착 상태(Deadlock)
파이프라인 요청에 대한 한 가지 주의사항: 너무 많은 요청을 파이프라이닝하면 교착 상태로 이어질 수 있습니다. 서버와 클라이언트가 동시에 데이터를 보낼 수 있는데, 만약 양쪽의 전송 버퍼가 모두 가득 차게 되면, 둘 다 전송 작업에 막혀 버퍼를 비울 수 없으므로 교착 상태에 빠집니다.
논의: 더 스마트한 버퍼
앞에서부터 데이터 제거하기
우리 버퍼 코드에는 여전히 $O(n^2)$ 동작이 남아있습니다. 버퍼에서 메시지를 제거할 때마다 남은 데이터를 앞으로 이동시켰기 때문입니다. 이는 작은 메시지들을 많이 파이프라이닝할 때 발생할 수 있습니다.
이 문제를 해결하려면 데이터 이동 비용을 분할 상환해야 합니다. 이는 데이터 이동을 지연시킴으로써 가능합니다. 앞부분의 낭비된 공간이 특정 임계값(예: 용량의 1/2)에 도달할 때까지 남은 데이터를 제자리에 유지할 수 있습니다. 이 수정 사항을 적용하려면 데이터의 시작점을 추적해야 하므로, 실제 데이터를 가져오는 새로운 메서드가 필요합니다. 해당 코드 작성은 독자의 연습 과제로 남겨둡니다.
재할당 없는 고정 크기 버퍼 사용하기
프로토콜의 메시지 크기가 합리적으로 작은 값으로 제한된다면, 크기 조절 없이 충분히 큰 버퍼 하나를 사용하는 것이 가능할 때가 있습니다. 예를 들어, 많은 HTTP 구현은 헤더에 많은 데이터를 넣을 합법적인 사용 사례가 없으므로 헤더 크기에 작은 제한을 둡니다. 이러한 구현에서는 연결당 하나의 버퍼 할당으로 충분하며, 전체 페이로드(payload)를 메모리에 저장할 필요가 없다면 이 버퍼는 페이로드를 읽는 데도 충분합니다.
이 방식의 장점은 다음과 같습니다.
- (초기 버퍼를 제외한) 동적 메모리 관리가 없습니다.
- 메모리 사용량을 예측하기 쉽습니다.
이는 Node.js 앱과는 관련성이 크지 않지만, 수동 메모리 관리가 필요하거나 하드웨어 제약이 있는 환경에서는 바람직한 장점들입니다.
결론: 네트워크 프로그래밍 기초
지금까지 우리가 배운 내용을 요약하면 다음과 같습니다.
소켓 API:listen,accept,read,write등이벤트 루프와 그 영향콜백(Callback) vs. 프로미스(Promise).async와await사용법역압력(Backpressure), 큐, 그리고 버퍼TCP 바이트 스트림, 프로토콜 파서, 파이프라인 메시지
5. HTTP 의미론과 구문
HTTP는 인간이 매우 읽기 쉬운(human-readable) 형태이므로, 사양(specification) 대신 예제를 보면서 서버를 구축할 수도 있습니다. 하지만 이런 접근 방식은 버그가 많은 장난감 수준의 코드를 낳게 되며, 많은 것을 배울 수도 없습니다. 따라서 여러 RFC 문서로 이루어진 사양을 참고해야 합니다.
상위 수준 구조
입문 장에서 이미 배운 내용을 복습해 보겠습니다.
HTTP 요청 메시지는 다음으로 구성됩니다.GET,POST와 같은 동사 형태의메서드(method)URI- 키-값 쌍의 목록인
헤더 필드(header fields) 리스트 - 요청 헤더 뒤에 오는
페이로드 본문(payload body). 특별한 경우로,GET과HEAD는 페이로드가 없습니다.
HTTP 응답은 다음으로 구성됩니다.- 주로 요청의 성공 여부를 나타내는
상태 코드(status code) 헤더 필드 리스트- 선택적인
페이로드 본문
- 주로 요청의 성공 여부를 나타내는
이러한 요소들은 HTTP/1.0부터 HTTP/3까지 대부분 동일합니다.
Content-Length
HTTP 의미론은 대부분 헤더 필드를 해석하는 것과 관련 있으며, 이는 RFC 9110에 기술되어 있습니다. 직접 한번 읽어보시길 권합니다. 가장 중요한 헤더 필드는 Content-Length와 Transfer-Encoding입니다. 이들은 프로토콜의 가장 중요한 기능인 HTTP 메시지의 길이를 결정하기 때문입니다.
HTTP 헤더의 길이는 요청과 응답은 모두헤더 + 본문의 두 부분으로 구성됩니다. 이 둘은 빈 줄(empty line)로 구분됩니다. 한 줄은\r\n으로 끝납니다. 따라서 헤더는 빈 줄을 포함하여\r\n\r\n으로 끝납니다. 이것이 우리가 헤더의 길이를 결정하는 방법입니다.HTTP 본문의 길이는 본문의 길이를 결정하는 방법이 세 가지 있어 복잡합니다.첫 번째방법은 본문의 길이를 담고 있는Content-Length를 사용하는 것입니다. 일부 오래된HTTP/1.0소프트웨어는Content-Length를 사용하지 않는데, 이 경우 본문은 단순히 연결 데이터의 나머지 부분이 됩니다. 파서(parser)는 소켓을EOF(End-Of-File)까지 읽어 들여 본문으로 간주합니다. 이것이 본문 길이를 결정하는두 번째방법입니다. 이 방식은 연결이 조기에 종료되었는지 알 수 없다는 문제점이 있습니다.
청크 분할 전송 인코딩
세 번째 방법은 Content-Length 대신 Transfer-Encoding: chunked를 사용하는 것입니다. 이를 청크 분할 전송 인코딩(chunked transfer encoding)이라고 합니다. 이 방식은 사전에 페이로드의 크기를 알지 못해도 페이로드의 끝을 표시할 수 있습니다. 이를 통해 서버는 응답을 실시간으로 생성하면서 동시에 전송할 수 있습니다. 이러한 사용 사례를 스트리밍(streaming)이라고 합니다. 예를 들어, 프로세스가 끝날 때까지 기다리지 않고 실시간 로그를 클라이언트에 표시하는 경우가 있습니다.
청크 분할 인코딩은 어떻게 동작할까요. 송신자 입장에서 우리는 전체 페이로드 길이는 모르지만, 현재 가지고 있는 페이로드의 일부는 알고 있습니다. 그래서 우리는 “청크(chunk)”라고 불리는 작은 메시지 형식으로 데이터를 보낼 수 있습니다. 그리고 특수한 청크가 스트림의 끝을 표시합니다. 수신자는 바이트 스트림을 청크 단위로 파싱하고 데이터를 소비하다가, 특수한 종료 청크를 받으면 처리를 멈춥니다.
구체적인 예는 다음과 같습니다.
4\r\nHTTP\r\n5\r\nserver\r\n0\r\n\r\n
이것은 세 개의 청크로 파싱됩니다.
4\r\nHTTP\r\n6\r\nserver\r\n0\r\n\r\n
어떻게 작동하는지 쉽게 짐작할 수 있을 것입니다. 청크는 데이터의 크기로 시작하며, 크기가 0인 청크는 스트림의 끝을 나타냅니다.
청크는 메시지가 아니다, 청크 데이터의 경계는 단지 부수적인 효과일 뿐이라는 점에 유의해야 합니다. 이 청크들은 애플리케이션에 개별 메시지로 표현되지 않습니다. 애플리케이션은 여전히 페이로드를 하나의 바이트 스트림으로 인식합니다.
HTTP의 모호성
본문 길이 판별의 정상적인 경우 (The Happy Cases of Body Length)
요약하자면, 페이로드 본문의 길이는 다음과 같이 결정됩니다 (HTTP 메서드가 페이로드 본문을 허용하는 경우, 즉 POST 또는 PUT).
Transfer-Encoding: chunked가 있는 경우, 청크를 파싱합니다.Content-Length: number가 유효한 경우, 길이는 이미 알려진 값입니다.- 두 필드 모두 없는 경우, 연결 데이터의 나머지 부분을 페이로드로 사용합니다.
GET, HEAD, 304 (Not Modified) 상태 코드와 같은 특수 사례들도 있어 HTTP 구현을 쉽지 않게 만듭니다.
까다로운 경우를 염두에 두세요 (Mind the Nasty Cases)
두 헤더 필드가 모두 존재하는 경우에는 어떻게 될지 궁금할 수 있습니다. 이를 해석할 명확한 방법이 없기 때문입니다. 이런 종류의 모호성은 “HTTP 요청 스머글링(HTTP request smuggling)”으로 알려진 보안 취약점의 원인이 됩니다.
또 다른 모호성은 GET 요청에는 페이로드 본문이 존재하지 않는다는 점입니다. 만약 요청에 Content-Length가 포함되어 있다면 어떻게 될까요? 서버는 이 필드를 무시해야 할까요, 아니면 금지해야 할까요? Content-Length: 0은 어떤가요? 또한, 서버나 클라이언트가 사용자가 Content-Length와 Transfer-Encoding 필드를 마음대로 조작하도록 허용해야 할까요? 인터넷에는 많은 논의가 있으며, RFC가 여러 사례를 열거하려고 노력했지만, 구현마다 다르게 처리합니다.
만약, 새로운 프로토콜을 설계한다면, 이와 같은 모호성을 어떻게 피하시겠습니까?
HTTP 메시지 형식
RFC 9112는 비트(bits)가 네트워크를 통해 어떻게 전송되는지 정확하게 설명합니다.
BNF 언어 읽기 (Read the BNF Language)
HTTP 메시지 형식은 BNF라는 언어로 기술됩니다.
이는 다음과 같은 의미입니다. HTTP 메시지는 요청 메시지 또는 응답 메시지입니다. 메시지는 요청 라인(request line) 또는 상태 라인(status line)으로 시작하고, 여러 개의 헤더 필드가 뒤따르며, 그 다음 빈 줄, 그리고 선택적인 페이로드 본문이 옵니다. 각 라인은 ASCII 문자열 \r\n인 CRLF로 구분됩니다. BNF 언어는 영어보다 훨씬 간결하고 모호함이 적습니다.
HTTP 헤더 필드 (HTTP Header Fields)
헤더 필드 이름(name)과 값(value)은 콜론(:)으로 구분되지만, 필드 이름과 값에 대한 규칙은 RFC 9110에 별도로 정의되어 있습니다.
field-name = token
token = 1*tchar
tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
/ "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
/ DIGIT / ALPHA
; any VCHAR, except delimiters
OWS = *( SP / HTAB )
; optional whitespace
field-value = *field-content
field-content = field-vchar
[ 1*( SP / HTAB / field-vchar ) field-vchar ]
field-vchar = VCHAR / obs-text
obs-text = %x80-FF이것이 필드 이름과 값에 대한 일반적인 규칙입니다. SP, HTAB, VCHAR는 각각 공백(space), 탭(tab), 그리고 출력 가능한 ASCII 문자를 가리킵니다. 헤더 필드에서는 일부 문자가 금지되며, 특히 CR과 LF가 그렇습니다. 일부 헤더 필드는 쉼표로 구분된 값이나 따옴표로 묶인 문자열과 같이 해석을 위한 추가 규칙을 가집니다. 지금은 필요해지기 전까지는 그대로 두어도 괜찮습니다. HTTP 사양은 매우 방대하며, 우리가 구현할 HTTP 서버의 가장 중요한 부분만 다룹니다.
일반적인 헤더 필드
많은 헤더 필드는 애플리케이션에 의해 해석되거나 선택적인 HTTP 기능에서 사용되므로, 우리의 구현과 즉각적인 관련은 없습니다. 브라우저 개발자 도구에서 HTTP 헤더를 검사해보면 이러한 필드들에 익숙해질 수 있습니다. 일반적인 헤더 필드입니다. (C는 클라이언트, S는 서버를 의미합니다.)
| 헤더 필드 | 주체 | 설명 |
|---|---|---|
Content-Length: 60 |
C/S | 논의됨 |
Transfer-Encoding: chunked |
C/S | 논의됨 |
Accept: text/html |
C | 콘텐츠 유형 협상용 |
Content-Type: text/html |
S | 콘텐츠 유형 |
Accept-Encoding: gzip |
C | 콘텐츠 압축 협상용 |
Content-Encoding: gzip |
S | 압축된 응답 |
Vary: content-encoding |
S | 프록시에게 콘텐츠 협상에 대해 알림 |
Authorization: Basic dTpw |
C | 사용자 이름과 비밀번호를 통한 인증 |
Cache-Control: no-cache |
C/S | 캐싱 동작에 영향을 줌 |
Age: 60 |
S | 항목이 프록시에 캐시된 시간 |
Set-Cookie: k=v |
S | HTTP 쿠키 |
Cookie: k=v |
C | HTTP 쿠키 |
Date |
C/S | 그다지 유용하지 않음 |
Expect: 100-continue |
C | 잘 알려지지 않은 기능 |
Host: example.com |
C/S | URL의 호스트 이름 |
Last-Modified |
S | 캐시 유효성 검사 및 304 Not Modified용 |
If-Modified-Since |
C | Last-Modified 유효성 검사 |
ETag: abcd |
S | 캐시 유효성 검사 및 304 Not Modified용 |
If-None-Match: abcd |
C | ETag 유효성 검사 |
Range: bytes=10- |
C | 범위 요청. 응답의 일부를 가져옴 |
Content-Range: bytes 10-/60 |
S | 범위 응답 |
Accept-Ranges: bytes |
S | 범위 요청이 허용됨을 나타냄 |
Referer: http://foo.com/ |
C | 사용자가 어디에서 왔는지 |
TE: gzip |
C | Transfer-Encoding 협상용 |
Trailer: Foo |
C/S | 잘 알려지지 않은 기능: 페이로드 뒤의 헤더 필드 |
User-Agent: Foo |
C | 클라이언트 소프트웨어 |
Server: Foo |
S | 서버 소프트웨어 |
Upgrade: websocket |
C/S | 웹소켓(WebSocket) 생성 |
Access-Control-* |
S | 교차 출처 리소스 공유(CORS)용 |
Origin |
C | CORS |
Location: http://bar.com/ |
S | 3xx 리다이렉션용 |
HTTP 메서드
읽기 전용 메서드 (Read-Only Methods)
가장 중요한 두 가지 HTTP 메서드는 GET과 POST입니다. 왜 다른 HTTP 메서드들이 필요할까요? POST 요청은 페이로드를 가질 수 있지만 GET은 그럴 수 없다는 명백한 사실 외에도, 읽기 전용 작업과 쓰기 작업을 분리하는 것이 좋은 생각이기 때문입니다. 읽기 전용 작업에는 GET을 사용하고 나머지는 POST를 사용합니다.
읽기 전용 메서드는 “안전한(safe)” 메서드라고 불립니다. 안전한 메서드는 세 가지가 있습니다.
GETHEAD:GET과 같지만 응답 본문이 없음.OPTIONS: 드물게 사용되며, 허용된 요청 메서드 식별 및 CORS 관련 용도로 쓰임.
캐시 가능성 (Cacheability)
읽기 전용 작업과 쓰기 작업을 분리하는 한 가지 이유는 읽기 전용 작업이 일반적으로 캐시 가능(cacheable)하기 때문입니다. 반면에, 상태를 변경하는 쓰기 작업을 캐시하는 것은 의미가 없습니다. 하지만 캐시 가능성에 대한 규칙은 단순히 HTTP 메서드를 구분하는 것보다 더 복잡합니다.
GET과HEAD는 캐시 가능한 메서드로 간주되지만,OPTIONS는 특수 목적용이므로 그렇지 않습니다.- 상태 코드 또한 캐시 가능성에 영향을 줍니다.
Cache-Control헤더는 캐시 가능성에 영향을 줄 수 있습니다.POST는 보통 캐시할 수 없지만, 잘 알려지지 않은 헤더 필드(Content-Location)가 사용되고 특정 캐시 지시문이 있는 경우는 예외입니다.- 구현마다 캐시 가능성 규칙이 다릅니다.
CRUD와 리소스
HTTP 메서드가 왜 이렇게 많은지 궁금했을 것입니다. GET과 POST만으로 충분하지 않을까요? 실제로 많은 애플리케이션이 그렇게 하고 있습니다. HTTP에 더 많은 메서드가 추가된 이유는 사람들이 HTTP를 “리소스(resources)”를 관리하기 위한 프로토콜로 생각했기 때문입니다. 예를 들어, 포럼 사용자는 자신의 게시물을 리소스로서 조작합니다.
PUT을 통해 게시물 생성(Create)GET을 통해 게시물 읽기(Read)PATCH를 통해 게시물 수정(Update)DELETE를 통해 게시물 삭제(Delete)
이 네 가지 동사는 종종 CRUD(Create, Read, Update, Delete)라고 불립니다.
멱등성
그런데 왜 CRUD를 HTTP 메서드로 추가했을까요? 포럼 사용자는 게시물을 다른 포럼으로 옮길 수도 있는데, 그렇다면 HTTP에 MOVE 메서드도 포함해야 할까요? 임의의 영어 동사를 그대로 반영하는 것은 HTTP 메서드를 정의하는 좋은 이유가 될 수 없습니다. 더 나은 이유 중 하나는 작업의 멱등성(idempotence)을 정의하기 위함입니다. 멱등성 있는 작업이란 여러 번 반복해도 결과가 같은 작업을 의미합니다. 이는 작업이 성공할 때까지 안전하게 재시도할 수 있다는 뜻입니다.
예를 들어, SSH를 통해 rm 명령으로 파일을 삭제하던 중 결과를 보기 전에 연결이 끊겼다고 가정해 봅시다. 파일의 상태를 알 수 없지만, (정말로 동일한 파일이라면) 언제든지 다시 rm을 실행할 수 있습니다.
- 이전 시도가 실패했다면, 아마 다시 실패할 것입니다.
- 이전
rm은 실패했지만 이번rm이 성공했다면, 의도한 바가 이루어진 것입니다. - 이전
rm이 성공했다면, 다시 실행해도 해가 될 것이 없습니다.
HTTP 상의 멱등성 있는 작업도 rm의 반환 코드처럼 다른 상태 코드를 반환할 수 있습니다.
HTTP에서의 멱등성
- 읽기 전용 메서드(
GET,HEAD)는 명백히 멱등합니다. PUT과DELETE는 파일을 덮어쓰거나 삭제하는 것과 같이 멱등합니다.POST와PATCH는 멱등하다고 정의되지 않습니다. 멱등할 수도 있고, 아닐 수도 있습니다.
브라우저에서의 멱등성:
- 만약
<form>을POST로 제출한 후 페이지를 새로고침하면, 브라우저는 멱등하지 않을 수 있는 폼을 다시 제출하는 것에 대해 경고합니다. - HTML 폼은
GET과POST로 제한됩니다. 이러한 멱등성 있는 메서드들을 사용하려면 AJAX가 필요합니다.
하지만 이것만으로는 왜 이렇게 많은 동사가 있는지에 대한 의문이 완전히 풀리지 않습니다. HTTP는 멱등성 있는 쓰기를 위해 (PATCH, PUT, DELETE) 3개 대신 단 하나의 메서드만 추가할 수도 있었기 때문입니다. 사실, 앱이 이 모든 것을 사용해야 할 강력한 이유는 없을지도 모릅니다.
HTTP 메서드 비교
| 동사 | 안전(Safe) | 멱등(Idempotent) | 캐시가능(Cacheable) | <form> |
CRUD | 요청 본문 | 응답 본문 |
|---|---|---|---|---|---|---|---|
GET |
예 | 예 | 예 | 예 | read | 아니오 | 예 |
HEAD |
예 | 예 | 예 | 아니오 | read | 아니오 | 아니오 |
PUT |
아니오 | 예 | \(No^{*}\) | 아니오 | create | 예 | 예 |
DELETE |
아니오 | 예 | 아니오 | 아니오 | delete | 선택 | 선택 |
POST |
아니오 | 아니오 | \(No^{*}\) | 예 | update | 예 | 예 |
PATCH |
아니오 | 아니오 | \(No^{*}\) | 아니오 | update | 예 | 선택 |
POST또는PATCH의 캐싱이 가능은 하지만, 거의 지원되지 않습니다.
논의: 텍스트 대 바이너리
HTTP는 telnet으로 요청을 보낼 수 있도록 설계되었으므로, 직접 시험해보면서 배울 수 있습니다. 하지만 텍스트 기반 프로토콜에는 단점이 있습니다.
텍스트는 종종 모호하다 (Text is Often Ambiguous)
한 가지 단점은 사람이 읽기 쉬운 형식이 기계가 읽기에는 덜 좋다는 것입니다. 필요 이상으로 유연하기 때문입니다. HTTP 페이로드 길이가 결정되는 방식을 생각해 봅시다.
- 일반적인 경우는
Content-Length와Transfer-Encoding입니다. - 일부 HTTP 메서드와 상태 코드에는 특수한 규칙이 있습니다.
- 요청 스머글링(request smuggling)처럼 서로 다른 해석을 유발하는 경우도 있습니다.
HTTP는 보기에는 쉬운, 즉 간단한(simple) 프로토콜입니다. 하지만 그것을 해석하기 위한 규칙이 너무 많고, 그 규칙들마저도 여전히 모호함을 남기기 때문에 코드를 작성하는 것은 간단하지 않습니다.
텍스트는 더 많은 작업이 필요하고 오류에 취약하다 (Text is More Work & Error-Prone)
또 다른 단점은 텍스트를 다루는 데 훨씬 더 많은 작업이 필요하다는 것입니다. 텍스트 문자열을 제대로 처리하려면 먼저 그 길이를 알아야 하는데, 이는 종종 구분자(delimiters)에 의해 결정됩니다. 구분자를 찾는 추가적인 작업은 사람이 읽기 쉬운 형식의 대가입니다. 또한 이는 오류에 취약합니다. C 프로그래밍에서 null로 끝나는 문자열(0으로 구분)은 수많은 보안 취약점의 원인이 되어 왔습니다.
HTTP/2는 바이너리 기반이며 HTTP/1.1보다 더 복잡하지만, 알 수 없는 길이의 요소를 다룰 필요가 없기 때문에 프로토콜 파싱은 오히려 더 쉽습니다.
논의: 구분자
1. 구분자로 나뉜 데이터의 직렬화 오류
구분자는 텍스트 기반 프로토콜 어디에나 존재합니다. 예를 들어, HTTP에서는 아래와 같습니다.
- 헤더의 각 라인은
CRLF로 구분됩니다. - 헤더와 본문은 빈 줄로 구분됩니다.
구분자의 한 가지 문제점은 데이터 자체가 구분자를 포함할 수 없다는 것입니다. 이 규칙을 강제하지 못하면 일부 인젝션 취약점(injection exploits)으로 이어질 수 있습니다. 만약 악의적인 클라이언트가 버그가 있는 서버를 속여 헤더 필드 값에 CRLF를 포함시키게 하고, 그 헤더 필드가 마지막 필드라면, 페이로드 본문은 공격자가 제어하는 필드 값의 일부로 시작하게 됩니다. 이를 HTTP 응답 스플리팅(HTTP response splitting)이라고 합니다.
적절한 HTTP 서버/클라이언트는 헤더 필드에 CRLF를 인코딩할 방법이 없으므로 이를 금지해야 합니다. 하지만 이는 많은 일반적인 데이터 형식에서는 사실이 아닙니다. 예를 들어, JSON은 {},[],:,를 사용하여 요소를 구분하지만, JSON 문자열은 임의의 문자를 포함할 수 있습니다. 그래서 문자열은 구분자와의 모호성을 피하기 위해 따옴표로 묶입니다. 하지만 따옴표 자체도 구분자이므로, 따옴표를 인코딩하기 위해 이스케이프 시퀀스(escape sequences)가 필요합니다. 이것이 바로 문자열을 그냥 이어 붙이는 대신 JSON 라이브러리를 사용해 JSON을 생성해야 하는 이유입니다.
HTTP는 JSON보다 덜 잘 정의되어 있고 더 복잡하므로, 사양에 주의를 기울여야 합니다.
2. 바이너리 프로토콜의 길이-접두어 데이터
텍스트에서 구분자는 요소를 분리하는 데 사용됩니다. 바이너리 프로토콜과 형식에서는 더 좋고 간단한 대안으로 길이-접두어 데이터(length-prefixed data)를 사용합니다. 즉, 요소 데이터 앞에 요소의 길이를 명시하는 방식입니다. 몇 가지 예는 다음과 같습니다.
- 청크 분할 전송 인코딩 (길이 자체는 여전히 구분자로 나뉘지만)
- 웹소켓(WebSocket) 프레임 형식(구분자가 전혀 없음)
- HTTP/2(프레임 기반)
- MessagePack 직렬화 형식(일종의 바이너리 JSON)
6. 기본적인 HTTP 서버 코딩하기
HTTP 서버는 이전 장의 메시지 에코 서버를 기반으로 하며, “메시지”가 HTTP 메시지로 대체되었습니다.
코드는 작은 단계로 나뉘어 있으며 하향식(top-down) 접근 방식을 따릅니다.
1단계: 타입과 구조 정의
첫 번째 단계는 HTTP의 의미론(semantics)에 대한 이해를 바탕으로 HTTP 메시지 구조를 정의하는 것입니다.
우리는 URI와 헤더 필드를 위해 string 대신 Buffer를 사용합니다. HTTP는 대부분 평문(plaintext)이지만, URI와 헤더 필드가 반드시 ASCII나 UTF-8 문자열이라는 보장은 없습니다. 그래서 우리는 그것들을 파싱해야 할 때까지 바이트(bytes) 형태로 그대로 둡니다.
BodyReader 타입은 본문 페이로드(payload)로부터 데이터를 읽기 위한 인터페이스입니다.
페이로드 본문은 임의로 길어질 수 있으며, 메모리에 맞지 않을 수도 있습니다. 따라서 단순한 Buffer 대신 read() 함수를 사용하여 읽어야 합니다. read() 함수는 soRead() 함수의 관례를 따릅니다 - 데이터의 끝은 빈 Buffer로 신호합니다. 그리고 청크 인코딩(chunked encoding)을 사용할 때 본문의 길이를 알 수 없다는 점도 이 인터페이스가 필요한 또 다른 이유입니다.
2단계: 서버 루프
cutMessage() 함수는 HTTP 헤더만 파싱합니다. 페이로드 본문은 요청을 처리하는 동안 읽히거나, 요청 처리 후에 버려질 것으로 예상됩니다. 이런 방식으로 우리는 전체 페이로드 본문을 메모리에 저장하지 않습니다.
async function serveClient(conn: TCPConn): Promise<void> {
const buf: DynBuf = {data: Buffer.alloc(0), length: 0};
while (true) {
// try to get 1 request header from the buffer
const msg: null|HTTPReq = cutMessage(buf);
if (!msg) {
// need more data
const data = await soRead(conn);
bufPush(buf, data);
// EOF?
if (data.length === 0 && buf.length === 0) {
return; // no more requests
}
if (data.length === 0) {
throw new HTTPError(400, 'Unexpected EOF.');
}
// got some data, try it again.
continue;
}
// process the message and send the response
const reqBody: BodyReader = readerFromReq(conn, buf, msg);
const res: HTTPRes = await handleReq(msg, reqBody);
await writeHTTPResp(conn, res);
// close the connection for HTTP/1.0
if (msg.version === '1.0') {
return;
}
// make sure that the request body is consumed completely
while ((await reqBody.read()).length > 0) { /* empty */ }
} // loop for IO
} HTTPError는 우리가 정의한 커스텀 예외 타입입니다. 이것은 오류 응답을 생성하고 연결을 닫는 데 사용됩니다. 이 기능은 오류 처리라는 까다로운 경우를 뒤로 미뤄 코드를 더 간단하게 만들기 위해서만 존재한다는 점에 유의하세요. 실제 프로덕션 코드에서는 아마도 이런 식으로 예외를 던지고 싶지 않을 것입니다.
async function newConn(socket: net.Socket): Promise<void> {
const conn: TCPConn = soInit(socket);
try {
await serveClient(conn);
} catch (exc) {
console.error('exception:', exc);
if (exc instanceof HTTPError) {
// intended to send an error response
const resp: HTTPRes = {
code: exc.code,
headers: [],
body: readerFromMemory(Buffer.from(exc.message + '\n')),
};
try {
await writeHTTPResp(conn, resp);
} catch (exc) { /* ignore */ }
}
} finally {
socket.destroy();
}
} 3단계: 헤더 분리
HTTP 헤더는 \r\n\r\n으로 끝나며, 이를 통해 길이를 결정합니다. 이론적으로 헤더 크기에는 제한이 없지만 실제로는 있습니다. 왜냐하면 우리는 헤더를 파싱하여 메모리에 저장할 것이고, 메모리는 유한하기 때문입니다.
// the maximum length of an HTTP header
const kMaxHeaderLen = 1024 * 8;
// parse & remove a header from the beginning of the buffer if possible
function cutMessage(buf: DynBuf): null|HTTPReq {
// the end of the header is marked by '\r\n\r\n'
const idx = buf.data.subarray(0, buf.length).indexOf('\r\n\r\n');
if (idx < 0) {
if (buf.length >= kMaxHeaderLen) {
throw new HTTPError(413, 'header is too large');
}
return null; // need more data
}
// parse & remove the header
const msg = parseHTTPReq(buf.data.subarray(0, idx + 4));
bufPop(buf, idx + 4);
return msg;
} 파싱은 전체 데이터를 가지고 있을 때 더 쉽습니다. 이것이 바로 우리가 무언가를 파싱하기 전에 전체 HTTP 헤더가 수신되기를 기다린 또 다른 이유입니다.
4단계: 헤더 파싱
HTTP 헤더를 파싱하기 위해, 우리는 먼저 완전한 헤더를 버퍼에 가지고 있으므로 데이터를 CRLF(\r\n) 기준으로 줄(line)들로 나눌 수 있습니다. 그런 다음 각 줄을 개별적으로 처리할 수 있습니다.
// parse an HTTP request header
function parseHTTPReq(data: Buffer): HTTPReq {
// split the data into lines
const lines: Buffer[] = splitLines(data);
// the first line is `METHOD URI VERSION`
const [method, uri, version] = parseRequestLine(lines[0]);
// followed by header fields in the format of `Name: value`
const headers: Buffer[] = [];
for (let i = 1; i < lines.length - 1; i++) {
const h = Buffer.from(lines[i]); // copy
if (!validateHeader(h)) {
throw new HTTPError(400, 'bad field');
}
headers.push(h);
}
// the header ends by an empty line
console.assert(lines[lines.length - 1].length === 0);
return {
method: method, uri: uri, version: version, headers: headers,
};
}첫 번째 줄은 단순히 공백으로 구분된 세 부분입니다. 나머지 줄들은 헤더 필드입니다. 비록 여기서 헤더 필드를 파싱하려고 하지는 않지만, 그것들에 대해 약간의 유효성 검사를 하는 것은 여전히 좋은 생각입니다. splitLines(), parseRequestLine(), 그리고 validateHeader() 함수들은 그다지 흥미롭지 않으므로 여기서 보여주지는 않겠습니다. RFC 문서를 참고하여 직접 쉽게 코드를 작성할 수 있습니다.
5단계: 본문 읽기
요청을 처리하기 전에, 우리는 먼저 핸들러 함수에 전달될 BodyReader 객체를 생성해야 합니다. 앞서 언급했듯이, 페이로드 본문을 읽는 방법에는 세 가지가 있습니다.
// BodyReader from an HTTP request
function readerFromReq(
conn: TCPConn, buf: DynBuf, req: HTTPReq): BodyReader
{
let bodyLen = -1;
const contentLen = fieldGet(req.headers, 'Content-Length');
if (contentLen) {
bodyLen = parseDec(contentLen.toString('latin1'));
if (isNaN(bodyLen)) {
throw new HTTPError(400, 'bad Content-Length.');
}
}
const bodyAllowed = !(req.method === 'GET' || req.method === 'HEAD');
const chunked = fieldGet(req.headers, 'Transfer-Encoding')
?.equals(Buffer.from('chunked')) || false;
if (!bodyAllowed && (bodyLen > 0 || chunked)) {
throw new HTTPError(400, 'HTTP body not allowed.');
}
if (!bodyAllowed) {
bodyLen = 0;
}
if (bodyLen >= 0) {
// "Content-Length" is present
return readerFromConnLength(conn, buf, bodyLen);
} else if (chunked) {
// chunked encoding
throw new HTTPError(501, 'TODO');
} else {
// read the rest of the connection
throw new HTTPError(501, 'TODO');
}
} 여기서는 Content-Length 필드와 Transfer-Encoding 필드를 확인해야 합니다. fieldGet() 함수는 이름으로 필드 값을 찾는 데 사용됩니다. 필드 이름은 대소문자를 구분하지 않는다는 점에 유의하세요. 구현은 독자에게 맡깁니다.
우리는 Content-Length 필드가 있는 경우만 구현할 것이며, 다른 경우들은 이후 장에서 다룰 것입니다.
// BodyReader from a socket with a known length
function readerFromConnLength(
conn: TCPConn, buf: DynBuf, remain: number): BodyReader
{
return {
length: remain,
read: async (): Promise<Buffer> => {
if (remain === 0) {
return Buffer.from(''); // done
}
if (buf.length === 0) {
// try to get some data if there is none
const data = await soRead(conn);
bufPush(buf, data);
if (data.length === 0) {
// expect more data!
throw new Error('Unexpected EOF from HTTP body');
}
}
// consume data from the buffer
const consume = Math.min(buf.length, remain);
remain -= consume;
const data = Buffer.from(buf.data.subarray(0, consume));
bufPop(buf, consume);
return data;
}
};
} readerFromConnLength() 함수는 Content-Length 필드에 명시된 만큼의 바이트를 정확하게 읽는 BodyReader를 반환합니다. 소켓의 데이터는 먼저 버퍼로 들어간 다음, 우리는 버퍼에서 데이터를 빼내 쓴다는 점에 유의하세요. 그 이유는 다음과 같습니다.
- 소켓에서 읽기 전에 버퍼에 추가 데이터가 있을 수 있습니다.
- 마지막 읽기 작업이 필요한 것보다 더 많은 데이터를 반환할 수 있으므로, 추가 데이터는 버퍼에 다시 넣어야 합니다.
remain 변수는 read() 함수에 의해 캡처된 상태(state)로, 남은 본문 길이를 추적하는 데 사용됩니다.
6단계: 요청 핸들러
이제 우리는 요청의 URI와 메서드에 따라 요청을 처리할 수 있습니다. 여기서는 두 가지 샘플 응답을 보여드리겠습니다.
// a sample request handler
async function handleReq(req: HTTPReq, body: BodyReader): Promise<HTTPRes> {
// act on the request URI
let resp: BodyReader;
switch (req.uri.toString('latin1')) {
case '/echo':
// http echo server
resp = body;
break;
default:
resp = readerFromMemory(Buffer.from('hello world.\n'));
break;
}
return {
code: 200,
headers: [Buffer.from('Server: my_first_http_server')],
body: resp,
};
} URI가 /echo이면, 우리는 단순히 응답 페이로드를 요청 페이로드로 설정합니다. 이것은 본질적으로 HTTP에 에코 서버를 만드는 것입니다. curl 명령어로 데이터를 POST하여 이것을 테스트할 수 있습니다.
다른 샘플 응답은 고정된 문자열인 ’hello world.\n’입니다. 이를 위해, 우리는 먼저 BodyReader 객체를 생성해야 합니다.
read() 함수는 첫 번째 호출에서 전체 데이터를 반환하고 그 이후에는 EOF(파일 끝)를 반환합니다. 이것은 작고 이미 메모리에 들어가는 무언가로 응답할 때 유용합니다.
7단계: 응답 보내기
요청을 처리한 후, 우리는 응답 헤더와 (만약 있다면) 응답 본문을 보낼 수 있습니다. 이번 장에서는 길이가 알려진 페이로드 본문만 다룰 것입니다. 청크 인코딩은 이후 장으로 남겨둡니다. 우리가 해야 할 일은 Content-Length 필드를 추가하는 것뿐입니다.
// send an HTTP response through the socket
async function writeHTTPResp(conn: TCPConn, resp: HTTPRes): Promise<void> {
if (resp.body.length < 0) {
throw new Error('TODO: chunked encoding');
}
// set the "Content-Length" field
console.assert(!fieldGet(resp.headers, 'Content-Length'));
resp.headers.push(Buffer.from(`Content-Length: ${resp.body.length}`));
// write the header
await soWrite(conn, encodeHTTPResp(resp));
// write the body
while (true) {
const data = await resp.body.read();
if (data.length === 0) {
break;
}
await soWrite(conn, data);
}
} encodeHTTPResp() 함수는 응답 헤더를 바이트 버퍼로 인코딩합니다. 메시지 형식은 첫 번째 줄을 제외하고 요청 메시지와 거의 동일합니다.
status-line = HTTP-version SP status-code SP [ reason-phrase ]
인코딩은 파싱보다 훨씬 쉬우므로 손쉽게 구현할 수 있습니다.
8단계: 서버 루프 검토
응답을 보낸 후에도 아직 할 일이 남아 있습니다. 우리는 HTTP/1.0 클라이언트를 위해 연결을 즉시 닫음으로써 어느 정도의 호환성을 제공할 수 있습니다. 어차피 연결을 재사용할 수 없기 때문입니다. 그리고 가장 중요한 것은, 다음 요청을 위해 루프를 계속하기 전에, 요청 본문이 완전히 소비되었는지 확인해야 한다는 것입니다. 왜냐하면 핸들러 함수가 요청 본문을 무시하여 파서가 잘못된 위치에 남아있을 수 있기 때문입니다.
async function serveClient(conn: TCPConn): Promise<void> {
const buf: DynBuf = {data: Buffer.alloc(0), length: 0};
while (true) {
// try to get 1 request header from the buffer
const msg: null|HTTPReq = cutMessage(buf);
if (!msg) {
// omitted ...
continue;
}
// process the message and send the response
const reqBody: BodyReader = readerFromReq(conn, buf, msg);
const res: HTTPRes = await handleReq(msg, reqBody);
await writeHTTPResp(conn, res);
// close the connection for HTTP/1.0
if (msg.version === '1.0') {
return;
}
// make sure that the request body is consumed completely
while ((await reqBody.read()).length > 0) { /* empty */ }
} // loop for IO
} 우리의 첫 번째 HTTP 서버가 이제 완성되었습니다.
9단계: 테스팅
가장 간단한 테스트 케이스는 curl로 요청을 보내는 것입니다. 서버는 “hello world”로 응답해야 합니다. 또한 /echo 경로로 데이터를 POST하면 서버는 그 데이터를 다시 보내야 합니다.
큰 HTTP 본문
curl 명령어는 파일로부터 데이터를 게시할 수도 있습니다. 우리는 아주 큰 파일을 게시하여 우리 서버가OOM(Out Of Memory)을 발생시키지 않고 상수 메모리만 사용하고 있는지 확인할 수 있습니다.
연결 재사용 & 파이프라이닝
테스트해야 할 또 다른 중요한 것은 연결당 여러 요청을 처리하는 능력입니다. 이것은 socat을 통해 대화형으로 테스트하거나 셸 스크립트를 통해 자동으로 테스트할 수 있습니다.
socat 명령어의 crnl 옵션에 주목하세요. 이것은 줄 끝이 그냥 LF가 아닌 CRLF로 끝나도록 하기 위함입니다. 위 스크립트에서 sleep 1을 제거하면 파이프라인 요청도 테스트하게 됩니다.
논의: 네이글 알고리즘 (Nagle’s Algorithm)
최적화: 작은 쓰기 작업 결합하기
응답을 보낼 때, 우리는 소켓에 응답을 쓰기 전에 헤더의 바이트 버퍼를 생성하기 위해 encodeHTTPResp() 함수를 사용했습니다. 어떤 사람들은 이 단계를 건너뛰고 소켓에 한 줄씩 쓸 수도 있습니다.
이것의 문제점은 많은 작은 쓰기 작업을 생성하여 TCP가 많은 작은 패킷을 보내게 한다는 것입니다. 각 패킷은 상대적으로 큰 공간 오버헤드를 가질 뿐만 아니라, 더 많은 패킷을 처리하기 위해 더 많은 계산이 필요합니다. 사람들은 이 최적화 기회를 보고 “네이글 알고리즘”으로 알려진 기능을 TCP 스택에 추가했습니다 - TCP 스택은 전송을 지연시켜 송신 버퍼에 데이터가 쌓이도록 허용하여, 여러 연속적인 작은 쓰기 작업이 결합될 수 있도록 합니다.
성급한 최적화
하지만 이것은 좋은 최적화가 아닙니다. TLS와 같은 많은 최신 네트워크 프로토콜 설계는 RTT(Round-Trip Time)를 줄이는 데 많은 노력을 기울였습니다. 왜냐하면 많은 성능 문제가 지연(latency) 문제이기 때문입니다. 이제 쓰기 작업을 결합하기 위해 TCP에 지연을 추가하는 것은 반최적화처럼 보입니다. 그리고 의도했던 최적화 목표는 대신 애플리케이션 수준에서 쉽게 달성할 수 있습니다. 애플리케이션은 지연 없이 작은 데이터들을 스스로 결합할 수 있습니다. 잘 작성된 애플리케이션은 데이터를 버퍼에 명시적으로 직렬화하거나 버퍼링된 IO 인터페이스를 사용하여 버퍼를 신중하게 관리해야 하므로 네이글 알고리즘이 필요하지 않습니다. 그리고 고성능 애플리케이션은 시스템 호출(syscall) 수를 최소화하기를 원하므로, 네이글 알고리즘은 더욱 쓸모없어집니다.
실제 업계에서 사람들이 하는 일
네트워크 애플리케이션을 개발할 때는 아래 두 가지를 지켜야 합니다.
- 쓰기 전에 작은 데이터들을 결합하여 작은 쓰기 작업을 피하세요.
- 네이글 알고리즘을 비활성화하세요.
네이글 알고리즘은 종종 기본적으로 활성화되어 있습니다. 이것은 Node.js에서 noDelay 플래그를 사용하여 비활성화할 수 있습니다.
논의: 버퍼링된 라이터 (Buffered Writer)
대안: 버퍼링을 반투명하게 만들기
응답 헤더에서처럼 데이터를 버퍼에 명시적으로 직렬화하는 대신, TCPConn 타입에 버퍼를 추가하고 작동 방식을 변경할 수도 있습니다.
새로운 방식에서는 soWrite() 함수가 TCPConn의 내부 버퍼에 데이터를 추가하도록 변경되고, 새로운 soFlush() 함수는 실제로 데이터를 쓰는 데 사용됩니다. 버퍼 크기는 제한되어 있으며, soWrite() 함수는 버퍼가 가득 찼을 때 버퍼를 플러시할 수도 있습니다. 이런 스타일의 IO는 매우 인기가 있으며 다른 프로그래밍 언어에서 본 적이 있을 것입니다. 예를 들어, C의 stdio는 기본적으로 활성화된 내장 버퍼를 가지고 있으며, 적절할 때 fflush()를 사용해야 합니다.
대안: 버퍼링된 래퍼(Wrapper) 추가하기
또는 TCPConn을 그대로 두고 다음과 같은 별도의 래퍼 타입을 추가할 수 있습니다.
이것은 Golang의 bufio.Writer와 유사합니다. 이 방식은 소켓 코드에 버퍼링을 추가하는 것보다 더 유연합니다. 왜냐하면 버퍼링된 래퍼는 다른 형태의 IO에도 적용할 수 있기 때문입니다. 그리고 Go 표준 라이브러리는 io.Writer와 같이 잘 정의된 인터페이스로 설계되어, 버퍼링된 라이터가 버퍼링되지 않은 라이터를 즉시 대체(drop-in replacement)할 수 있게 만듭니다.
Go 표준 라이브러리에서 훔쳐올 만한 좋은 아이디어는 더 많이 있습니다. 그중 하나는 bufio.Writer가 단지 io.Writer일 뿐만 아니라, 내부 버퍼를 노출하여 직접 쓸 수 있게 한다는 것입니다 ! 이것은 데이터를 직렬화할 때 임시 버퍼와 추가적인 데이터 복사를 제거할 수 있습니다.
7. 동적 콘텐츠와 스트리밍
역사적으로 WWW는 대부분 하이퍼링크로 연결된 정적 웹 페이지로 구성되었으며, HTTP는 원래 이를 위해 설계되었습니다. 그 후 동적 콘텐츠를 제공하는 웹 기반 앱이 등장했으며, 이 장에서는 바로 이 동적 콘텐츠에 대해 다룰 것입니다.
청크 분할 전송 인코딩
동적 콘텐츠를 제공하기 위해서는 청크 분할 인코딩(chunked encoding)을 구현해야 합니다. 웹 페이지를 즉석에서 생성할 때는 미리 결과물의 길이를 알 수 없기 때문입니다.
청크 메시지 형식
먼저 사양(RFC 9112)부터 살펴보겠습니다.
chunked-body = *chunk
last-chunk
trailer-section
CRLF
chunk = chunk-size [ chunk-ext ] CRLF
chunk-data CRLF
chunk-size = 1*HEXDIG
last-chunk = 1*("0") [ chunk-ext ] CRLF
chunk-data = 1*OCTET ; a sequence of chunk-size octets
참고를 위해 아래 예시를 보겠습니다.
4\r\nHTTP\r\n5\r\nserver\r\n0\r\n\r\n
예시를 보면 형식이 꽤 직관적이라는 것을 알 수 있습니다.
- 각 청크는 CRLF로 끝나는 16진수 숫자로 시작하며, 이는 청크 데이터의 길이를 나타냅니다.
- 그 뒤를 잇는 청크 데이터 또한 CRLF로 끝나는데, 이 CRLF는 프로토콜을 사람이 읽기 쉽게 만드는 것 외에 다른 목적은 없습니다.
- 길이가 0인 청크는 HTTP 본문(body)의 끝을 표시합니다. 이 방식은 자주 보셨을 겁니다 !
잘 알려지지 않은 HTTP 기능들
BNF 사양에는 아직 다루지 않은 몇 가지 구조가 남아있습니다. 바로 chunk-size 라인의 선택적 chunk-ext와 마지막의 trailer-section입니다. 이것들은 사양에 포함되어 설계되었지만 실제로는 거의 사용되지 않는 잘 알려지지 않은 기능들입니다. chunk-ext는 추가적인 키-값 쌍(key-value pairs)을 추가하여 청크 형식을 확장하도록 설계되었지만, 실제로는 그러한 확장이 거의 필요하지 않습니다. trailer-section은 HTTP 본문 뒤에 일부 헤더 필드를 넣기 위해 설계되었습니다.
왜 이런 기능이 필요한지 궁금할 수 있는데, 이는 일부 드문 사용 사례를 위한 것입니다. 어떤 사람들은 데이터의 체크섬(checksum)과 같은 애플리케이션별 정보를 HTTP 헤더 필드에 넣는 것을 선호합니다. 문제는 데이터를 스트리밍할 때 체크섬은 데이터 스트리밍이 완료된 후에야 알 수 있다는 점입니다. 그래서 마지막 청크 뒤에 일부 헤더 필드를 추가하는 메커니즘이 설계되었습니다.
많은 HTTP 서버나 클라이언트 구현체들은 이러한 기능들이 거의 쓸모나 가치가 없기 때문에 그냥 무시합니다. 다른 한편으로, RFC에 명시된 기능이라 할지라도 잘 알려지지 않은 기능을 사용하도록 애플리케이션을 설계하는 것은 현명하지 않습니다. 클라이언트나 미들웨어에서 문제에 부딪힐 가능성이 더 높기 때문입니다. 우리 구현에서도 이 기능들은 무시하겠습니다.
청크 응답 생성하기
첫 번째 단계는 BodyReader 인터페이스로부터 응답 본문을 가져오는 writeHTTPResp() 함수에 청크 분할 인코딩을 추가하는 것입니다.
지난 장의 인터페이스는 청크 분할 인코딩을 염두에 두고 설계되었습니다. 우리가 해야 할 일은 length가 -1(알 수 없음)인 경우를 구현하는 것입니다.
// send an HTTP response through the socket
async function writeHTTPResp(conn: TCPConn, resp: HTTPRes): Promise<void> {
// set the "Content-Length" or "Transfer-Encoding" field
if (resp.body.length < 0) {
resp.headers.push(Buffer.from('Transfer-Encoding: chunked'));
} else {
resp.headers.push(Buffer.from(`Content-Length: ${resp.body.length}`));
}
// write the header
await soWrite(conn, encodeHTTPResp(resp));
// write the body
const crlf = Buffer.from('\r\n');
for (let last = false; !last; ) {
let data = await resp.body.read();
last = (data.length === 0); // ended?
if (resp.body.length < 0) { // chunked encoding
data = Buffer.concat([
Buffer.from(data.length.toString(16)), crlf,
data, crlf,
]);
}
if (data.length) {
await soWrite(conn, data);
}
}
} 청크 메시지는 Buffer.concat()으로 생성된 후 단일 쓰기(single write)로 소켓에 전송됩니다. 이는 지난 장에서 다룬 버퍼링된 I/O(buffered IO)에 대한 조언을 따르는 것입니다.
JS 제너레이터
다음 단계는 응답 본문을 생성하는 애플리케이션 코드(생산자, producer)를 BodyReader 인터페이스(큐, queue)에 연결하는 것입니다. writeHTTPResp() 함수(소비자, consumer)는 이 인터페이스로부터 데이터를 가져옵니다.
생산자, 소비자, 그리고 큐
큐를 사용하는 것은 생산자-소비자 문제에 대한 일반적인 해결책입니다. 그리고 큐는 프라미스(promise)를 사용하여 구현할 수 있습니다. 하지만 블로킹 큐(blocking queue)를 구현하는 것은 제너레이터를 사용하는 것에 비해 간단하지 않으므로, 이 방법은 나중에 살펴보겠습니다.
생산자로서의 JS 제너레이터
자바스크립트 제너레이터는 생산자-소비자 문제에 매우 적합합니다. 생산자 제너레이터는 yield 문을 사용하여 데이터와 제어권을 소비자에게 넘겨줍니다. 그리고 소비자가 다시 데이터를 요청하면, 마지막 yield 문에서부터 실행이 재개됩니다. 아래는 샘플 응답 제너레이터입니다.
function 키워드 뒤의 별표(*)는 JS 제너레이터의 구문입니다. 제너레이터는 여러 개의 반환(yield)을 갖는 함수로 생각할 수 있습니다. 이 문장은 yield라고 불리는데, 이는 일반 함수의 return이나 비동기 함수의 await처럼 런타임에 제어권을 양보(yield)하기 때문입니다. 그리고 일반 제너레이터와 비동기 제너레이터가 모두 존재합니다.
AsyncGenerator 타입스크립트 인터페이스는 비동기 제너레이터의 타입을 지정하는 데 사용되며, 3개의 타입 매개변수를 가집니다.
yield값의 타입return값의 타입next()메서드의 선택적 인자 타입 (나중에 설명)
JS 제너레이터로부터 소비하기
응답 제너레이터를 위한 새로운 URI 핸들러를 추가해 봅시다. readerFromGenerator() 함수는 JS 제너레이터를 BodyReader로 변환합니다.
제너레이터에서 데이터를 가져오려면 next() 메서드를 사용합니다. next() 메서드는 제너레이터가 yield 하거나 return 할 때 반환되며, 이는 done 플래그로 구분되고 데이터는 value 멤버에서 검색할 수 있습니다.
next() 메서드는 선택적 인자를 받을 수 있는데, 이 인자는 생산자에게 yield 문의 결과로 전달됩니다. 이는 JS 제너레이터가 양방향(bi-directional)이라는 것을 의미합니다. 지금 당장은 이 기능이 필요하지는 않습니다.
curl로 /sheep URI를 테스트해 보면 1초마다 카운터가 출력되는 것을 볼 수 있으며, 이는 청크 분할 인코딩이 잘 동작하고 있음을 의미합니다. 청크 분할 인코딩을 사용할 때는 전체 본문 길이에 제한이 없으며, 심지어 무한한 바이트 스트림을 생성할 수도 있습니다.
청크 요청 읽기
청크 분할 인코딩은 주로 요청(request)보다는 응답(response)에 사용됩니다. 웹 브라우저는 클라이언트 JS가 실험적인 스트림 API(Streams API)를 사용해 요청을 보내지 않는 한, 항상 업로드 길이를 알고 있습니다. 이러한 클라이언트를 지원하기 위해 청크 분할 요청을 읽는 코드를 추가해 보겠습니다.
생산자로서의 청크 파서(Chunk Parser)
청크 분할 요청을 디코딩하는 코드는 BodyReader 인터페이스에 연결되어야 합니다. 이는 응답 제너레이터를 BodyReader 인터페이스에 연결했을 때와 정확히 같은 작업입니다. 이번에도 JS 제너레이터를 사용할 것입니다.
readChunks() 제너레이터는 우리가 이전에 작성했던 샘플 응답 제너레이터처럼 동작해야 합니다. 따라서 readerFromGenerator()를 사용해 BodyReader로 변환할 수 있습니다.
// BodyReader from an HTTP request
function readerFromReq(
conn: TCPConn, buf: DynBuf, req: HTTPReq): BodyReader
{
// omitted ...
if (bodyLen >= 0) {
// "Content-Length" is present
return readerFromConnLength(conn, buf, bodyLen);
} else if (chunked) {
// chunked encoding
return readerFromGenerator(readChunks(conn, buf));
The chunked encoding is mostly used for responses instead of requests. A web browser always knows the length of the upload, unless the client JS is using the experimental Streams API to send requests. To support such clients, let’s add code to read chunked requests.
Chunk Parser as a Producer
The code to decode the chunked request must be connected to the BodyReader interface. This is exactly the same task when we connected the response generator to the BodyReader interface. We will use a JS generator again.
// decode the chunked encoding and yield the data on the fly
async function* readChunks(conn: TCPConn, buf: DynBuf): BufferGenerator;
The readChunks() generator should behave just like the sample response generator we coded before. So we can just use readerFromGenerator() to convert it into a BodyReader.
// BodyReader from an HTTP request
function readerFromReq(
conn: TCPConn, buf: DynBuf, req: HTTPReq): BodyReader
{
// omitted ...
if (bodyLen >= 0) {
// "Content-Length" is present
return readerFromConnLength(conn, buf, bodyLen);
} else if (chunked) {
// chunked encoding
return readerFromGenerator(readChunks(conn, buf));
} else {
// read the rest of the connection
return readerFromConnEOF(conn, buf);
}
}readerFromConnEOF() 함수는 HTTP/1.0 클라이언트와의 호환성을 위한 것입니다. 코드 리스팅은 생략하겠습니다.
청크 형식 디코딩
readChunks()의 구현은 다음과 같습니다.
// decode the chunked encoding and yield the data on the fly
async function* readChunks(conn: TCPConn, buf: DynBuf): BufferGenerator {
for (let last = false; !last; ) {
// read the chunk-size line
const idx = buf.data.subarray(0, buf.length).indexOf('\r\n');
if (idx < 0) {
// need more data, omitted ...
continue;
}
// parse the chunk-size and remove the line
let remain = /* omitted ... */;
bufPop(buf, /* omitted ... */);
// is it the last one?
last = (remain === 0);
// read and yield the chunk data
while (remain > 0) {
if (buf.length === 0) {
await bufExpectMore(conn, buf, 'chunk data');
}
const consume = Math.min(remain, buf.length);
const data = Buffer.from(buf.data.subarray(0, consume));
bufPop(buf, consume);
remain -= consume;
yield data;
}
// the chunk data is followed by CRLF
// omitted ...
bufPop(buf, 2);
} // for each chunk
}이 구조는 HTTP 자체의 헤더-본문 구조와 유사합니다. 헤더(chunk-size)를 기다려 본문(chunk-data)의 길이를 알아냅니다. 헤더는 가변 길이이므로, HTTP 헤더를 파싱할 때와 마찬가지로 파싱 중에 크기를 제한해야 합니다.
청크 분할 인코딩은 바이트 스트림을 생성한다
형식을 디코딩하는 것은 쉽습니다. 여기서 주목해야 할 점은 청크 데이터를 읽는 내부 루프입니다. 이 코드는 전체 청크 데이터가 도착하기를 기다리지 않고, 데이터가 도착할 때마다 즉시 yield 합니다. 청크 분할 인코딩은 애플리케이션에 메시지가 아닌 바이트 스트림(byte stream)을 제공해야 한다는 점을 기억하세요. 만약 전체 청크 데이터를 기다린다면, 전체 청크를 메모리에 저장해야 하고 최대 청크 크기 제한을 두어야 할 것입니다.
청크 분할 요청은 curl을 사용하여 테스트할 수 있습니다.
위 curl 명령어는 EOF(End of File)를 만날 때까지 표준 입력(stdin)으로부터 데이터를 읽어오며, 사전에 요청 길이를 알지 못한 채로 동작합니다.
논의: 패킷 캡처 도구를 이용한 디버깅
우리는 지금까지 간단하지 않은 네트워킹 코드를 작성해왔습니다. 코드를 디버깅하는 방법을 아는 것은 중요합니다. print 문을 추가하는 것은 무슨 일이 일어나고 있는지 검사하는 빠른 방법입니다. 하지만 네트워크 애플리케이션에는 더 저렴한 방법이 있습니다. tcpdump, ngrep, Wireshark와 같은 패킷 캡처 도구는 네트워크로부터 TCP 데이터를 가로챌 수 있습니다. 이 방법은 코드를 바로 수정할 수 없는 운영 환경(production)에서 특히 유용합니다.
tcpdump로 데이터 캡처 및 검사하기
예시: tcpdump -X -i lo port 1234
-X플래그는tcpdump에게 헥사 덤프(hex dump)를 하도록 지시합니다.-i lo플래그는 네트워크 인터페이스를 제한합니다.127.0.0.1:1234로의 연결만 신경 쓰므로 루프백(loopback) 인터페이스인lo를 사용합니다.port 1234는 관련 없는 패킷을 걸러내기 위한 불리언 표현식(boolean expression)입니다.
tcpdump의 샘플 출력은 다음과 같습니다.
12:00:00.222444 IP 127.0.0.1.1234 > 127.0.0.1.59872:
0x0000: 4500 0079 c492 4000 4006 77ea 7f00 0001 E..y..@.@.w.....
0x0010: 7f00 0001 04d2 e9e0 42d3 38e1 bb89 becb ........B.8.....
0x0020: 8018 0200 fe6d 0000 0101 080a 8f86 a39c .....m..........
0x0030: 8f86 a395 4854 5450 2f31 2e31 2032 3030 ....HTTP/1.1.200
0x0040: 204f 4b0d 0a53 6572 7665 723a 206d 795f .OK..Server:.my_
0x0050: 6669 7273 745f 6874 7470 5f73 6572 7665 first_http_serve
0x0060: 720d 0a43 6f6e 7465 6e74 2d4c 656e 6774 r..Content-Lengt
0x0070: 683a 2031 330d 0a0d 0a h:.13....
12:00:00.222666 IP 127.0.0.1.59872 > 127.0.0.1.1234:
...
12:00:00.333444 IP 127.0.0.1.1234 > 127.0.0.1.59872:
0x0000: 4500 0041 c493 4000 4006 7821 7f00 0001 E..A..@.@.x!....
0x0010: 7f00 0001 04d2 e9e0 42d3 3926 bb89 becb ........B.9&....
0x0020: 8018 0200 fe35 0000 0101 080a 8f86 a39d .....5..........
0x0030: 8f86 a39c 6865 6c6c 6f20 776f 726c 642e ....hello.world.
0x0040: 0a .
...
Wireshark로 패킷 분석하기
-w FILE 플래그를 사용하여 캡처된 패킷을 출력하는 대신 파일에 저장할 수도 있습니다. 이 파일 형식은 tcpdump 자신을 포함한 다른 모든 패킷 분석 소프트웨어에서 읽을 수 있습니다. GUI를 선호한다면 캡처된 파일을 Wireshark로 열 수 있습니다. Wireshark는 헥사 덤프를 보여주는 것 외에도 다음과 같은 기능을 제공합니다.
- 불리언 표현식으로 패킷 필터링. 이 기능은 즉시 적용되므로, 여러 번 시도하며 최적의 필터를 찾을 수 있습니다.
- 개별 연결(connection) 하이라이트.
- 프로토콜 분해(disassemble), 각 요소와 구조를 보여주고 하이라이트.
- 실제 데이터를 사용하여 새로운 프로토콜을 빠르게 학습.
- 자신이 구현한 코드의 문제점을 파악.
ngrep으로 패킷 데이터 검색하기
HTTP와 같은 텍스트 기반 프로토콜의 경우, 헥사 덤프는 과할 수 있습니다. ngrep을 사용하면 데이터를 텍스트로 출력할 수 있습니다.
-d lo는 네트워크 인터페이스를 지정합니다.port 1234는tcpdump에서 사용된 불리언 표현식과 유사합니다.- 빈 문자열
''은 TCP 데이터를 필터링하는 정규 표현식입니다. 모든 패킷을 보기 위해 빈 정규식을 사용합니다.
ngrep은 파일 대신 소켓을 위한 grep과 같아서 이런 이름이 붙었습니다.
이 모든 도구들은 상호 교환 가능한 형식(pcap)으로 패킷을 파일에 쓸 수 있다는 점을 알아둘 가치가 있습니다. 패킷을 한 번 캡처해두고 나중에 분석할 수 있습니다.
논의: 웹소켓(WebSocket)
업데이트를 위한 폴링(Polling)
일부 웹 기반 앱은 서버로부터 실시간 업데이트를 받아야 합니다. 이를 수행하는 가장 순진한 방법은 폴링(polling)을 통하는 것입니다. 클라이언트 JS는 주기적으로 AJAX 호출을 보내 업데이트를 확인합니다. 이 방법은 자원을 낭비하고 지연 시간이 짧아야 하는 시나리오에서는 사용할 수 없습니다.
실시간 서버 푸시(Server Push)
폴링은 덜 순진한 방식으로 구현할 수 있습니다. 서버는 연결을 유지하고 있다가 새로운 업데이트가 도착했을 때만 응답을 보내는 것입니다. 더 나아가 생각해 보면, 청크 분할 인ко딩을 사용하면 단일 응답 내에서 무한한 수의 업데이트를 허용할 수 있습니다. 이를 롱 폴링(long polling) 이라고 합니다.
예를 들어, 서버는 줄 바꿈으로 구분된 JSON 메시지 스트림을 생성하여 청크 분할 인코딩을 통해 전달할 수 있습니다. 클라이언트는 단 한 번의 요청만 보내면 되고, 메시지는 폴링으로 인한 추가 지연 시간 없이 클라이언트에서 읽을 수 있습니다.
client server
------ ------
|header| ==>
<== |header|
...
<== |JSON\n|
<== |JSON\n|
<== |JSON\n|
...
하지만, 이 사용 사례는 HTTP 프로토콜 확장인 웹소켓(WebSocket)에 의해 구식이 되었습니다.
웹소켓은 메시지 기반(Message-Based)이다
웹소켓 설계背后에는 여러 동기가 있습니다. 그중 하나는 바이트 스트림(byte stream)과 대조되는 메시지 지향(message-oriented) 설계입니다. ‘한 줄에 하나의 JSON’ 예제는 충분히 간단하게 들릴 수 있지만, 여전히 바이트 스트림을 줄 단위로 나눠야 합니다. 네트워크 애플리케이션 코딩에서 가장 흔한 실수 1위는 바이트 스트림을 제대로 이해하지 못하는 것입니다. 웹소켓은 바이트 대신 메시지를 출력하는 내장 프레이밍 형식(framing format)을 갖추고 있어, 프로그래머들이 초보적인 실수를 반복하지 않도록 도와줍니다.
웹소켓은 요청-응답(Request-Response) 방식이 아니다
웹소켓의 또 다른 기여는 양방향(bi-directional) 및 전이중(full-duplex) 통신입니다. 이는 클라이언트와 서버가 동시에 서로에게 메시지를 보낼 수 있음을 의미합니다. 기술적으로는 웹소켓 없이 2개의 HTTP 연결(1개는 푸시용, 1개는 풀용)을 통해 양방향 메시징을 흉내 낼 수 있습니다. 하지만 웹소켓은 클라이언트와 서버 양쪽 모두에게 이를 더 쉽게 만들어 줍니다.
client server
------ ------
|header| ==>
<== |header|
... ...
|message| ==>
|message| ==> <== |message|
|message| ==>
<== |message|
|message| ==>
우리는 다른 중요한 HTTP 사용법을 탐색한 후, 다음 장에서 웹소켓을 구현할 것입니다.
8. 파일 IO와 리소스 관리
정적 파일을 제공하는 것은 HTTP가 설계된 주된 용도 중 하나입니다. 이와 관련하여 몇 가지 더 탐색해 볼 주제가 있습니다.
범위 요청 (Ranged requests). 파일의 특정 부분을 가져오는 기능입니다. 이를 통해 전송이 중단되더라도 나중에 이어서 받을 수 있습니다.캐싱 (Caching). 불필요한 데이터 전송을 줄이기 위함입니다.압축 (Compression). 전송 시간과 비용을 절감하기 위함입니다.
먼저 기본적인 파일 서버를 구현하며 파일 API에 익숙해지는 것으로 시작하겠습니다.
Node.js에서의 파일 IO
앞서 언급했듯이 Node.js의 파일 API는 세 가지 형태로 제공됩니다.
- 동기(synchronous) API: 우리는 사용할 수 없습니다.
- 콜백(callback) 기반 API: 우리는 사용하고 싶지 않습니다.
- 프로미스(promise) 기반 API: 우리는 이것을 사용할 것입니다.
우리는 네 가지 파일 연산을 사용할 것입니다.
- 파일 열기 (Open)
- 파일의 메타데이터(크기 등)를 얻기 위한
stat호출 - 파일 데이터 읽기 (Read)
- 파일 닫기 (Close)
소켓과 마찬가지로, 디스크 파일은 JS 객체로 감싸진 불투명한 핸들(opaque handle)로 표현됩니다. 아래는 우리가 사용할 단순화된 TypeScript API 정의입니다.
function open(path: string, flags?: string): Promise<FileHandle>;
interface FileReadResult {
bytesRead: number;
buffer: Buffer;
}
interface FileReadOptions {
buffer?: Buffer;
offset?: number | null;
length?: number | null;
position?: number | null;
}
interface Stats {
isFile(): boolean;
isDirectory(): boolean;
// ...
size: number;
// ...
}
interface FileHandle {
read(options?: FileReadOptions): Promise<FileReadResult>;
close(): Promise<void>;
stat(): Promise<Stats>;
}디스크 파일 제공하기
단계 1: 정적 파일을 위한 핸들러 추가하기
첫 단계는 디스크 파일을 제공하는 코드를 작성하는 것입니다. 현재 작업 디렉토리로부터 파일을 제공하는 핸들러를 추가할 것입니다.
프로덕션 환경의 웹 서버라면 URI 경로가 의도된 디렉토리 내에 확실히 포함되는지(URI 정규화), 그리고 해당 파일에 실제로 접근 가능한지를 보장하기 위한 추가 작업이 필요합니다. 우리는 이 작업을 생략하겠습니다.
단계 2: 파일 열고 닫기
'r' 플래그는 파일을 읽기 전용 모드로 열 때 사용됩니다. 파일을 여는 과정에서 파일 접근 가능 여부도 함께 확인합니다. fs.open() 함수는 파일이 존재하지 않거나 다른 이유로 접근할 수 없을 때 예외(exception)를 발생시킵니다.
async function serveStaticFile(path: string): Promise<HTTPRes> {
let fp: null|fs.FileHandle = null;
try {
// open the file
fp = await fs.open(path, 'r');
// later ...
} catch (exc) {
// cannot open the file or whatever
console.info('error serving file:', exc);
return resp404();
} finally {
// make sure the file is closed
await fp?.close();
}
}소켓과 마찬가지로 디스크 파일은 반드시 수동으로 닫아야 하며, try-finally 블록은 이 작업을 보장하기 위해 사용됩니다.
단계 3: 파일 stat() 호출하기
stat() 메서드는 파일 핸들에서 메타데이터를 가져오는 데 사용됩니다. 결과에는 파일 타입(일반 파일, 디렉토리, 또는 다른 특수 타입), 크기, 시간 및 기타 정보가 포함됩니다.
핸들 대신 경로를 인자로 받는 fs.stat(path) 함수도 있습니다. 이 함수는 파일을 먼저 열지 않고도 경로를 이용해 파일의 정보를 가져올 수 있습니다.
하지만 파일 핸들에 대해 stat을 사용하는 것이 더 바람직합니다. 왜냐하면 경로는 서로 다른 파일을 참조할 수 있기 때문입니다. 만약 stat으로 경로를 확인하고 나중에 그 경로를 열 경우, 그 사이에 해당 경로가 다른 파일로 대체될 수 있으며(이름 변경이나 삭제 등으로), 이는 경쟁 조건(race condition)의 한 예입니다. 파일을 먼저 열면 항상 동일한 파일에 대해 작업하고 있음을 보장할 수 있습니다.
단계 4: BodyReader 구성하기
readerFromStaticFile() 함수는 파일 핸들로부터 BodyReader를 반환합니다. BodyReader가 나중에 파일을 읽을 것이므로, 함수가 반환되기 전에 fp를 null로 설정하는 것이 중요합니다. 이 시점 이후로는 finally 블록이 파일을 닫아서는 안 됩니다.
async function serveStaticFile(path: string): Promise<HTTPRes> {
let fp: null|fs.FileHandle = null;
try {
// open the file
fp = await fs.open(path, 'r');
// ...
// the body reader
const reader: BodyReader = readerFromStaticFile(fp, size);
fp = null; // the reader is now responsible for closing it instead
return {code: 200, headers: [], body: reader};
} catch (exc) {
// ...
} finally {
// make sure the file is closed
await fp?.close();
}
} BodyReader의 read() 함수는 fp.read() 메서드에 직접 연결됩니다. 하지만 몇 가지 주의할 점이 있습니다.
fp.read()메서드는 버퍼가 제공되지 않으면 자동으로 생성하지만, 놀랍게도 데이터를 읽은 크기에 맞게 버퍼를 자르지 않고 그대로 반환합니다.- 정적 파일을 제공할 때, 우리가 보내는 파일이
Content-Length헤더와 일치하는지 반드시 확인해야 합니다. 파일 크기가 변경되면 복구할 수 없으며, 이 상태에서는 연결을 끊는 것만이 유일한 방법입니다.
function readerFromStaticFile(fp: fs.FileHandle, size: number): BodyReader {
let got = 0; // bytes read so far
return {
length: size,
read: async (): Promise<Buffer> => {
const r: fs.FileReadResult<Buffer> = await fp.read();
got += r.bytesRead;
if (got > size || (got < size && r.bytesRead === 0)) {
// unhappy case: file size changed.
// cannot continue since we have sent the `Content-Length`.
throw new Error('file size changed, abandon it!');
}
// NOTE: the automatically allocated buffer may be larger
return r.buffer.subarray(0, r.bytesRead);
},
};
} 단계 5: 파일이 확실히 닫히도록 만들기
핸들러 함수가 성공적으로 BodyReader를 반환하면, 파일은 여전히 열려 있는 상태이며 나중에 반드시 닫혀야 합니다. 정리 작업을 수행하기 위해 BodyReader 인터페이스에 close() 함수를 추가하겠습니다.
close() 멤버는 선택 사항(optional)으로 만들어, readerFromStaticFile() 함수만 수정하면 됩니다.
핸들러 함수 호출 이후의 코드는 try-finally 블록으로 감싸져 정리(cleanup) 함수가 반드시 호출되도록 보장합니다.
이것이 가장 기초적인 파일 서버의 모습입니다. 이제 잠시 멈추고 테스트할 시간입니다.
논의: 수동 리소스 관리
자바스크립트(JS)는 가비지 컬렉터(GC)가 있는 언어로, 프로그래머를 비생산적이고 오류가 발생하기 쉬운 수동 메모리 할당 및 해제 작업에서 해방시켜 줍니다. 하지만, 반드시 닫아야 하는 파일이나 소켓과 같이 수동으로 관리해야 하는 비(非)메모리 리소스는 여전히 존재합니다. 만약 수동 메모리 관리에 대한 경험이 없다면, 아마도 비메모리 리소스를 일반적으로 어떻게 관리해야 하는지에 대해서도 잘 모를 것입니다.
리소스는 무언가에 의해 소유된다
리소스의 소유권(ownership)은 수동 리소스 관리에서 가장 중요한 개념입니다. 즉, 누가 해당 리소스를 종료하고 정리할 책임이 있는가에 대한 것입니다. 리소스의 소유자는 다음 중 하나가 될 수 있습니다.
함수: 종료되기 전에 리소스를 해제합니다.코드 스코프(scope): 벗어나기 전에 리소스를 해제합니다.객체: 자신이 소멸될 때 리소스를 해제합니다.
소유권은 정적(static)이거나 동적(dynamic)일 수 있습니다. 동적이라는 것은 런타임에 소유자가 변경될 수 있음을 의미합니다. 어떤 방식을 사용하든, 하나의 리소스는 항상 정확히 1개의 소유자를 가집니다.
함수 또는 스코프가 소유하는 리소스
serveStaticFile() 함수에서, 열린 파일은 초기에 함수에 의해 소유되므로 함수가 파일을 닫아야 합니다. 함수 전체가 try-finally 블록으로 감싸여 있고, 정리 코드는 함수 끝의 finally 블록에 위치합니다.
다른 언어에도 함수 수준의 정리를 위한 유사한 구조가 있습니다.
Go:defer함수를 사용할 수 있습니다.C: 함수 끝에 정리 코드를 두고, 반환하는 대신goto를 사용해 정리 코드로 점프하는 관행이 일반적입니다.C++: 소멸자(destructor)를 사용할 수 있으며, 특히 예외(exception)가 사용될 때 유용합니다.Python:try-finally블록 외에with블록이 있습니다.
Go의 defer를 제외한 이 모든 메커니즘은 함수 전체보다 작은 스코프에서도 사용될 수 있습니다.
소유권은 이전될 수 있다
serveStaticFile() 함수에서, BodyReader를 생성하고 반환하기 직전에 파일 객체를 즉시 null로 설정합니다. 이는 파일의 소유권이 함수에서 BodyReader 객체로 변경되었음을 의미합니다. “1개의 소유자” 규칙은 리소스에 대한 참조를 단 하나만 유지함으로써 만족됩니다.
리소스에 대한 참조는 소유하는 참조(owning reference)와 소유하지 않는 참조(non-owning reference)로 분류할 수 있습니다. 여러분은 반드시 정확히 1개의 소유하는 참조를 유지해야 합니다. 소유하는 참조는 스코프나 객체에 연결되며, 다른 소유자에게 이전되어 참조가 무효화되지 않는 한 해당 스코프나 객체가 리소스를 종료시킵니다.
이 장의 readerFromStaticFile() 함수는 절대 예외를 던지지 않지만, 만약 던진다면 파일을 소유하고 있으므로 파일을 닫아야 합니다. 호출자 코드의 한 가지 문제점은 예외가 발생했을 때 파일 참조가 null로 설정되지 않아 fp가 두 번 닫힐 수 있다는 것입니다. 더 견고한 패턴은 다음과 같이 try-finally 블록을 사용하는 것입니다.
소유자는 연쇄적으로 이어진다
객체가 리소스를 소유할 때, 그 객체 자체도 다른 객체나 코드 블록에 의해 소유되어야 합니다. 모든 객체 소유권은 결국 코드 블록으로 거슬러 올라가며, 그렇지 않으면 리소스 누수(leak)가 발생할 것입니다.
serveClient() 함수에서, 핸들러가 응답 객체를 반환한 후에 나타나는 소유권 체인의 예시가 있습니다.
| serveClient() | ==> | HTTPRes | ==> | BodyReader | ==> | file |
function object object resource
제너레이터(Generator)에서 정리 작업을 수행하는 방법
함수 호출은 반환(return)하거나 예외를 던지지만(throw), 제너레이터의 경우에는 이것이 사실이 아닙니다. 제너레이터는 실행이 중단(suspended)된 후 무기한으로 무시될 수 있습니다. 만약 제너레이터 코드를 try-finally 블록으로 감싼다면, finally 블록은 영원히 실행되지 않을 수도 있습니다.
/sheep URI로 이 경우를 테스트해볼 수 있습니다. 만약 클라이언트가 중간에 연결을 끊으면 finally 블록은 실행되지 않을 것입니다. 다행히도, 이 문제를 해결할 방법이 있습니다.
function readerFromGenerator(gen: BufferGenerator): BodyReader {
return {
Study the source code to understand how each owner holds the reference and does the cleanup.
How to Do Cleanups in a Generator
A function call will either return or throw, but this is not true for generators. Generators can be suspended and then ignored indefinitely; if you wrap the generator code in a try-finally block, the finally block may never be executed.
// count to 99
async function *countSheep(): BufferGenerator {
try {
for (let i = 0; i < 100; i++) {
// sleep 1s, then output the counter
await new Promise((resolve) => setTimeout(resolve, 1000));
yield Buffer.from(`${i}\n`);
}
} finally {
console.log('cleanup!');
}
}
You can test this case with the '/sheep' URI; if the client disconnects midway, the finally block will not be executed. Fortunately, there is a way to fix this.
function readerFromGenerator(gen: BufferGenerator): BodyReader {
return {
// ...
close: async (): Promise<void> => {
// force it to `return` so that the `finally` block will execute
await gen.return();
},
};
} 자바스크립트 제너레이터의 return() 메서드는 제너레이터가 중단된 지점(즉, yield 문)에 return 문이 삽입된 것처럼 강제로 반환하게 만드는 데 사용됩니다. 이를 통해 finally 블록이 있는 경우 그것이 반드시 실행되도록 보장합니다.
여기서 우리는 리소스 소유권을 가진 제너레이터를 사용할 때의 규칙을 배웠습니다. 제너레이터의 소유자는 해당 제너레이터가 반드시 반환되도록 보장해야 한다는 것입니다.
yield 문에서 예외를 발생시키는 throw() 메서드도 있습니다. 이 방법 역시 finally 블록을 실행시킵니다. 심지어 특정 타입의 예외를 사용하여 제너레이터를 중간에 중단시키려는 의도를 전달하고, 제너레이터가 이 특정 사례를 처리하도록 할 수도 있습니다.
파이썬 제너레이터에도 유사한 메서드들이 있습니다. 그리고 놀랍게도, 파이썬의 가비지 컬렉터는 제너레이터의 finally 블록을 자동으로 실행해 주지만, GC가 비(非)메모리 관련 작업을 처리해야 하는지에 대해서는 논쟁의 여지가 있습니다.
논의: 버퍼 재사용하기
버퍼 할당에는 비용이 따른다
readerFromStaticFile() 함수는 매 read 마다 새로운 버퍼를 할당하고 반환합니다. 버퍼 할당에는 비용이 따르기 때문에 이는 그다지 효율적이지 않습니다.
메모리 할당자의 비용. 많은 구현에서 이 비용은 대부분의 경우 일정합니다. 최상의 경우, 할당자는 free-list에서 객체를 반환하거나 큰 메모리 덩어리(chunk)에서 할당합니다.0으로 초기화하는 비용. 이 비용은 버퍼 크기에 따라 선형적으로 증가합니다.
초기화되지 않은 버퍼 사용하기
시스템 호출(syscall) 횟수를 줄일 수 있기 때문에, 더 큰 버퍼는 잠재적으로 더 높은 처리량을 위해 바람직한 경우가 많습니다. 하지만 크기가 큰 버퍼를 할당하는 것은 선형적인 초기화 비용을 수반하며, 이는 Node.js에서 초기화되지 않은 버퍼를 사용하여 피할 수 있습니다.
이 함수가 allocUnsafe라고 불리는 이유는, 버그가 있는 프로그램이 사용자의 민감한 내부 정보를 유출할 가능성이 더 커지기 때문입니다. 또한 초기화되지 않은 데이터 관련 버그는 예측 불가능성 때문에 디버깅하기가 더 어렵습니다.
대용량 버퍼 재사용하기
Go나 파이썬과 같은 일부 언어에서는 초기화되지 않은 버퍼를 쉽게 얻을 수 없습니다. 이런 경우 할 수 있는 일은 여러 IO 연산에 걸쳐 동일한 버퍼를 재사용하는 것입니다.
function readerFromStaticFile(fp: fs.FileHandle, size: number): BodyReader {
const buf = Buffer.allocUnsafe(65536); // reused for each read
return {
length: size,
read: async (): Promise<Buffer> => {
const r = await fp.read({buffer: buf});
// ...
either returns an object from a free list or allocates from a large chunk.
The cost of initializing with zeros. Scales linearly with buffer size.
Using Uninitialized Buffers
Larger buffers are often desirable for potentially higher throughput due to the reduced number of syscalls. However, allocating oversized buffers has a linear initialization cost, which can be avoided by using uninitialized buffers in Node.js.
const buf = Buffer.allocUnsafe(65536);
This function is called allocUnsafe because a buggy program has a greater chance of leaking sensitive internal information to users. Uninitialized data bugs are also harder to debug due to their unpredictability.
Reusing Large Buffers
Uninitialized buffers are not easily obtainable in some languages, such as Go or Python. What you can do is reuse the same buffer for multiple IO operations.
function readerFromStaticFile(fp: fs.FileHandle, size: number): BodyReader {
const buf = Buffer.allocUnsafe(65536); // reused for each read
return {
length: size,
read: async (): Promise<Buffer> => {
const r = await fp.read({buffer: buf});
// ...
return r.buffer.subarray(0, r.bytesRead);
},
};
} 이는 버퍼 할당 비용을 분할 상환(amortize)하는 효과를 가져옵니다.
풀링된 버퍼 (Pooled Buffers)
버퍼를 지역적으로 할당하는 대신, 사용된 버퍼들을 위한 전역 풀(global pool)을 가질 수 있습니다. 버퍼 사용이 끝나면 해당 풀에 다시 넣어둡니다. 그리고 버퍼가 필요할 때는 풀에서 먼저 가져오려고 시도하고, 풀이 비어 있을 때만 새 버퍼를 생성합니다.
이 방법의 장점은 다음과 같습니다.
- 풀에서 가져오는 것이 메모리 할당자보다 비용이 훨씬 적게 들 수 있습니다.
- 서로 다른 코드 경로가 모두 풀의 이점을 누릴 수 있습니다. 전체적으로 실제 할당 횟수가 줄어듭니다.
객체 풀은 코드로 구현하기 매우 간단합니다. Node.js의 Buffer 타입은 내장된 풀을 사용하지만, 작은 버퍼에만 한정됩니다. Go 표준 라이브러리에도 객체 풀이 포함되어 있습니다.
버퍼 재사용 시 생명주기(Lifetime)에 유의하기
버퍼는 생산자(producer) 또는 소비자(consumer)에 의해 할당될 수 있습니다. 우리가 지금까지 본 모든 경우에서는 생산자가 버퍼를 할당하고 데이터를 사용하는 측에 반환했습니다. 이는 다음 read()가 실행되는 동안 버퍼가 여전히 어딘가에서 참조되고 사용될 수 있기 때문에 문제가 될 수 있습니다.
function readerFromStaticFile(fp: fs.FileHandle, size: number): BodyReader {
const buf = Buffer.allocUnsafe(65536); // reused for each read
return {
length: size,
read: async (): Promise<Buffer> => {
const r = await fp.read({buffer: buf});
// CAUTION: the lifetime of the buffer is unclear!
return r.buffer.subarray(0, r.bytesRead);
},
};
}리소스 관리 토론에서 배운 교훈을 적용해 봅시다. 누가 버퍼를 소유하는가?
이 장의 코드에서는, 생산자(BodyReader)가 반환한 버퍼가 소켓에 쓰이고, 우리 코드는 쓰기가 완료될 때까지 기다린 후에 다음 read()를 호출합니다. 따라서 생산자만이 버퍼를 참조하는 유일한 곳이므로, 소비자가 버퍼를 소유할 필요가 없다고 추론할 수 있으며, 버퍼 재사용은 유효합니다. 하지만, 더 복잡한 소비자의 경우에는 이것이 사실이 아닐 수 있습니다 !
다른 경우는 소비자가 버퍼를 할당하는 경우입니다. 데이터가 그 자리에서 처리되어 소비자가 유일한 소유자인 한, 버퍼의 생명주기는 명확합니다.
Go 언어에서는 io.Reader 인터페이스에서 데이터를 가져올 때, 버퍼가 소비자에 의해 제공됩니다. 이는 설계적으로 버퍼 재사용을 장려하고 불필요한 할당을 억제합니다.
9. 범위 요청
파일 서버에 범위 요청(range requests) 기능을 추가하여, 이어받기(resumeable) 전송이 가능하도록 만드는 것입니다.
범위 요청의 작동 방식
Range 헤더 필드 문법
범위 요청은 클라이언트가 Range 헤더 필드를 사용하여 시작합니다. 예를 들어, Range: 0-9는 페이로드의 첫 10바이트를 가져옵니다. 이 헤더 필드에 대한 전체 명세는 RFC 9110에 기술되어 있습니다.
ranges-specifier = range-unit "=" range-set
range-set = 1#range-spec
range-spec = int-range
/ suffix-range
/ other-range
int-range = first-pos "-" [ last-pos ]
first-pos = 1*DIGIT
last-pos = 1*DIGIT
suffix-range = "-" suffix-length
suffix-length = 1*DIGIT
range-unit은 항상 bytes이며, range-set은 쉼표로 구분된 범위 목록입니다. 단일 범위에는 두 가지 가능한 형식이 있습니다.
12-34와 같이 양 끝을 포함하는 구간(inclusive interval)은 12번째 바이트부터 34번째 바이트까지의 범위를 의미합니다.-12와 같은 음의 정수는 마지막 12바이트를 의미합니다.
유효 범위와 예시
유효 범위(effective range)는 실제 파일 범위와의 교집합입니다. 50바이트 크기의 파일이 있다고 가정할 때, 몇 가지 예시와 그에 따른 유효 범위는 다음과 같습니다.
Range: bytes=x |
유효 범위 |
|---|---|
0-0 |
[0, 1) |
0-1 |
[0, 2) |
10- |
[10, 50) |
10-60 |
[10, 50) |
0-60 |
[0, 50) |
4-3 |
invalid |
70-60 |
invalid |
-10 |
[40, 50) |
-60 |
[0, 50) |
-0 |
invalid |
--1 |
invalid |
foobar |
invalid |
60- |
out of range |
60-70 |
out of range |
서버는 다음과 같은 예외적인 경우들도 처리해야 합니다.
- 교집합이 비어 있다면, 서버는 상태 코드
416 (Range Not Satisfiable)으로 응답 - 범위 중 하나라도 유효하지 않다면, 서버는 해당 헤더 필드를 무시(그리고 전체 파일을 제공)
범위 요청은 선택 사항입니다
HTTP 서버에 기능을 추가하다 보면 대부분의 HTTP 기능이 선택 사항이라는 것을 알게 될 것이며, 이러한 선택적 기능들은 보기보다 상황을 더 복잡하게 만들 수 있습니다. 서버는 범위 요청을 전혀 지원하지 않을 수도 있고, 지원하더라도 모든 요청에 적용되는 것은 아닙니다. 예를 들어, 동적으로 생성되는 콘텐츠에 대해 범위 응답을 반환하는 것은 의미가 없습니다. 왜냐하면 콘텐츠의 길이를 미리 알 수 없을 뿐만 아니라, 서버가 요청이 범위를 벗어났는지 판단할 수도 없기 때문입니다.
범위 응답은 206으로 표시됩니다
클라이언트는 서버가 범위 요청을 지원하는지 어떻게 알 수 있을까요? 이는 206 (Partial Content) 상태 코드의 역할입니다. 이 코드는 전체 응답(200) 대신 부분적인 응답임을 나타냅니다.
Content-Range는 유효 범위를 반환합니다
앞선 예제에서 보았듯이, 유효 범위는 요청된 범위와 다를 수 있습니다. 따라서 서버는 클라이언트에게 반환된 정확한 범위를 알려주어야 합니다. 이것이 바로 Content-Range 헤더 필드의 역할입니다. 이 헤더는 응답의 유효 범위와 함께 전체 콘텐츠 길이를 포함합니다.
Content-Range: bytes 12-34/1234
이 헤더 필드는 416 (Range Not Satisfiable) 응답에서도 클라이언트에게 올바른 콘텐츠 길이를 알려주기 위해 사용됩니다.
Content-Range: bytes */1234
범위 지원 알리기
클라이언트에게 범위 요청이 가능하다는 것을 알리는 방법들이 있습니다. 서버는 범위 요청이 아닌 응답에 Accept-Ranges: bytes 헤더 필드를 포함하여 이 URI가 범위 요청을 지원함을 나타낼 수 있습니다. 이를 통해 클라이언트는 다운로드가 중단될 경우 범위 요청으로 이어받을 수 있음을 알게 됩니다.
HEAD 메서드로 탐색하기
응답의 일부를 전혀 가져오지 않고도 범위 요청이 지원되는지 알 수 있는 방법이 있는데, 바로 HEAD HTTP 메서드입니다. 만약 서버가 HEAD 메서드를 지원한다면, 응답 본문(body) 없이 GET처럼 동작해야 하며, 요청이 완전히 처리된 것처럼 응답 헤더 필드와 상태 코드를 반환해야 합니다. 그러면 클라이언트는 실제로 콘텐츠를 받지 않고도 Accept-Ranges나 Content-Range를 확인할 수 있습니다.
HTTP에서 페이로드 길이가 결정되는 방식은 복잡하다는 점을 기억하세요. HEAD 메서드는 클라이언트에게 특별한 경우를 추가합니다. 서버가 Content-Length를 반환할 수 있지만, 이번에는 페이로드 길이를 의미하지 않습니다. HEAD 메서드는 헤더 필드의 내용과 관계없이 응답 본문을 가지지 않습니다!
멀티파트 메시지를 이용한 다중 범위
드물게 사용되지만, Range 헤더 필드는 쉼표로 구분된 여러 범위를 가질 수 있습니다. 다음은 그 예시입니다.
GET / HTTP/1.1
Range: bytes=0-5,8-13
HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Length: 227
Content-Type: multipart/byteranges; boundary=SEPARATOR1234567
--SEPARATOR1234567
Content-Type: text/html; charset=UTF-8
Content-Range: bytes 0-5/1234
<html>
--SEPARATOR1234567
Content-Type: text/html; charset=UTF-8
Content-Range: bytes 8-13/1234
<head>
--SEPARATOR1234567--이 응답은 단일 범위 응답과는 완전히 다릅니다.
- 응답은 새로운 메시지 형식으로 인코딩
- 이는
Content-Type헤더 필드로 표시 - 여러 부분(part)들은 임의의 문자열 구분자(delimiter)로 분리
- 각 부분은 HTTP 자체와 유사한 헤더-페이로드 구조
- 이는
Content-Range는 사용되지 않고, 대신Content-Type: multipart/byteranges; boundary=x를 사용- 헤더는 각 부분에 대한
Content-Range헤더 필드를 포함 Content-Length헤더 필드는 여전히 동일한 목적으로, 즉 여러 범위의 합이 아닌 페이로드(멀티파트 메시지)의 길이를 나타냄
여기서 우리는 데이터를 분리하는 새로운 아이디어, 즉 임의의 문자열 구분자를 배웠습니다. 구분자는 페이로드에 포함될 가능성이 거의 없도록 충분히 길어야 합니다. RFC 2046에 따르면 최대 길이는 70바이트입니다. 그럼에도 불구하고, 구분자는 일반적으로 좋은 아이디어는 아닙니다.
멀티파트 메시지는 기술적으로 필요한가?
이러한 메시지 캡슐화 없이 여러 범위를 단순히 이어 붙이면 안 되는 걸까요? 만약 Content-Range가 범위 목록을 반환할 수 있다면 가능할 것입니다. 많은 컴퓨터 문제가 추가적인 캡슐화를 통해 해결되지만, 아무 역할도 하지 않는 캡슐화를 보는 것도 흔한 일입니다.
클라이언트가 마주할 수 있는 경우들
클라이언트 관점에서 범위 요청을 요약하면 다음과 같습니다.
200 OK: 전체 응답206 Partial Content:Content-Range가 포함된 부분 응답- 멀티파트 메시지입니다.
Content-Type: multipart/byteranges; boundary=x를 예상해야 함
416 Range Not Satisfiable: 범위를 벗어났음
범위 요청 구현하기
1단계: Range 헤더 필드 파싱하기
이 단계는 RFC 명세를 따르는 과정일 뿐이므로, 코드 자체는 그다지 흥미롭지 않아 생략하겠습니다.
2단계: 파일의 일부 읽기
readerFromStaticFile() 함수를 수정하여 전체 파일이 아닌 파일의 일부를 읽도록 변경할 것입니다.
fp.read() 메서드의 position 인자를 사용하여 원하는 파일 위치에서부터 읽을 수 있습니다. 파일 IO API는 읽기/쓰기 위치를 설정하기 위해 seek() 메서드를 포함하는 경우가 많지만, Node.js에서는 그럴 필요가 없습니다. read() 메서드에서 위치를 지정할 수 있으며, 어차피 주요 운영체제들에서 기반이 되는 OS 인터페이스는 단일 시스템 콜(syscall) 또는 API 호출이기 때문입니다.
나머지 코드는 생략합니다. 이 코드를 작성할 때는 이전 장에서 다룬 논의를 적용하는 것을 잊지 마세요.
3단계: 범위 응답 생성하기
이제 꽤 많은 코드를 추가해야 합니다. serveStaticFile()에서 새로운 함수를 추출할 때입니다. 이전 장의 리소스 관리 가이드를 사용하는 것을 기억하세요.
새로운 staticFileResp() 함수 내부에서는 다음을 수행해야 합니다.
Range헤더 필드를 확인하고 유효 범위를 계산합니다. 요청된 범위가 파일과 교차하지 않으면416 (Range Not Satisfiable)을 반환합니다.- 유효 범위를 포함하는
Content-Range헤더 필드를 추가합니다. 206 (Partial Content)로 응답합니다.
이것이 단일 범위 응답을 위한 코드 경로입니다. 다중 범위의 경우는 더 이상 배울 것이 없으므로 생략하겠습니다.
4단계: HEAD 메서드 추가하기
serveClient() 함수에서 HEAD 메서드의 경우에는 HTTP 본문을 쓰지 않도록 하면 됩니다. 이를 가능하게 하기 위해 writeHTTPResp() 함수를 두 개로 분리합니다.
BodyReader는 어쨌든 닫힙니다. 왜냐하면 우리의 경우 BodyReader가 여전히 파일 리소스를 소유하고 있기 때문입니다. 요청 핸들러에서 HEAD 메서드를 처리하고 파일을 더 일찍 닫을 수도 있습니다 (그리고 애초에 BodyReader를 생성하지 않을 수도 있습니다).
10. HTTP 캐싱
이 장에서는 불필요한 데이터 전송을 줄여주는 HTTP 캐싱에 대해 알아보겠습니다.
캐시 유효성 검사기 (Cache Validator)
웹 브라우저는 서버 측에서 특별한 노력을 기울이지 않아도 기본적으로 응답을 캐싱합니다. 문제는 클라이언트가 캐시된 항목이 유효한지 어떻게 알 수 있는가입니다. 서버에 이 정보를 문의하기 위한 메커니즘이 존재합니다.
타임스탬프를 이용한 유효성 검사
한 가지 방법은 Last-Modified와 If-Modified-Since 헤더 필드를 이용하는 것입니다. 이 방식은 다음 단계로 동작합니다.
- 서버는
Last-Modified헤더 필드에 파일의 파일시스템 수정 시간을 담아 반환합니다. - 클라이언트는 이 타임스탬프와 함께 응답을 캐시합니다.
- 클라이언트는 캐시된 응답을 재사용하기 전에,
If-Modified-Since헤더 필드에 캐시된 타임스탬프 값을 설정하여 요청을 보내 캐시된 응답의 유효성을 검사해야 합니다.
- 만약 서버의 파일이 수정되었다면(타임스탬프가 다른 경우), 서버는 평소와 같이 응답을 반환
- 만약 서버가 이 유효성 검사 방법을 지원하지 않아 헤더 필드를 무시하는 경우에도 평소와 같이 응답을 반환
- 만약 서버의 타임스탬프가 동일하다면, 서버는 상태 코드 304 (Not Modified)를 반환, 이 상태 코드는 페이로드 본문(payload body)이 없으며, 단지 클라이언트에게 캐시를 재사용하라고 알려주는 역할을 함
- 클라이언트는 상태 코드가 304이면 캐시된 응답을 재사용하고, 그렇지 않으면 일반 응답을 사용하고 캐시를 업데이트합니다.
ETag를 이용한 유효성 검사
타임스탬프의 해상도는 1초에 불과하여 일부 사용 사례에는 부적합하며, 파일시스템 타임스탬프는 콘텐츠를 식별하는 신뢰할 수 있는 방법이 아닙니다. 콘텐츠를 식별하는 또 다른 방법은 ETag 헤더 필드를 이용하는 것입니다.
이 방식은 Last-Modified와 동일하게 작동하지만, 그 값은 임의적일 수 있습니다. 콘텐츠의 해시 값을 사용하거나 콘텐츠를 업데이트할 때마다 버전 번호를 유지할 수 있습니다. ETag의 유효성은 If-Modified-Since와 유사한 If-None-Match를 통해 검사합니다.
범위 요청(Range Requests)에 대한 유효성 검사
위의 두 가지 유효성 검사 방법은 클라이언트가 오래된 버전을 캐시하고 있고, 새로운 버전이 있을 때만 서버가 전체 응답을 반환하기를 기대하는 시나리오에서 작동합니다. 하지만 범위 요청의 시나리오는 다릅니다. 서버는 콘텐츠가 클라이언트가 이미 가지고 있는 것과 동일한 경우에만 부분 응답을 반환할 수 있습니다.
이 경우 클라이언트는 If-Modified-Since나 If-None-Match를 사용할 수 없습니다. 대신 ETag나 타임스탬프를 포함하는 If-Range 헤더 필드를 사용합니다. 만약 If-Range 값이 일치하지 않으면 서버는 Range 헤더 필드를 무시해야 합니다(결과적으로 전체 응답을 반환하게 됩니다).
유효성 검사 헤더 요약
| 서버에서 클라이언트로 | 클라이언트에서 서버로 | 설명 |
|---|---|---|
Last-Modified |
If-Modified-Since |
타임스탬프 |
ETag |
If-None-Match |
해시, 버전 번호 등 |
ETag/Last-Modified |
If-Range |
부분 또는 전체 |
타임스탬프 유효성 검사기 구현하기
간단한 파일 서버로서 Last-Modified와 If-Modified-Since 헤더 필드를 추가하는 것은 매우 쉽습니다.
const ts = Math.floor(stat.mtime.getTime() / 1000); // modified ts
const headers: Buffer[] = [
// indicate the support for range requests
Buffer.from('Accept-Ranges: bytes'),
// for cache validation
Buffer.from(`Last-Modified: ${stat.mtime.toUTCString()}`),
];
// check conditions
const ifm = fieldGet(req.headers, 'If-Modified-Since');
if (ifm && parseHTTPDate(ifm.toString('latin1')) === ts) {
const empty = readerFromMemory(Buffer.from(''));
return {code: 304, headers: headers, body: empty};
}
let hrange = fieldGet(req.headers, 'Range');
const ifr = fieldGet(req.headers, 'If-Range');
if (ifr && parseHTTPDate(ifr.toString('latin1')) !== ts) {
hrange = null; // ignore the `Range` field
}
// use `hrange` to check the requested range ... ETag 방식은 콘텐츠가 해시 값이든 버전 번호든, 이를 추적하기 위한 별도의 하위 시스템이 필요할 가능성이 높으므로 여기서는 코드로 구현하지 않겠습니다.
논의: 서버 측 캐시 제어
지금까지 클라이언트가 자신의 캐시를 어떻게 유효성 검사하는지에 대해 이야기했습니다. 다음은 서버 측에서 캐싱 동작을 제어하는 방법입니다. 즉, 클라이언트가 캐시를 얼마나 오래 보관해야 하는지에 대한 것입니다.
서버는 Cache-Control 헤더 필드를 사용하여 클라이언트에게 캐싱에 대해 조언합니다. 그 값은 쉼표로 구분된 지시문(directives) 목록입니다. 예를 들면 다음과 같습니다.
Cache-Control: no-store, no-cache, max-age=0, must-revalidate
캐시를 재검증해야 하는 시점
max-age=x 지시문은 캐시된 항목이 살아있을 시간(TTL, time-to-live)을 초 단위로 지정합니다. 하지만 캐시는 항목이 만료되었다고 해서 단순히 삭제하지는 않습니다. 이는 매우 복잡해서 ’RFC 9111 - HTTP Caching’이라는 별도의 문서가 있을 정도입니다.
캐시된 항목은 max-age보다 오래되었는지 여부에 따라 ‘fresh’ 또는 ‘stale’로 표현됩니다. ’fresh’ 또는 ‘stale’ 상태는 해당 항목을 언제 유효성 검사해야 하는지를 결정합니다. 이는 아래 표와 같이 다른 캐시 지시문에도 의존합니다.
서버의 Cache-Control |
‘Fresh’ 상태에서 유효성 검사 | ‘Stale’ 상태에서 유효성 검사 | TTL |
|---|---|---|---|
max-age=x |
maybe | should | \(x\) |
max-age=x, must-revalidate |
maybe | must | \(x\) |
max-age=x, immutable |
should not | should | \(x\) |
max-age=0, must-revalidate |
(not fresh) | must | 0 |
no-cache |
(not fresh) | must | 0 |
| (없음) | heuristic | heuristic | heuristic |
TTL 제어하기
max-age 지시문만으로는 캐시의 수명을 크게 제어할 수 없습니다. 표에서 “maybe”와 “should”와 같은 모호한 단어를 사용하기 때문입니다. 이 둘의 차이점은 무엇일까요?
브라우저는 일반적으로 ‘fresh’ 상태의 항목을 재사용하지만, 캐시가 ‘fresh’ 상태이더라도 항목을 재검증할 수 있습니다(MAY). 이는 사용자가 새로고침 버튼을 눌렀을 때 발생할 수 있습니다. 그런데 왜 “‘Stale’ 상태에서 유효성 검사”가 “should”일까요? 그 효과가 보장되지도 않는데 TTL을 설정하는 의미가 무엇일까요? 여기에는 두 단계의 보장이 있습니다.
must-revalidate가 없으면, ‘stale’ 상태의 항목이라도 재사용될 수 있습니다. 이는 서버에 연결할 수 없을 때 발생할 수 있습니다.must-revalidate가 있으면, 캐시 TTL이 실제로 존중됩니다.
하지만 구현에 따라 “should”와 “must” 경우를 반드시 구분하지는 않을 수 있습니다.
클라이언트가 항상 서버를 확인하도록 만들기
no-cache 지시문은 혼동을 줄 수 있는데, “캐시하지 말라”는 의미가 아닙니다. 이는 max-age=0, must-revalidate의 줄임말로, “캐시하되 항상 유효성을 검사하라”는 의미입니다. 최신 데이터를 원할 때 사용됩니다.
캐싱 방지하기
캐싱을 완전히 방지하려면 no-store 지시문을 사용합니다. 이는 클라이언트에게 캐시를 전혀 사용하지 말라고 지시합니다. (이 지시문이 기존 캐시 항목을 삭제하게 하지는 않습니다.)
‘Fresh’ 상태에서의 유효성 검사 줄이기
immutable 지시문은 최적화를 위한 것입니다. 이는 클라이언트에게 ‘fresh’ 상태의 항목은 새로고침할 때조차도 재검증하지 말라고 알려줍니다. 이는 리소스가 해시 값으로 검색되는 경우처럼 URL이 변경되지 않을 때 사용할 수 있습니다. 하지만 아직 모든 브라우저가 이를 구현한 것은 아닙니다.
휴리스틱 캐싱(Heuristic Caching)
이러한 지시문들이 있더라도 클라이언트는 여전히 많은 자유를 가집니다. 실제 사용 사례 없이는 사양서만으로 유용한 소프트웨어를 구현하는 것은 불가능합니다. 클라이언트의 자유는 서버가 Cache-Control을 전혀 사용하지 않을 때 극대화됩니다. 이를 웹 브라우저에서는 휴리스틱 캐싱이라고 부릅니다. 예를 들어, 브라우저는 Last-Modified 시간을 휴리스틱으로 사용하여, 마지막 수정 시간부터 현재까지의 기간의 일부를 캐시 TTL로 사용할 수 있습니다.
클라이언트가 아닌 환경에서의 캐싱
지금까지 우리는 캐싱이 브라우저와 같은 사용자 대상 클라이언트에 의해 수행된다고 가정했습니다. 실제로는 다음과 같은 환경에서도 캐시가 제공될 수 있습니다.
서버 애플리케이션에 의해:동적으로 생성되는 콘텐츠에 특히 유용합니다. 앱이 생성된 콘텐츠를 재사용하기 위해 자체적으로 저장합니다. 목표는 콘텐츠 생성에 드는 지연 시간 및/또는 비용을 절약하는 것입니다. 앱이 콘텐츠를 제어하므로ETag방식을 쉽게 사용하여 클라이언트 측 캐싱을 도울 수도 있습니다.투명 프록시(transparent proxy) 또는 미들웨어에 의해:프록시는 원본 서버의 클라이언트 역할을 하며, 실제 클라이언트를 대신하여 요청을 처리합니다. 다른 클라이언트들처럼 응답을 캐시할 수 있습니다. 이러한 형태의 프록시는 서비스 개발자, CDN, 또는 (HTTPS가 보편화되기 전에는) ISP에 의해 배포될 수 있습니다.
논의: 클라우드에서의 캐싱
RFC는 의도된 캐싱 동작을 매우 느슨한 의미로만 정의합니다. 실제 프로젝트를 개발하거나 이해하기 위해서는, 대신 소프트웨어/서비스 설명서에 의존해야 합니다. CDN이나 캐싱 서비스를 제공하는 많은 클라우드 제공업체들이 있으며, 일부는 추가적인 캐싱 제어 기능을 제공하기도 합니다. 실제로 어떻게 작동하는지 이해하려면 그들의 문서를 읽어보아야 합니다. 몇 가지 예는 다음과 같습니다.
- Cloudflare
- AWS CloudFront
- Azure CDN
- Google Cloud CDN
11. 압축과 스트림 API
이제부터 다룰 주요 기능은 압축(compression)입니다. 또한, 이전에 잠시 넘어갔던 Node.js의 스트림(stream) 추상화에 대해서도 자세히 살펴보겠습니다.
HTTP 압축의 작동 방식
Accept-Encoding으로 협상하기
다른 기능들과 마찬가지로 HTTP 압축은 선택 사항이므로, 클라이언트는 서버에 응답을 압축해달라고 명시적으로 요청해야 합니다.
이 요청은 Accept-Encoding 헤더 필드를 통해 이루어지며, 이 헤더에는 쉼표로 구분된 압축 메서드 목록이 포함됩니다. 일반적으로 사용되는 메서드는 다음과 같습니다.
gzipdeflatebr: Brotli 압축 방식(더 최신 기술이지만 아직 널리 지원되지는 않음)
메서드 뒤에는 우선순위를 나타내는 선택적 가중치(weight)가 붙을 수 있습니다. 정확한 구문은 RFC 문서를 참조하세요.
Accept-Encoding: deflate, gzip;q=1.0
Content-Encoding으로 압축하기
서버가 제안된 압축 메서드 중 하나를 선택하면, Content-Encoding 헤더로 어떤 메서드를 사용했는지 응답합니다.
Content-Encoding: gzip
둘 이상의 Content-Encoding이 적용될 수도 있지만, 실제적으로는 거의 사용되지 않습니다.
PNG, JPG, ZIP과 같이 일부 콘텐츠 유형은 이미 압축되어 있습니다. 이러한 파일들은 더 이상 압축되지 않으므로 압축 대상에서 제외하는 것이 가장 좋습니다.
압축과 Content-Length
Content-Length의 목적은 압축 여부와 관계없이 동일합니다. 즉, 압축 해제된 데이터의 길이가 아니라 HTTP 본문(body)의 길이를 나타냅니다.
압축이 실시간으로(on the fly) 적용될 때는 압축된 크기를 미리 알 수 없으므로(전체 출력을 버퍼링하지 않는 한, 대용량 콘텐츠에서는 불가능), 청크 분할 인코딩(chunked encoding)을 함께 사용해야 합니다.
압축과 캐싱
압축은 CPU를 많이 사용하는 작업이므로 항상 실시간으로 적용되지는 않습니다. 캐싱 프록시(caching proxy)는 압축된 응답을 캐시하고 서빙할 수 있습니다(Accept-Encoding이 허용하는 경우).
만약 캐싱 프록시가 압축을 고려하지 않더라도, 최소한 서로 다른 압축 메서드로 생성된 응답을 섞어서는 안 됩니다. 바로 이것이 Vary 헤더 필드의 목적입니다. 이 헤더는 특정 헤더 필드가 사용될 경우 응답이 달라질 수 있음을 프록시에게 알립니다.
예를 들어, Vary: content-encoding이 사용되면 캐시 항목은 Content-Encoding의 값을 키(key)로 사용하므로, 캐시 조회 시 이 값을 고려하게 됩니다.
압축된 업로드
콘텐츠 협상(content negotiation)의 특성상, 요청 본문(request body)을 압축하는 표준적인 방법은 없습니다. 만약 클라이언트가 Content-Encoding을 사용하여 이를 수행한다면, 요청의 압축 해제는 HTTP 서버가 아닌 애플리케이션이 처리하게 될 가능성이 높습니다.
압축과 범위 요청(Range Requests)
Content-Encoding은 콘텐츠의 속성입니다. 따라서 Range 헤더 필드는 압축된 데이터를 기준으로 동작하게 되는데, 이는 우리가 원하는 동작 방식이 아닙니다.
일반적으로는 범위 요청에 대한 응답을 제공할 때 압축을 사용하지 않는 것이 관례입니다.
Transfer-Encoding으로 압축하기
압축을 수행하는 또 다른 방법이 있지만 거의 지원되지 않습니다. Transfer-Encoding은 보통 청크 분할 인코딩을 위해 사용되지만, 페이로드(payload)를 압축하는 데에도 사용될 수 있습니다.
위 헤더 필드는 페이로드가 먼저 gzip으로 압축된 다음, 청크로 분할된다는 의미입니다.
Content-Encoding과의 차이점은 무엇일까요? Transfer-Encoding은 콘텐츠의 속성이 아니라는 점이 다릅니다. 따라서 범위 요청은 Content-Encoding과는 작동하지 않지만 Transfer-Encoding과는 함께 작동할 수 있습니다.
잘 알려지지 않은 다른 HTTP 기능들과는 달리, Transfer-Encoding을 이용한 압축은 실제로 더 실용적이고 우수하지만, 안타깝게도 구현은 다른 방향으로 이루어졌습니다.
TE 헤더 필드는 Transfer-Encoding을 협상하기 위한 것으로, 아래 표에 요약되어 있습니다.
| 요청 헤더 | 응답 헤더 | Range | 널리 사용되는가? |
|---|---|---|---|
Accept-Encoding |
Content-Encoding |
아니오 | 예 |
TE |
Transfer-Encoding |
지원됨 | 아니오 |
파이프를 이용한 데이터 처리
데이터 처리와 입출력(IO)
응답 본문을 압축하기 위해 내장된 zlib 모듈을 사용할 것입니다. 데이터 압축에는 두 가지 종류의 API가 있습니다. 가장 간단한 방법은 입력을 단일 버퍼로 제공하고 압축된 데이터를 단일 버퍼로 반환하는 것입니다.
이 방식은 입력 데이터가 클 경우 메모리에 다 담지 못할 수 있어 작동하지 않을 수 있습니다. 설령 메모리가 충분하더라도 지연 시간(latency)이 길어지는 문제가 발생할 수 있습니다.
두 번째 방식은 실시간으로 출력을 생성하는 것입니다. 먼저 상태를 가지는(stateful) 압축기 객체를 초기화한 다음, 압축기에 입력을 제공하면 압축기가 동시에 출력을 생성합니다.
압축기에는 역압력(backpressure) 기능이 있어서 올바르게 사용하면 메모리 문제를 일으키지 않습니다. 또한, 입력이 계속 흐르도록 하려면 출력을 동시에 비워주어야(drain) 합니다.
데이터를 실시간으로 처리하는 방법은 소켓(socket)을 다룰 때와 유사하게, 동시에 읽고 쓸 수 있는 상태 기반(stateful) 프로세서를 사용하는 것입니다.
조합 가능한 데이터 프로세서를 위한 유닉스 파이프
여러 단계의 데이터 처리를 거치는 것은 흔한 일이며, 유닉스 파이프(Unix pipe)는 이러한 단계들을 프로그래밍하고 조합하는 고수준의 방법입니다.
구체적인 예시는 다음과 같습니다.
이 명령어는 2개의 파이프로 연결된 3개의 단계로 구성됩니다.
tar: 아카이브를 생성하는 생산자(producer)입니다.gzip: 아카이브를 소비(consume)하여 gzip 데이터를 생성합니다.nc: gzip 데이터를 소비하여 네트워크를 통해 전송합니다.
파이프는 사용자가 단계 간 데이터 이동 코드를 직접 작성하지 않아도 출력과 입력을 자동으로 연결해 줍니다.
추상화된 파이프
우리 HTTP 서버에서도 파이프와 유사한 추상화(abstraction)를 사용할 수 있습니다.
produce-response | gzip-compress | chunk-encode | write-to-socket
이 패턴은 다음과 같은 장점이 있습니다.
- 데이터를 명시적으로 읽고 쓸 필요가 없어 코드 작성이 줄어듭니다!
- 각 프로세서가 제대로 구현되었다면 역압력이 자동으로 처리됩니다.
- 앞으로 보게 되겠지만, 오류 발생 가능성이 더 적습니다.
Node.js의 스트림 API 탐색하기
스트림 인터페이스 목록
Node.js에도 파이프가 존재하며, 스트림 API를 사용하여 여러 요소를 파이프로 연결할 수 있습니다.
이 모듈은 우리가 구현할 수 있는 여러 인터페이스를 정의합니다.
| 이름 | 설명 | 예시 |
|---|---|---|
Readable |
출력을 소비(consume)합니다. | fs.createReadStream() |
Writable |
입력을 제공(feed)합니다. | fs.createWriteStream() |
Duplex |
Readable + Writable |
net.Socket |
Transform |
Duplex를 확장합니다. |
zlib.createGzip() |
Readable과 Writable은 두 가지 기본 추상화입니다. Duplex와 Transform은 모두 Readable과 Writable의 조합이지만, 현시점에서는 둘의 차이점을 자세히 알 필요는 없습니다. 표에서 볼 수 있듯이, 일부 내장 Node.js 모듈은 이미 이 인터페이스들을 구현하고 있습니다.
스트림 인터페이스에서 파이프 사용하기
이러한 추상화를 탐색하는 우리의 목표는 여기에 파이프를 사용하는 것입니다.
pipeline() 함수는 stream.Readable을 stream.Writable에 연결합니다. 파이프를 생성하면 실제 유닉스 파이프처럼 데이터가 생산자에서 소비자에게 자동으로 흐르고, 역압력도 적용됩니다. 이 함수는 유닉스 파이프에서 이름을 따왔으므로, 요청 파이프라이닝(request pipelining)과 혼동해서는 안 됩니다.
소스(source)와 목적지(destination) 사이에 하나 이상의 stream.Duplex를 둘 수도 있습니다.
HTTP 압축 구현하기
zlib 모듈은 압축 객체를 위해 stream.Duplex를 구현합니다. 이는 Node.js 스트림 API 사용법을 배우기에 좋은 기회입니다.
1단계: 헤더 필드 확인 및 압축 활성화
헤더 필드에 대한 우리의 이해를 바탕으로 이 단계를 진행합니다. 클라이언트가 요청하면 조건 없이 응답을 gzip으로 압축할 것입니다. 애플리케이션이 압축을 사용하지 않을 시점을 제어할 수 있도록 하는 것도 좋은 방법입니다.
function enableCompression(req: HTTPReq, res: HTTPRes): void {
// inform proxies that the response is variable
res.headers.push(Buffer.from('Vary: content-encoding'));
// check header fields
if (fieldGet(req.headers, 'Range')) {
return; // incompatible
}
const codecs: string[] = fieldGetList(req.headers, 'Accept-Encoding');
if (!codecs.includes('gzip')) { // TODO: parse the weight: `gzip;q=0`
return;
}
// transform the response using gzip
res.headers.push(Buffer.from('Content-Encoding: gzip'));
res.body = gzipFilter(res.body);
} gzipFilter() 함수는 다음에 구현할 함수로, 데이터를 압축하기 위해 BodyReader의 래퍼(wrapper)를 반환합니다.
2단계: 파이프를 사용하지 않을 때의 문제점 이해하기
pipeline() 함수를 사용하지 않는다면, 해결책을 다음과 같이 간단하게 상상할 수 있습니다.
// pseudo code!
function gzipFilter(reader: BodyReader): BodyReader {
const gz: stream.Duplex = zlib.createGzip();
return {
length: -1,
read: async (): Promise<Buffer> => {
const data = await reader.read();
await write_input(gz, data); // deadlock by backpressure!
return await read_output(gz); // deadlock by buffering!
},
}
}안타깝게도 이 의사 코드는 두 가지 이유로 작동하지 않습니다.
압축기는 역압력을 구현합니다. 즉, 큰 데이터를 쓰려고 하면 해당 입력이 처리되고 비워질 때까지 코드가 블로킹(block)됩니다. 하지만 위 의사 코드에서는 쓰기 작업이 완료된 후에야 데이터가 비워지므로, 이는 교착 상태(deadlock)의 한 사례입니다! (“파이프라인 요청” 섹션에서 유사한 교착 상태를 논의한 바 있습니다.)
대부분의 데이터 압축 방식은 본질적으로 버퍼링을 필요로 합니다. 압축기에 1개의 데이터가 입력된다고 해서 1개의 압축된 출력이 나오지는 않습니다. 데이터가 충분하지 않으면, 압축기는 데이터를 어떻게 압축할지 결정하지 못했기 때문에 출력을 생성하지 않습니다. 따라서 더 많은 데이터를 기다리는 동안 압축기에서 데이터를 읽으려고 하면 멈추게(stuck) 됩니다!
유닉스 파이프에서는 읽기와 쓰기가 서로 다른 프로세스에서 발생하며, 파이프는 동시에 채워지고 비워지므로 위와 같은 교착 상태가 발생하지 않습니다. 이것이 바로 제가 파이프 추상화를 사용하는 것이 오류 발생 가능성이 적다고 언급한 이유입니다.
3단계: stream.Readable 구현하기
다시 본론으로 돌아와서, pipeline() 함수를 사용하기 전에 데이터 소스인 BodyReader를 위해 stream.Readable 인터페이스를 구현해야 합니다.
stream.Readable을 구현하는 방법은 다음과 같습니다.
stream.Readable에는 내부 큐(queue)가 있습니다.push()메서드를 사용하여 큐에 데이터를 추가하면, 해당 데이터를 소비할 수 있게 됩니다.- 언제 큐에 데이터를 추가해야 할까요?
_read()콜백을 구현해야 합니다. 이 콜백은 큐가 비어 있고 누군가가 데이터를 소비하려고 할 때 호출됩니다. 이는 역압력을 유지하는 데 필수적입니다. destroy()메서드를 사용하여 소비자에게 오류를 전파합니다.
function body2stream(reader: BodyReader): stream.Readable {
let self: null|stream.Readable = null; // make TS happy
self = new stream.Readable({
read: async () => {
try {
const data: Buffer = await reader.read();
self!.push(data.length > 0 ? data : null);
} catch (err) {
self!.destroy(err instanceof Error ? err : new Error('IO'));
}
},
});
return self;
}
push()메서드는 스트림의 끝을 표시하기 위해null을 사용하는데, 이는 우리가 사용하는 방식(길이가 0인 버퍼)과는 다릅니다.
4단계: 파이프 생성하기
이제 BodyReader와 압축기 사이에 파이프를 생성해 보겠습니다.
pipeline() 함수는 우리를 대신해 데이터를 이동시키고, 우리가 await 할 수 있는 프로미스(promise)를 반환합니다. 프로미스를 await 하는 동안 발생하는 모든 예외를 반드시 잡아야(catch) 하며, 잡힌 오류는 다음 스트림으로 전파해야 합니다. 그렇지 않으면 해당 스트림의 소비자는 영원히 멈춰있게 됩니다.
gzipFilter() 함수에서는 호출자에게 래핑된 BodyReader를 즉시 반환해야 하므로 파이프가 완료될 때까지 기다릴 수 없습니다. 그래서 try-catch 블록은 await 없이 익명의 비동기 함수 호출로 래핑됩니다. 이는 프로미스에 오류 처리 콜백을 등록함으로써 더 간단하게 만들 수 있습니다.
5단계: stream.Readable에서 읽기
이제 파이프가 실행 중이므로 압축기에서 출력을 읽을 수 있습니다. net.Socket 또한 stream.Readable이라는 점을 기억하세요. soRead() 함수는 (타입 어노테이션을 제외하고는) 수정 없이 사용할 수 있는데, ‘data’, ’end’와 같은 다양한 소켓 이벤트와 pause(), resume() 메서드들이 실제로는 stream.Readable에 정의되어 있기 때문입니다.
하지만 stream.Readable 인터페이스에는 이미 프로미스 기반의 읽기 메서드가 있습니다. iterator() 메서드는 AsyncIterator를 반환하며, 이를 제너레이터(generator)처럼 for await...of 루프에서 사용할 수 있습니다. AsyncIterator에는 제너레이터와 유사한 next() 메서드도 있습니다.
function gzipFilter(reader: BodyReader): BodyReader {
// ...
const iter: AsyncIterator<Buffer> = gz.iterator();
return {
length: -1, // the compressed "Content-Length" is not known
read: async (): Promise<Buffer> => {
const r: IteratorResult<Buffer, void> = await iter.next();
return r.done ? Buffer.from('') : r.value;
},
close: reader.close,
};
} 반환된 BodyReader 래퍼는 이 새로운 메서드를 사용하여 압축기에서 데이터를 읽습니다. 학습 목적이 아니었다면 soRead() 함수는 건너뛸 수도 있었습니다.
6단계: 테스트하기
거의 다 끝났습니다. 이제 모든 응답에 압축을 활성화하고 테스트해 보겠습니다.
curl을 사용하여 압축된 응답을 요청할 수 있습니다.
7단계: 압축기 버퍼 플러시하기
압축은 잘 동작합니다. 단 한 가지 문제가 있습니다. ‘/echo’나’/sheep’ URI를 테스트할 때 응답이 즉시 나타나지 않습니다. 이는 압축기가 수행하는 버퍼링 때문입니다.
“버퍼링된 라이터(Buffered Writer)” 논의에서, 내부 버퍼를 플러시(flush)하여 압축기가 즉시 출력하도록 강제하는 방법이 있어야 한다는 것을 알고 있습니다.
하지만 우리는 명시적인 입출력 대신 pipeline()을 사용하고 있으므로 flush() 호출을 삽입할 곳이 없습니다. 그러나 압축기가 모든 입력을 자동으로 플러시하도록 만드는 옵션이 있습니다.
압축기를 자주 플러시하면 압축 효율성이 떨어집니다. 따라서 애플리케이션이 플러시 여부를 제어하도록 하는 것이 좋습니다. 예를 들어, 정적 파일을 제공할 때는 데이터 소스가 시스템이 할 수 있는 한 가장 빠르게 생성되므로 압축기를 플러시할 필요가 없습니다.
아주 작은 데이터 청크를 생성하는 애플리케이션의 경우, 아예 압축하지 않는 것이 더 나을 수 있습니다. 작은 데이터는 잦은 플러시와 함께 압축될 경우 효율이 좋지 않을 가능성이 높기 때문입니다.
논의: 스트림으로 리팩토링하기
더 많은 스트림과 파이프를 사용하도록 코드를 리팩토링해 볼 수 있습니다. 우선 BodyReader 인터페이스의 read() 함수를 교체하는 것부터 시작할 수 있습니다.
더 많은 스트림과 파이프를 사용하면 많은 코드를 절약할 수 있습니다.
soRead()함수의 구현이 더 이상 필요하지 않습니다.- 디스크 파일을 읽는 코드는
fs.createReadStream()으로 대체될 수 있습니다. writeHTTPBody()함수는 단순히 응답과 소켓 사이에 파이프를 생성하는 것으로 바뀔 수 있습니다.- 더 이상 명시적인 읽기/쓰기를 다루지 않으므로, 청크 인코더는
stream.Transform을 구현하여 대체할 수 있습니다. - 제너레이터는
stream.Readable.from()을 통해stream.Readable로 변환될 수 있습니다.
그리고 요청에 응답하는 전체 프로세스는 다음과 같은 일련의 파이프가 됩니다.
produce-response | gzip-compress | chunk-encode | write-to-socket
논의: 수위점(High Water Mark)과 역압력
단일 항목 큐(One-Item Queue)
우리가 역압력을 유지하는 방식은 큐나 버퍼가 비워질 때까지 기다리는 것입니다. 생산자와 소비자의 관점에서 큐는 두 가지 상태를 가집니다.
| 큐 상태 | 생산 가능 | 소비 가능 |
|---|---|---|
| 비어 있음 | 예 | 아니오 |
| 비어 있지 않음 | 아니오 | 예 |
이는 큐가 최대 1개의 항목 또는 1개의 보류 중인 쓰기 작업으로 제한됨을 의미합니다.
수위점(High Water Mark)에 의한 역압력
그러나 일부 역압력 구현은 약간 더 정교합니다. 큐가 단 1개의 쓰기 작업이 아닌 바이트 수에 의해 제한됩니다. 이 한계를 종종 수위점(high water mark) 이라고 부릅니다. 수위점을 역압력에 사용하면 큐는 세 가지 상태를 가집니다.
| 큐 상태 | 생산 가능 | 소비 가능 |
|---|---|---|
| 비어 있음 | 예 | 아니오 |
| 절반 참 | 예 | 예 |
| 가득 참 | 아니오 | 예 |
이 방식은 유닉스 파이프와 더 유사합니다. 유닉스 파이프는 크기가 정해진 버퍼(bounded buffer)이며, 가득 차지 않는 한 계속해서 바이트를 추가할 수 있습니다. 완전히 비워질 때까지 기다릴 필요가 없습니다.
큐를 이용한 데이터 일괄 처리(Batching)
단일 항목 큐 대신 수위점을 사용하는 이유는 큐가 여러 개의 작은 쓰기 작업을 하나로 합칠 수 있기 때문입니다. 예를 들어, 큐에서 소켓으로 데이터를 이동할 때 다음과 같은 최적화가 가능합니다.
- 큐를 단일 버퍼로 만들 수 있습니다. 데이터를 쓸 때마다 버퍼에 추가(append)됩니다. 이는 여러 개의 작은 쓰기 작업을 자동으로 하나로 합쳐주며, 모든 데이터는 한 번에 소켓으로 전송됩니다.
- 큐가 개별 버퍼 객체들을 저장하고, 여러 버퍼를 소켓으로 보내기 전에 하나의 더 큰 버퍼로 합칠 수 있습니다.
- 큐가 개별 버퍼 객체들을 저장하고,
writev()시스템 콜을 사용하여 수동으로 버퍼를 합치지 않고도 여러 버퍼를 한 번에 보낼 수 있습니다.
이러한 최적화는 모두 이전에 논의했던 반투명 버퍼링(semi-transparent buffering)을 구현합니다. stream.Writable.writev도 참고하세요.
Node.js의 수위점(High Water Mark)
stream.Readable과 stream.Writable은 모두 내장된 수위점을 가지고 있으며, 이를 구현할 때 highWaterMark 옵션을 통해 변경할 수 있습니다. 높은 처리량이 필요한 애플리케이션에서는 더 큰 값을 사용하는 것이 바람직할 때가 많습니다.
역압력을 위해 수위점을 사용할 때는 Readable.push()와 Writable.write()의 반환 값을 확인해야 합니다.
- 큐가 가득 찼을 때 이 메서드들은
false를 반환하므로, 큐가 비워지기를 기다릴 수 있습니다. - 그렇지 않다면, 더 많은 데이터를 큐에 푸시할 수 있습니다.
우리는 이들의 반환 값을 사용하지 않았는데, 이는 단순히 수위점을 0으로 간주하고 매 쓰기 작업 후에 큐를 비웠기 때문입니다.
스트림의 수위점
| 인터페이스 | 역압력 표시기 | 큐가 비워질 때까지 대기하는 방법 |
|---|---|---|
Readable |
.push(data)의 반환 값 |
_read() 콜백을 통해 |
Writable |
.write(data, cb)의 반환 값 |
‘drain’ 이벤트를 통해 |
스트림 인터페이스는 역압력을 지원하지만, 강제하지는 않습니다. 큐가 가득 차더라도 계속해서 데이터를 푸시할 수 있는데, 이는 초보자에게는 실수하기 쉬운 부분(footgun)입니다.
12. WebSocket과 동시성
이번에는 좀 다른 주제인 WebSocket (RFC6455)에 대해 알아보겠습니다. 또한 네트워크 애플리케이션에서 흔히 발생하는 생산자-소비자 문제(producer-consumer problems)에 대해서도 더 자세히 살펴볼 것입니다.
WebSocket 연결 설정
“동적 콘텐츠” 장에서 WebSocket에 대해 이미 논의한 바 있습니다. 사용자 관점에서 WebSocket은 다음과 같은 특징을 가집니다.
- 양방향(bi-directional), 전이중(full-duplex) 채널
- 바이트 스트림(byte stream)이 아닌 메시지 기반(message-based)
- 기존 HTTP 서버에 대한 확장
HTTP/1.1로부터의 업그레이드
WebSocket은 HTTP 헤더로 시작하며, 이것이 HTTP와 갖는 유일한 공통점입니다. 클라이언트는 Upgrade: websocket 헤더 필드를 사용하여 WebSocket을 생성할 의도가 있음을 알리고, 서버는 이 헤더와 함께 상태 코드 101로 응답하여 WebSocket이 설정되었음을 나타냅니다. 이후의 연결은 WebSocket 프로토콜이 담당하게 됩니다.
만약 서버가 WebSocket에 대해 전혀 모른다면, 일반적인 HTTP 요청처럼 응답할 것입니다. 이는 기존 프로토콜을 확장하는 한 가지 방법이며, HTTP/1.1에서 HTTP/2로 전환할 때도 사용됩니다.
WebSocket 핸드셰이크
Upgrade: websocket 외에도 몇 가지 추가 헤더가 있습니다.
- 클라이언트는
Sec-WebSocket-Key를 통해 임의의 문자열을 보내야 함 - 서버는 해당 문자열의 해시(hash) 값을
Sec-WebSocket-Accept를 통해 응답해야 함
이 핸드셰이크의 근거는 다음과 같습니다.
- 서버가 WebSocket을 이해한다는 것을 증명하기 위함입니다.
Sec-*헤더 필드를 금지함으로써 WebSocket이 아닌 클라이언트가 WebSocket을 생성하는 것을 방지하기 위함입니다.- 임의의 값을 사용하여 캐싱 프록시(caching proxies)가 WebSocket 데이터를 캐싱하는 것을 방지하기 위함입니다.
생각해보면 이러한 근거들은 다소 취약하기 때문에 혼란스러울 수 있습니다.
- 서버는 이미 101 상태 코드로 자신을 증명했습니다.
- 만약 WebSocket이 아닌 클라이언트가 사용자의 WebSocket 생성을 막고 싶다면, 아마도
Upgrade헤더 필드를 금지할 것입니다. - 프록시가 101 상태 코드를 캐시할 가능성은 거의 없습니다.
해시는 다음 함수에 의해 계산됩니다.
Connection 헤더 필드
Connection: Upgrade 헤더 필드는 비-투명 프록시(non-transparent proxies)에게 Upgrade 헤더 필드를 소비하고 제거하도록 지시하는 데 사용됩니다. 이를 통해 비-투명 프록시는 해당 프로토콜을 이해하지 않는 한 WebSocket 핸드셰이크를 전달하지 않습니다.
이것은 홉-바이-홉(hop-by-hop) 헤더 필드 중 하나입니다. 우리는 프록시가 아닌 서버이므로 이 부분에 대해서는 신경 쓰지 않아도 됩니다.
WebSocket 프로토콜
프레임 형식
핸드셰이크 이후의 나머지 연결은 일련의 프레임(frames)으로 구성됩니다.
0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| | Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data... |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued... |
+---------------------------------------------------------------+
구조는 HTTP처럼 헤더 + 페이로드(payload)입니다. 하지만 HTTP와는 다릅니다.
- 형식은 바이너리(binary)이며, 구분자(delimiters)가 사용되지 않음
- 페이로드 길이는 헤더에 간단하게 표시
WebSocket 프레임을 처리하는 것은 HTTP를 처리하는 것보다 훨씬 쉽습니다.
- 첫 번째 바이트는 FIN 플래그와 4비트 opcode를 포함합니다.
- 두 번째 바이트의 MASK 비트는 선택적인 마스킹 키(masking-key)를 나타냅니다.
- 다음 필드는 페이로드 길이이며, 두 번째 바이트의 7비트 정수로 시작하는 가변 길이 정수(variable-length integer)로 인코딩됩니다.
- 0에서 125까지의 길이는 해당 7비트 정수에 저장
- 126은 뒤따르는 16비트 빅 엔디안(big-endian) 정수를 나타냄
- 127은 뒤따르는 64비트 빅 엔디안 정수를 나타냄
- MASK 비트가 설정되어 있으면, 4바이트의 임의 데이터 마스크가 뒤따릅니다.
4바이트 임의 데이터 마스크는 페이로드 데이터와 XOR 연산됩니다. 마스크는 클라이언트에서 서버로 전송되는 프레임에 사용되어야 하지만, 그 반대의 경우에는 사용되지 않아야 합니다. XOR 마스크의 목적은 특정 유형의 캐시 포이즈닝(cache poisoning) 공격을 방지하는 것입니다.
프레임의 종류
- 데이터 메시지를 위한 Opcode는 아래와 같습니다.
$0 \times 01$: 텍스트(text) 데이터 메시지, 애플리케이션용$0 \times 02$: 바이너리(binary) 데이터 메시지, 애플리케이션용$0 \times 00$: 메시지 분할(fragmentation)
텍스트와 바이너리의 구분은 애플리케이션에 달려 있습니다.
- 제어 메시지를 위한 Opcode는 아래와 같습니다.
$0 \times 08$: 정상적인 연결 종료(graceful termination)를 위한 제어 메시지, 선택적 페이로드에는 상태 코드와 문자열 메시지가 포함될 수 있음$0 \times 09$및$0 \times 10$: Ping과 Pong, 또는 하트비트(heartbeats), 연결을 유지하고 끊어진 연결을 감지하기 위한 것, 애플리케이션에는 투명하게 처리될 수 있음
동시성 프로그래밍 소개
경쟁 조건 (Race Conditions)
메시지 기반 설계에서는 여러 메시지 소스를 단일 소비자가 소비하는 경우가 흔합니다. 예를 들어, 여러 동시성 태스크(concurrent tasks)가 단일 WebSocket을 통해 메시지를 보내는 경우가 있습니다. WebSocket 메시지는 단일 TCP 바이트 스트림으로 직렬화(serialized)되기 때문에, 소켓에 대한 동시적인 쓰기(concurrent writes)는 프로토콜을 망가뜨릴 수 있습니다.
가상의 예제를 통해 이 문제를 설명해 보겠습니다.
이 예제에서 하나의 메시지를 쓰는 데는 두 번의 소켓 쓰기가 포함되며, 코드는 각 await 구문에서 런타임에 제어권을 양보합니다.
문제는 런타임으로 제어권이 돌아갔을 때, 런타임이 메시지를 보내는 다른 태스크를 스케줄링하여 소켓 쓰기가 서로 뒤섞이는(interleaved) 결과를 낳을 수 있다는 것입니다.
이를 동시성 프로그래밍에서 경쟁 조건(race condition)이라고 부릅니다. 이는 단일 스레드 런타임이나 CPU 코어 수와는 아무런 관련이 없다는 점에 유의해야 합니다.
원자적 연산 (Atomic Operations)
각 메시지마다 단 한 번의 소켓 쓰기만 사용하면 해결될 것이라고 생각할 수도 있습니다. 하지만 이 해결책은 다음과 같은 문제가 있습니다.
- 소켓 쓰기가 원자적(atomic)이라는 보장이 있을 때만 정확하며, 이는 항상 보장되지 않을 수 있음
- 항상 가능하지도 않고 편리하지도 않음, WebSocket 메시지는 분할되어 스트림처럼 사용될 수 있다는 점을 고려해야 함
운영체제는 동시적 쓰기를 원자적 연산으로 구현하려고 노력하지만, 제정신인 애플리케이션이라면 이러한 동작에 의존하지 않을 것이며, 바이트 스트림은 어쨌든 메시지에 관한 것이 아닙니다.
뮤텍스를 이용한 상호 배제 (Mutual Exclusion with Mutexes)
한 가지 해결책은 잠금(lock), 즉 뮤텍스(mutex)를 사용하여 소켓에 대한 동시적 쓰기를 제한하는 것입니다.
뮤텍스는 한 번에 하나의 태스크만 잠긴 상태(locked state)로 들어가는 것을 허용하기 때문에 “상호 배제(mutual exclusion)”라고 불립니다.
동시 접근이 발생할 경우, 하나의 태스크가 잠금을 보유하고 나머지 태스크들은 await 구문에서 블록(block)됩니다. 잠금을 해제하면 대기 중인 태스크 중 하나가 블록 해제됩니다.
뮤텍스는 많은 동시성 프로그래밍 환경에서 사용되는 동기화 프리미티브(synchronization primitives) 중 하나입니다.
큐를 이용한 다중화 (Multiplexing with Queues)
동시 접근을 방지하는 또 다른 해결책은 큐(queue)를 사용하는 것입니다.
큐는 동시적인 생산자들로부터 WebSocket 메시지를 받고, 전담 태스크가 큐에서 메시지를 소비하여 소켓에 씁니다.
이것이 우리가 구현할 방식입니다.
// pseudo code!
let queue = createQueue();
// for multiple producers
async function produce(msg) {
await queue.pushBack(msg);
}
async function single_consumer() {
while (running()) {
const msg = await queue.popFront();
// write to the socket
}
}
// pseudo code!
async function send(mutex, msg) {
await mutex.lock();
try {
// write to the socket
} finally {
mutex.unlock();
}
}뮤텍스 역시 데이터를 전달하지 않고 제어권(control)을 전달하는 큐의 한 형태라고 볼 수 있습니다.
블로킹 큐를 이용한 흐름 제어 (Flow Control with Blocking Queues)
자바스크립트 배열은 push와 pop이 가능한 큐이지만, 동시성 프로그래밍에는 유용하지 않습니다. 소비자 입장에서 큐가 비어있지 않다는 것을 어떻게 알 수 있을까요? 소비자가 생산자를 기다릴 수 있게 하는 메커니즘이 필요합니다. 이 메커니즘은 큐 자체에 구현될 수 있습니다! 이것이 바로 위 의사 코드에서 await를 사용하여 큐에서 소비하는 이유입니다. 소비자는 큐가 비어있을 때 블록되기 때문에 이를 블로킹 큐(blocking queue)라고 부릅니다. 또한, 역압력(backpressure)을 위해서는 큐의 용량이 제한되어야 합니다. 이는 큐가 가득 찼을 때 생산자를 블록시킴으로써 달성됩니다. Node.js의 Stream API와 대조적으로, 이는 역압력을 의무화하지 않아 발생하는 위험(footgun)을 제거합니다.
블로킹 큐는 생산자와 소비자 간에 데이터를 전달할 뿐만 아니라, 뮤텍스와 유사하게 제어권도 전달합니다. 사실, 데이터 전달이 편리하긴 하지만, 동시성 프로그래밍에서는 제어권을 전달하는 것이 더 근본적입니다.
닫을 수 있는 큐를 이용한 취소 (Cancellation with Closeable Queues)
블로킹 큐는 블로킹 동작 측면에서 유닉스 파이프(Unix pipe)와 유사합니다. 단 한 가지 차이점은 파이프는 닫힐 수 있다는 것입니다!
- 생산자에 의해 닫히면, 소비자는 EOF(End-Of-File)를 받음
- 소비자에 의해 닫히면, 생산자는 쓰기 시도 시 에러를 받음
동시성 프로그래밍에서는 때때로 태스크들이 하던 일을 중단하게 만들어야 합니다. 예를 들어, 애플리케이션 태스크가 WebSocket에서 읽거나 쓰면서 블록되어 있는 동안 WebSocket이 닫히면, 해당 태스크는 영원히 대기하는 대신 깨어나야 합니다 (EOF나 에러를 받기 위해).
만약 태스크가 큐의 소비자라면, 큐에 특별한 값을 넣어 소비자에게 종료를 알릴 수 있습니다. 하지만 큐를 기다리는 생산자에게는 이것이 쉽지 않습니다.
다행히 유닉스 파이프에서 배울 수 있습니다. close() 메서드를 추가하여 모든 생산자와 소비자를 블록 해제하는 것입니다.
닫힌 파이프를 모방하여, 생산자에게는 예외를 던지고 소비자에게는 null을 반환할 수 있습니다.
동기화 프리미티브로서의 블로킹 큐 (The Blocking Queue as a Synchronization Primitive)
동기화 프리미티브는 태스크를 블록하고 블록 해제하는(제어권을 전달하는) 데 사용되는 것입니다. 전통적인 동기화 프리미티브에는 다음이 포함됩니다.
- 뮤텍스 (Mutex)
- 세마포어 (Semaphore)
- 조건 변수 (Condition variable)
- 이벤트 (Event)
전통적인 동기화 프리미티브 외에도, Golang은 “채널(channel)”을 주요 동기화 프리미티브로 사용하는데, 이는 우리가 논의하고 있는 블로킹 큐와 거의 유사합니다.
많은 간단한 동시성 문제는 데이터를 전달함으로써 해결되며, 이는 Go에서 선호되는 방식입니다. 우리는 WebSocket 구현을 통해 이를 보여줄 것입니다.
블로킹 큐 코딩하기
Node.js에는 이러한 동기화 프리미티브가 기본적으로 제공되지 않으며, 동시성 문제는 종종 수많은 콜백(callbacks) 뒤에 가려져 있습니다. 다행히 async/await가 추가되면서 이러한 프리미티브들을 자바스크립트로 가져올 수 있게 되었습니다. 하지만 먼저 프로미스(promises)와 콜백을 사용하여 직접 만들어야 합니다.
1단계: 문제 분석
버퍼링 용량(buffering capacity)이 없고 push와 pop만 있는 블로킹 큐를 생각해 봅시다. 이 큐는 제너레이터(generators)보다 더 다재다능하게 여러 생산자와 여러 소비자와 함께 사용할 수 있어야 합니다.
프로미스를 생성하면 나중에 그 프로미스를 이행(fulfill)하는 데 사용되는 resolve 콜백이 생깁니다. 생산자는 소비자를 기다리거나, 기다리고 있는 소비자를 깨워야 합니다.
- 기다리는 소비자가 없다면, 콜백을 어딘가에 저장함, 미래의 소비자는 이 콜백을 호출하여 데이터를 가져가고 생산자를 이행시킴
- 기다리는 소비자가 있다면, 그 중 하나의 콜백을 호출하여 이행시킴
소비자의 상황도 비슷합니다.
- 생산자를 기다리기 위해 콜백을 저장하거나,
- 기다리는 생산자의 콜백을 호출해야 함
2단계: Push와 Pop
여러 생산자나 소비자가 대기할 수 있으므로, 그들의 콜백을 리스트에 저장해야 합니다. 소비자의 경우, 데이터를 받기 위해 자신의 resolve 콜백만 저장하면 됩니다. 생산자의 콜백은 두 가지 일을 합니다.
- 소비자의 콜백을 가져와 (데이터와 함께) 이행시킴
- 자신의 프로미스를 이행시킴
// a multi-producer, multi-consumer, and 0-capacity queue.
function createQueue<T>(): Queue<T> {
type Taker = (item: T) => void; // fulfill a consumer
type Giver = (take: Taker) => void; // wake up a producer
const producers: Giver[] = [];
const consumers: Taker[] = [];
return {
pushBack: (item: T): Promise<void> => {
return new Promise<void>((done: () => void) => {
const give: Giver = (take: Taker) => {
take(item);
done();
};
if (consumers.length) { // consumers are waiting
give(consumers.shift()!);
} else { // wait for a producer
producers.push(give);
}
});
},
popFront: (): Promise<T> => {
return new Promise<T>((take: Taker) => {
if (producers.length) { // producers are waiting
producers.shift()!(take);
} else { // wait for a consumer
consumers.push(take);
}
});
},
};
} 3단계: 큐 닫기
close() 메서드는 나중에 유용하게 사용될 것입니다.
생산자에게 예외를 던질 수 있으므로, reject 콜백도 저장합니다.
닫힌 상태도 기억해야 합니다. 이 상태는 push나 pop을 하기 전에 확인해야 합니다. 그리고 큐를 닫으면 모든 대기자들이 블록 해제됩니다.
4단계: 버퍼 추가
이 큐는 우리의 목적에는 충분합니다. 하지만 다른 사용 사례에서는 큐가 생산자를 블록시키지 않고 제한된 양의 항목을 버퍼링해야 할 수도 있습니다.
이것은 독자의 연습 문제로 남겨둡니다.
WebSocket 서버
1단계: 메시지 인터페이스 설계
WebSocket 데이터 메시지는 WSMsg 타입으로 애플리케이션에 표현됩니다. 페이로드 데이터는 BodyReader 인터페이스처럼 read() 함수를 사용하여 소비(consume)함으로써, 임의의 크기를 가진 메시지와 데이터의 실시간 수신을 지원합니다.
read() 함수는 전체 메시지를 메모리에 저장할 필요가 없게 해주지만, 단점도 있습니다. 애플리케이션은 메시지 데이터가 반드시 소비되도록 보장해야 하며, 그렇지 않으면 다음 프레임이 파싱되지 않을 것입니다.
2단계: 서버 애플리케이션 인터페이스 설계
WSServer는 데이터 메시지를 보내고 받기 위한 애플리케이션 인터페이스입니다.
이것은 소켓에서 읽고 쓰는 것과 유사합니다. 차이점은 동시적인 읽기와 쓰기가 유효한 사용 사례를 가지며 반드시 지원되어야 한다는 것입니다.
동시적 쓰기: 여러 앱 태스크가 독립적으로 메시지를 생성함동시적 읽기: 태스크 풀(task pool)을 이용한 동시적 메시지 처리
메시지를 능동적으로 읽는 대신 콜백을 사용하여 전달할 수도 있습니다. 하지만 이 방식은 pause()와 resume() 메서드도 필요하게 되므로 역압력(backpressure)을 덜 명확하게 만듭니다. 이것이 우리가 초기에 콜백 기반 I/O를 버린 이유입니다.
3단계: 서버 태스크 설계
요청-응답(request-response) 모델과 달리, 보내기와 받기는 독립적입니다. 따라서 다음이 필요합니다.
- 들어오는 바이트 스트림을 파싱하는 태스크:
wsServerRecv() - 소켓에 메시지를 쓰는 태스크:
wsServerSend()
이 두 태스크는 애플리케이션 태스크와 동시에 실행됩니다. 두 개의 큐가 이 두 태스크를 애플리케이션(송신 및 수신)에 연결하는 데 사용됩니다. 또 다른 큐는 wsServerSend()를 소켓에 연결하는 데 사용됩니다.
단일 WebSocket으로 동시에 여러 앱 태스크가 생산하거나 소비할 수 있지만, 소켓에 읽고 쓰는 태스크는 순차적이어야 합니다. 이것이 데이터를 전달함으로써 동시성 문제를 해결하는 예입니다.
4단계: 서버 태스크 시작
이전 단계를 코드로 작성해 봅시다.
function createWSServer(reqBody: BodyReader): [WSServer, BodyReader] {
// WS API
const qrecv: Queue<WSMsg> = createQueue<WSMsg>();
const qsend: Queue<WSMsg> = createQueue<WSMsg>();
const ws: WSServer = {
send: qsend.pushBack, // throws if closed
recv: qrecv.popFront, // returns null if closed
close: (): void => {
qsend.close(); // generates a WS_CTRL_CLOSE
qrecv.close();
},
};
// task 1: reading from the socket
wsServerRecv(reqBody, qrecv, ws)
.finally(ws.close) // closes the WS API
.catch(console.error); // no await
// task 2: writing to the socket
const qsock: Queue<Buffer> = createQueue<Buffer>();
wsServerSend(qsend, qsock)
.finally(ws.close) // closes the WS API
.finally(qsock.close) // closes the socket
.catch(console.error); // no await
const resBody: BodyReader = {
length: -1,
read: async () => await qsock.popFront() || Buffer.from(''),
};
return [ws, resBody];
} 프로미스에 등록된 finally() 콜백은 try-finally 블록처럼 동작합니다. 이는 태스크가 큐에서 영원히 대기하지 않도록 큐를 닫는 데 사용됩니다.
async함수를await하지 않으면,catch()콜백으로 예외를 잡는 것이 매우 중요합니다. 그렇지 않으면 프로그램이 충돌할 것입니다. 이 경우에는 단순히 예외를 기록하고 무시하지만, 때로는 에러를 전파해야 할 필요가 있습니다.
5단계: 소켓에 메시지 쓰기
wsServerSend() 태스크는 큐에서 WSMsg 입력을 받아 qsock 큐로 데이터를 출력하며, 이 데이터는 BodyReader에 의해 가져옵니다. 프레임 코드는 생략되었습니다.
// format WS messages and send them to the socket
async function wsServerSend(
qsend: Queue<WSMsg>, qsock: Queue<Buffer>): Promise<void>
{
while (true) {
const msg = await qsend.popFront();
if (!msg) { // close it
// omitted. send a "close" frame ...
break;
}
// omitted. write frame data to `qsock` ...
}
} 이것이 유닉스 파이프를 사용하는 것과 얼마나 유사한지 주목하십시오:
- 입력과 출력을 위한 통일된 인터페이스.
- 조합 가능(Composable).
6단계: 소켓에서 프레임 파싱
wsServerRecv()는 각 프레임에 대해 루프를 돌며 WSMsg를 출력 큐로 공급합니다.
WSMsg의 read() 함수에 프레임 데이터를 보내기 위해 또 다른 큐를 사용할 것입니다.
데이터 메시지는 여러 프레임에 걸쳐 있을 수 있으므로, 루프 외부에서 큐를 저장하고 더 많은 프레임이 도착함에 따라 큐에 데이터를 공급해야 합니다. FIN 플래그가 설정되거나 루프를 빠져나갈 때 큐가 닫히도록 해야 합니다.
7단계: HTTP 서버와 통합
요청은 WebSocket 또는 HTTP 요청으로 처리됩니다. 이는 getWSApp()에서 결정되며, 이 함수는 애플리케이션 로직 함수 또는 null을 반환합니다.
WebSocket은 다르게 처리됩니다.
- 요청은
handleWS()에 의해 처리되며, 이 함수는 애플리케이션 함수를 호출함 - 나머지 소켓 데이터는
readerFromConnEOF()함수에 의해 추가 처리 없이 프로토콜로 전달됨 - 응답 데이터는 소켓에 직접 쓰여짐
따라서 응답 압축, 추가 헤더 필드, 청크 인코딩(chunked encoding)과 섞이지 않도록 주의해야 합니다.
// process the message and send the response
let reqBody: BodyReader;
let res: HTTPRes;
const wsapp: null|WSApplication = getWSApp(msg);
if (wsapp) { // upgrade to WebSocket
reqBody = readerFromConnEOF(conn, buf);
res = await handleWS(msg, reqBody, wsapp);
} else { // normal HTTP connection
reqBody = readerFromReq(conn, buf, msg);
res = await handleReq(msg, reqBody);
}
try {
if (!wsapp) {
enableCompression(msg, res);
}
await writeHTTPHeader(conn, res);
if (msg.method !== 'HEAD') { // omit the body
await writeHTTPBody(conn, res.body, !!wsapp);
}
} finally {
await res.body.close?.(); // cleanups
}
// close the connection for HTTP/1.0 or WebSocket
if (msg.version === '1.0' || wsapp) { return; } handleWS() 함수는 이 모든 것을 하나로 합칩니다.
- 프로토콜을 처리하기 위한 태스크를 시작함
- 애플리케이션 태스크를 시작함
Upgrade헤더를 반환함
async function handleWS(
req: HTTPReq, reqBody: BodyReader, app: WSApplication): Promise<HTTPRes>
{
// handle the WS protocol
const [ws, resBody]: [WSServer, BodyReader] = createWSServer(reqBody);
// launch the WS appliction
app(ws).finally(ws.close).catch(console.error); // no await
// the upgrade response header
const key: Buffer = fieldGet(req.headers, 'Sec-WebSocket-Key')!;
return {
code: 101, // Switching Protocols
headers: [
Buffer.from('Upgrade: websocket'),
Buffer.from('Connection: Upgrade'),
Buffer.from(`Sec-WebSocket-Accept: ${wsKeyAccept(key)}`),
],
body: resBody,
};
} 8단계: 테스트 및 디버그
테스트를 위해 웹 브라우저를 사용하여 WebSocket을 생성하십시오. 패킷 캡처 도구를 사용하는 것을 잊지 마십시오.
Wireshark는 프로토콜을 분해할 수 있으므로 다음을 위해 사용할 수 있습니다.
- 프레임 파싱에 실패한 경우 올바른 형식이 어떤 것인지 학습합니다.
- 생성한 프레임에 무엇이 잘못되었는지 확인합니다.
논의: 브라우저에서의 WebSocket
WebSocket은 양쪽 피어(peer)의 기능 측면에서 대칭적입니다. 따라서 애플리케이션 인터페이스는 클라이언트와 서버 모두에서 사용할 수 있어야 합니다. 브라우저의 WebSocket API를 살펴보고 우리의 것과 비교해 봅시다.
브라우저의 WebSocket에는 역압력이 없음
가장 중요한 차이점은 브라우저 API가 콜백 기반이라는 것입니다. 브라우저는 메시지가 도착하면 이벤트 핸들러를 호출합니다. 이는 Node.js의 net.Socket과 유사하지만, pause()와 resume() 메서드가 없어서 수신에 대한 역압력(backpressure)이 불가능하다는 점이 다릅니다.
메시지를 보낼 때, send() 메서드는 단순히 데이터를 버퍼링하고 아무것도 반환하지 않습니다. 버퍼 크기를 알려주는 bufferedAmount 속성이 있어서 애플리케이션이 여전히 역압력을 제어할 수는 있습니다.
우리가 택한 방향인 읽기와 쓰기에 async/await를 사용하는 대안적인 API 디자인도 있습니다.
애플리케이션 레벨 역압력
역압력의 부재는 아마도 네트워크 프로그래밍에서 두 번째로 큰 실수일 것입니다. 하지만 브라우저에서 역압력이 불가능하더라도, 애플리케이션은 더 높은 수준에서 이를 구현할 수 있습니다.
- 소비자는 처리된 메시지에 대해 생산자에게 확인 응답(acknowledges)을 보냅니다.
- 생산자는 이 확인 응답을 사용하여 흐름 제어(flow control)를 구현합니다.
RSocket과 같이 WebSocket 위에서 실행되며 역압력을 구현하는 메시징 프로토콜들이 있습니다.
메시지 크기는 제한됨
분할된 프레임(fragmented frames)을 사용하여 WebSocket 메시지를 무제한 스트림처럼 취급할 수 있지만, 브라우저를 포함한 대부분의 소프트웨어는 메시지를 단일 버퍼로 저장합니다. 따라서 메시지 크기와 프레임 크기 모두에 제한이 있습니다. (우리는 read() 함수를 사용하여 실시간으로 데이터를 소비하기 때문에 이러한 제한이 없습니다).
WebSocket을 통해 큰 데이터 덩어리를 보내는 애플리케이션은 메시지 크기를 작게 유지하기 위한 분할 메커니즘을 가져야 합니다.
결론: 우리가 배운 것
기본적인 HTTP 서버 이후 다룬 중요한 주제들은 다음과 같습니다.
HTTP 시맨틱스Content-Length와 청크 전송 인코딩- 범위 요청(Range requests)
- 캐싱, 압축
- 소유권(ownership)에 기반한 수동 리소스 관리
- 버퍼 사용 기술
생산자-소비자 문제와 역압력을 위한 추상화- 제너레이터(Generators)
- 스트림(Streams)
- 블로킹 큐(Blocking queues)
생산자-소비자 문제에 대한 해결책들은 다음과 같습니다.
| 블로킹 큐 (Blocking Queue) | 스트림 API (Stream API) | 비동기 제너레이터 (Async generator) | |
|---|---|---|---|
생산자 |
await q.pushBack(x) |
this.push(x) |
yield x; |
소비자 |
await q.popFront() |
'data' 이벤트 |
await g.next() |
다중 생산자 |
예 | 예 | 아니요 |
역압력 |
자동 | 수동 | 자동 |