NestJS + TypeORM 프로젝트에서 typeorm-transactional 패키지의 @Transactional() 데코레이터를 사용하면, 서로 다른 Repository에서 실행되는 Query들이 하나의 트랜잭션으로 묶이게 된다.
@Transactional()
async createMasterUser(dto: CreateMasterUserDto) {
// 서로 다른 repository지만 같은 트랜잭션으로 묶임.
const savedCompany = await this.companyRepository.save(companyData);
await this.userRepository.save({companyId: savedCompany.id, ...UserData);
}
logging을 통해 터미널을 보니 하나의 트랜잭션으로 묶이는 걸 보고 어떻게 별도의 Repository 토큰을 가진 객체들이 하나의 트랜잭션을 공유할 수 있는지 궁금해졌다.
typeorm-transactional은 Node.js의 내장 API인 AsyncLocalStorage(ALS) 를 사용한다.
AsyncLocalStorage는 비동기 작업 전체에 걸쳐 컨텍스트를 유지하는 기능을 제공한다. Java의 ThreadLocal과 비슷한 개념이지만, 싱글 스레드인 Node.js의 비동기 환경에 맞게 설계되엇다.
import { AsyncLocalStorage } from 'async_hooks';
const asyncLocalStorage = new AsyncLocalStorage();
// run() 내부에서 실행되는 모든 비동기 코드는 동일한 storage에 접근 가능
asyncLocalStorage.run({ requestId: 'abc-123' }, async () => {
await someAsyncFunction(); // getStore() -> { requestId: 'abc-123' }
await anotherASyncFunction(); // getStore() -> { requestId: 'abc-123' }
});
run(store, callback): 새로운 컨텍스트를 생성하고 store를 설정getStore(): 현재 비동기 컨텍스트의 store를 조회어플리케이션 시작 전에 트랜잭션 컨텍스트를 초기화한다.
// main.ts
import { initializeTransactionalContext, StoreageDriver, addTransactionalDataSource} from 'typeorm-transactional';
initializeTransactionalContext({ storageDriver: StorageDriver.AUTO });
// DataSource 등록
addTransactionalDataSource(dataSource);
⚠️
initializeTrasactionalContext()는 반드시 어플리케이션 초기화 전에 호출해야 한다.
데코레이터가 메서드를 감싸서 트랜잭션 컨텍스트를 생성한다.
// 내부 동작을 단순화한 의사 코드
function Transactional() {
return function(target, key, descriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function(...args) {
// 1. 트랜잭션 시작
return dataSource.transaction(async (trasactionalEntityManager) => {
// 2. AsyncLocalStorage에 트랜잭션 EntityManager 저장
return asyncLocalStorage.run(
{ entityManager: trasactionalEntityManager },
// 3. 원본 메서드 실행
() => originalMethod.apply(this, args)
);
});
};
};
}
typeorm-transactional 은 DataSource의 메서드들을 패치(fetch) 한다.
// 패치된 repository 메서드 (의사 코드)
async save(entity) {
// AsyncLocalStorage에서 현재 컨텍스트 store 조회
const store = asyncLocalStorage.getStore();
if (store?.entityManager) {
// 트랜잭션 컨텍스트가 존재하면 해당 EntityManager 사용
return store.entityManager.getRepository(this.target).save(entity);
}
// 없으면 일반 EntityManager 사용
return originalSave(entity);
}
1. createMasterUser() 호출
│
2. @Transactional() 데코레이터가 가로챔
│
3. DataSource.transaction() 시작
│ └─ 트랜잭션용 EntityManager 생성
│
4. AsyncLocalStorage.run(store, callback) 실행
│ └─ store = { entityManager: 트랜잭션EM }
│
5. ┌─ callback 내부 (async context 유지) ─────────────────┐
│ │
│ Company save() 호출 │
│ └─ companyRepository.save() │
│ └─ getStore() → 트랜잭션 EM 획득 │
│ └─ 트랜잭션 EM으로 INSERT 실행 │
│ │
│ User Save() 호출 │
│ └─ userRepository.save() │
│ └─ getStore() → 동일한 트랜잭션 EM 획득 │
│ └─ 트랜잭션 EM으로 INSERT 실행 │
│ │
└────────────────────────────────────────────────────┘
│
6. 성공 → COMMIT / 예외 발생 → ROLLBACK
| 개념 | 역할 |
|---|---|
| AsyncLocalStorage | 비동기 호출 체인 전체에서 컨텍스트(store) 공유 |
| run() | 새로운 컨텍스트 생성, 내부 모든 async 작업에 전파 |
| getStore() | 현재 컨텍스트의 store 조회 |
| DataSource 패치 | repository 메서드들이 자동으로 트랜잭션 EM 사용하도록 변경 |
결론: Repository 토큰이 달라도 같은 DataSource를 사용하고, @Transactional() 내부에서 호출되면 AsyncLocalStorage를 통해 동일한 트랜잭션 EntityManager를 공유하게 된다.