Cloud & Databases

PostgreSQL 쿼리 생명주기: 백엔드 프로세스 내부 파헤치기

PostgreSQL에 쿼리를 보낼 때 내부적으로 어떤 일이 벌어지는지 궁금하신 적 있으신가요? 단순한 코드 실행이 아니라, 정교하게 조율된 프로세스와 신호들의 춤이 펼쳐집니다.

PostgreSQL 백엔드 프로세스 내 쿼리 처리 단계를 보여주는 다이어그램.

Key Takeaways

  • PostgreSQL은 연결당 하나의 OS 프로세스 아키텍처를 사용합니다.
  • 백엔드 프로세스는 쿼리를 처리하기 전에 시그널 핸들러와 트랜잭션 시스템을 초기화합니다.
  • 핵심 쿼리 처리는 무한 루프 내에서 발생하며, 메시지 타입을 기반으로 디스패치됩니다.
  • 단순('Q') 및 확장('P','B','E','S') 쿼리 프로토콜 간의 분기는 반복 작업의 성능에 상당한 영향을 미칩니다.

서버실의 웅웅거림은 희미해지고, 단 한 번의 키 입력이 PostgreSQL 백엔드 내 복잡한 교향곡을 촉발합니다. 우리는 매번 당연하게 여기는 순간이지만, 반환되는 행 하나하나 뒤에는 연산의 작은 우주가 펼쳐집니다.

이것은 단순한 코드가 아닙니다. 데이터에 접근 가능하고, 관리 가능하며, 궁극적으로 유용한 상태로 만드는 근본적인 아키텍처를 이해하는 것입니다. 클라이언트가 연결하는 순간부터 데이터의 마지막 바이트가 반환될 때까지 PostgreSQL 쿼리 생명주기의 경로를 추적하며 그 이면을 파헤쳐 보겠습니다. 모든 쿼리가 데이터베이스 엔진의 미로를 어떻게 탐색하는지에 대한 내부 청사진이라고 생각하시면 됩니다.

백엔드의 탄생: 모든 것을 지배하는 단 하나의 프로세스

클라이언트가 PostgreSQL과 대화를 시작할 때, 단순히 예의 바른 고갯짓만 받는 것이 아닙니다. 아닙니다, 전용 백엔드 프로세스를 받게 됩니다. 이것은 매우 중요한 차이점입니다. 여러 개의 스레드를 사용하여 단일 프로세스 내에서 여러 클라이언트 요청을 처리하는 스레드 풀 모델을 사용하는 다른 많은 관계형 데이터베이스 시스템과 달리, PostgreSQL은 연결당 하나의 OS 프로세스를 선택합니다. 이 결정은 나중에 살펴보겠지만, 안정성과 리소스 관리에 엄청난 영향을 미칩니다. 이 전용 프로세스는 연결이 끊어질 때까지 해당 클라이언트의 쿼리를 전체 수명 주기 동안 유일하게 관리하는 역할을 합니다.

그리고 이 전담 수호자의 여정은 어디에서 시작될까요? PostgresMain이라는, 다소 극적인 이름이 붙여진 함수에서 시작됩니다. 하지만 그 웅장함에 속지 마세요. 초기 작업은 놀랍도록 간결합니다. 두 가지 기본적인 단계를 거치면 바로 시작됩니다.

첫째, 모든 것은 안전과 응답성에 관한 것입니다: 시그널 핸들러 설치. 시그널은 OS가 프로세스의 어깨를 툭 치며 긴급 메시지를 보내는 방식이라고 상상해 보세요. ‘우아하게 종료할 시간입니다’ (SIGTERM), 또는 ‘다른 백엔드와 통신해야 합니다’ (SIGUSR1). 각 백엔드 프로세스는 이러한 비동기 알림에 적절하게 반응하도록 연결되어야 하며, 이는 부모 postmaster 및 다른 프로세스와의 원활한 통신을 보장합니다. 이 시그널 관리는 나중에 더 자세히 탐구할 주제인 안정적인 프로세스 간 통신을 위한 발판을 마련하는 기초입니다.

