애플리케이션 레벨 연결 풀링이 높은 동시성 환경에서 부족한 이유
Lukas Schneider
DevOps Engineer · Leapcell

소개
고성능 애플리케이션의 세계에서 데이터베이스 연결을 효율적으로 관리하는 것은 매우 중요합니다. 각 연결은 귀중한 서버 리소스를 소모하며, 제대로 관리되지 않은 연결은 빠르게 병목 현상을 일으켜 애플리케이션 응답성과 확장성에 심각한 영향을 미칠 수 있습니다. 대부분의 최신 애플리케이션 프레임워크는 데이터베이스 연결을 재사용할 수 있는 편리한 방법을 제공하는 내장 연결 풀링 기능을 제공하지만, 작업이 확장될 때 중요한 질문이 자주 발생합니다. 애플리케이션 레벨 연결 풀링이 높은 동시성 환경의 요구 사항을 충족하기에 충분한가? 이 글은 애플리케이션 레벨 풀링에만 의존하는 것의 한계를 심층적으로 분석하고, PgBouncer 및 RDS Proxy와 같은 전용 연결 풀러를 사용하는 것이 왜 강력하고 확장 가능한 데이터베이스 아키텍처에 필수적인지 설명합니다.
애플리케이션 레벨 연결 풀링의 병목 현상
애플리케이션 레벨 풀링이 왜 부족할 수 있는지 이해하기 위해서는 관련된 핵심 개념을 먼저 정의해야 합니다.
- 데이터베이스 연결: 애플리케이션과 데이터베이스 서버 간의 열린 통신 채널입니다. 새 연결을 설정하는 것은 핸드셰이크 프로토콜, 인증 및 양측의 리소스 할당을 포함하는 비교적 비싼 작업입니다.
- 연결 풀링 (애플리케이션 레벨): 애플리케이션이 열린 재사용 가능한 데이터베이스 연결 풀을 유지하는 기술입니다. 각 요청에 대해 새 연결을 여는 대신, 애플리케이션은 풀에서 연결을 빌려오고 완료되면 반환합니다. 이는 연결 설정 오버헤드와 데이터베이스 리소스 소비를 줄입니다. 예로는 Java의 HikariCP, Python의 SQLAlchemy
QueuePool또는 Ruby on Rails의 내장 풀링 메커니즘이 있습니다. - 전용 연결 풀러 (예: PgBouncer, RDS Proxy): 애플리케이션과 데이터베이스 사이에 위치하는 별도의 경량 프록시 서비스입니다. 훨씬 더 큰 데이터베이스 연결 풀을 관리하며 여러 애플리케이션 연결이 더 작은 실제 데이터베이스 연결 세트를 공유할 수 있도록 합니다. 이는 연결 다중화, 인증 및 애플리케이션을 방해하지 않는 안전한 데이터베이스 다시 시작과 같은 고급 기능을 제공합니다.
애플리케이션 레벨 풀링은 적당한 부하에는 잘 작동하지만, 근본적인 한계는 프로세스 중심 또는 인스턴스 중심이라는 점입니다. 애플리케이션의 각 인스턴스(예: 웹 서버 프로세스, 마이크로서비스 컨테이너)는 자신의 독립적인 연결 풀을 유지합니다. 부하 분산기 뒤에 배포된 여러 인스턴스에 걸쳐 애플리케이션이 배포된 경우를 고려하십시오.
애플리케이션 인스턴스 1 --(풀 1)--> 데이터베이스
애플리케이션 인스턴스 2 --(풀 2)--> 데이터베이스
애플리케이션 인스턴스 3 --(풀 3)--> 데이터베이스
이 시나리오에서는 애플리케이션 레벨 풀링을 사용하더라도 데이터베이스는 여전히 여러 독립적인 풀에서의 연결을 보고 있습니다. 각 애플리케이션 인스턴스가 예를 들어 20개의 연결을 유지하고 10개의 애플리케이션 인스턴스가 있다면, 데이터베이스는 200개의 동시 연결을 처리할 수 있습니다. 이러한 각 연결은 데이터베이스 서버에서 메모리와 CPU 리소스를 소비합니다. 동시성이 증가함에 따라 데이터베이스 서버는 쿼리 실행 때문이 아니라 관리해야 하는 활성 연결의 엄청난 수 때문에 과부하가 걸릴 수 있습니다. 이 현상은 종종 높은 메모리 사용량, 증가된 컨텍스트 스위칭 및 리소스 경합으로 인한 느린 쿼리 실행으로 특징지어집니다.
이 문제는 연결 급증 또는 "천둥치는 군중" 시나리오 중에 더욱 악화됩니다. 애플리케이션 인스턴스가 다시 시작되거나 확장될 때, 모두 동시에 새 연결을 설정하려고 시도하여 데이터베이스에 연결 요청을 넘칠 수 있습니다. 애플리케이션 레벨 풀이 건강한 최소 및 최대 크기로 구성되어 있더라도, 연결의 총 수는 빠르게 임계 수준에 도달하여 잠재적으로 데이터베이스 중단으로 이어질 수 있습니다.
이것이 전용 연결 풀러가 중요해지는 곳입니다. 추상화 및 제어 계층을 도입합니다.
애플리케이션 인스턴스 1 --(앱 연결)--> PgBouncer/RDS Proxy --(DB 연결)--> 데이터베이스
애플리케이션 인스턴스 2 --(앱 연결)--> PgBouncer/RDS Proxy --(DB 연결)--> 데이터베이스
애플리케이션 인스턴스 3 --(앱 연결)--> PgBouncer/RDS Proxy --(DB 연결)--> 데이터베이스
이 설정에서는 각 애플리케이션 인스턴스가 데이터베이스에 직접 연결하는 대신 연결 풀러에 연결합니다. 그런 다음 풀러는 데이터베이스에 대한 훨씬 더 작고 최적화된 실제 연결 풀을 유지합니다. 예를 들어, 100개의 애플리케이션 연결은 풀러에 의해 단 20개의 데이터베이스 연결로 다중화될 수 있습니다.
psycopg2를 사용한 간단한 Python 예제로 설명하고 개념적으로 풀러가 어떻게 개입하는지 비교해 보겠습니다.
애플리케이션 레벨 풀링 (개념적 Python psycopg2 예):
import psycopg2 from psycopg2 import pool import threading import time # 실제 앱에서는 이것이 전역적으로 또는 마이크로서비스별로 구성될 것입니다. # 각 앱 인스턴스는 자체 풀을 가집니다. min_connections = 5 max_connections = 10 conn_pool = pool.SimpleConnectionPool(min_connections, max_connections, host="localhost", database="mydatabase", user="myuser", password="mypassword") def worker_thread(thread_id): connection = None try: connection = conn_pool.getconn() print(f"Thread {thread_id}: Acquired connection. Total active: {conn_pool.closed_and_idle_connections + conn_pool.used_connections}") cursor = connection.cursor() # 약간의 데이터베이스 작업 시뮬레이션 cursor.execute("SELECT pg_sleep(0.1)") cursor.close() print(f"Thread {thread_id}: Released connection.") except Exception as e: print(f"Thread {thread_id}: Error: {e}") finally: if connection: conn_pool.putconn(connection) # 이 애플리케이션 인스턴스에서 오는 여러 동시 요청 시뮬레이션 threads = [] for i in range(20): # 단일 앱 인스턴스에서 20개의 동시 요청 thread = threading.Thread(target=worker_thread, args=(i,)) threads.append(thread) thread.start() for thread in threads: thread.join() conn_pool.closeall() print("All connections closed.")
이것을 로컬에서 실행하면 psycopg2의 SimpleConnectionPool이 이 특정 Python 프로세스 내에서 연결을 관리하는 것을 볼 수 있습니다. max_connections에 도달하면 후속 getconn() 호출은 연결을 사용할 수 있거나 시간 초과가 발생할 때까지 차단됩니다. 이러한 10개의 Python 프로세스가 실행 중이라면 데이터베이스는 최대 10 * max_connections개의 연결을 보게 됩니다.
전용 풀러 (PgBouncer/RDS Proxy)의 역할:
대신, 애플리케이션은 PgBouncer/RDS Proxy에 연결합니다.
애플리케이션 -> PgBouncer/RDS Proxy -> 데이터베이스
Pgbouncer는 여러 모드로 작동합니다:
- 세션 풀링 (기본값): 가장 일반적인 모드입니다. 서버 연결은 클라이언트 세션 기간 동안 클라이언트에 할당됩니다. 클라이언트가 연결을 끊으면 서버 연결이 풀로 반환됩니다. 이는 비교적 오래 지속되는 연결을 가지지만 트랜잭션 간에 영구적인 상태를 유지할 필요가 없는 애플리케이션에 좋습니다.
- 트랜잭션 풀링: 서버 연결은 트랜잭션 기간 동안만 클라이언트에 할당됩니다. 트랜잭션이 완료되면 서버 연결이 즉시 풀로 반환됩니다. 이는 많은 짧은 트랜잭션을 가진 워크로드에 매우 효율적입니다. 이것이 명시적인 연결 다중화가 발생하는 곳입니다.
- 문장 풀링: 프로토콜 제한으로 인해 Postgres에서는 일반적으로 사용되지 않지만, 단일 문장에 대한 연결을 할당합니다.
가장 높은 효율성을 제공하는 transaction 풀링 모드를 고려해 보겠습니다.
-- 애플리케이션이 PgBouncer에 연결하고, PgBouncer가 client_id를 할당합니다. BEGIN; SELECT * FROM users WHERE id = 1; UPDATE products SET stock = stock - 1 WHERE id = 10; COMMIT; -- PgBouncer는 애플리케이션의 *PgBouncer에 대한 클라이언트 연결*이 여전히 열려 있더라도 즉시 데이터베이스 연결을 내부 풀로 반환합니다. -- 다른 애플리케이션 요청 (같거나, 동일한 앱 클라이언트이지만 새 트랜잭션이라도) -- 이제 실행할 수 있습니다: BEGIN; INSERT INTO orders (user_id, product_id) VALUES (1, 10); COMMIT; -- PgBouncer는 이 짧은 트랜잭션에 대해 다시 데이터베이스 연결을 재사용합니다.
여기서 핵심 이점은 PgBouncer(또는 RDS Proxy)가 애플리케이션 연결의 수와 데이터베이스 연결의 수를 효과적으로 분리한다는 것입니다. 데이터베이스는 풀러가 관리하는 연결만 봅니다. 이는 데이터베이스의 오버헤드를 크게 줄이고 높은 부하에서의 안정성을 향상시킵니다.
또한 전용 풀러는 다음을 제공합니다:
- 중앙 집중식 연결 관리: 애플리케이션 인스턴스 전체에서 연결 제한을 모니터링하고 구성하기 쉽습니다.
- 스로틀링 및 큐잉: 백엔드 데이터베이스가 과부하되면 풀러는 들어오는 연결 요청을 큐에 넣어 연쇄 실패를 방지할 수 있습니다.
- 안전한 장애 조치/다시 시작: 데이터베이스를 다시 시작하거나 장애 조치가 발생해야 할 때, 풀러는 애플리케이션 연결을 유지하고 새 기본값에 연결을 투명하게 다시 설정하여 애플리케이션 다운타임을 최소화합니다.
- 인증 및 권한 부여: 클라이언트 인증을 처리하여 데이터베이스의 부하를 줄이거나 추가 보안 계층을 제공할 수 있습니다.
예를 들어, RDS Proxy는 RDS 인스턴스의 장애 조치를 자동으로 처리합니다. RDS 인스턴스가 장애 조치되면 DNS가 변경됩니다. RDS Proxy가 없으면 애플리케이션 연결이 끊어져 애플리케이션이 다시 설정해야 합니다. RDS Proxy가 있는 경우 장애 조치를 감지하고, 이전 인스턴스에 대한 연결을 안전하게 닫고, 새 기본값에 새 연결을 설정하는 동안 클라이언트 연결을 열어 둡니다. 이 프로세스는 애플리케이션에 훨씬 빠르고 투명합니다.
결론
애플리케이션 레벨 연결 풀링은 효율적인 데이터베이스 상호 작용을 위한 기본 모범 사례이지만, 여러 애플리케이션 인스턴스에 걸쳐 분산된 특성으로 인해 본질적으로 제한적입니다. 높은 동시성 환경, 트래픽 급증 또는 대규모 마이크로서비스 생태계의 경우, 이러한 내부 풀에만 의존하면 데이터베이스 서버에 열린 연결이 폭발적으로 증가하여 성능 저하와 불안정을 초래합니다. PgBouncer 및 AWS RDS Proxy와 같은 전용 연결 풀러는 연결 관리를 중앙 집중화하고, 연결을 다중화하며, 데이터베이스 복원력과 확장성을 크게 향상시키는 고급 기능을 제공하는 중요한 중개자 역할을 합니다. 본질적으로, 진정한 고동시성 성능을 위해서는 애플리케이션 레벨 연결 풀링이 필요한 첫걸음이지만, 전용 연결 풀러는 필수적인 다음 단계입니다.

