본문 바로가기

데이터베이스/Postgres

[PostgreSQL] 프로세스 아키텍처 & 공유 메모리

PostgreSQL은 프로세스인가

많은 현대 데이터베이스는 멀티스레드 모델을 채택한다. MySQL의 InnoDB, MongoDB, SQL Server 모두 하나의 프로세스 안에서 여러 스레드가 클라이언트 요청을 처리한다. 그런데 PostgreSQL은 정반대다. 클라이언트가 접속할 때마다 OS 수준의 새로운 프로세스fork()되어 그 연결을 전담한다.

 

이 설계는 1986년 UC Berkeley에서 시작된 POSTGRES 프로젝트의 유산이다. 당시는 POSIX 스레드 표준이 정립되기 전이었고, UNIX 계열 시스템 간에 스레드 API가 통일되어 있지 않았다. 가장 이식성 높은 동시성 단위는 fork() 한 번으로 복제되는 프로세스뿐이었다.

 

하지만 30년이 지난 지금까지 PostgreSQL이 프로세스 모델을 유지하는 것은 단순한 레거시가 아니다. 이 모델은 세 가지 실질적 이점을 준다.

  • 첫째, 프로세스 격리다. 한 백엔드에서 일어난 메모리 손상은 그 프로세스의 주소 공간에 갇힌다. OS가 SIGSEGV로 해당 프로세스만 죽이면, 다른 연결은 그대로 살아 있다. 스레드 모델에서는 한 스레드의 stray pointer가 전체 서버의 메모리를 오염시킬 수 있다. PostgreSQL이 ereport(PANIC) 대신 ereport(FATAL) 로 해당 연결만 종료하고 넘어갈 수 있는 것도 이 격리 덕분이다.

  • 둘째, OS 자원 관리의 위임이다. 각 백엔드의 스택, 힙, 파일 디스크립터는 OS가 직접 관리한다. 메모리 누수 한 건이 연결이 끊어질 때 OS에 의해 자동 정리된다. PostgreSQL은 MemoryContext라는 자체 메모리 관리 계층을 가지고 있지만, 최악의 경우 프로세스 종료가 모든 것을 되돌린다는 안전망이 있어 내부 메모리 관리자를 세밀하게 만들 필요가 없다

  • 셋째, NUMA 친화성이다. 현대 서버는 NUMA(Non-Uniform Memory Access) 구조를 가진다. 하나의 거대한 스레드 프로세스는 NUMA 경계를 넘나들며 성능을 잃기 쉬운 반면, 프로세스별 주소 공간은 OS의 NUMA 스케줄러가 적절한 노드에 배치해줄 여지를 남긴다. 실제로 대형 서버에서는 numactl --interleave=all로 PostgreSQL을 기동하거나, NUMA 노드별로 인스턴스를 분리하는 방식이 유효한 튜닝 전략으로 쓰인다.

여기서 한 번 숫자를 확인하고 가자. 상용 워크로드에서 실제 백엔드 프로세스의 상주 메모리(RSS)를 측정해보면 다음과 같은 값이 나온다.

$ ps -o pid,rss,vsz,cmd -p 14521
    PID   RSS     VSZ CMD
  14521  13524  245832 postgres: myuser mydb 10.0.0.5(52341) idle

RSS(Resident Set Size)가 약 13 MB, VSZ(Virtual Size)가 약 240 MB다. 그런데 여기에는 맹점이 있다. 백엔드 프로세스는 postmaster(PostgreSQL 데이터베이스의 메인 서버 프로세스)가 할당한 공유 메모리를 매핑한 상태이므로, 가상 크기에 포함되지만 실제 물리 메모리는 전체 인스턴스에서 한 번만 잡힌다. 즉 백엔드 100개가 있어도 shared_buffers는 한 덩어리다. 그래도 각 백엔드가 자기만의 스택, 힙(work_mem, maintenance_work_mem, 기타 MemoryContext), 카탈로그 캐시, 플랜 캐시를 들고 있어야 하므로, 백엔드당 수 MB의 추가 메모리는 피할 수 없다.

 

물론 이 모델에는 뚜렷한 비용이 따른다. 연결 하나당 프로세스 하나이므로 커넥션 비용이 상대적으로 비싸다. 수천 개의 동시 연결이 필요한 워크로드에서는 그대로 감당하기 어렵다. 다음과 같은 비용 구조를 이해해두면 도움이 된다.

항목 스레드 모델 PostgreSQL 프로세스 모델
연결 생성 비용 스레드 생성 (~μs) fork() + 초기화 (~ms)
연결당 추가 메모리 스택 + TLS (~MB 미만) 스택 + 힙 + 카탈로그 캐시 (~MB)
컨텍스트 스위칭 같은 주소 공간 내부 MMU TLB flush 발생
크래시 격리 전체 프로세스 위험 해당 연결만 손실
디버깅 gdb attach 단일 지점 각 백엔드별 attach 필요

 

