[TIL] TypeORM @Transactional 데코레이터는 어떻게 동작하지?

곽태민·2026년 4월 3일

TIL

목록 보기
77/78

들어가며

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 토큰을 가진 객체들이 하나의 트랜잭션을 공유할 수 있는지 궁금해졌다.


핵심: AsyncLocalStorage

typeorm-transactional은 Node.js의 내장 API인 AsyncLocalStorage(ALS) 를 사용한다.

AsyncLocalStorage란?

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를 조회
  • callback 내에서 호출되는 모든 비동기 작업에 컨텍스트가 자동 전파됨

@Transactional() 동작 원리

1단계: 초기화

어플리케이션 시작 전에 트랜잭션 컨텍스트를 초기화한다.

// main.ts
import { initializeTransactionalContext, StoreageDriver, addTransactionalDataSource} from 'typeorm-transactional';

initializeTransactionalContext({ storageDriver: StorageDriver.AUTO });

// DataSource 등록
addTransactionalDataSource(dataSource);

⚠️ initializeTrasactionalContext() 는 반드시 어플리케이션 초기화 전에 호출해야 한다.

2단계: @Transactional() 데코레이터

데코레이터가 메서드를 감싸서 트랜잭션 컨텍스트를 생성한다.

// 내부 동작을 단순화한 의사 코드
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)
            );
        });
      };
    };
}

3단계: Repository에서 트랜잭션 EntityManager 사용

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);
}

전체 Flow

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를 공유하게 된다.


참고 자료

profile
Node.js 백엔드 개발자입니다!

0개의 댓글