05 — Transaction & DataSource
이 챕터가 답하는 질문: “TypeORM에서 여러 쿼리를 하나로 묶는다는 게 정확히 어떤 객체 위에서 일어나는 일이며, 왜 그 객체를 직접 들고 다녀야 하는가?” 한 줄 답 (Pyramid Top): “DataSource는 연결의 정체성(누구·어느 DB·풀 설정)이고, Transaction은 그 연결 위에 잠시 그어진 약속(BEGIN~COMMIT)이다. 그 약속을 들고 다니는 객체가
QueryRunner이며, release를 잊으면 풀은 영구적으로 새어 나간다.”
이전 챕터(04 — QueryBuilder)까지는 쿼리 하나의 모양만 다뤘다. 이번 챕터는 그 쿼리들을 한 묶음으로 일관되게 보내는 레이어 — Transaction & DataSource를 다룬다.
핵심은 한 가지다. TypeORM 0.3 이후, 트랜잭션은 암묵적인 전역 객체에서 명시적으로 들고 다녀야 하는 객체로 바뀌었다. getConnection()이 사라지고 DataSource 인스턴스를 직접 주입해야 하는 이유 — 그리고 그 안에서 트랜잭션을 transaction() 콜백·QueryRunner·(과거의) @Transaction 데코레이터 세 가지 방식으로 어떻게 표현하는지가 이 챕터의 전부다.
한 문장 답
DataSource는 풀과 정체성을 들고, QueryRunner는 그 풀에서 하나의 연결을 빌려 들고, Transaction은 그 연결 위에 BEGIN/COMMIT을 긋는다. 세 개가 항상 명시적이어야 안전하며 — TypeORM은 분산 트랜잭션을 지원하지 않으므로 여러 DataSource를 하나로 묶고 싶다면 사가 패턴으로 우회해야 한다.
챕터 지도 (Mermaid)
읽는 순서
| # | 파일 | 읽는 데 | 핵심 키워드 |
|---|---|---|---|
| 01 | 01-datasource-vs-connection | 8분 | DataSource · getConnection 제거 · 0.2→0.3 마이그레이션 · 다중 DB |
| 02 | 02-connection-pool | 10분 | poolSize · idleTimeoutMillis · leak detector · extra 패스스루 |
| 03 | 03-transactions | 12분 | dataSource.transaction() · QueryRunner.startTransaction() · @Transaction deprecated |
| 04 | 04-isolation-levels | 10분 | 4단계 격리 · PostgreSQL 기본 READ COMMITTED · MySQL 기본 REPEATABLE READ |
| 05 | 05-query-runner-deep-dive | 12분 | 연결 하나를 통째로 들고 있는 객체 · try/finally · release 미호출 = leak |
| 06 | 06-distributed-and-saga | 12분 | 2PC 미지원 · 사가 패턴 · outbox · 보상 트랜잭션 |
추천 동선: 1→2→3이 기본 트라이앵글이다. 4·5는 production에서 다친 뒤 돌아오는 reference. 6은 여러 DB·여러 서비스를 묶기 시작할 때 필수.
의존성: 01이 연결의 정체성을 정의하고, 02가 그 위의 풀을, 03이 그 위의 약속을, 04가 그 약속의 강도를, 05가 그 약속을 들고 다니는 객체를, 06이 그 약속의 한계를 다룬다.
6개 문서 한 줄 요약
| # | 문서 | 한 줄 답 |
|---|---|---|
| 01 | DataSource vs Connection | 0.3 이전의 Connection은 전역 싱글톤처럼 다뤄졌고, 0.3은 그것을 명시적으로 들고 다니는 객체인 DataSource로 바꿨다. getConnection()이 사라진 이유. |
| 02 | Connection Pool | TypeORM의 풀은 드라이버 풀의 얇은 래퍼다 — poolSize·idleTimeoutMillis·extra 세 손잡이가 전부. leak은 QueryRunner release 누락에서 100% 온다. |
| 03 | 트랜잭션 3가지 방법 | dataSource.transaction(cb)이 95%의 경우고, QueryRunner는 콜백 밖으로 트랜잭션을 들고 나가야 할 때만, @Transaction 데코레이터는 deprecated. |
| 04 | 격리 수준 | SQL 표준의 4단계 — READ UNCOMMITTED < READ COMMITTED < REPEATABLE READ < SERIALIZABLE. PostgreSQL은 READ COMMITTED, MySQL InnoDB는 REPEATABLE READ가 기본. |
| 05 | QueryRunner 심화 | QueryRunner는 풀에서 빌린 단일 연결을 통째로 들고 있는 객체다. try/finally로 release()를 호출하지 않으면 그 연결은 영원히 풀로 돌아오지 않는다. |
| 06 | 분산 트랜잭션 & 사가 | TypeORM은 2-phase commit을 지원하지 않는다. 여러 DB·여러 서비스의 일관성은 사가(보상 트랜잭션)·outbox(이벤트 발행을 같은 트랜잭션에) 패턴으로 우회한다. |
트랜잭션 표현 방식 — 한 표로
| 방식 | 위치 | 권장도 | 트랜잭션 범위 | 격리 지정 | release |
|---|---|---|---|---|---|
dataSource.transaction(cb) | 0.3+ | ✅ 기본 | 콜백 안 | 두 번째 인자 | 자동 |
QueryRunner 수동 | 0.3+ | ⭕ 필요 시 | 명시 BEGIN/COMMIT | startTransaction(level) | 직접 호출 필수 |
@Transaction() 데코레이터 | ~0.2.x | ❌ deprecated | 메서드 전체 | 데코레이터 인자 | 자동 |
@Transactional() (typeorm-transactional 패키지) | NestJS 진영 | ⭕ NestJS 한정 | AsyncLocalStorage 컨텍스트 | 옵션 | 자동 |
Why — 왜 이 챕터가 필요한가
TypeORM에서 런타임에 가장 많이 깨지는 것은 쿼리 문법이 아니라 트랜잭션·연결 관리 가정이다.
- “0.2로 짠 코드를 0.3으로 올렸더니
getConnection()이 없어졌다” → DataSource로 전환 (01) - “production에서
connection pool exhausted에러가 떴다” → QueryRunner release 누락 (02,05) - “테스트는 통과하는데 동시 요청에서 dirty read가 보인다” → 격리 수준 (
04) - “트랜잭션 안에서 콜백이 아닌 다른 함수로 EntityManager를 넘기고 싶다” → QueryRunner 패턴 (
03,05) - “결제는 DB-A, 재고는 DB-B인데 두 DB에 걸친 트랜잭션을 원한다” → TypeORM은 2PC 미지원 (
06) - “Decorator로 트랜잭션을 깔끔하게 표현하고 싶은데 deprecated라고 한다” →
@Transactional또는transaction()(03)
이 챕터는 트랜잭션 표현 3가지의 위치를 확정하고, 그 트랜잭션을 들고 다니는 객체의 정체(QueryRunner)를 해부한다.
How — 어떻게 읽나
- 0.2에서 0.3으로 마이그레이션 중인 사람: 01만 정독하면 대부분의 컴파일 에러가 풀린다.
- production에서 연결이 새는 사람: 02 → 05. 어떤 코드가 release를 빼먹는지 패턴이 다섯 개로 압축된다.
- NestJS로 트랜잭션 표현이 더러워진 사람: 03만.
typeorm-transactional패키지로 가는 길. - 여러 서비스·여러 DB를 묶고 싶은 사람: 06. 2PC가 없다는 사실을 받아들이는 게 시작.
- 격리 수준이 처음인 사람: 04. SQL 표준 4단계를 각 DB가 어떻게 다르게 해석하는지까지.
What-if — 이 챕터를 건너뛰면
- DataSource를 모르면: 0.3으로 올린 순간 모든 곳에서 컴파일 에러.
getConnection()이 더 이상 답이 아니다. - Pool 설정을 모르면: 트래픽 피크에 조용히 timeout만 늘어난다 — 에러 메시지는 모호한
Connection terminated. - 트랜잭션 3가지를 모르면: 콜백 안에 비즈니스 코드 전체를 욱여넣고, 그 함수에서 return 한 줄을 빼먹으면 무음 롤백된다.
- 격리 수준을 모르면: dev에서는 통과하는 테스트가 production의 동시성에서 데이터 정합성 깨짐으로 돌아온다.
- QueryRunner의 release 강제를 모르면: 30분 뒤 모든 요청이 pool에서 대기하다가 timeout — 흔한 Friday afternoon outage.
- 분산 트랜잭션을 지원한다고 착각하면: 두 DB에 걸친 코드를 transaction()으로 감싸고 한 쪽이 실패해도 다른 쪽은 commit되는 부분 실패에 당황한다.
Insight — 한 단락 이야기
“TypeORM 0.3의 가장 큰 변화는 데코레이터가 아니라 getConnection()의 죽음이었다”
0.2까지 TypeORM은 전역 싱글톤 Connection이라는 가정 위에 서 있었다.
getConnection()한 줄이면 어디서든 연결을 얻었고,getRepository(User)도 그 전역에서 끌어왔다. 이 가정은 단일 DB · 단일 환경에서는 잘 동작했지만, 멀티 테넌시 · 테스트 격리 · 멀티 DB에서는 공유 가변 상태가 되어 폭발했다. 2022년 0.3 릴리스는 전역을 제거했다 —DataSource라는 명시적 객체를 도입하고, 그 객체를 의존성 주입으로 들고 다니는 것을 강제했다. 같은 시기에@Transaction()데코레이터를 deprecated 처리했는데, 이유도 같았다 — 전역 EntityManager를 가정하는 데코레이터로는 어떤 트랜잭션에 붙어 있는지를 명시할 수 없었기 때문이다. 이 챕터의 모든 결정은 “전역을 제거하고 연결의 정체성을 객체로 들고 다닌다”는 0.3의 철학에서 흘러나온다. — DataSource가 정체성, QueryRunner가 빌린 연결, Transaction이 그 위의 약속, Saga가 약속이 끝나는 지점.
한 단락 요약
이 챕터를 끝내면 “트랜잭션을 어떻게 시작하지” 대신 *“이 약속은 어느 DataSource 위에 있고, 어느 QueryRunner가 들고 있으며, 어느 격리 수준에서 어디까지 일관성을 보장하는가”*라는 질문을 던지게 된다. 다음 챕터(
06-migration-schema)는 이 약속들이 그 위에서 동작하는 스키마를 어떻게 진화시키는가 — 즉 마이그레이션을 다룬다.