이것이 바로 PgBouncer 같은 커넥션 풀러가 PostgreSQL 생태계에서 거의 필수 인프라가 된 이유다. PgBouncer 커넥션 풀러는 짧은 애플리케이션 연결을 소수의 장기 PostgreSQL 연결에 다중화(multiplex)하여 프로세스 생성 비용을 피해간다.

 

이 글은 postmaster가 최초로 수행하는 일, 공유 메모리가 어떻게 초기화되는지, 그리고 수십 개의 보조 프로세스가 어떻게 협력해 하나의 데이터베이스를 돌아가게 하는지를 차례로 본다.


핵심 개념과 용어 정의

이후 섹션에서 반복적으로 쓸 용어들을 정리한다.

  • postmaster는 PostgreSQL의 최상위 프로세스다. pg_ctl startpostgres -D 명령으로 시작되는 바로 그 프로세스이며, 공유 메모리를 초기화하고 listen socket을 열며 자식 프로세스들을 fork()해 띄운다. 이후로는 주로 감독자 역할을 해 자식 프로세스의 비정상 종료를 감지하고, 필요하면 재시작하거나 데이터베이스 전체를 안전 종료(shutdown) 상태로 이끈다.
  • backend process는 클라이언트 연결 하나를 전담하는 자식 프로세스다. 클라이언트가 접속할 때마다 postmaster가 fork하여 생성하며, 그 연결이 끊어지면 종료된다. PostgreSQL의 쿼리 처리(파싱, 계획, 실행) 는 전부 이 백엔드 안에서 일어난다.
  • auxiliary process(보조 프로세스)는 특정 백엔드와 무관하게 데이터베이스 전체를 위해 일하는 프로세스들이다. bgwriter(dirty page 플러시), checkpointer(체크포인트 수행), walwriter(WAL 버퍼 플러시),
    autovacuum launcher(자동 VACUUM 트리거) 등이 여기에 속한다. 이들은 postmaster가 시작 시 자동으로 띄운다.
  • background worker는 동적으로 등록 가능한 프로세스다. PostgreSQL 자체의 기능(예: parallel query의 워커, logical replication의 apply worker)이나 extension(예: pg_cron의 잡 실행자)이 실시간으로 요청해 생성한다. max_worker_processes GUC로 총 개수를 제한한다.
  • shared memory는 모든 프로세스가 접근하는 공통 메모리 영역이다. postmaster가 시작 시 shm_open()으로 한 번 할당하고, 이후 fork된 자식들이 그 매핑을 상속받는다. shared_buffers라는 GUC가 정의하는 페이지 캐시가 이 영역 중 가장 크지만, 그 외에도 락 테이블, CLOG 버퍼, WAL 버퍼, 통계 정보 등이 함께 들어 있다.
  • PGPROC는 각 백엔드가 공유 메모리에 소유하는 구조체다. 현재 실행 중인 트랜잭션 ID(xid), 가시성 계산의 기준이 되는 xmin, 대기 중인 락 정보 등이 여기 담긴다. 다른 백엔드가 내 상태를 들여다봐야 할 때(예: 내가 본 스냅샷에 내가 어느 트랜잭션을 제외해야 하는지), 그 답은 PGPROC에서 온다.
  • latch는 프로세스 간 가벼운 이벤트 알림 메커니즘이다. "일이 생겼으니 깨어나라"는 신호를 전달한다. 내부적으로는 SIGUSR1과 self-pipe 조합으로 구현되며, 백엔드가 WaitLatch()로 이벤트를 기다리고, 다른 프로세스가 SetLatch()로 깨운다.
  • MemoryContext는 각 백엔드 내부에서 메모리를 계층적으로 관리하는 추상화다. 프로세스 모델과 직접적 관계는 없지만, 알아두면 유용하다. 쿼리가 끝나면 해당 쿼리의 MemoryContext 전체가 한 번에 해제되어 메모리 누수가 사실상 봉쇄된다. 프로세스 종료 시 OS가 전체를 정리해주는 것과 같은 안전망이 쿼리 단위로도 있는 셈이다.
  • GUC (Grand Unified Configuration)는 PostgreSQL의 모든 설정 파라미터를 지칭하는 내부 용어다. shared_buffers, max_connections, work_mem 같은 postgresql.conf의 모든 설정이 GUC다. 일부 GUC는 런타임에 변경 가능하지만, 공유 메모리 크기에 영향을 주는 것들(shared_buffers, max_connections, max_locks_per_transaction 등)은 postmaster 재시작이 필요하다.

내부 구조 — 프로세스 계층과 공유 메모리 레이아웃

프로세스 계층

PostgreSQL 서버가 정상 실행 중일 때 운영체제에서 본 프로세스 트리는 다음과 같다.

모든 프로세스는 postmaster의 자식이다. 다만 여기서 자식은 초기화 시점의 부모-자식 관계일 뿐, 실제 동작 중에는 대부분 독립적으로 움직인다. postmaster는 이들의 비정상 종료만 감시한다. 예를들어 자식이 SIGSEGV 등으로 죽으면 postmaster는 waitpid()에서 이를 감지하고, 상황에 따라 전체 데이터베이스를 안전 종료 모드로 전환하거나 해당 자식을 재시작한다.

 

