---
title: "pg-pool 에서 빌린 연결을 오래 쥐면 error 리스너가 없을 때 프로세스가 죽는다"
description: "pool.connect() 로 빌린 연결에 error 리스너를 달지 않으면, 쉬는 동안 연결이 끊기는 순간 Node 프로세스 전체가 죽습니다. advisory lock 연결을 예로 원인과 리스너 처리 패턴을 정리했습니다."
date: 2026-10-09
updated: 2026-10-09T12:07:18.516Z
tags: [postgresql, node-postgres, pg-pool, advisory-lock, error-handling]
canonical: https://blog.wooncloud.com/posts/pg-pool-borrowed-client-error-listener
---

긴 배치 작업 두 개가 동시에 돌지 않게 하려고 PostgreSQL 세션 advisory lock 을 썼습니다. 그러다 잠금을 쥐고 쉬는 연결 하나가 프로세스 전체를 죽일 수 있다는 걸 알게 됐습니다.

## 쉬고 있는 연결이 왜 프로세스를 죽일까요

세션 잠금은 연결에 붙습니다. 그래서 `pool.connect()` 로 연결 하나를 빌려 그 연결에서만 `pg_try_advisory_lock` 을 잡았습니다. 실제 작업은 풀의 다른 연결로 몇 시간씩 돕니다. 잠금 연결은 그동안 쿼리 없이 가만히 쉽니다.

pg-pool 은 연결이 풀에 있는 동안에만 그 연결의 `error` 이벤트를 대신 받아 줍니다. `pool.connect()` 로 빌려 주는 순간 그 리스너를 뗍니다. 빌려 간 쪽이 `client.on('error', …)` 를 달지 않았는데 쉬는 사이 연결이 끊기면, `Client` 는 리스너 없는 `error` 이벤트를 냅니다.

그런데 Node 의 EventEmitter 는 리스너 없는 `error` 를 그대로 throw 합니다.

![빌린 연결에 error 리스너를 달지 않으면 끊긴 연결이 프로세스 전체를 종료시킨다.](/images/posts/pg-pool-borrowed-client-error-listener/9fba1a5e-063e-4fa7-8b7a-f5485b2e5875.webp)

![빌린 연결에 error 리스너를 달지 않으면 쉬는 중에 끊긴 연결이 EventEmitter 의 throw 로 이어져 프로세스가 종료되고, 빌린 직후 리스너를 달면 이벤트를 받아 프로세스가 유지된다.](/images/posts/pg-pool-borrowed-client-error-listener/3680518b-cc9a-4b71-91df-f1bf2da524ef.svg)

`Unhandled 'error' event` 와 함께 프로세스가 통째로 종료됩니다. 같은 프로세스에서 도는 큐 워커도 같이 죽습니다. 운 좋게 살아남아도 연결이 끊기면서 세션 잠금이 풀리니, 두 배치의 상호 배제는 조용히 깨집니다.

촉발 조건은 흔합니다. DB 재시작이나 페일오버, 관리자의 `pg_terminate_backend`, 방화벽이나 프록시의 idle 연결 정리가 모두 해당합니다.

짧은 트랜잭션만 빌려 쓰는 코드에서는 이 창이 몇 밀리초라 거의 드러나지 않습니다. 하지만 **연결을 쉬게 둔 채 오래 쥐는 패턴**(세션 잠금, LISTEN, 커서)을 도입하는 순간 노출 시간이 몇 시간 단위로 늘어납니다.

## 빌리자마자 리스너를 답니다

해결은 단순합니다. 빌린 직후 리스너를 달고, 잠금 연결은 끝나면 풀로 돌려보내지 말고 파기합니다.

```ts
const client = await pool.connect();
let lost = false;
client.on("error", (err) => {
  lost = true; // 잠금도 같이 사라졌다. 작업 루프가 이 값을 보고 멈춘다
  logger.warn({ err }, "lock connection lost");
});
try {
  const { rows } = await client.query("SELECT pg_try_advisory_lock($1) AS ok", [LOCK_KEY]);
  if (!rows[0].ok) return { locked: false };
  try {
    return { locked: true, value: await work(() => lost) };
  } finally {
    await client.query("SELECT pg_advisory_unlock($1)", [LOCK_KEY]).catch(() => {});
  }
} finally {
  client.release(true); // 세션 잠금이 풀에 남지 않게 연결째 버린다
}
```

