03 — 트랜잭션 3가지 방법
질문: TypeORM에서 트랜잭션을 표현하는 세 방법이 각각 어디에 위치하며, 언제 어느 것을 써야 하는가? 한 줄 답:
dataSource.transaction(cb)이 95%의 경우고,QueryRunner는 콜백 밖으로 트랜잭션을 들고 나가야 할 때만,@Transaction()데코레이터는 0.3에서 deprecated. NestJS는typeorm-transactional패키지로 데코레이터 표현을 살려뒀다.
Why — 왜 세 가지가 있나
트랜잭션은 *“여러 쿼리를 하나의 BEGIN/COMMIT으로 감싼다”*는 한 가지 의미를 가진다. 그런데 그 의미를 코드에 어떻게 표현하느냐는 세 갈래로 갈렸다.
| 표현 방식 | 동기 | 트레이드오프 |
|---|---|---|
| 콜백 | ”트랜잭션 범위를 블록으로 시각화” | 콜백 밖으로 EntityManager를 못 보냄 |
| 명시적 객체 (QueryRunner) | “트랜잭션을 변수처럼 들고 다님” | release를 직접 책임짐 |
| 데코레이터 | ”트랜잭션 경계를 메서드 시그니처로 선언” | 어떤 DataSource를 쓰는지 암묵적 |
0.3에서 데코레이터가 deprecated된 이유는 마지막 줄이다 — 멀티 DB 시대에 암묵적 DataSource 가정이 깨졌다.
How — 방법 1: dataSource.transaction(cb) ✅ 기본
import { AppDataSource } from './data-source';
async function transfer(fromId: string, toId: string, amount: number) {
return AppDataSource.transaction(async (manager) => {
const from = await manager.findOne(Account, { where: { id: fromId } });
const to = await manager.findOne(Account, { where: { id: toId } });
if (from.balance < amount) throw new InsufficientBalanceError();
from.balance -= amount;
to.balance += amount;
await manager.save([from, to]);
return { from: from.balance, to: to.balance };
});
}동작 순서:
- TypeORM이 내부적으로
QueryRunner를 만들고 풀에서 연결을 빌린다. BEGIN을 보낸다.- 콜백에
manager를 넘긴다 — 이 manager는 그 트랜잭션 위에서만 동작하는 EntityManager. - 콜백이 정상 종료 →
COMMIT. - 콜백이 throw →
ROLLBACK. - 어떤 경로든 finally로
QueryRunner.release().
격리 수준을 지정하려면:
await AppDataSource.transaction('SERIALIZABLE', async (manager) => {
// ...
});이 옵션은 04 — 격리 수준에서 자세히.
How — 방법 2: QueryRunner 직접 ⭕ 필요할 때만
async function complexFlow() {
const qr = AppDataSource.createQueryRunner();
await qr.connect();
await qr.startTransaction('REPEATABLE READ');
try {
// 트랜잭션 위에서 작업
const user = await qr.manager.findOne(User, { where: { id: 'u_1' } });
await qr.manager.save(user);
// 콜백 밖으로 manager를 다른 함수에 *명시적으로* 넘긴다
await sendNotification(qr.manager, user);
await auditLog(qr.manager, 'user_updated', user.id);
await qr.commitTransaction();
} catch (err) {
await qr.rollbackTransaction();
throw err;
} finally {
await qr.release(); // ⚠️ 반드시 호출
}
}
// 다른 함수가 manager를 *주입받아* 같은 트랜잭션 안에서 동작
async function sendNotification(manager: EntityManager, user: User) {
await manager.save(Notification, { userId: user.id, type: 'update' });
}언제 이걸 쓰나 — transaction() 콜백이 부족한 경우:
- EntityManager를 다른 함수에 명시적으로 넘기고 싶을 때 — 콜백 안에 비즈니스 로직 전체를 욱여넣지 않기 위해.
- 트랜잭션 시작과 끝이 다른 호출 사이에 있을 때 — 예: HTTP 요청 시작 시 BEGIN, 응답 직전에 COMMIT (savepoint 활용).
- savepoint를 수동으로 관리해야 할 때 —
qr.query('SAVEPOINT sp1'). - 트랜잭션 메타데이터를 외부에서 들여다봐야 할 때 —
qr.isTransactionActive등.
이 패턴의 유일한 위험은 release() 누락 — 05 — QueryRunner 심화에서 깊이.
How — 방법 3: @Transaction() 데코레이터 ❌ deprecated
// ❌ 0.2.x — 0.3에서 deprecated
import { Transaction, TransactionManager, EntityManager } from 'typeorm';
class UserService {
@Transaction()
async createUser(
@TransactionManager() manager: EntityManager,
dto: CreateUserDto,
) {
const user = manager.create(User, dto);
await manager.save(user);
return user;
}
}왜 deprecated인가:
- 어느 DataSource를 쓰는지 암묵적 — 데코레이터는 전역 EntityManager를 가정. 멀티 DB에서 결정 불가.
@TransactionManager()인자 주입이 프레임워크 마법. 표준 TypeScript 데코레이터 spec과 어긋난다.- TC39 데코레이터 spec이 바뀌면서 호환성 위험 — TypeORM은 0.3에서 명시적 함수 호출 패턴으로 무게중심을 옮겼다.
0.3에서 이 데코레이터는 남아 있지만 새 코드에서 쓰지 마라 — 제거 예고.
How — 방법 3.5: typeorm-transactional 패키지 (NestJS 진영)
NestJS 사용자들은 데코레이터 표현을 완전히 포기하지 못했다. typeorm-transactional 패키지가 그 자리를 채웠다.
import { Transactional, Propagation, IsolationLevel } from 'typeorm-transactional';
@Injectable()
export class TransferService {
@Transactional({
propagation: Propagation.REQUIRED,
isolationLevel: IsolationLevel.REPEATABLE_READ,
})
async transfer(fromId: string, toId: string, amount: number) {
// 이 메서드 안의 모든 Repository 호출이 *자동으로* 같은 트랜잭션에 묶임
const from = await this.accountRepo.findOne({ where: { id: fromId } });
from.balance -= amount;
await this.accountRepo.save(from);
// 다른 서비스 호출도 같은 트랜잭션
await this.notificationService.send(fromId);
}
}핵심 차이 — 이 패키지는 AsyncLocalStorage로 현재 트랜잭션 컨텍스트를 들고 다닌다. Repository 호출이 암묵적으로 그 컨텍스트의 EntityManager를 쓴다.
| 특성 | TypeORM 내장 @Transaction | typeorm-transactional |
|---|---|---|
| 상태 | ❌ deprecated | ✅ 활발히 유지 |
| 메커니즘 | 메서드 인자 주입 | AsyncLocalStorage |
| Propagation | ❌ 없음 | ✅ REQUIRED · REQUIRES_NEW · NESTED |
| Multi-DataSource | ❌ | ⭕ (옵션) |
Spring @Transactional과 유사도 | 낮음 | 매우 높음 |
언제 쓰나:
- NestJS + 여러 Service가 한 트랜잭션을 공유해야 할 때.
- Spring 출신 개발자가 익숙한 표현을 원할 때.
- 전파(Propagation) 시맨틱을 원할 때 — REQUIRES_NEW로 별도 트랜잭션에서 로그를 기록.
What — 세 방법의 비교
| 항목 | transaction(cb) | QueryRunner | @Transaction | typeorm-transactional |
|---|---|---|---|---|
| 상태 | ✅ 기본 | ✅ 활발 | ❌ deprecated | ✅ NestJS 표준 |
| release | 자동 | 수동 | 자동 | 자동 |
| 격리 수준 지정 | 첫 인자 | startTransaction(level) | 데코레이터 인자 | 옵션 |
| 트랜잭션 범위 | 콜백 안 | BEGIN ~ COMMIT 명시 | 메서드 전체 | 메서드 전체 |
| manager 전달 | 콜백 인자 | qr.manager 변수 | @TransactionManager 주입 | AsyncLocalStorage |
| 여러 함수 분할 | ⚠️ manager를 넘겨야 함 | ✅ 명시 | ⚠️ 데코레이터 중첩 모호 | ✅ 자동 |
| Propagation | ❌ | 수동 (savepoint) | ❌ | ✅ |
| 테스트 격리 | ✅ 쉬움 | ✅ | ⚠️ 전역 가정 | ✅ |
| 추천도 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐ (NestJS만) |
What — 결정 트리
What-if — 트랜잭션 밖에서 save하면
가장 흔한 실수.
// ❌ manager가 아니라 *원래 Repository*를 사용
await AppDataSource.transaction(async (manager) => {
const user = await userRepo.findOne({ where: { id: 'u_1' } });
// ^^^^^^^^ — 트랜잭션 밖의 Repository!
user.balance -= 100;
await userRepo.save(user);
// ^^^^^^^^ — 이 save는 트랜잭션 *밖에서* 일어난다
});userRepo는 DataSource에서 만든 기본 Repository다. 콜백 인자로 받은 manager와 별개다. userRepo.save()는 풀에서 다른 연결을 빌려 자기만의 트랜잭션(autocommit)에서 실행된다.
증상: 콜백 안의 다른 작업이 throw로 rollback돼도 — 이 save는 이미 commit돼서 데이터가 남는다. 반쪽짜리 일관성.
정답:
// ✅ 콜백 안에서는 *오직 manager*만 쓴다
await AppDataSource.transaction(async (manager) => {
const user = await manager.findOne(User, { where: { id: 'u_1' } });
user.balance -= 100;
await manager.save(user);
});이 함정이 흔한 이유 — NestJS의 Repository DI가 트랜잭션을 자동으로 따라가지 않는다. typeorm-transactional이 등장한 진짜 이유다.
Insight — 왜 콜백 패턴이 기본인가
세 가지가 다 동등한 표현인데, 왜 0.3은 콜백 패턴을 기본으로 밀었나?
답은 release 보장이 언어 메커니즘으로 자동화되기 때문이다.
- 콜백 = 함수 호출 스택의 자연스러운 경계. throw가 위로 전파되면 그 위의 finally가 cleanup한다.
- QueryRunner = 변수 lifecycle. 변수가 들고 다니는 객체의 정리를 개발자가 책임진다.
- 데코레이터 = 메서드 시그니처. 메서드의 entry/exit를 가로채야 하므로 프레임워크 마법이 필요.
0.3의 베팅: “표현은 단순하게, lifecycle은 언어에 맡긴다”. 이게 콜백이 기본이 된 이유다.
흥미로운 이야기 — Spring @Transactional이 30년 동안 살아남은 이유
자바 진영에서 @Transactional은 사실상 표준이다. Spring이 이 데코레이터 패턴을 *AOP(Aspect-Oriented Programming)*로 구현했고, AsyncLocalStorage에 해당하는 ThreadLocal로 트랜잭션 컨텍스트를 들고 다닌다.
TypeORM이 0.2에서 시도한 @Transaction은 그 표현은 베꼈지만 메커니즘은 못 베꼈다 — Node.js의 단일 스레드 + 비동기 모델에서는 동기 ThreadLocal이 없기 때문이다.
해결책으로 등장한 게 Node.js의 AsyncLocalStorage (Node 14+). 비동기 컨텍스트를 따라가는 storage. typeorm-transactional 패키지가 이것을 활용해서 Spring 스타일을 Node.js에서 재현했다.
즉,
@Transaction이 deprecated된 자리에typeorm-transactional이 들어선 게 아니다 — 그 자리는 AsyncLocalStorage가 가능해진 시점에야 비로소 제대로 채울 수 있었다.
요약 (Pyramid Top 재정렬)
트랜잭션 표현 방식은 세 갈래로 나뉘었다가 두 개로 수렴했다.
dataSource.transaction(cb)— 95%의 경우. release 자동, 격리 수준은 첫 인자.QueryRunner직접 — 콜백 밖으로 manager를 들고 나가야 할 때만. try/finally 강제.@Transaction()데코레이터 — deprecated. 새 코드에서 쓰지 마라.typeorm-transactional(NestJS) — 데코레이터 표현이 진짜로 필요한 자리. AsyncLocalStorage가 받쳐준다.다음 문서는 이 트랜잭션의 강도를 정하는 격리 수준을 다룬다.
다음: 04 — 격리 수준 —
READ COMMITTED/REPEATABLE READ/SERIALIZABLE.