보조 프로세스(auxiliary process)와 background worker는 역사적으로 다른 개념이다. 보조 프로세스는 PostgreSQL 코어가 하드코딩으로 정의한 고정된 역할들로, postmaster 시작 시 항상 생성된다. 반면 background worker는 PostgreSQL 9.3에 도입된 일반화된 메커니즘으로, extension이나 코어 기능이 런타임에 동적으로 등록할 수 있다. 최근 버전에서는 autovacuum launcher, logical replication launcher처럼 기능적으로는 보조에 가까운 것들도 background worker 인프라를 쓰는 방향으로 통합되고 있다.

백엔드 프로세스는 병렬 쿼리를 수행할 때 parallel worker를 추가로 띄운다. 이 워커들도 결국 postmaster의 자식이지만, 수행하는 일(쿼리 계획의 일부를 병렬로 처리)은 요청한 백엔드가 제어한다.

 

각 보조 프로세스의 역할을 좀 더 구체적으로 정리한다.

  • checkpointer는 주기적으로 체크포인트를 수행한다. 체크포인트란 "이 시점까지의 모든 변경이 디스크에 반영되었다"는 지점을 만드는 작업이다. shared_buffers의 모든 dirty 페이지를 디스크에 쓰고, 그 사실을 제어 파일(pg_control)에 기록한다. 기본적으로 5분마다(checkpoint_timeout) 또는 WAL이 일정량(max_wal_size) 쌓이면 트리거된다.
    한꺼번에 몰아치면 I/O 스파이크가 발생하므로, PostgreSQL은
    checkpoint spreading이라는 기법으로 이를 checkpoint_completion_target(기본 0.9) 비율만큼 시간상 분산시킨다.
  • background writer는 checkpointer의 보조 역할이다. checkpoint 시점 사이에도 dirty 페이지가 쌓이면, 백엔드가 새 페이지를 shared_buffers에 로드하려 할 때 페이지를 찾지 못해 직접 dirty 페이지를 플러시해야 하는 상황이 벌어진다. 이를 방지하기 위해 bgwriter는 미리 LRU 끝쪽의 dirty 페이지를 조금씩 디스크로 내려둔다. 얼마나 자주 진행할지는  bgwriter_lru_maxpages, bgwriter_delay GUC로 조절한다.
  • walwriter는 WAL 버퍼의 내용을 디스크의 WAL 세그먼트 파일로 플러시한다. wal_writer_delay(기본 200ms)마다 버퍼를 flush한다. 백엔드가 커밋할 직접 fsync하지 않고 walwriter에게 위임할 수 있는 근거가 이 프로세스다. 하지만  synchronous_commit = on(기본값)에서는 커밋하는 백엔드가 WAL이 디스크에 내려갔음을 확인하기 전까지 블록된다.
  • autovacuum launcher는 autovacuum 워커를 띄우는 관리자다. 통계 테이블을 주기적으로 살펴 "이 테이블에 dead tuple이 임계값 이상 쌓였다"를 판단하면, fork()로 autovacuum worker를 띄워 그 테이블의 VACUUM을 맡긴다. 동시에 뜰 수 있는 워커 수는 autovacuum_max_workers(기본 3)로 제한된다.
  • logical replication launcher는 논리 복제의 subscriber 측 관리자다. 각 subscription마다 apply worker를 띄워 publisher에서 들어오는 변경사항을 재생한다.
  • startup process는 기동 시에만 일시적으로 존재한다. 역할이 두 가지인데, 정상 부팅의 crash recovery 수행과, standby에서의 continuous recovery(WAL receiver가 받은 WAL을 끊임없이 재생) 수행이다. standby에서는 이 프로세스가 서버의 수명 내내 유지된다.
  • cumulative stats system(PG 15+)에 대해 언급한다. PG 14까지는 stats collector라는 별도 프로세스가 모든 통계 갱신을 UDP로 받아 처리했는데, 이 설계는 프로세스 간 통신 비용과 통계 집계 지연 문제로 비판받았다. PG 15부터는 공유 메모리 기반으로 바뀌어, 각 백엔드가 통계를 직접 갱신하는 구조가 되었다. 다만 관리 역할(파일 저장, shutdown 시 플러시)을 맡는 별도 보조 프로세스는 여전히 있다.

공유 메모리 레이아웃

모든 프로세스가 공유하는 메모리는 하나의 거대한 영역에 모여 있다. 대략적인 구성은 다음과 같다.

이 영역의 크기는 대부분 shared_buffers 설정에 지배된다. 통상 전체 시스템 RAM의 25% 정도를 할당하는 것이 일반적인 권장치지만, 실제 최적값은 워크로드에 따라 달라진다.

 

PostgreSQL은 이 영역을 OS의 System V shared memory(shmget)나 POSIX shared memory(shm_open)로 할당하는데, 9.3 이후로는 기본이 POSIX + mmap 방식으로 바뀌었다. 그래서 Linux에서 /dev/shm/을 들여다보면 PostgreSQL이 생성한 파일을 볼 수 있다.