재현은 어렵지 않습니다. 연결을 빌려 잠금을 잡은 뒤 다른 연결에서 `SELECT pg_terminate_backend(<pid>)` 를 실행해 보면 됩니다. 리스너가 없으면 프로세스가 `57P01` 로 종료되고, 있으면 이벤트만 받고 계속 돕니다.

## 쿼리를 기다리는 중이면 괜찮지 않을까요?

쿼리를 `await` 하는 중이면 에러가 그 쿼리의 reject 로 올 테니 괜찮을 것 같지만, 아니었습니다.

`pg` 8.x `Client` 는 연결이 예기치 않게 닫히면 진행 중인 쿼리를 전부 reject 한 뒤에 `error` 이벤트도 따로 냅니다(`_errorAllQueries`, `_handleErrorEvent`, `emit('error')` 순서). 30초짜리 리포트 쿼리를 기다리는 중에 페일오버가 와도, 쿼리 reject 와 별개로 리스너 없는 `error` 가 터져 프로세스가 죽습니다.

반대로 `pool.query()` 는 안전합니다. pg-pool 이 내부에서 연결을 빌린 뒤 `client.once('error', …)` 를 직접 달아 두기 때문입니다. 노출되는 곳은 수동 `pool.connect()` 뿐이고, 직접 만든 트랜잭션 헬퍼(`BEGIN … COMMIT`)도 여기에 해당합니다.

헬퍼에 적용할 때 챙길 것은 세 가지였습니다.

- 빌린 직후 `client.on('error', …)` 로 로그만 남기고, 실패 처리는 쿼리 reject 쪽에서 합니다.
- 실패한 연결은 `client.release(err)` 로 버립니다. 인자 없는 `release()` 는 깨진 연결을 풀로 돌려보낼 수 있습니다.
- 정상 반납 전에는 단 리스너를 뗍니다.

마지막이 좀 의외였습니다. pg-pool 은 반납할 때 자기 idle 리스너만 다시 붙이고, 호출자가 단 리스너는 그대로 둡니다. 풀이 같은 연결을 계속 재사용하면 호출할 때마다 리스너가 하나씩 쌓이고, 11개째에 `MaxListenersExceededWarning` 이 뜹니다. 페이지 루프처럼 같은 헬퍼를 수천 번 부르는 배치에서 특히 잘 드러납니다.

파기(`release(err)`)할 때만 리스너를 남겨 둡니다. 파기 도중에 오는 오류도 받아야 하니까요.

```ts
const client = await pool.connect();
let broken: Error | undefined;
const onError = (err: Error) => { broken ??= err; logger.warn({ err }, "borrowed client error"); };
client.on("error", onError);
try {
  return await work(client);
} catch (err) {
  // ROLLBACK 이 실패할 만큼 깨졌으면 연결째 버린다
  try { await client.query("ROLLBACK"); } catch (e) { broken ??= e as Error; }
  throw err;
} finally {
  if (broken) client.release(broken);            // 파기: 리스너 유지
  else { client.removeListener("error", onError); client.release(); } // 반납: 리스너 제거
}
```

테스트는 두 가지를 봤습니다. 빌린 client 에 `emit('error')` 를 흘려도 프로세스가 사는지, 그리고 `max: 1` 풀에서 여러 번 호출한 뒤 `client.listenerCount('error')` 가 늘지 않는지입니다.

수동으로 연결을 빌리는 코드가 있다면 `error` 리스너부터 확인해 볼 만합니다.

## 참고

- node-postgres Pool, checkout, use, and return: https://node-postgres.com/apis/pool
- node-postgres Client events (`error`): https://node-postgres.com/apis/client
- Node.js EventEmitter error events: https://nodejs.org/api/events.html#error-events
- PostgreSQL advisory locks: https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS
