05 — QueryRunner 심화

질문: QueryRunner어떤 객체이며, 왜 release()를 잊으면 영구 leak이 되는가? 그리고 어떤 구조적 패턴으로 그 leak을 발생 불가능하게 만들 수 있나? 한 줄 답: QueryRunner풀에서 빌린 단일 물리 연결을 통째로 들고 있는 wrapper다. 이 객체가 가비지 컬렉트되어도 풀은 그 연결을 회수하지 않는다 — 풀이 알 수 있는 유일한 신호는 release() 호출. 그래서 try/finally언어 차원의 강제 메커니즘이 된다.


Why — 왜 QueryRunner라는 별도 객체가 있나

TypeORM의 일반 Repository.find()EntityManager.save()매번 풀에서 연결을 빌렸다가 즉시 돌려준다:

1. acquire connection
2. execute query
3. release connection

이 모델로는 여러 쿼리를 같은 연결에서 실행할 수 없다. 트랜잭션은 정의상 같은 연결에서 BEGIN ~ COMMIT을 묶어야 하는데, 풀에서 매번 다른 연결을 빌리면 BEGIN과 그 다음 쿼리가 다른 연결로 갈 수 있다.

해결책: 연결 한 개를 명시적으로 빌리고, 일정 시간 동안 그것을 들고 다니는 객체 — 이게 QueryRunner다.


How — QueryRunner의 lifecycle

const qr = AppDataSource.createQueryRunner();
//   ↑ 객체 생성 — 아직 풀에서 연결을 빌리지 않음
 
await qr.connect();
//   ↑ 풀에서 연결 한 개를 acquire — 이 시점부터 풀의 idle 카운트 -1
 
await qr.startTransaction('REPEATABLE READ');
//   ↑ 연결에 BEGIN 전송
 
try {
  await qr.manager.save(User, { /* ... */ });
  //   ↑ 같은 연결에서 INSERT
  await qr.manager.save(Order, { /* ... */ });
  //   ↑ 같은 연결에서 INSERT
 
  await qr.commitTransaction();
  //   ↑ 연결에 COMMIT 전송
} catch (err) {
  await qr.rollbackTransaction();
  //   ↑ 연결에 ROLLBACK 전송
  throw err;
} finally {
  await qr.release();
  //   ↑ 연결을 풀로 반환 — 이 시점부터 풀의 idle 카운트 +1
}

6가지 메서드의 역할

메서드동작호출 빈도
createQueryRunner()QueryRunner 객체 생성 (lazy)1번
connect()풀에서 연결 acquire1번
startTransaction(level?)BEGIN 전송0~1번
commitTransaction()COMMIT 전송0~1번
rollbackTransaction()ROLLBACK 전송0~1번
release()연결을 풀로 반환 + QR 비활성화1번 — 반드시

주의: startTransaction 없이도 QueryRunner를 쓸 수 있다 — 트랜잭션 없는 같은 연결 보장만 원할 때 (예: SET SESSION 후 SELECT).


What — QueryRunner가 들고 있는 진짜

내부적으로 QueryRunner는 다음을 들고 있다:

class PostgresQueryRunner {
  databaseConnection: pg.Client;     // ← 빌려온 물리 연결
  isReleased: boolean;               // ← release 후 다시 못 씀
  isTransactionActive: boolean;      // ← BEGIN 후 COMMIT/ROLLBACK 전까지 true
  manager: EntityManager;            // ← 이 QR에 묶인 EntityManager
  // ...
}

핵심은 databaseConnection풀의 한 슬롯을 점유한다는 사실이다. 이 객체가 JavaScript 변수에서 사라져도 — 풀은 그것을 모른다. GC가 일어나도 풀의 idle 카운트가 올라가지 않는다.

왜 GC가 풀을 도와주지 않나 — pg.Client 객체는 풀이 자기 내부 배열에 직접 참조하고 있다. 즉 애플리케이션이 참조를 놓아도 풀이 들고 있으므로 GC되지 않는다. 풀이 그것을 ‘idle’로 표시하는 유일한 방법은 release 메서드 호출을 받는 것.


What — Release 누락의 경로별 분류

production에서 leak을 만드는 다섯 가지 정형 패턴:

패턴 1: throw가 try 밖에 있다