ls -la /dev/shm/ | grep postgres
# -rw------- 1 postgres postgres 16777216 Oct 20 10:15 PostgreSQL.2028317474

중요한 것은 이 영역이 postmaster 시작 시 단 한 번 할당된다는 점이다. fork된 자식들은 부모의 가상 주소 공간을 상속받으면서 이 공유 메모리 매핑도 함께 받는다. 이후 자식이 특정 주소에 쓰면, 다른 모든 프로세스가 그 쓰기를 즉시 볼 수 있다. 

 

공유 메모리 크기는 대략 다음 공식으로 추정할 수 있다.

총 공유 메모리 ≈ shared_buffers
              + wal_buffers
              + (max_connections × PGPROC 크기)
              + (max_locks_per_transaction × max_connections × LOCK 크기)
              + CLOG / SUBTRANS / MultiXact 버퍼
              + 기타 작은 구조들

실제 값은 pg_shmem_allocations 뷰로 정확히 볼 수 있지만, 이 공식을 이해하는 것이 왜 중요한지는 max_connections를 변경할 때 드러난다. 예를 들어 max_connections를 100에서 1000으로 10배 늘리면, 공유 메모리의 ProcArray, Lock Table 등이 모두 10배로 커져 수백 MB가 추가로 예약된다. 이것이 postmaster 재시작이 필요한 이유이며, 또한 과도한 max_connections 설정이 단순한 "상한 제한"이 아닌 실질적 메모리 낭비인 이유다.

$PGDATA 파일 시스템 레이아웃

프로세스 구조와 짝을 이루는 것이 디스크 상의 데이터 디렉터리(PGDATA) 구조다. postmaster는 이 디렉터리를 기준으로 모든 것을 관리한다.

$PGDATA/
├── PG_VERSION              # 클러스터의 메이저 버전
├── postmaster.pid          # 실행 중이면 생성, postmaster PID 등 포함
├── postgresql.conf         # 주 설정 파일
├── pg_hba.conf             # 클라이언트 인증 규칙
├── pg_ident.conf           # ident 매핑
├── global/
│   └── pg_control          # 클러스터 제어 파일 (크래시 복구의 앵커)
├── base/
│   ├── 1/                  # template1 데이터베이스
│   ├── 13751/              # template0
│   ├── 13752/              # postgres 데이터베이스
│   └── 16384/              # 사용자 생성 데이터베이스
├── pg_wal/                 # WAL 세그먼트 파일들 (16 MB 단위)
├── pg_xact/                # 트랜잭션 커밋 로그 (CLOG)
├── pg_multixact/           # MultiXact 데이터
├── pg_subtrans/            # 서브트랜잭션 부모 추적
├── pg_tblspc/              # 테이블스페이스 심볼릭 링크
├── pg_stat/                # 영구 저장 통계
├── pg_stat_tmp/            # 임시 통계 (14까지)
└── pg_logical/             # 논리 복제 슬롯 데이터

눈여겨볼 것은 postmaster.pid 파일이다. 이 파일은 postmaster가 기동하면서 만들고 종료하면서 지운다. 비정상 종료로 파일이 남아 있으면 pg_ctl start가 "이미 실행 중인 것 같다"며 거부한다. 이 파일의 내용을 직접 보면 다음과 비슷하다.

$ cat $PGDATA/postmaster.pid
12340
/var/lib/postgresql/16/main
1729472894
5432
/var/run/postgresql
*
  5432001     65536
ready

각 줄은 postmaster PID, 데이터 디렉터리, 기동 타임스탬프, 포트, 소켓 디렉터리, listen 주소, 공유 메모리 키 + 크기, 서버 상태(ready/standby 등)를 의미한다. 이 정보는 다른 PostgreSQL 유틸리티(pg_isready, pg_ctl)가 서버와 상호작용할 때 참조한다.

3.4 IPC 메커니즘

프로세스 간 통신은 세 가지 도구로 이루어진다.

  • 공유 메모리는 앞서 설명한 대로 대량의 데이터를 교환하는 주된 수단이다. 백엔드가 페이지를 읽을 때는 shared_buffers에서 찾고, 락을 잡을 때는 lock table에 엔트리를 추가한다. 모두 공유 메모리 접근이다.
  • 세마포어는 여러 프로세스가 동시에 접근하면 안 되는 자원을 보호한다. PostgreSQL은 그 위에 LWLock(Lightweight Lock)이라는 자체 프리미티브를 얹었는데, LWLock은 빠른 경로에서는 atomic 연산으로 경합 없이 획득되고, 경합이 발생할 때만 세마포어로 떨어져 잠든다.
  • 시그널과 latch는 이벤트 알림용이다. "WAL에 쓸 일이 생겼으니 walwriter야 일어나라", "LISTEN하고 있는 채널에 메시지가 왔으니 백엔드야 확인해라" 같은 깨우기가 여기 해당한다. 전통적으로는 SIGUSR1 같은 시그널을 직접 썼지만, 현대 PostgreSQL은 latch라는 추상화 계층을 쓴다. Latch는 내부적으로 self-pipe(또는 Linux의 eventfd)를 이용해, 시그널과 파일 디스크립터 기반 이벤트(소켓 I/O 등)를 poll() 한 번으로 함께 기다릴 수 있게 해준다.

