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() | 풀에서 연결 acquire | 1번 |
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 연결)에 대해서는 다음 한계가 있다:
- finalizer가 언제 실행되는지 보장 못함 — 수 분 후일 수도, 영영 안 될 수도.
- finalizer가 비동기일 수 없음 —
release()는 *네트워크 메시지(COMMIT)*를 보내야 한다. - WeakRef는 약함 — 풀이 강한 참조를 들고 있으므로 GC 대상이 안 됨.
이게 try/finally가 언어 차원의 강제인 이유다. 다른 언어들도 같은 결론에 도달했다:
- Python:
with문 (context manager) - C#:
using문 (IDisposable) - Rust:
Droptrait (자동 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-resources는 2011년에야 추가됐다 — 14년 동안 Java 개발자들은 try/finally로 수동 cleanup을 했다. 수많은 production leak 후의 언어 변경이었다.
TypeScript 5.2의 using 문은 같은 진화 경로를 따른다. 2026년 현재 아직 TypeORM 공식 통합은 없지만 — 도착하는 순간 Java 7 모먼트가 될 것이다.
요약 (Pyramid Top 재정렬)
QueryRunner는 풀에서 빌린 단일 물리 연결을 들고 있는 wrapper다.
release()가 풀의 유일한 회수 신호 — GC는 풀을 도와주지 않는다.- leak은 5가지 정형 패턴에서 발생 — throw 위치, await 누락, double release 등.
- 구조적 방어:
transaction()콜백 기본 +withQueryRunner헬퍼 + ESLint 룰.- production 디버깅:
pg_stat_activity에서idle in transaction이 leak의 지문.- **TypeScript 5.2+의
using**이 미래의 언어 차원 자동화. 도착할 때까지는try/finally가 표준.다음 문서는 이 단일 DB 트랜잭션의 한계를 다룬다 — 여러 DB·서비스에 걸친 일관성을 어떻게 표현하는가.
다음: 06 — 분산 트랜잭션 & 사가 — TypeORM은 2PC를 지원하지 않는다.