// ❌
const qr = ds.createQueryRunner();
await qr.connect();
const user = await qr.manager.findOne(User, { where: { id } });
if (!user) throw new NotFoundError(); // ← release 없음!
await qr.startTransaction();
try {
  // ...
} finally {
  await qr.release();
}

패턴 2: connect 후 startTransaction 사이의 throw

// ❌
const qr = ds.createQueryRunner();
await qr.connect();
validateInput(); // ← throw 가능. release 없음.
await qr.startTransaction();
try { /* ... */ }
finally { await qr.release(); }

패턴 3: try 안에서 비동기 작업에 await을 빼먹음

// ❌ Floating promise
try {
  qr.manager.save(user); // ← await 없음. throw가 try 밖으로 샌다
  await qr.commitTransaction();
} finally {
  await qr.release();
}

패턴 4: finally에서 await을 빼먹음

// ❌
try { /* ... */ }
finally {
  qr.release(); // ← await 없음. Promise가 떠다님.
}
// 함수가 빨리 return하면 release가 실행되기 전에
// 다음 요청이 풀에서 빌리려다 timeout

패턴 5: release를 catch에서 또 호출

// ❌ double release
try { /* ... */ }
catch (err) {
  await qr.release(); // ← 1번
  throw err;
} finally {
  await qr.release(); // ← 2번 — 에러 또는 미정의 동작
}

What — Leak을 구조적으로 막는 5가지 방법

1. transaction() 콜백을 기본 패턴으로 강제

// ✅ release를 의식할 필요가 없다
await ds.transaction(async (manager) => {
  // ...
});

코드 리뷰 룰: createQueryRunner를 직접 호출하지 않는다.”

2. 전용 헬퍼 함수로 래핑

async function withQueryRunner<T>(
  ds: DataSource,
  fn: (qr: QueryRunner) => Promise<T>,
): Promise<T> {
  const qr = ds.createQueryRunner();
  await qr.connect();
  try {
    return await fn(qr);
  } finally {
    await qr.release();
  }
}
 
// 사용
await withQueryRunner(AppDataSource, async (qr) => {
  await qr.startTransaction();
  try {
    await qr.manager.save(/* ... */);
    await qr.commitTransaction();
  } catch (err) {
    await qr.rollbackTransaction();
    throw err;
  }
});

3. AsyncDisposable (TypeScript 5.2+)

// using 문법 (ES proposal — Stage 3, TS 5.2+)
{
  await using qr = ds.createDisposableQueryRunner();
  // 블록 끝에서 자동으로 release
}

⚠️ 2026년 현재 TypeORM 코어는 공식 지원하지 않는다. 커뮤니티 패치로 가능. 언어 메커니즘으로 leak을 막는 방향이 미래.

4. ESLint 룰로 정적 차단

// .eslintrc
{
  "rules": {
    "no-restricted-syntax": [
      "error",
      {
        "selector": "CallExpression[callee.property.name='createQueryRunner']",
        "message": "직접 호출 금지 — withQueryRunner() 헬퍼를 사용하세요."
      }
    ]
  }
}

5. 런타임 모니터링 — leak detector

// 개발 환경에서만
let activeRunners = new WeakSet<QueryRunner>();
const originalCreate = AppDataSource.createQueryRunner.bind(AppDataSource);
AppDataSource.createQueryRunner = (...args) => {
  const qr = originalCreate(...args);
  activeRunners.add(qr);
  const stack = new Error().stack;
  setTimeout(() => {
    if (activeRunners.has(qr) && !qr.isReleased) {
      console.error('LEAK: QueryRunner not released after 30s', stack);
    }
  }, 30_000);
  return qr;
};

What-if — transaction() 콜백 안에서 manager를 외부로 escape

// ❌ 안티패턴 — manager를 콜백 밖으로 빼낸다
let savedManager: EntityManager;
await AppDataSource.transaction(async (manager) => {
  savedManager = manager;
});
// 콜백이 끝나면 release됨
await savedManager.save(user); // ← 이미 release된 연결을 쓰려 함 — 에러

콜백 인자로 받은 manager그 콜백의 lifecycle에 묶여 있다. 콜백이 끝나면 release되고 manager는 무효화된다.

만약 manager를 escape 시켜야 한다면QueryRunner 직접 패턴으로 가야 한다. 그게 이 챕터의 본질이다.