PostgreSQL이 백엔드에 보내는 주요 시그널을 정리하면 다음과 같다.

시그널 핸들러 동작 전형적 송신자
SIGHUP 설정 파일 재로드 pg_ctl reload, 사용자
SIGINT 현재 쿼리 취소 (트랜잭션은 유지) pg_cancel_backend()
SIGTERM 세션 종료 (트랜잭션 롤백) pg_terminate_backend()pg_ctl stop -m smart
SIGQUIT 즉시 종료 (정리 없이) pg_ctl stop -m immediate
SIGUSR1 프로시저 시그널 + latch 발사 다른 백엔드, 보조 프로세스
SIGUSR2 비동기 알림(LISTEN/NOTIFY) 확인 요청 NOTIFY 송신자

 

특히 SIGUSR1 procsignal이라는 다중 신호 시스템을 가져 중요한 역할을 한다. 단일 SIGUSR1 수신 시, 수신자는 공유 메모리의 자기 슬롯을 확인해 실제로 어떤 "가상 시그널"이 요청되었는지를 알아낸다. 예를 들어 PROCSIG_CATCHUP_INTERRUPT, PROCSIG_NOTIFY_INTERRUPT, PROCSIG_RECOVERY_CONFLICT_* 같은 내부 이벤트들이 모두 SIGUSR1 위에 다중화되어 있다.

 

정적인 구조를 봤으니, 이제 이 구조가 실제로 어떻게 움직이는지 추적해본다.


동작 원리 — 기동에서 쿼리 실행까지

postmaster 부팅 시퀀스

pg_ctl start를 실행하면 내부적으로 postgres 바이너리가 -D <데이터 디렉터리> 옵션과 함께 실행된다. 이 프로세스가 postmaster가 되며, 다음 순서로 데이터베이스를 깨운다.

눈여겨볼 점은 공유 메모리 할당이 listen socket 열기보다 먼저 일어난다는 것이다. 이 순서는 필수다 — 클라이언트가 접속하면 백엔드가 fork되어 즉시 공유 메모리를 써야 하는데, 그 시점에 공유 메모리가 없으면 치명적이다.

 

또 하나 중요한 것은 크래시 복구 단계다. 직전 종료가 비정상(예: kill -9, OS 크래시)이었다면, pg_control 파일의 상태가 "in production"으로 남아 있다. postmaster는 이를 보고 startup process를 fork하여 WAL을 재생(replay)해 일관성을 회복한 뒤에야 일반 연결을 받기 시작한다.

 

postmaster가 메인 루프에 진입하면 실제로 하는 일은 굉장히 단순하다. 의사 코드로 표현하면 다음과 같다.

// src/backend/postmaster/postmaster.c의 ServerLoop() 요약
for (;;) {
    WaitEventSet 에서 이벤트 대기:
        - listen socket에 새 연결 (accept)
        - 자식 프로세스 종료 (waitpid)
        - SIGHUP: 설정 재로드
        - SIGTERM: 정상 종료 시작
        - 타이머: autovacuum launcher 확인 등

    if (새 연결) {
        BackendStartup(client_socket);  // 내부에서 fork
    }
    if (자식 종료) {
        HandleChildCrash(pid, exit_code);
        // 비정상 종료면 데이터베이스 전체를 재초기화 모드로
    }
}

보다시피 postmaster는 직접 쿼리를 처리하지 않는다. 오로지 감독자다. 이 단순성 덕분에 postmaster 자체가 복잡한 상태를 가지지 않아 전체 인스턴스가 무너질 일도 드물다.

 

반대로 말하면, 어느 자식 프로세스라도 공유 메모리를 오염시키면 postmaster는 방어적으로 전체를 재시작한다. 공유 메모리 손상은 다른 자식에게도 전파될 수 있기 때문이다. 이 동작은 restart_after_crash GUC(기본 on)로 제어된다. 프로덕션에서는 반드시 켜두어야 하는 설정이며 자동 재부팅 없이 장기 운영이 가능하게 하는 핵심 설정이다.

클라이언트 연결이 백엔드가 되기까지

외부 클라이언트(psql, libpq 응용 프로그램 등)가 5432 포트에 TCP 연결을 시도하면, postmaster의 메인 루프가 이를 받는다.

여기서 주목할 것은 인증이 fork 이후에 일어난다는 점이다. 클라이언트가 접속만 했다고 해서 postmaster가 그 자리에서 pg_hba.conf를 확인하지 않는다. 일단 fork해서 자식을 만든 뒤, 그 자식이 자신의 컨텍스트에서 인증을 수행한다. 이렇게 하는 이유는 postmaster가 인증 같은 잠재적으로 느리거나 오류 가능성 있는 작업에 막혀 다른 연결 수락이 지연되는 것을 피하기 위해서다.

 

