Intro
프론트엔드에서 백엔드로 요청을 보낼 때, 대부분은 처리과정에 집중하는데 사실 이것은 마지막 단계일 뿐이다.
요청이 처리 준비가 되기 전까지 아주 많은 일들이 일어나는데 이 단계를 6단계로 분리할 수 있다.
1-수락
요청은 연결을 하기 위해서 TCP, QUIC 중 하나를 요청하게 되며, 백엔드에서 수락을 하게 되면 연결이 맺어지게 된다.
클라이언트에서 443 포트를 통해 서버에 연결할 때, 서버 OS 커널을 통해서 3-way handshake가 완료가 되고 연결은 리스너 대기열에 배치가 된다. 대기열을 수락 Queue라고 한다.
백엔드 애플리케이션은 리스너 소켓에서 syscall accpet()을 호출하여 연결을 나타내는 파일 디스크립터를 생성하는 책임을 진다
이 단계에서 백엔드가 연결을 수락하는 것이 느린 경우에 병목지점이 될 수 있다. 이로 인해 백로그가 크게 쌓여서 결국 대기열이 가득 차게 되어 새로운 연결이 안 될 수 있다.
포트에서 수신 대기를 할 때, 수락 대기열의 크기를 지정할 수 있는데, 이 매개변수를 백로그라고 한다.
Node.js에서의 예시로, 연결 수락 속도를 높이기 위해 연결 수락을 위한 전용 스레드를 가진다. 하나의 스레드로 처리할 수 없을 경우, 멀티 스레드가 연결을 수락하도록 할 수도 있다. 그러나 이럴 경우 스레드가 동일한 소켓에서 수락하면 다른 스레드를 블록시키는 또다른 병목 현상이 발생한다.
이때, SO_REUSEPORT 옵션을 사용하여 동일한 포트에서 여러 리스너 소켓(즉 여러 수락 대기열)을 생성하고, 각 스레드/프로세스가 소켓 대기열을 가지도록 할 수 있다. 이는 Nginx에서 기본 옵션으로 사용된다.
2-읽기
연결이 맺어지게 되면 클라이언트는 백엔드로 요청을 보낼 수 있다. 요청은 사용되는 프로토콜에 의해 정의된 명확한 시작과 끝을 가진 일련의 바이트들이다. 여기서 클라이언트와 백엔드는 반드시 같은 프로토콜을 써야 하는데 HTTP가 가장 일반적인 프로토콜이다.
HTTP 프토콜도 버전에 따라 완전히 다른 전송 방식을 가지므로 파싱에 추가적인 비용이 발생되기도 한다.
클라이언트는 요청을 암호화(연결에 TLS를 사용할 경우), 본문을 압축하고 데이터 타입(JSON/protobuff 등)을 on-wire 표현으로 직렬화한다. 그런 다음 마지막으로 원시 바이트를 네트워크 바이트 순서로 연결에 기록한다.
이러한 원시 바이트는 NIC에서 운영 체제 커널로 전달되고 커널이 관리하는 연결 수신 대기열로 들어간다.
패킷은 백엔드 애플리케이션이 read() 또는 rcv() syscall을 적용할 때까지 대기하고 있으며 백엔드 프로세스의 사용자 공간 메모리로 이동한다.
이곳에서 읽히는 것은 암호화되고 인코딩된 원시 바이트이므로 여기에는 요청이 아닌 단순히 바이트만 있다. 읽은 바이트는 10개의 요청 또는 요청의 절반일 수도 있다.
읽기 작업은 해당하는 스레드에서 수행될 수도 있고, acceptor와 동일한 스레드에서 수행될 수도 있다
3-복호화
이제 백엔드 프로세스 메모리에 암호화된 원시 바이트를 갖고 있으며, 이를 이해하기 위해 코드와 연결된 SSL 라이브러리를 호출하여 내용을 복호화한다.
복호화를 해서 헤더와 기타 메타데이터를 보기 전에는 어떠한 요청인지 또는 프로토콜의 범위는 얼마인지 등을 전혀 알 수 없다. 이는 HTTP/1.1, HTTP/2 또는 심지어 SSH 일 수도 있다.
복호화 작업은 CPU에 바인딩되는 작업이며, 별도의 스레드에서 수행하거나 읽기 및 수락과 동일한 스레드에서 수행할 수 있다.
4-파싱
평문으로 읽을 수 있는 바이트를 가지게 되었으므로 협의된 프로토콜에 대한 지식을 활용하여 요청을 파싱할 수 있다. 읽어들인 바이트 조각은 완전한 요청일 수도 있고 아닐 수도 있다. 어쩌면 아무런 요청이 아닐 수도 있다.
선택한 프로토콜에 기반하여 파싱을 수행하는데 HTTP/1.1이라면 사용한 라이브러리는 평문을 읽고 HTTP 사양의 정의에 따라 요청의 시작과 끝을 찾을 것이다. 예를 들어, content-length 또는 전송 인코딩과 같은 것들을 기준으로 한다.
HTTP/2나 HTTP/3의 경우도 동일한 원리가 적용되는데 바이너리 프로토콜과 관련된 메타데이터가 훨씬 더 많기 때문에 파싱하는데 더 많은 작업을 필요로 한다
파싱은 CPU 사이클을 소모하고 특히 HTTP2와 HTTP3에 대해서는 백엔드에 부담을 줄 수도 있다. 이것은 Lucid Chart가 어렵게 알아낸 사실이다.
5-디코더
이 단계는 요청에 대한 추가 작업이 필요한 곳이다. JSON이나 protobuf를 사용하는 요청은 선택한 언어에 기반한 개체로 역직렬화될 수 있다. 원시 바이트를 언어 구조체로 변환하여 별도의 비용과 메모리 용량이 발생한다.
JavaScript에서도 JSON 문자열을 사용할 수 없으며, 이를 위해서는 JSON.parse()를 호출해야 한다. 심지어 express와 같은 라이브러리에서 자동으로 처리를 해주더라도 이 과정은 무료로 이루어지는 것이 아니다.
6-처리 과정
마지막으로 우리가 요청을 이해했다면 이제 그것이 데이터베이스에 대한 요청이나 디스크에서의 읽기, 비용이 많이 드는 계산이 되었든 실제로 수행한다. 이 단계는 동일한 스레드에서 수행될 수 있지만, 처리를 위해 전용 워커를 갖는 것이 권장된다. 이때 워커 풀 패턴이 잘 동작한다
'Backend > Backend Engineering' 카테고리의 다른 글
| MSA 공부하기 전에 기본 예습하기 using NestJS (7) | 2024.11.08 |
|---|---|
| Nest.js를 활용하여 Login, AuthGuard 구현하기 (0) | 2024.10.26 |