DevOps/백엔드

Request가 실질적으로 처리되기까지의 여정

검정비니 2023. 10. 31. 13:29
728x90
반응형

백엔드에 요청을 보낼 때 대부분의 경우 요청의 처리 측면에 집중하는데, 이는 실제로는 마지막 단계에 불과하다.

요청이 처리될 준비가 되기까지 훨씬 더 많은 일이 일어나게 된다.

이를 6단계로 나누면 각 단계는 이론적으로 전용 스레드나 프로세스에 의해 실행될 수 있다.

 

거의 모든 백엔드, 웹 서버, 프록시, 프레임워크, 심지어 데이터베이스도 이 모든 단계를 수행해야 하며, 각 단계마다 다른 방식으로 수행한다.

 

1. Accept

요청은 종종 연결(TCP/QUIC)을 통해 전송되며 백엔드에서 연결을 수락해야 한다. 클라이언트가 포트 443에서 서버에 연결하면 서버 OS 커널에 의해 3방향 핸드셰이크가 완료되고 연결이 수신기 대기열에 배치되게 되는데, 이 대기열을 수락 대기열(accept queue)이라고 부른다. 백엔드 애플리케이션은 수신기 소켓에서 syscall accept()를 호출하여 연결을 나타내는 파일 설명자를 생성한다.

 

백엔드의 연결 accept 속도가 느리면 이 단계가 서버의 병목이 될 수 있다. 또한, 연결 백로그가 많아지면 결국 대기열이 가득 차서 새로운 연결이 실패하게 된다.

 

참고로, 파이썬에서 gunicorn의 경우 기본적으로 이 백로그 값이 커널 파라미터인 `tcp_max_syn_backlog`값을 사용하게 되기 때문에 커널 파라미터 튜닝으로 성능 개선이 가능하다. 또한, tcp_max_syn_backlog 값은 net.core.somaxconn 값보다 작아야만 하기 때문에 현재 플랫폼에서 지원하는 최대 값을 미리 확인해야만 한다. 아래는 net.core.somaxconn 값을 확인하고, 백로그 값을 확인한 후, 새로운 백로그 값으로 1024를 설정하는 예시 코드이다.

# SOMAXCONN값 확인하기 (Ubuntu 22.04 LTS 기준 4096)
sysctl net.core.somaxconn

# 현재 백로그 값 확인하기
sysctl net.ipv4.tcp_max_syn_backlog

# 백로그 값을 1024로 설정하기
sudo sysctl -w net.ipv4.tcp_max_syn_backlog=1024

연결 수락 속도를 높이기 위해 대부분의 백엔드는 연결 수락에만 하나의 스레드를 할당한다. 단일 스레드가 여러 스레드를 따라잡을 수 없는 경우 여러 스레드가 연결 수락을 시작할 수 있지만 동일한 소켓에서 수락할 때 스레드가 서로를 차단하므로 병목 현상이 발생하게 된다.

 

이 경우 SO_REUSEPORT 옵션을 사용하여 소켓 대기열을 소유하는 각 스레드/프로세스와 함께 동일한 포트에 여러 리스너 소켓(따라서 여러 수신 대기열)을 생성할 수 있다. SO_REUSEPORT 옵션은 여러 서버(복수의 서버 스레드 혹은 복수의 서버 프로세스)가 동시에 동일한 포트에 리스너 소켓을 생성할 수 있도록 만들어준다. 이 옵션은 현재 NGINX, HAPROXY와 같은 대부분의 백엔드에서 기본 옵션이다.

 

2. Read

연결이 설정되면 클라이언트는 백엔드에 요청을 보낼 수 있다. Request는 실제로는 사용되는 프로토콜에 의해 정의된 명확한 시작과 끝이 있는 일련의 바이트에 불과하다. 이때, 클라이언트와 백엔드는 가장 일반적인 프로토콜인 HTTP에 동의해야 한다.

 

만약 TLS가 설정되어 있다면, 클라이언트는 먼저 동의된 암호화 방식으로 데이터를 암호화하고, 압축 관련 설정에 따라 요청의 본문을 압축한 뒤, 데이터 유형(JSON/프로토버프 등)을 on-wire representation으로 직렬화합니다. 즉, 네트워크를 통해 전송할 수 있는 이진 포맷으로 직렬화한다. 그런 다음 마지막으로 원시 바이트를 네트워크 바이트 순서대로 connection을 통해 전송하게 된다.

 

백엔드(서버) 측면에서 보게 되면, 전송된 원시 바이트는 NIC에서 OS 커널에 도달하여 커널에서 관리하는 연결 수신 대기열(connection receive queue)로 이동하게 된다.

