MikroORM 트랜잭션
데이터베이스에서 여러 개의 쓰기 작업을 하나의 단위로 묶어야 하는 상황은 매우 흔하다. 주문을 생성하면서 재고를 차감하고, 결제 내역을 기록하는 과정에서 하나라도 실패하면 전부 되돌려야 한다. 이걸 보장하는 메커니즘이 트랜잭션이다.
대부분의 데이터베이스는 기본적으로 auto-commit 모드로 동작한다. SQL 문 하나하나가 각각의 작은 트랜잭션으로 감싸져서 실행된다는 뜻이다. 이렇게 하면 "5개의 INSERT를 하나의 단위로 묶는다"는 게 불가능하고, 트랜잭션을 열고 닫는 비용이 매 쿼리마다 발생해서 성능도 나빠진다.
MikroORM은 이 문제를 Unit of Work 패턴으로 해결한다. 변경사항을 즉시 DB에 반영하지 않고, em.flush()가 호출될 때까지 모아뒀다가 한 번에 하나의 트랜잭션으로 실행한다.
암묵적 트랜잭션 (Implicit)
가장 기본적인 방식이다. 별도로 트랜잭션을 선언하지 않아도, flush()가 자동으로 트랜잭션을 열고 모든 변경을 실행한 뒤 커밋한다.
const user = new User('George');
const profile = new Profile(user);
em.persist(user);
em.persist(profile);
await em.flush();
// 내부적으로: BEGIN → INSERT user → INSERT profile → COMMIT
persist()를 여러 번 호출해도 실제 쿼리는 나가지 않는다. flush() 시점에 MikroORM이 변경된 엔티티들을 탐지(change detection)하고, INSERT/UPDATE/DELETE 쿼리를 모아서 하나의 트랜잭션으로 묶어 실행한다. 중간에 에러가 나면 자동으로 롤백된다.
이 방식은 모든 데이터 조작이 엔티티를 통해 이루어지는 경우에 충분하다. 별도의 트랜잭션 코드 없이도 원자성이 보장되니까 대부분의 CRUD 작업에서는 이것만으로도 괜찮다.
명시적 트랜잭션 (Explicit)
암묵적 방식으로는 부족한 경우가 있다. 여러 서비스에 걸쳐 작업해야 하거나, 트랜잭션 범위를 세밀하게 제어하고 싶거나, raw 쿼리를 트랜잭션에 포함시키고 싶을 때다. MikroORM은 세 가지 명시적 트랜잭션 방식을 제공한다.
1. em.transactional()
가장 많이 쓰는 방식이다. 콜백 함수를 받아서 그 안의 모든 작업을 하나의 트랜잭션으로 묶는다.
await em.transactional(async (em) => {
const order = new Order(customer);
em.persist(order);
const product = await em.findOneOrFail(Product, productId);
product.stock -= quantity;
const payment = new Payment(order, amount);
em.persist(payment);
});
// 콜백이 정상 완료되면 → 자동 flush + commit
// 콜백에서 에러가 throw되면 → 자동 rollback
콜백에 전달되는 em은 현재 EntityManager의 fork다. 이 fork된 em은 독립적인 identity map을 가지지만, 기존 컨텍스트의 관리 대상 엔티티들을 그대로 물려받는다(clear: false). 그래서 바깥에서 로드한 엔티티를 콜백 안에서 바로 사용할 수 있다.
핵심 포인트: 콜백이 끝나면 MikroORM이 자동으로 flush()를 호출한 뒤 COMMIT을 실행한다. 에러가 발생하면 ROLLBACK을 실행한다. try-catch를 직접 작성할 필요가 없다.
2. begin/commit/rollback
좀 더 세밀한 제어가 필요할 때 직접 트랜잭션 경계를 관리할 수 있다.
const em = orm.em.fork();
await em.begin();
try {
const user = new User('George');
em.persist(user);
// raw 쿼리도 같은 트랜잭션에 포함
await em.execute('UPDATE counters SET value = value + 1 WHERE name = ?', ['users']);
await em.commit(); // flush 후 commit
} catch (e) {
await em.rollback();
throw e;
}
em.transactional()과 동일한 동작이지만, 트랜잭션 시작/커밋/롤백 시점을 직접 결정한다. em.commit()은 내부적으로 flush()를 먼저 호출하므로, 별도로 flush할 필요는 없다.
이 방식은 여러 비동기 작업 사이에서 트랜잭션을 유지하거나, 조건부로 커밋/롤백을 결정해야 할 때 유용하다. 다만 try-catch를 직접 관리해야 하므로 실수하기 쉽다. 가능하면 em.transactional()을 쓰자.
3. @Transactional() 데코레이터
클래스 메서드에 데코레이터를 붙여서 해당 메서드 전체를 트랜잭션으로 감싸는 방식이다.
import { Transactional } from '@mikro-orm/core';
export class OrderService {
constructor(private readonly em: EntityManager) {}
@Transactional()
async createOrder(customerId: number, items: OrderItem[]) {
const customer = await this.em.findOneOrFail(Customer, customerId);
const order = new Order(customer);
for (const item of items) {
const product = await this.em.findOneOrFail(Product, item.productId);
product.stock -= item.quantity;
order.items.add(new OrderLine(product, item.quantity));
}
this.em.persist(order);
// 메서드 종료 시 자동 flush + commit
}
}
em.transactional()을 내부적으로 호출하는 것과 같다. NestJS 같은 DI 프레임워크에서 서비스 메서드에 붙이면 깔끔하게 트랜잭션을 선언할 수 있다.
em.fork()의 역할
명시적 트랜잭션에서 em.fork()는 중요한 역할을 한다. MikroORM의 EntityManager는 identity map을 내장하고 있어서, 같은 em을 여러 요청에서 공유하면 데이터가 꼬인다. fork()는 독립적인 identity map을 가진 새 em을 만든다.
// ❌ 위험: 글로벌 em을 직접 사용
await orm.em.begin(); // 다른 요청과 충돌 가능
// ✅ 안전: fork된 em 사용
const em = orm.em.fork();
await em.begin();
em.transactional()을 사용하면 내부적으로 fork를 처리해주므로, 직접 fork를 호출할 필요가 없다. 하지만 begin/commit/rollback을 직접 사용할 때는 반드시 fork부터 해야 한다.
NestJS에서 @RequestScope() 또는 RequestContext 미들웨어를 사용하면 요청마다 자동으로 fork된 em이 주입되므로, 이 경우에도 별도의 fork 없이 em.transactional()만 사용하면 된다.
트랜잭션 전파 (Propagation)
트랜잭션이 중첩되면 어떻게 동작할까? 서비스 A의 트랜잭션 안에서 서비스 B의 트랜잭션 메서드를 호출하면? 이걸 제어하는 게 전파(propagation) 옵션이다.
MikroORM은 7가지 전파 모드를 제공한다.
NESTED (기본값)
기존 트랜잭션이 있으면 savepoint를 생성한다. 없으면 새 트랜잭션을 시작한다.
await em.transactional(async (em1) => {
const author = new Author('Kim');
em1.persist(author);
try {
await em1.transactional(async (em2) => {
const book = new Book('Draft');
em2.persist(book);
throw new Error('내부 실패');
});
} catch (e) {
// 내부 트랜잭션만 롤백됨 (savepoint까지)
// author는 여전히 저장됨
}
});
savepoint는 PostgreSQL의 SAVEPOINT/ROLLBACK TO SAVEPOINT를 사용한다. 내부 트랜잭션이 실패해도 외부 트랜잭션은 영향받지 않는다. 가장 안전한 기본 동작이다.
REQUIRED
기존 트랜잭션이 있으면 그대로 합류한다. savepoint를 만들지 않는다. 없으면 새 트랜잭션을 시작한다.
@Transactional()
async createAuthorWithBook() {
const author = new Author('Martin');
this.em.persist(author);
await this.addBook(author);
}
@Transactional({ propagation: TransactionPropagation.REQUIRED })
async addBook(author: Author) {
const book = new Book('Clean Code');
book.author = author;
this.em.persist(book);
throw new Error(); // author와 book 모두 롤백!
}
NESTED와의 차이: NESTED에서는 내부가 실패해도 외부는 살아남을 수 있다. REQUIRED에서는 내부 실패 = 전체 실패다. "전부 성공하거나 전부 실패하거나"가 필요할 때 사용한다.
REQUIRES_NEW
기존 트랜잭션과 완전히 독립적인 새 트랜잭션을 만든다.
@Transactional()
async processOrder() {
const order = new Order();
this.em.persist(order);
await this.logAudit('ORDER_CREATED'); // 독립 트랜잭션
throw new Error(); // order는 롤백, 하지만 audit log는 이미 커밋됨
}
@Transactional({ propagation: TransactionPropagation.REQUIRES_NEW })
async logAudit(action: string) {
const log = new AuditLog(action);
this.em.persist(log);
// 여기서 독립적으로 커밋됨
}
감사 로그, 알림 기록처럼 "메인 작업이 실패해도 반드시 남겨야 하는 기록"에 사용한다. 주의할 점은 독립 트랜잭션이므로 외부 트랜잭션에서 persist한 엔티티를 참조하면 아직 커밋되지 않은 상태라 보이지 않을 수 있다는 것이다.
MANDATORY
반드시 기존 트랜잭션 안에서만 실행되어야 한다. 트랜잭션 없이 호출하면 에러가 발생한다.
@Transactional({ propagation: TransactionPropagation.MANDATORY })
async deductStock(productId: number, qty: number) {
const product = await this.em.findOneOrFail(Product, productId);
product.stock -= qty;
}
// ✅ 트랜잭션 안에서 호출 → 정상
await em.transactional(async () => {
await stockService.deductStock(1, 5);
});
// ❌ 트랜잭션 없이 호출 → ValidationError!
await stockService.deductStock(1, 5);
재고 차감, 잔액 변경 같은 "절대로 단독으로 실행되면 안 되는" 작업에 사용한다. 개발 시점에 잘못된 사용을 바로 잡아준다.
SUPPORTS
트랜잭션이 있으면 합류하고, 없으면 트랜잭션 없이 실행한다.
@Transactional({ propagation: TransactionPropagation.SUPPORTS })
async findBook(id: number) {
return this.em.findOneOrFail(Book, id, { populate: ['author'] });
}
읽기 전용 메서드에 적합하다. 트랜잭션 안에서 호출되면 해당 트랜잭션의 일관된 스냅샷을 읽고, 트랜잭션 밖에서 호출되면 그냥 최신 데이터를 읽는다.
NOT_SUPPORTED / NEVER
NOT_SUPPORTED는 기존 트랜잭션을 일시 중단하고 트랜잭션 없이 실행한다. NEVER는 트랜잭션이 존재하면 에러를 던진다. 외부 API 호출이나 트랜잭션이 필요 없는 리포트 생성 같은 작업에 사용한다.
격리 수준 (Isolation Level)
트랜잭션의 격리 수준은 동시에 실행되는 트랜잭션들이 서로의 데이터를 어느 정도까지 볼 수 있는지를 결정한다.
import { IsolationLevel } from '@mikro-orm/core';
await em.transactional(async (em) => {
const product = await em.findOneOrFail(Product, productId);
product.stock -= 1;
}, { isolationLevel: IsolationLevel.SERIALIZABLE });
PostgreSQL 기준으로 4가지 레벨이 있다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read | 설명 |
|---|---|---|---|---|
| READ UNCOMMITTED | 가능 | 가능 | 가능 | 커밋 안 된 데이터도 읽음 (PostgreSQL에서는 READ COMMITTED로 동작) |
| READ COMMITTED | 방지 | 가능 | 가능 | 커밋된 데이터만 읽음. PostgreSQL 기본값 |
| REPEATABLE READ | 방지 | 방지 | 가능 | 트랜잭션 시작 시점의 스냅샷을 읽음 |
| SERIALIZABLE | 방지 | 방지 | 방지 | 완전 직렬화. 가장 엄격하지만 가장 느림 |
Dirty Read: 다른 트랜잭션이 커밋하지 않은 데이터를 읽는 것. 그 트랜잭션이 롤백하면 존재하지 않는 데이터를 읽은 셈이 된다.
Non-Repeatable Read: 같은 트랜잭션 안에서 같은 행을 두 번 읽었는데 값이 다른 것. 중간에 다른 트랜잭션이 그 행을 수정하고 커밋했기 때문이다.
Phantom Read: 같은 조건으로 두 번 조회했는데 행의 수가 달라지는 것. 중간에 다른 트랜잭션이 새 행을 삽입하고 커밋했기 때문이다.
실무에서는 대부분 기본값인 READ COMMITTED로 충분하다. 재고 차감이나 좌석 예매처럼 동시성 충돌이 치명적인 경우에만 SERIALIZABLE이나 REPEATABLE READ를 고려한다.
Flush Mode
MikroORM은 flush 타이밍도 제어할 수 있다. 트랜잭션 내에서 쿼리를 실행할 때, 아직 flush되지 않은 변경사항이 쿼리 결과에 영향을 줄 수 있기 때문이다.
import { FlushMode } from '@mikro-orm/core';
await em.transactional(async (em) => {
const user = new User('Alice');
em.persist(user);
// FlushMode.AUTO: 쿼리 전에 자동 flush
// FlushMode.COMMIT: 커밋 시에만 flush (기본값)
// FlushMode.ALWAYS: 모든 쿼리 전에 flush
const count = await em.count(User); // COMMIT 모드에서는 Alice가 안 잡힘
}, { flushMode: FlushMode.AUTO });
| 모드 | 설명 | 사용 시점 |
|---|---|---|
| COMMIT | 커밋 시에만 flush. 기본값 | 대부분의 경우 |
| AUTO | 쿼리 전에 관련 엔티티 자동 flush | 트랜잭션 안에서 방금 persist한 데이터를 바로 조회해야 할 때 |
| ALWAYS | 모든 쿼리 전에 무조건 flush | 거의 사용하지 않음. 성능 저하 |
COMMIT 모드가 기본값인 이유는 성능 때문이다. 불필요한 flush를 줄여서 DB 왕복을 최소화한다. 하지만 "persist 직후에 그 데이터를 조회"해야 하는 경우에는 AUTO가 필요하다.
실전 패턴
NestJS 서비스에서의 트랜잭션
NestJS에서 가장 흔한 패턴은 서비스 메서드에 @Transactional()을 붙이는 것이다.
@Injectable()
export class OrderService {
constructor(
private readonly em: EntityManager,
private readonly paymentService: PaymentService,
private readonly inventoryService: InventoryService,
) {}
@Transactional()
async placeOrder(dto: CreateOrderDto) {
// 1. 주문 생성
const order = new Order(dto.customerId);
this.em.persist(order);
// 2. 재고 차감
for (const item of dto.items) {
await this.inventoryService.deduct(item.productId, item.quantity);
}
// 3. 결제 처리
await this.paymentService.charge(order.id, dto.amount);
return order;
}
}
placeOrder 안의 모든 작업이 하나의 트랜잭션으로 묶인다. inventoryService.deduct()가 @Transactional({ propagation: REQUIRED })로 선언되어 있다면, 외부 트랜잭션에 합류해서 하나의 단위로 동작한다.
여러 엔티티의 원자적 업데이트
@Transactional()
async transferBalance(fromId: number, toId: number, amount: number) {
const from = await this.em.findOneOrFail(Account, fromId);
const to = await this.em.findOneOrFail(Account, toId);
if (from.balance < amount) {
throw new BadRequestException('잔액 부족');
}
from.balance -= amount;
to.balance += amount;
// 이체 기록
const transfer = new TransferLog(from, to, amount);
this.em.persist(transfer);
}
잔액 차감과 증가, 이체 기록이 모두 하나의 트랜잭션 안에서 실행된다. 중간에 서버가 죽어도 "한쪽만 차감되고 다른 쪽은 안 늘어나는" 상태는 발생하지 않는다.
트랜잭션 안에서 외부 API 호출 주의
// ❌ 나쁜 예: 트랜잭션 안에서 외부 API 호출
@Transactional()
async processPayment(orderId: number) {
const order = await this.em.findOneOrFail(Order, orderId);
order.status = OrderStatus.PAID;
// 외부 결제 API 호출 - 응답이 느리면 트랜잭션이 오래 열려있음
await this.stripeClient.charge(order.amount);
}
// ✅ 좋은 예: 외부 호출은 트랜잭션 밖에서
async processPayment(orderId: number) {
// 1. 외부 결제 먼저
const charge = await this.stripeClient.charge(amount);
// 2. 성공하면 DB 업데이트 (트랜잭션은 짧게)
await this.em.transactional(async (em) => {
const order = await em.findOneOrFail(Order, orderId);
order.status = OrderStatus.PAID;
order.chargeId = charge.id;
});
}
트랜잭션이 열려 있는 동안 DB 커넥션을 점유한다. 외부 API 호출이 느리면 커넥션 풀이 고갈될 수 있다. 외부 호출은 트랜잭션 밖에서, DB 작업은 트랜잭션 안에서 짧게 처리하는 게 원칙이다.
주의사항
1. nativeUpdate/nativeDelete는 트랜잭션과 별개
await em.transactional(async (em) => {
// nativeUpdate는 Unit of Work를 우회한다
await em.nativeUpdate(User, { id: 1 }, { name: 'New' });
// 이 쿼리는 즉시 실행됨. flush를 기다리지 않음
});
nativeUpdate와 nativeDelete는 Unit of Work를 거치지 않고 즉시 DB에 쿼리를 보낸다. 트랜잭션 안에서 사용하면 해당 트랜잭션에는 포함되지만, identity map과의 동기화가 깨질 수 있으므로 주의해야 한다.
2. 긴 트랜잭션 피하기
트랜잭션은 가능한 짧게 유지해야 한다. 긴 트랜잭션은 DB 락을 오래 잡고, 커넥션을 점유하며, 다른 트랜잭션과의 충돌 가능성을 높인다. "읽기 → 비즈니스 로직 → 쓰기"를 하나의 트랜잭션에 넣되, 불필요한 대기(외부 API, 파일 I/O)는 빼자.
3. 데드락
두 트랜잭션이 서로 상대방이 잡고 있는 락을 기다리면 데드락이 발생한다. PostgreSQL은 데드락을 자동 감지하고 한쪽을 롤백시키지만, 이를 방지하려면 엔티티 접근 순서를 일관되게 유지하는 게 좋다(예: 항상 ID 오름차순으로 처리).
4. flush()와 트랜잭션의 관계
em.transactional() 콜백 안에서 flush()를 직접 호출할 수도 있다. 이 경우 쿼리는 실행되지만 아직 커밋되지 않은 상태다. 콜백이 끝나야 최종 커밋이 된다.
await em.transactional(async (em) => {
em.persist(entity1);
await em.flush(); // 쿼리 실행, 하지만 아직 커밋 아님
em.persist(entity2);
// 콜백 종료 → 자동 flush + commit
});
정리
- em.flush()가 암묵적 트랜잭션을 열어 원자성을 보장하고, em.transactional()로 명시적 범위를 지정하면 fork/commit/rollback을 자동 처리한다
- 전파 모드(NESTED/REQUIRED/REQUIRES_NEW/MANDATORY)로 중첩 트랜잭션의 savepoint, 합류, 독립 실행, 필수 조건을 선언적으로 제어할 수 있다
- 외부 API 호출은 트랜잭션 밖에서 처리하고, 트랜잭션은 짧게 유지하며, 엔티티 접근 순서를 일관되게 유지해 데드락을 방지한다
관련 문서
- Entity Manager - EntityManager의 기본 사용법
- Entity 정의 - 엔티티 정의와 데코레이터
- Repository 패턴 - EM vs EntityRepository 선택