본문으로 바로가기 본문 바로가기
WIKI 기술 지식 베이스

클라이언트 연결하기 (Connect Your Client)

원문 보기 위키 갱신

이 가이드는 애플리케이션을 Fly Managed Postgres에 연결하고, 안정적이고 성능 좋은 연결을 위해 데이터베이스 클라이언트를 구성하는 방법을 다뤄요. 연결 문자열 사용법부터 연결 수명, PgBouncer 풀 모드, 언어별 구성, 그리고 흔한 오류 해결까지 알려드릴게요.

출처: 문서

본문

이 가이드는 애플리케이션을 Fly Managed Postgres에 연결하고 안정적이고 성능 좋은 연결을 위해 데이터베이스 클라이언트를 구성하는 방법을 다뤄요.

빠른 시작

MPG 클러스터 생성 및 앱 연결 후에 필수적인 것들:

1. 연결 문자열. fly mpg attach로 앱을 연결하면 Fly가 앱에 DATABASE_URL 시크릿을 자동으로 설정해요. 연결 중에 변수 이름을 커스터마이즈할 수 있어요. 앱은 런타임에 이를 환경 변수로 받아요. 풀링된 URL과 직접 URL 모두 클러스터 대시보드의 Connect 탭에서 확인할 수 있어요.

  • 풀링된 URL(기본값): postgresql://fly-user:***@pgbouncer.YOUR_CLUSTER.flympg.net/fly-db — PgBouncer를 통해 라우팅돼요. 애플리케이션에는 이것을 사용하세요.
  • 직접 URL: postgresql://fly-user:***@direct.YOUR_CLUSTER.flympg.net/fly-db — PgBouncer를 우회해요. 마이그레이션, 어드바이저리 락(advisory locks), 또는 LISTEN/NOTIFY에는 이것을 사용하세요.

모든 MPG 연결에는 SSL이 기본적으로 활성화돼요. 연결 문자열에 sslmode를 설정할 필요는 없어요.

2. 코드에서 연결 수명과 유휴 타임아웃을 설정하세요. 이것들은 앱의 데이터베이스 클라이언트 또는 연결 풀 라이브러리에서 구성하는 설정이지, 데이터베이스나 클러스터 측 설정이 아니에요. 모든 클라이언트 라이브러리가 이 설정을 직접 지원하는 것은 아니에요 — 아래 언어별 예시를 확인하세요.

Setting Recommended value Why
Max connection lifetime 600 seconds (10 min) Recycle connections before the proxy closes them
Idle connection timeout 300 seconds (5 min) Releases unused connections before they're forcibly closed

3. 마이그레이션용 직접 URL을 설정하세요. 대부분의 프레임워크는 배포 시 마이그레이션을 실행해요. 마이그레이션은 어드바이저리 락과 기타 세션 범위 기능을 사용하므로, 풀링된 URL이 아니라 직접 URL이 필요해요.

fly secrets set \
  DATABASE_URL="postgresql://...@pgbouncer.YOUR_CLUSTER.flympg.net/fly-db" \
  DIRECT_DATABASE_URL="postgresql://...@direct.YOUR_CLUSTER.flympg.net/fly-db"
# fly.toml
[deploy]
  release_command = "/bin/sh -lc 'DATABASE_URL=$DIRECT_DATABASE_URL bin/migrate'"

Elixir 전용 마이그레이션 설정은 Phoenix 가이드를 참조하세요.

4. 트랜잭션 모드에서 준비된 문장(prepared statements) 비활성화. PgBouncer 풀 모드가 Transaction으로 설정되어 있다면(Ecto에 필요하며 고처리량 앱에 권장) 클라이언트에서 명명된 준비된 문장을 비활성화해야 해요. PgBouncer는 트랜잭션 간에 준비된 문장을 추적할 수 없어요. 풀 모드 변경 방법은 클러스터 구성을 참조하세요.


클라이언트 구성이 중요한 이유

여러분의 Fly.io 앱과 Fly.io Postgres 데이터베이스는 모두 Fly.io 프록시 뒤에 있어요. 공개 및 프라이빗 트래픽이 이 프록시를 통과해요. 때때로 프록시가 재시작되고 프록시는 재시작 전에 연결을 최대한 드레인(drain)하려고 해요. Postgres에는 특정 연결에서 쿼리 전송을 중단하라고 클라이언트에 알려 주는 프로토콜 수준 메커니즘이 없으므로, 원활한 핸드오프를 처리하려면 클라이언트 측 구성에 의존해야 해요.

