TypeORM 트랜잭션이 중요한 이유
주문 생성 시 재고 차감, 결제 기록, 포인트 적립이 하나라도 실패하면 전부 롤백해야 한다. 트랜잭션은 이런 원자적 작업을 보장하는 핵심 메커니즘이다. TypeORM은 여러 트랜잭션 API를 제공하지만, 각각의 동작 방식과 함정이 다르다. 특히 NestJS의 DI 환경에서 트랜잭션용 EntityManager 전파가 가장 빈번한 실수 포인트다.
이 글에서는 TypeORM의 4가지 트랜잭션 패턴, 비관적/낙관적 잠금, 격리 수준, NestJS 서비스 계층 전파, 그리고 테스트 전략까지 실무에서 바로 적용할 수 있는 수준으로 다룬다.
4가지 트랜잭션 패턴
1. DataSource.transaction() — 가장 권장
@Injectable()
export class OrderService {
constructor(private readonly dataSource: DataSource) {}
async createOrder(userId: number, items: OrderItemDto[]): Promise<Order> {
return this.dataSource.transaction(async (manager) => {
// manager는 트랜잭션에 바인딩된 EntityManager
const user = await manager.findOneOrFail(User, {
where: { id: userId },
});
const order = manager.create(Order, {
user,
status: OrderStatus.PENDING,
});
await manager.save(order);
for (const item of items) {
// 재고 차감 (같은 트랜잭션)
await manager
.createQueryBuilder()
.update(Product)
.set({ stock: () => `stock - ${item.quantity}` })
.where('id = :id AND stock >= :qty', {
id: item.productId,
qty: item.quantity,
})
.execute();
const orderItem = manager.create(OrderItem, {
order,
productId: item.productId,
quantity: item.quantity,
price: item.price,
});
await manager.save(orderItem);
}
// 포인트 적립 (같은 트랜잭션)
user.points += order.totalPoints;
await manager.save(user);
return order;
// 콜백 정상 종료 → 자동 COMMIT
// 예외 발생 → 자동 ROLLBACK
});
}
}
2. QueryRunner — 수동 제어
async transferFunds(fromId: number, toId: number, amount: number) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction('SERIALIZABLE'); // 격리 수준 지정
try {
const from = await queryRunner.manager.findOneOrFail(Account, {
where: { id: fromId },
lock: { mode: 'pessimistic_write' },
});
if (from.balance < amount) {
throw new InsufficientFundsError(from.balance, amount);
}
await queryRunner.manager.update(Account, fromId, {
balance: () => `balance - ${amount}`,
});
await queryRunner.manager.update(Account, toId, {
balance: () => `balance + ${amount}`,
});
await queryRunner.manager.save(
queryRunner.manager.create(TransferLog, {
fromId, toId, amount, executedAt: new Date(),
}),
);
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release(); // 커넥션 반환 필수!
}
}
3. @Transaction 데코레이터 (비권장)
// ⚠️ TypeORM 공식 데코레이터 — NestJS DI와 궁합이 나쁨
// 주입된 Repository가 아닌 @TransactionManager를 써야 하므로
// 서비스 간 호출 시 트랜잭션이 전파되지 않는 문제가 있음
// → DataSource.transaction() 사용 권장
4. save() 자동 트랜잭션
// save()는 단일 엔티티(+ cascade) 저장 시 내부적으로 트랜잭션 사용
// 하지만 여러 Repository에 걸친 작업에는 부적합
const user = userRepository.create({ name: 'Alice', profile: { bio: '...' } });
await userRepository.save(user); // User + Profile INSERT가 원자적
| 패턴 | 용도 | 권장도 |
|---|---|---|
DataSource.transaction() |
대부분의 비즈니스 로직 | ⭐⭐⭐ 최우선 |
QueryRunner |
세밀한 제어, 격리 수준 지정 | ⭐⭐ 필요 시 |
@Transaction |
— | ❌ 비권장 |
save() 자동 |
단일 엔티티 + cascade | ⭐ 단순 작업만 |
NestJS 서비스 간 트랜잭션 전파
가장 흔한 실수: 서비스 A에서 트랜잭션을 시작하고 서비스 B를 호출했는데, B가 자체 Repository를 사용하여 트랜잭션 밖에서 쿼리가 실행되는 것이다.
// ❌ 잘못된 패턴: 트랜잭션이 전파되지 않음
@Injectable()
export class OrderService {
constructor(
private readonly dataSource: DataSource,
private readonly inventoryService: InventoryService,
) {}
async createOrder(dto: CreateOrderDto) {
return this.dataSource.transaction(async (manager) => {
const order = manager.create(Order, dto);
await manager.save(order);
// ❌ inventoryService 내부에서 주입된 Repository 사용
// → 트랜잭션 밖에서 실행됨!
await this.inventoryService.decrementStock(dto.productId, dto.qty);
});
}
}
// ✅ 올바른 패턴: manager를 전달
@Injectable()
export class OrderService {
async createOrder(dto: CreateOrderDto) {
return this.dataSource.transaction(async (manager) => {
const order = manager.create(Order, dto);
await manager.save(order);
// ✅ manager를 전달하여 같은 트랜잭션에서 실행
await this.inventoryService.decrementStock(
dto.productId, dto.qty, manager,
);
});
}
}
@Injectable()
export class InventoryService {
async decrementStock(
productId: number,
qty: number,
manager?: EntityManager, // 선택적 manager 수신
) {
const em = manager ?? this.dataSource.manager;
const result = await em
.createQueryBuilder()
.update(Product)
.set({ stock: () => `stock - ${qty}` })
.where('id = :id AND stock >= :qty', { id: productId, qty })
.execute();
if (result.affected === 0) {
throw new InsufficientStockError(productId);
}
}
}
비관적 잠금 vs 낙관적 잠금
// 비관적 잠금: SELECT ... FOR UPDATE
// 동시 접근이 빈번한 재고, 잔액 등에 적합
async reserveStock(productId: number, qty: number, manager: EntityManager) {
const product = await manager.findOne(Product, {
where: { id: productId },
lock: { mode: 'pessimistic_write' }, // 행 잠금
});
if (!product || product.stock < qty) {
throw new InsufficientStockError(productId);
}
product.stock -= qty;
await manager.save(product);
}
// 낙관적 잠금: @VersionColumn + 충돌 감지
@Entity()
export class Product {
@PrimaryGeneratedColumn()
id: number;
@Column()
stock: number;
@VersionColumn() // 자동 증가 버전 컬럼
version: number;
}
// 사용: 버전 불일치 시 OptimisticLockVersionMismatchError 발생
async updateProduct(id: number, dto: UpdateProductDto) {
const product = await this.productRepository.findOneOrFail({
where: { id },
});
Object.assign(product, dto);
try {
await this.productRepository.save(product);
} catch (err) {
if (err instanceof OptimisticLockVersionMismatchError) {
// 재시도 또는 사용자에게 충돌 알림
throw new ConflictException('Data was modified by another user');
}
throw err;
}
}
| 잠금 방식 | 메커니즘 | 적합 사례 | 위험 |
|---|---|---|---|
| 비관적 (FOR UPDATE) | DB 행 잠금 | 재고, 잔액, 좌석 예약 | 데드락, 성능 저하 |
| 낙관적 (@VersionColumn) | 버전 비교 후 충돌 감지 | 프로필 수정, 설정 변경 | 충돌 시 재시도 필요 |
격리 수준(Isolation Level)
// QueryRunner로 격리 수준 지정
await queryRunner.startTransaction('READ COMMITTED'); // 기본값 (PostgreSQL)
await queryRunner.startTransaction('REPEATABLE READ'); // 트랜잭션 내 일관된 읽기
await queryRunner.startTransaction('SERIALIZABLE'); // 가장 엄격
// DataSource.transaction()에서 격리 수준 지정
await this.dataSource.transaction(
'SERIALIZABLE',
async (manager) => {
// 금융 이체 등 정합성이 최우선인 작업
},
);
| 격리 수준 | Dirty Read | Non-Repeatable | Phantom | 성능 |
|---|---|---|---|---|
| READ UNCOMMITTED | 가능 | 가능 | 가능 | 최고 |
| READ COMMITTED | 방지 | 가능 | 가능 | 높음 |
| REPEATABLE READ | 방지 | 방지 | 가능 | 보통 |
| SERIALIZABLE | 방지 | 방지 | 방지 | 낮음 |
트랜잭션 테스트 전략
describe('OrderService.createOrder', () => {
let dataSource: DataSource;
let service: OrderService;
beforeAll(async () => {
// 테스트용 DB 연결
dataSource = await new DataSource({
type: 'sqlite',
database: ':memory:',
entities: [Order, OrderItem, Product, User],
synchronize: true,
}).initialize();
service = new OrderService(dataSource, new InventoryService(dataSource));
});
// 각 테스트를 트랜잭션으로 감싸고 롤백 → 테스트 간 격리
let queryRunner: QueryRunner;
beforeEach(async () => {
queryRunner = dataSource.createQueryRunner();
await queryRunner.startTransaction();
});
afterEach(async () => {
await queryRunner.rollbackTransaction();
await queryRunner.release();
});
it('should rollback all changes on insufficient stock', async () => {
// seed
await queryRunner.manager.save(Product, { id: 1, stock: 2, name: 'Item' });
await queryRunner.manager.save(User, { id: 1, name: 'Alice', points: 0 });
// 재고 2개인데 3개 주문 → 에러
await expect(
service.createOrder({ userId: 1, items: [{ productId: 1, qty: 3, price: 100 }] }),
).rejects.toThrow(InsufficientStockError);
// 롤백 확인: 주문이 생성되지 않았어야 함
const orders = await queryRunner.manager.find(Order);
expect(orders).toHaveLength(0);
// 재고도 원래대로
const product = await queryRunner.manager.findOneBy(Product, { id: 1 });
expect(product.stock).toBe(2);
});
});
운영 체크리스트
- QueryRunner 반환:
finally블록에서 반드시queryRunner.release()를 호출한다. 미반환 시 커넥션 풀이 고갈된다 - 트랜잭션 크기: 가능한 짧게 유지한다. 긴 트랜잭션은 락 경합과 커넥션 점유를 유발한다
- 데드락 방지: 여러 테이블을 잠글 때 항상 같은 순서로 잠근다. Spring @Transactional의 전파 수준 개념을 참고하면 도움이 된다
- 멱등성: 재시도 시 중복 처리가 발생하지 않도록 유니크 키나 idempotency key를 활용한다
- 이벤트 발행: 트랜잭션 커밋 후에 이벤트를 발행한다. 커밋 전 발행하면 롤백되었는데 이벤트는 이미 처리된 상태가 된다
- 모니터링: Micrometer로 트랜잭션 평균 시간, 롤백 비율, 커넥션 풀 사용률을 추적한다
TypeORM 트랜잭션의 핵심은 DataSource.transaction() 콜백 패턴을 기본으로 사용하고, 서비스 간 호출 시 EntityManager를 명시적으로 전달하여 트랜잭션 경계를 유지하는 것이다. 비관적/낙관적 잠금과 격리 수준은 비즈니스 요구사항에 맞게 선택하되, 트랜잭션을 가능한 짧게 유지하는 것이 성능과 안정성의 핵심이다.