본문으로 건너뛰기
wooncloud

pg-pool 에서 빌린 연결을 오래 쥐면 error 리스너가 없을 때 프로세스가 죽는다

pool.connect() 로 빌린 연결에 error 리스너를 달지 않으면, 쉬는 동안 연결이 끊기는 순간 Node 프로세스 전체가 죽습니다. advisory lock 연결을 예로 원인과 리스너 처리 패턴을 정리했습니다.

·8 min read·— views··

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

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

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

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

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

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

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

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

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

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

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))할 때만 리스너를 남겨 둡니다. 파기 도중에 오는 오류도 받아야 하니까요.

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 리스너부터 확인해 볼 만합니다.

참고