프록시의 종료 타임아웃은 10분이에요. 그 이후에도 열려 있는 연결은 종료돼요. 앱이 이것보다 오래 연결을 유지한다면 — 대부분의 연결 풀의 기본 동작이에요 — 프록시 배포 중에 tcp recv (idle): closed나 ECONNRESET 같은 오류를 만날 수 있어요.

해결 방법은 간단해요: 프록시의 타임아웃보다 짧은 간격으로 연결을 적극적으로 재활용하도록 연결 풀을 구성하세요.

PgBouncer 모드와 클라이언트

모든 MPG 클러스터에는 연결 풀링용 PgBouncer가 포함돼요. 클러스터 측에서 선택하는 풀 모드는 클라이언트가 할 수 있는 일에 영향을 줘요. 모드 변경 방법은 클러스터 구성을 참조하세요.

세션 모드(기본값): PgBouncer 연결은 전체 클라이언트 세션 동안 유지돼요. 완전한 PostgreSQL 기능 호환성 — 준비된 문장, 어드바이저리 락, LISTEN/NOTIFY, 다중 문장 트랜잭션이 모두 정상적으로 작동해요. 연결 재사용률은 더 낮아요.

트랜잭션 모드: PgBouncer가 트랜잭션당 하나의 연결을 할당하고 그 후에 풀로 반환해요. 더 높은 처리량과 연결 재사용을 제공하지만:

  • 명명된 준비된 문장이 작동하지 않아요 — 이름 없는/확장 쿼리 프로토콜을 사용해야 해요.
  • 어드바이저리 락이 세션 범위가 아니에요 — 마이그레이션에는 직접 URL을 사용하세요.
  • **LISTEN/NOTIFY**가 작동하지 않아요 — 대체 알림기를 사용하세요(Phoenix 가이드의 Oban 예시 참조).
  • SET 명령은 현재 트랜잭션에만 영향을 줘요.

ORM이나 드라이버가 지원한다면, 이름 없는 준비된 문장이 있는 트랜잭션 모드는 대부분의 웹 애플리케이션에 더 나은 선택이에요.

언어별 구성

```javascript const { Pool } = require('pg');
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,

  // Pool sizing
  max: 10,

  // Connection lifecycle
  idleTimeoutMillis: 300_000,       // 5 min — close idle connections
  maxLifetimeSeconds: 600,          // 10 min — recycle before proxy timeout
  connectionTimeoutMillis: 5_000,   // 5s — fail fast on connection attempts
});
```

`maxLifetimeSeconds`는 `pg-pool` 3.5.1(`pg` 8.8+에 포함)에서 추가됐어요. 이전 버전을 사용 중이라면 업그레이드하세요 — 이 설정은 Fly에서 안정적인 연결에 중요해요.
연결 문자열에 다음 쿼리 매개변수를 추가하세요:
| Parameter | Value | Purpose |
| - | - | - |
| `pgbouncer` | `true` | Disables prepared statements for PgBouncer compatibility |
| `connection_limit` | `10` | Pool size per Prisma client instance |
| `pool_timeout` | `30` | Seconds to wait for a connection |

Prisma 스키마에서:

```prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
```

Prisma는 내부적으로 자체 연결 풀을 관리해요. `connection_limit` 매개변수는 Prisma 클라이언트 인스턴스당 풀 크기를 제어해요.
```python import os from sqlalchemy import create_engine
engine = create_engine(
    os.environ["DATABASE_URL"],

    # Pool sizing
    pool_size=10,
    max_overflow=5,

    # Connection lifecycle
    pool_recycle=600,       # 10 min — recycle connections before proxy timeout
    pool_timeout=30,        # 30s — wait for available connection
    pool_pre_ping=True,     # verify connections before use

    # Disable prepared statements for PgBouncer transaction mode
    # (uncomment if using transaction mode)
    # connect_args={"prepare_threshold": None},  # for psycopg3
    # connect_args={"options": "-c plan_cache_mode=force_custom_plan"},  # alternative
)
```