PGPROC 등록도 중요하다. 각 백엔드는 공유 메모리의 ProcArray에 자기 슬롯을 차지해야만 다른 백엔드에게 "나 지금 이 트랜잭션 돌고 있어"를 알릴 수 있다. 이 등록이 실패하면(예: max_connections 초과) 백엔드는 오류를 반환하고 즉시 종료된다.

 

fork()의 이점 하나를 더 짚고 가자. 최신 Linux 커널은 COW(Copy-On-Write) 기법으로 fork를 구현한다. 부모의 페이지 테이블을 그대로 복사하되, 페이지 자체는 쓰기가 발생하는 순간에만 실제로 복사본을 만든다. 그래서 PostgreSQL에서 백엔드 fork는 이론상 매우 가볍다. 하지만 실제로는 backend 초기화 중 많은 페이지에 쓰기가 발생하므로(카탈로그 캐시, 플랜 캐시, 로컬 MemoryContext 등), 완전한 "가벼움"을 기대하기는 어렵다. 벤치마크상 접속 1건당 1~5 ms 정도의 초기화 지연이 관측되는 것이 일반적이다. 이것이 초당 수천 건의 짧은 쿼리를 요구하는 워크로드에서 PgBouncer가 필수인 이유다.

 

또 하나 짚어야 할 것이 exec_backend 빌드 옵션이다. PostgreSQL의 기본 빌드에서 백엔드는 fork만 한다(exec 없음). 반면 Windows 빌드에서는 fork()가 없으므로 postmaster가 CreateProcess()로 새 프로세스를 띄운 뒤, 공유 메모리 상태를 그쪽으로 직렬화-역직렬화해 전달한다. Unix 시스템에서도 디버깅 용도로 --with-exec-backend로 빌드하면 이 Windows-style 경로를 강제할 수 있지만, 프로덕션에선 거의 쓰지 않는다.

쿼리 실행 중의 프로세스 간 협력

백엔드가 단순한 SELECT를 실행하는 것처럼 보여도, 그 뒤에는 여러 프로세스가 맞물려 돌아간다. UPDATE customers SET email = ... WHERE id = 1 한 줄을 예로 든다.

  1. 백엔드가 쿼리를 파싱하고 계획한다. 해당 페이지가 shared_buffers에 없으면 디스크에서 읽어 온다. 이 읽기는 별도 I/O 프로세스 없이 자기 프로세스가 직접 read()를 호출하는 것이다.
  2. 페이지를 읽은 뒤, 백엔드는 tuple을 수정하고 WAL 레코드를 만든다. 이 레코드는 공유 메모리의 WAL Buffers에 먼저 들어간다.
  3. 트랜잭션을 커밋할 때, 백엔드는 WAL을 디스크로 내려야 한다. 직접 fsync()하거나, walwriter에게 넘길 수 있다. synchronous_commit 설정이 이를 지배한다.
  4. 페이지 자체(heap block)는 커밋 시점에 디스크로 내려가지 않는다. dirty 상태로 shared_buffers에 남아 있다가, 나중에 bgwriter가 이들을 점진적으로 디스크에 쓴다. 또는 checkpointer가 정기적 체크포인트 시 한꺼번에 플러시한다.
  5. 이 트랜잭션이 delete/update를 했다면 dead tuple이 생긴다. 일정량 쌓이면 autovacuum launcher가 이를 감지하고 autovacuum worker를 띄워 VACUUM을 수행한다.

이 모든 과정에서 프로세스들은 공유 메모리를 통해 협력한다. 백엔드가 만든 dirty 페이지를 bgwriter가 바로 볼 수 있고, 백엔드가 쓴 WAL 버퍼를 walwriter가 바로 플러시할 수 있는 것은 같은 메모리를 보고 있기 때문이다.


SQL로 직접 관찰하기

모든 예시는 PostgreSQL 16 기준이며, psql로 직접 접속한 상태에서 실행한다.

현재 실행 중인 프로세스들 엿보기

먼저 OS 수준에서 실제로 어떤 PostgreSQL 프로세스가 떠 있는지 본다.

ps -o pid,ppid,cmd -C postgres

출력 예시:

    PID    PPID CMD
  12340       1 /usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/16/main
  12342   12340 postgres: checkpointer
  12343   12340 postgres: background writer
  12344   12340 postgres: walwriter
  12345   12340 postgres: autovacuum launcher
  12346   12340 postgres: logical replication launcher
  14521   12340 postgres: myuser mydb 10.0.0.5(52341) idle
  14522   12340 postgres: myuser mydb 10.0.0.7(52400) SELECT

PPID 열이 전부 12340(postmaster)을 가리킨다. 즉 모든 프로세스가 postmaster의 직계 자식이다. 백엔드 프로세스의 커맨드 이름(postgres: myuser mydb ...)은 실제로는 PostgreSQL이 setproctitle()로 런타임에 다시 쓴 것이다. 원래 이들은 postgres라는 동일한 바이너리에서 fork된 자식들이지만, 가독성을 위해 자신의 역할을 프로세스 타이틀에 표시한다.

백엔드 프로세스 PID 확인

같은 정보를 PostgreSQL 내부에서도 볼 수 있다. 지금 접속한 세션의 백엔드 PID는 pg_backend_pid()로 얻는다.

SELECT pg_backend_pid();
 pg_backend_pid
----------------
          14521

이 값이 ps 출력의 한 줄과 정확히 일치한다는 것을 확인할 수 있다. 여러 세션을 동시에 열어 각각 pg_backend_pid()를 실행하면 서로 다른 PID가 나오고, ps에 그만큼의 백엔드 프로세스가 생긴다.

전체 활동 상태 한눈에 보기

pg_stat_activity는 모든 백엔드의 상태를 보여주는 뷰다. 이 뷰는 사실상 공유 메모리의 ProcArray + 각 백엔드의 세션 정보를 조합해 만든 것이다.

SELECT
    pid,
    backend_type,
    state,
    wait_event_type,
    wait_event,
    left(query, 60) AS query_snippet
FROM pg_stat_activity
ORDER BY backend_type, pid;

출력 예시:

  pid  |         backend_type          |  state  | wait_event_type |     wait_event      |              query_snippet
-------+-------------------------------+---------+-----------------+---------------------+-----------------------------------------
 12342 | checkpointer                  |         | Activity        | CheckpointerMain    |
 12343 | background writer             |         | Activity        | BgWriterMain        |
 12344 | walwriter                     |         | Activity        | WalWriterMain       |
 12345 | autovacuum launcher           |         | Activity        | AutoVacuumMain      |
 12346 | logical replication launcher  |         | Activity        | LogicalLauncherMain |
 14521 | client backend                | idle    | Client          | ClientRead          | SELECT pid, backend_type, state, wait_...
 14522 | client backend                | active  |                 |                     | SELECT count(*) FROM large_table;

여기서 backend_type 컬럼이 핵심이다. client backend는 우리가 논의한 일반 백엔드고, 나머지는 보조 프로세스들이다. wait_eventCheckpointerMain이라는 것은 "체크포인트 본문 루프에서 다음 이벤트를 기다리고 있다"는 뜻이고, ClientRead는 "클라이언트로부터 다음 쿼리가 오기를 기다리는 중"을 의미한다.

 

각 프로세스 유형이 어떤 wait_event에서 대부분의 시간을 보내는지를 아는 것은 성능 분석의 기본이다. 이 주제는 34편 모니터링 편에서 깊이 다룬다.

서버 전체 구성값 확인

postmaster가 기동 시 할당한 자원들의 크기를 확인한다.

SELECT name, setting, unit, short_desc
FROM pg_settings
WHERE name IN (
    'max_connections',
    'shared_buffers',
    'wal_buffers',
    'max_worker_processes',
    'max_parallel_workers',
    'max_parallel_workers_per_gather',
    'autovacuum_max_workers'
)
ORDER BY name;

출력 예시:

              name               | setting | unit |                   short_desc
---------------------------------+---------+------+--------------------------------------------------
 autovacuum_max_workers          | 3       |      | Sets the maximum number of simultaneously ...
 max_connections                 | 100     |      | Sets the maximum number of concurrent connections.
 max_parallel_workers            | 8       |      | Sets the maximum number of parallel workers ...
 max_parallel_workers_per_gather | 2       |      | Sets the maximum number of parallel processes ...
 max_worker_processes            | 8       |      | Maximum number of concurrent worker processes.
 shared_buffers                  | 16384   | 8kB  | Sets the number of shared memory buffers used ...
 wal_buffers                     | 512     | 8kB  |
 max_connections                 | 100     |      | Sets the maximum number of concurrent connections.

shared_buffers의 단위가 8kB라는 점을 놓치면 안 된다. 설정값 163848 KB × 16384 = 128 MB를 의미한다.

 

wal_buffers5124 MB다. 이 값들은 postmaster 시작 시 결정된 공유 메모리는 이미 할당되어 자식 프로세스들에게 상속된 상태이기 때문에 런타임에 바꿀 수 없다.

보조 프로세스의 작업량 보기

pg_stat_bgwriter는 background writer와 checkpointer의 통계를 보여준다.

SELECT
    checkpoints_timed,
    checkpoints_req,
    buffers_checkpoint,
    buffers_clean,
    buffers_backend,
    buffers_backend_fsync,
    stats_reset
FROM pg_stat_bgwriter;

출력 예시:

 checkpoints_timed | checkpoints_req | buffers_checkpoint | buffers_clean | buffers_backend | buffers_backend_fsync |          stats_reset
-------------------+-----------------+--------------------+---------------+-----------------+-----------------------+-------------------------------
               142 |               8 |             328104 |         12407 |            3291 |                     0 | 2024-09-18 03:21:11.412+09

이 숫자들은 어느 프로세스가 얼마나 일했는지를 말해준다. buffers_checkpoint는 checkpointer가 내린 페이지 수, buffers_clean은 bgwriter가 내린 페이지 수, buffers_backend백엔드가 자기 손으로 내린 페이지 수다. 마지막 값이 크다는 것은 bgwriter/checkpointer가 따라가지 못해 백엔드가 자기 쿼리 도중에 write를 하고 있다는 뜻으로, 튜닝이 필요한 신호다. 

공유 메모리의 실체 확인

PostgreSQL이 할당한 공유 메모리를 OS 수준에서 직접 확인한다.

-- PostgreSQL 내부에서 shared memory segment 정보
SELECT * FROM pg_shmem_allocations ORDER BY allocated_size DESC LIMIT 10;

출력 예시:

        name         |   off    | allocated_size
---------------------+----------+----------------
 Buffer Blocks       | 16778240 |      134217728
 Buffer Descriptors  | 16253952 |         524288
 <anonymous>         |        | |         524288
 XLOG Ctl            | 16121600 |         132368
 Xact                |   153856 |         525440
 CommitTs            |   153856 |         265472
 SUBTRANS            |    90368 |          63616
 MultiXactOffset     |    22016 |          64512

Buffer Blocks가 128 MB로 압도적으로 크다. 이것이 shared_buffers의 실체다. 나머지 Xact(CLOG 버퍼), XLOG Ctl(WAL 제어 구조), MultiXactOffset 등은 상대적으로 작지만 각자 중요한 역할을 한다. 이 뷰는 PostgreSQL 13에서 도입되어, 그 이전에는 소스를 보거나 매뉴얼에 의존해야 알 수 있던 정보를 한눈에 보여준다.

연결 수가 백엔드 수와 같은지 확인

"백엔드 = 연결"이라는 원칙을 직접 검증해본다.

SELECT
    (SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend') AS backend_count,
    (SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend' AND state IS NOT NULL) AS non_null_state,
    current_setting('max_connections') AS max_conn;

출력 예시:

 backend_count | non_null_state | max_conn
---------------+----------------+----------
             7 |              7 |      100

지금 연결된 클라이언트 세션이 7개고, 각각이 별개의 client backend로 등록되어 있다. 이 7이라는 숫자는 ps aux에서 본 postgres: ... mydb ... 라인의 수와 정확히 일치해야 한다.

특정 백엔드를 관찰하기

이번에는 두 번째 psql 세션을 열어 아래와 같이 의도적으로 트랜잭션을 열어둔다.

-- 세션 2에서 실행
BEGIN;
SELECT pg_backend_pid();
-- 결과: 14522
-- 커밋하지 않고 그대로 둠

첫 번째 세션으로 돌아와 이 백엔드를 관찰한다.

SELECT
    pid,
    xact_start,
    state,
    wait_event_type,
    wait_event,
    backend_xmin,
    left(query, 50) AS query
FROM pg_stat_activity
WHERE pid = 14522;

출력 예시:

  pid  |         xact_start          |        state        | wait_event_type | wait_event  | backend_xmin |              query
-------+-----------------------------+---------------------+-----------------+-------------+--------------+-------------------------------------
 14522 | 2024-10-20 11:02:14.892+09  | idle in transaction | Client          | ClientRead  |       843127 | SELECT pg_backend_pid();

 

stateidle in transaction이다. 이것은 트랜잭션을 열어두고 클라이언트가 다음 명령을 주기를 기다리는 상태다. 장기간 이 상태에 머무는 백엔드는 VACUUM을 차단하고 테이블 팽창(bloat)을 유발하는 가장 흔한 원인이다.

 

backend_xmin은 이 백엔드의 스냅샷이 유지해야 할 최소 트랜잭션 ID다. 이 값이 오래된 xid로 고정되어 있으면, VACUUM은 그 xid보다 최근에 삭제된 dead tuple을 회수할 수 없다. 다시 말해, 하나의 idle in transaction 백엔드가 서버 전체의 VACUUM을 마비시킬 수 있다는 뜻이다.

백엔드를 강제 종료하기

특정 백엔드를 원격으로 종료해야 할 때가 있다. PostgreSQL은 두 가지 수단을 제공한다.

-- 쿼리만 취소 (트랜잭션은 열린 채 유지)
SELECT pg_cancel_backend(14522);

-- 연결 자체를 종료 (트랜잭션도 롤백)
SELECT pg_terminate_backend(14522);

내부적으로 이 함수들은 대상 백엔드 프로세스에 시그널을 보낸다. pg_cancel_backendSIGINT, pg_terminate_backendSIGTERM에 해당한다. 백엔드는 자신의 시그널 핸들러에서 이 신호를 받아 현재 쿼리를 중단하거나(CancelRequest) 정리 절차를 거쳐 종료한다. 이 메커니즘이 작동할 수 있는 것도 각 백엔드가 독립된 OS 프로세스이기 때문이다. 스레드였다면 단일 스레드만 안전하게 중단하는 것이 훨씬 까다로웠을 것이다.


 

다음에는 스토리지 레이어: 페이지 레이아웃 & TOAST를 다뤄보자. 백엔드가 실제로 디스크에서 가져오는 그 8KB 페이지의 내부 구조 — PageHeader, ItemId 배열, HeapTupleHeader의 비트 필드, 그리고 8KB를 초과하는 대형 값을 처리하는 TOAST 메커니즘까지 — 물리적 수준에서 파고든다.