백엔드 애플리케이션이 read() 또는 rcv() syscall을 호출하여 수신 대기열에서 백엔드 프로세스 사용자 공간 메모리로 데이터를 이동할 때까지 패킷은 대기열 내에서 대기하게 된다. 여기서 중요한 점은, 이 읽어들이는 데이터가 단일 요청에 의한 것인지 아니면 복수의 요청에 의한 것인지는 아직은 알 수가 없다는 점이다.

 

3. Decrypt

이제 백엔드 프로세스 메모리에 원시 바이트가 있고 암호화되어 있다는 것을 알았으므로 코드가 연결된 SSL 라이브러리(OpenSSL이든 다른 것이든)를 호출하여 콘텐츠를 복호화하여 이해할 수 있도록 한다. 콘텐츠를 복호화하여 헤더와 기타 메타데이터를 확인하기 전까지는 어떤 요청도 볼 수 없고 프로토콜의 경계도 알 수 없다는 점을 주의해야 한다. 프로토콜은 HTTP/1.1, HTTP/2, 심지어 SSH일 수도 있다. 복호화는 CPU에 종속된 작업으로, 자체 스레드에서 수행하거나 읽기 및 수락과 같은 스레드에서 수행될 수 있다.

 

4. Parse

이제 일반 텍스트로 읽을 수 있는 바이트가 있으므로 합의된 프로토콜에 대한 지식을 사용하여 요청을 구문 분석할 수 있으며, 우리가 읽은 바이트 덩어리에는 전체 요청이 있을 수도 있고 없을 수도 있다.

혹은, 요청의 본문은 없고 프로토콜 시스템 헤더만 있을 수도 있다(예: h2의 SETTINGS 프레임).

 

이때 프로토콜을 기반으로 구문 분석을 수행하기 위해 선택한 라이브러리가 작동하며, HTTP/1.1인 경우 사용한 라이브러리는 일반 텍스트를 읽고 HTTP 사양의 정의에 따라 요청의 시작과 끝을 찾게 된다. 예를 들어 콘텐츠 길이 또는 전송 인코딩을 사용한다. 바이너리 프로토콜과 관련된 메타데이터가 훨씬 더 많기 때문에 이를 구문 분석하는 데 훨씬 더 많은 작업이 필요하지만, HTTP/2 또는 HTTP/3 라이브러리인 경우에도 동일한 방식이 적용됩니다.

 

구문 분석에는 CPU 사이클이 필요하며 특히 http2 및 http3의 경우 백엔드에 부담을 줄 수 있다는 점을 명심해야한다. 단순히 밴치마크 상으로 더 빠르다는 이유만으로 무지성적으로 HTTP/2나 HTTP3를 도입하게 되면 오히려 예상치 못한 이슈들을 많이 마주하게 될 수도 있다.

 

일단 바이트열을 파싱하고 요청을 찾으면 거의 준비가 완료되었다고 볼 수 있다 (이 때, 프로토콜 구문 분석은 자체 스레드에서 수행하거나 다른 스레드와 동일한 스레드에서 수행할 수 있다).

 

5. Decode

이 단계에서는 요청에 대한 추가 작업이 필요하다. JSON 또는 프로토버프를 사용하는 요청은 이 단계에서 선택한 언어에 따라 객체로 역직렬화할 수 있다. 원시 바이트를 자체 비용과 메모리 사용량이 있는 언어 구조로 변환하게 된다.

이는 UTF8로 인코딩된 텍스트를 나타내는 바이트에도 적용된다.

콘텐츠가 해당 형식임을 알고 있는 경우 원시 바이트를 UTF8로 디코딩해야 하며, 그렇지 않으면 UTF8은 일부 문자를 표현하는 데 최대 4바이트를 사용하므로 뒤죽박죽인 형태로 디코딩하게 된다. (당연하지만, ASCII의 20바이트는 UTF8의 20바이트와 다르게 보일 수 있다)

 

디코딩의 또 다른 단계는 요청 압축 해제이며, 드물지만 POST로 전송된 대용량 요청 본문이 압축될 수도 있다. 요청을 처리하기 전에 본문의 압축을 풀어야 요청에 포함된 내용을 확인할 수 있다.

 

6. Process

요청의 분석 및 디코딩이 완료되면 실질적인 처리 작업이 시작된다. 바로 이 부분에서 진행되는 것이 WAS의 로직 처리 등이다.

 

보다시피, 실제로 우리가 코딩하는 WAS(Web Application Server)보다 그 앞단에서 진행되는 일들이 더욱 복잡하고 다양하다. 이것이 DevOps가 중요한 이유이며, 많은 테크 블로그들에서 네트워크 문제 해결을 위해 WAS에 코딩을 더 하는 것이 아니라 리눅스 커널 파라미터의 튜닝을 통해 문제를 해결하는 이유라고 할 수 있다.

물론 WAS 코딩 자체도 매우 중요하지만, 그렇다고 CS 전공지식 및 OS 커널 관련 공부를 게을리해도 된다는 것은 아닐 것이다.

 

반응형