`pool_recycle`은 최대 연결 수명이에요 — SQLAlchemy는 이 값보다 오래된 연결을 닫고 교체해요.

`pool_pre_ping`은 각 연결 체크아웃 전에 가벼운 `SELECT 1`을 발행해요. 작은 라운드트립이 추가되지만 쿼리가 실패하기 전에 오래된 연결을 잡아내요.
```python import os from psycopg_pool import ConnectionPool
pool = ConnectionPool(
    conninfo=os.environ["DATABASE_URL"],

    # Pool sizing
    min_size=2,
    max_size=10,

    # Connection lifecycle
    max_lifetime=600,       # 10 min, stays ahead of PgBouncer's 600s idle timeout
    max_idle=300,           # 5 min — close idle connections
    reconnect_timeout=5,

    # Disable prepared statements for PgBouncer transaction mode
    # (uncomment if using transaction mode)
    # kwargs={"prepare_threshold": None},
)
```
```go import ( "database/sql" "os" "time"
    _ "github.com/jackc/pgx/v5/stdlib"
)

// Open the connection pool.
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
    log.Fatal(err)
}

// Pool sizing
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)

// Connection lifecycle
db.SetConnMaxLifetime(10 * time.Minute) // recycle before proxy timeout
db.SetConnMaxIdleTime(5 * time.Minute)  // close idle connections
```

Go의 `database/sql`은 연결 재활용을 기본적으로 처리해요. `SetConnMaxLifetime`이 핵심 설정이에요 — 지정된 기간을 넘어 어떤 연결도 재사용되지 않도록 보장해요.

PgBouncer 트랜잭션 모드용 준비된 문장을 비활성화하려면 `default_query_exec_mode` 연결 매개변수를 사용하세요:

```go
connStr := os.Getenv("DATABASE_URL") + "?default_query_exec_mode=exec"
db, err := sql.Open("pgx", connStr)
```
```yaml # config/database.yml production: url: ENV["DATABASE_URL"] pool: ENV.fetch("RAILS_MAX_THREADS", 5) idle_timeout: 300 # 5 min — close idle connections checkout_timeout: 5 prepared_statements: false # required for PgBouncer transaction mode ```
최대 연결 수명의 경우 Rails 8.1+는 `max_age`를 기본 지원해요:

```yaml
# config/database.yml (Rails 8.1+)
production:
  url: ENV["DATABASE_URL"]
  pool: ENV.fetch("RAILS_MAX_THREADS", 5)
  max_age: 600                # 10 min — recycle before proxy timeout
  idle_timeout: 300
  checkout_timeout: 5
  prepared_statements: false
```

이전 Rails 버전에는 내장된 최대 연결 수명이 없어요. 앱에 트래픽이 충분하면 유휴 타임아웃으로 연결이 여전히 재활용되지만, 오래 지속되는 바쁜 연결은 오류가 발생할 때까지 재활용되지 않아요.
```elixir # config/runtime.exs config :my_app, MyApp.Repo, url: System.fetch_env!("DATABASE_URL"), pool_size: 8, max_lifetime: 540_000..600_000, # max connection lifetime; the range staggers reconnects prepare: :unnamed # required for PgBouncer transaction mode ```
마이그레이션, Oban 구성, Ecto 전용 트러블슈팅을 포함한 포괄적인 Phoenix 설정은 [Managed Postgres와 Phoenix](/postgres/guides-examples/phoenix-guide) 가이드를 참조하세요.

<Info>
  **Ecto의 연결 수명에 대한 참고:** `:max_lifetime`은 DBConnection 2.10.0 이상(연결이 유휴 상태로 있으면 2.10.1+)이 필요합니다. 단일 값이 아니라 밀리초 단위의 범위를 취하므로 전체 풀이 한 번에 만료되는 대신 재연결이 분산됩니다. MPG의 PgBouncer는 600초 유휴 후 클라이언트 연결을 닫고(`client_idle_timeout`) 600초 후 자체 서버 연결을 은퇴시킵니다(`server_lifetime`). 9~10분 일정으로 재활용하면 풀이 둘 다보다 앞서므로 연결이 쿼리 도중 닫혀 있는 것으로 나타나는 대신 여러분이 원하는 시점에 교체됩니다.
</Info>

연결 제한