What — 복합 패턴: 트랜잭션 안의 savepoint

await ds.transaction(async (manager) => {
  // 메인 트랜잭션 안에서 부분 롤백 가능한 savepoint
  await manager.queryRunner!.query('SAVEPOINT sp1');
 
  try {
    await manager.save(user);
    await manager.save(order);
  } catch (err) {
    await manager.queryRunner!.query('ROLLBACK TO SAVEPOINT sp1');
    // 메인 트랜잭션은 살아 있음
  }
 
  // 다른 작업
  await manager.save(auditLog);
});

manager.queryRunner는 콜백 안에서 그 트랜잭션을 들고 있는 QR을 노출한다. 이것으로 savepoint 같은 raw SQL 명령에 접근할 수 있다.


What — Pool 메트릭으로 leak 추적

production에서 leak이 의심될 때:

-- PostgreSQL: idle in transaction 찾기
SELECT pid, application_name, state, query_start, NOW() - query_start AS duration, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND NOW() - query_start > interval '1 minute'
ORDER BY query_start;

idle in transaction 상태가 길게 머무는 것 = QueryRunner가 BEGIN을 보내고 COMMIT/ROLLBACK을 안 보냈다 + release도 안 했다. 전형적인 leak 시그니처다.


Insight — 왜 release를 자동화하지 못하나

JavaScript의 GC는 순수 메모리 관리에 최적화돼 있다. 외부 리소스(파일·소켓·DB 연결)에 대해서는 다음 한계가 있다:

  1. finalizer가 언제 실행되는지 보장 못함 — 수 분 후일 수도, 영영 안 될 수도.
  2. finalizer가 비동기일 수 없음release()는 *네트워크 메시지(COMMIT)*를 보내야 한다.
  3. WeakRef는 약함 — 풀이 강한 참조를 들고 있으므로 GC 대상이 안 됨.

이게 try/finally언어 차원의 강제인 이유다. 다른 언어들도 같은 결론에 도달했다:

  • Python: with 문 (context manager)
  • C#: using 문 (IDisposable)
  • Rust: Drop trait (자동 RAII)
  • TypeScript 5.2+: using 문 (ES proposal)

Rust처럼 컴파일러가 강제하는 언어를 제외하면 — 모든 GC 언어는 수동 cleanup 약속에 의존한다. TypeORM은 그 약속을 release()라는 이름으로 부르고, 개발자의 try/finally 규율로 보호한다.


흥미로운 이야기 — JDBC의 PreparedStatement도 같은 문제

이 문제는 Node.js만의 것이 아니다. Java의 JDBC는 1997년부터 PreparedStatement, ResultSet, Connection을 close해야 했다 — finalize는 신뢰 못 함.

// Java — 같은 문제
try (Connection conn = ds.getConnection();
     PreparedStatement ps = conn.prepareStatement("...");
     ResultSet rs = ps.executeQuery()) {
    // ...
} // try-with-resources가 자동으로 close — Java 7+

Java 7의 try-with-resources2011년에야 추가됐다 — 14년 동안 Java 개발자들은 try/finally수동 cleanup을 했다. 수많은 production leak 후의 언어 변경이었다.

TypeScript 5.2의 using 문은 같은 진화 경로를 따른다. 2026년 현재 아직 TypeORM 공식 통합은 없지만 — 도착하는 순간 Java 7 모먼트가 될 것이다.


요약 (Pyramid Top 재정렬)

QueryRunner풀에서 빌린 단일 물리 연결을 들고 있는 wrapper다.

  1. release()가 풀의 유일한 회수 신호 — GC는 풀을 도와주지 않는다.
  2. leak은 5가지 정형 패턴에서 발생 — throw 위치, await 누락, double release 등.
  3. 구조적 방어: transaction() 콜백 기본 + withQueryRunner 헬퍼 + ESLint 룰.
  4. production 디버깅: pg_stat_activity에서 idle in transactionleak의 지문.
  5. **TypeScript 5.2+의 using**이 미래의 언어 차원 자동화. 도착할 때까지는 try/finally가 표준.

다음 문서는 이 단일 DB 트랜잭션한계를 다룬다 — 여러 DB·서비스에 걸친 일관성을 어떻게 표현하는가.

다음: 06 — 분산 트랜잭션 & 사가 — TypeORM은 2PC를 지원하지 않는다.