둘째, 그리고 똑같이 중요한 것은 트랜잭션 시스템 초기화입니다. 여기서 잠시 생각할 부분이 있습니다. PostgreSQL에서 명시적으로 BEGIN을 입력했는지 여부에 관계없이 실행되는 모든 SQL 문은 본질적으로 트랜잭션의 일부입니다. 이 정교한 시스템은 PostgreSQL의 데이터 무결성의 심장부이며, 트랜잭션 경계(BEGIN/COMMIT)를 꼼꼼하게 추적하고, 가시성을 위해 다중 버전 동시성 제어(MVCC)를 관리하며, 트랜잭션 ID(XID)를 할당합니다. 백엔드가 단일 SQL 문을 ‘보기’도 전에, 이 기계 장치가 윙윙거리며 데이터의 원자성 및 일관성을 관리할 준비를 합니다.

쿼리 처리의 무한 루프

이러한 필수 준비가 완료되면 백엔드 프로세스는 주요 지시 사항인 무한 루프에 진입합니다. 이곳에서 마법, 아니면 끊임없는 엔지니어링이 일어납니다.

for (;;) {
...
ReadyForQuery(whereToSendOutput);
...
firstchar = ReadCommand(&input_message);
...
switch (firstchar) {
case PqMsg_Query: // 'Q', 단순 쿼리
 exec_simple_query(query_string);
 break;
case PqMsg_Parse: // 'P', 확장: 파싱
 exec_parse_message(...);
 break;
case PqMsg_Bind: // 'B', 확장: 바인딩
 exec_bind_message(&input_message);
 break;
case PqMsg_Execute: // 'E', 확장: 실행
 exec_execute_message(portal_name, max_rows);
 break;
case PqMsg_Sync: // 'S', 확장 주기 종료
 finish_xact_command();
 send_ready_for_query = true;
 break;
...
}
}

이 루프는 설명은 간단하지만, “준비 완료 알림, 메시지 하나 읽기, 타입에 따라 분기”하는 것이 백엔드 프로세스의 전체 존재 이유입니다. 클라이언트가 'X' (종료) 메시지를 보내 이 루프가 종료되고 프로세스가 소멸될 때까지 반복됩니다.

갈림길: 단순 쿼리 프로토콜 vs. 확장 쿼리 프로토콜

그리고 바로 이 루프의 심장부에 첫 번째 중요한 분기점, 갈림길이 있습니다. 들어오는 메시지의 firstchar를 기반으로 하는 switch 문은 PostgreSQL이 쿼리를 처리하는 방식의 근본적인 분할을 보여줍니다. 이것은 'Q' 경로 – 단순 쿼리 프로토콜 – 와 'P' / 'B' / 'E' 경로 – 확장 쿼리 프로토콜 – 의 분기입니다.

단순 쿼리는 말 그대로 단순합니다. 전체 SQL 명령이 단일 메시지에 캡슐화됩니다. psql 클라이언트에 SELECT 1;을 입력하고 Enter를 누르면 네트워크를 통해 정확히 그렇게 전송됩니다. 백엔드는 이 하나의 메시지를 수신하고, 5단계 쿼리 주기(파싱, 분석/재작성, 계획, 포털, 실행)를 충실히 거친 후 결과를 보냅니다. 직관적이고 꾸밈없는 접근 방식입니다.

하지만 확장 쿼리는 더 미묘하고 종종 더 효율적인 비즈니스 처리 방식을 제공합니다. 동일한 최종 목표를 달성하지만, 프로세스를 4개의 개별 메시지로 나눕니다. 핵심은 준비된 문(prepared statement)입니다. 준비된 문은 데이터베이스에서 이미 파싱 및 분석된 SQL 템플릿입니다. 값이 나중에 삽입될 민감한 부분은 $1 또는 $2와 같은 플레이스홀더로 표시됩니다. 실행 시점에는 실제 값만 전송됩니다.

INSERT INTO users (id, name) VALUES ($1, $2) 문을 생각해 보세요. 일단 준비되면, (1, 'Alice')를 삽입한 다음 (2, 'Bob')을 삽입하는 등 다른 값으로 반복적으로 실행할 수 있습니다. 핵심은 매번 삽입할 때마다 전체 SQL 텍스트를 다시 파싱하고 분석할 필요가 없다는 것입니다. 이 준비된 문에 이름을 지정하면, 세션의 나머지 기간 동안 해당 이름으로 쉽게 사용할 수 있는 이름이 지정된 준비된 문이 됩니다. 이것이 특히 반복적인 작업에서 성능 향상이 실현되는 지점입니다.

확장 쿼리 메시지의 4단계

확장 프로토콜을 구성하는 네 가지 메시지는 단순히 전송을 위해 분해된 일반적인 쿼리 주기의 단계입니다.

  • 'P' Parse: SQL 템플릿을 수신하고, 파싱 및 분석을 완료하며, 나중에 사용할 준비된 문을 저장합니다.
  • 'B' Bind: 준비된 문에 특정 매개변수 값을 연결하고 포털이라는 임시 실행 계획을 생성합니다.
  • 'E' Execute: 바인딩된 매개변수로 포털을 실행하고 결과 행을 클라이언트로 다시 전송합니다.
  • 'S' Sync: 확장 쿼리 주기의 끝을 표시하여 클라이언트와 서버가 동기화되었는지 확인하고 다음 쿼리에 대한 준비 상태를 알립니다.

이러한 단계적 접근 방식은 동일한 준비된 문을 사용하지만 다른 매개변수로 'B' 다음에 'E'를 여러 번 수행할 수 있음을 의미합니다. 데이터베이스에 사용자 천 명을 삽입하는 것을 상상해 보세요.

# 드라이버 의사 코드: 준비된 문을 통한 1000번의 INSERT
stmt = conn.prepare("INSERT INTO users (id, name) VALUES ($1, $2)")
for i in range(1000):
    stmt.execute(i, f"user{i}")

이 시나리오에서 conn.prepare(...)는 단일 'P' 메시지에 해당합니다. 파싱 및 계획의 핵심 작업은 단 한 번만 수행됩니다. 각 후속 stmt.execute(...) 호출은 'B' + 'E' 메시지 쌍으로 변환됩니다. 파싱 및 계획 오버헤드는 한 번만 발생하며, 1000개의 개별 단순 쿼리를 보내는 것에 비해 대량 작업의 계산 비용을 크게 줄입니다.

개발자와 관리자에게 중요한 이유

이 내부 아키텍처를 이해하는 것은 데이터베이스 애호가를 위한 학술적인 연습에 불과하지 않습니다. 개발자에게는 단순 쿼리 프로토콜과 확장 쿼리 프로토콜 간의 선택이 성능에 미치는 영향을 조명합니다. 예를 들어, 매개변수가 다른 반복적인 작업은 준비된 문에 이상적인 후보이며, 확실히 더 빠른 실행으로 이어집니다. 데이터베이스 관리자에게는 각 연결이 전용 OS 프로세스를 생성한다는 것을 알면 애플리케이션 수준의 연결 풀링이 서버 리소스를 효과적으로 관리하고 프로세스 수를 폭주시키는 것을 방지하는 데 중요함을 강조합니다.

쿼리 생명주기에 대한 이 심층 분석은 PostgreSQL을 단순한 데이터 저장 솔루션이 아니라, 각 구성 요소가 속도와 무결성으로 데이터를 제공하는 데 중요한 역할을 하는 미세 조정된 엔진으로 보여줍니다. 이는 쿼리가 처리되는 근본적인 방식조차 효율성과 확장성을 위해 구축되었다는 사려 깊은 설계의 증거입니다.


🧬 관련 인사이트

자주 묻는 질문

PostgreSQL에서 백엔드 프로세스란 무엇인가요?

백엔드 프로세스는 PostgreSQL이 활성 클라이언트 연결마다 생성하는 전용 운영 체제 프로세스입니다. 이 프로세스는 연결이 닫힐 때까지 해당 특정 클라이언트의 모든 쿼리를 처리합니다.

단순 쿼리 프로토콜과 확장 쿼리 프로토콜의 차이점은 무엇인가요?

단순 쿼리는 전체 SQL 명령을 하나의 메시지로 전송하며, 각 실행마다 파싱 및 계획이 수행됩니다. 확장 쿼리는 준비된 문을 사용하여 ‘Parse’, ‘Bind’, ‘Execute’ 메시지로 프로세스를 분해하여, 반복적인 작업에 대해 파싱 및 계획을 한 번만 수행할 수 있도록 합니다.

준비된 문을 사용하는 것이 항상 단순 쿼리보다 더 나은가요?

매개변수가 다른 쿼리가 여러 번 실행되는 경우, 파싱 및 계획 오버헤드가 한 번만 발생하므로 준비된 문(확장 프로토콜)이 일반적으로 더 효율적입니다. 애드혹, 단일 실행 쿼리의 경우, 준비된 문을 설정하는 오버헤드가 이점보다 클 수 있습니다.

Written by
Open Source Beat Editorial Team

Curated insights, explainers, and analysis from the editorial team.

Worth sharing?

Get the best Open Source stories of the week in your inbox — no noise, no spam.

Originally reported by Dev.to