각 MPG 요금제에는 모든 클라이언트가 공유하는 고정된 수의 PgBouncer 연결 슬롯이 있어요. 전체 풀 크기(모든 앱 프로세스에 걸친)가 이 제한을 초과하면 새 연결은 대기열에 넣어지거나 거부돼요.

Plan Max client connections Max database connections Reserve pool
Basic 200 50 10
Starter 200 50 10
Launch 500 150 30
Scale 1000 300 50
Performance 1000 300 50

Max client connections은 PgBouncer가 수락할 클라이언트 연결의 총 수예요. Max database connections은 PgBouncer가 PostgreSQL에 여는 실제 연결 수예요. 예약 풀(reserve pool)은 일반 풀 크기 이상의 버스트를 처리해요.

흔한 연결 제한 오류

FATAL: too many connections for role 또는 remaining connection slots are reserved for roles with the SUPERUSER attribute: 모든 프로세스의 총 풀 크기가 PgBouncer 연결 제한을 초과했어요. 해결 방법:

  • 각 프로세스에서 pool_size / max 줄이기
  • 더 나은 연결 재사용을 위해 transaction 풀 모드로 전환
  • 연결 누수 확인(열렸지만 풀로 반환되지 않은 연결)

총 풀 사용량 계산: pool_size: 10인 웹 프로세스 3개와 pool_size: 5인 워커 프로세스 2개가 있다면 총계는 (3 × 10) + (2 × 5) = 40 연결이에요.

트러블슈팅

`tcp recv (idle): closed` 또는 `tcp recv (idle): timeout`

원인: PgBouncer 또는 프록시가 유휴 연결을 닫았어요. MPG의 PgBouncer는 600초 유휴 후 클라이언트 연결을 닫아요(client_idle_timeout). 프록시 배포도 이전 인스턴스가 종료될 때 연결을 드레인해요.

해결: 클라이언트의 유휴 타임아웃을 300초(5분)로, 최대 연결 수명을 600초(10분)로 설정해 PgBouncer의 자체 유휴 타임아웃보다 풀을 앞서게 하세요. 대부분의 연결 풀은 연결이 닫힐 때 자동으로 재연결하므로 이런 오류는 일시적이에요. 자주 보인다면 풀 크기를 줄여 더 적은 연결이 유휴 상태로 있게 하세요.

`ECONNRESET` 또는 "connection reset by peer"

원인: 오래 살아 있는 연결이 업스트림의 무언가(예: 프록시 재시작이 남은 연결을 드레인하는 것)에 의해 종료됐어요.

해결: 최대 연결 수명을 600초(10분) 이하로 설정해 풀이 자체 일정으로 연결을 재활용하게 하세요. 일시적 실패에는 백오프를 포함한 재시도 로직을 활성화하세요.

준비된 문장 오류

prepared statement "..." does not exist, prepared statement "..." already exists, 또는 로그인 시 protocol_violation 같은 오류는 모두 같은 근본 원인을 가리켜요: 클라이언트가 트랜잭션 모드의 PgBouncer를 통해 명명된 준비된 문장을 보내고 있다는 것이에요. PgBouncer는 트랜잭션마다 다른 백엔드 연결을 할당하므로 한 연결에서 만든 준비된 문장은 다음 연결에서 사용할 수 없어요.

해결: 클라이언트 구성에서 명명된 준비된 문장을 비활성화하세요:

  • Node.js (pg): 기본 동작이에요 — 변경 필요 없음
  • Prisma: 연결 문자열에 ?pgbouncer=true 추가
  • Python (psycopg3): prepare_threshold=None 설정
  • Go (pgx): default_query_exec_mode=exec 사용
  • Ruby (ActiveRecord): prepared_statements: false 설정
  • Elixir (Ecto): prepare: :unnamed 설정

시작 시 연결 멈춤

원인: Fly 내부 IPv6 네트워크에서 DNS 확인 실패. 앱이 .flympg.net 주소를 확인하지 못해요.

해결: 앱이 IPv6로 구성되었는지 확인하세요. Elixir 앱은 IPv6 설정 가이드를 참조하세요. 다른 런타임은 DNS 리졸버가 Fly 프라이빗 네트워크에서 AAAA 레코드를 지원하는지 확인하세요.

더 알아보기 (Learn more)