Skip to content

[BE] 트랜잭션 락 구현과 최적화

이승관 edited this page Dec 2, 2024 · 1 revision

트랜잭션 락

// 1. 공유 락 (Shared Lock, S-Lock)
const sharedLockExample = await queryRunner.manager
    .createQueryBuilder()
    .setLock('pessimistic_read')  // 공유 락
    .getOne();

// 2. 배타적 락 (Exclusive Lock, X-Lock)
const exclusiveLockExample = await queryRunner.manager
    .createQueryBuilder()
    .setLock('pessimistic_write')  // 배타적 락
    .getOne();
  • 공유 락(S-Lock):
    • 읽기 작업만 허용
    • 다른 트랜잭션의 읽기는 허용
    • 다른 트랜잭션의 쓰기는 차단
    • SELECT ... FOR SHARE
  • 배타적 락(X-Lock):
    • 읽기/쓰기 모두 차단
    • 다른 트랜잭션 접근 완전 차단
    • SELECT ... FOR UPDATE

락 동작 방식

// 1. Waiting 방식
await queryRunner.manager
    .createQueryBuilder()
    .setLock('pessimistic_write')
    .getOne();

// 2. No Wait 방식
await queryRunner.manager
    .createQueryBuilder()
    .setLock('pessimistic_write')
    .setQueryLockTimeout(0)  // 즉시 실패
    .getOne();

// 3. Timeout 방식
await queryRunner.manager
    .createQueryBuilder()
    .setLock('pessimistic_write')
    .setQueryLockTimeout(5000)  // 5초 대기
    .getOne();

격리 수준

// 1. READ UNCOMMITTED
/*
- 더티 리드 발생 가능
- 커밋되지 않은 데이터 읽기 가능
- 성능은 가장 좋음
*/

await queryRunner.startTransaction('READ COMMITTED');
/*
- 커밋된 데이터만 읽기 가능
- Non-Repeatable Read 발생 가능
- 일반적으로 가장 많이 사용
*/
await queryRunner.startTransaction('REPEATABLE READ');
/*
- 트랜잭션 시작시 스냅샷 생성
- 동일 쿼리 반복 시 동일 결과 보장
- Phantom Read 발생 가능
*/
await queryRunner.startTransaction('SERIALIZABLE');
/*
- 가장 강력한 격리 수준
- 완벽한 데이터 일관성
- 성능 저하가 가장 심함
*/

실제 적용 예시

//trade-bid.service.ts
async executeTrade(bidDto, order) {
		const queryRunner = this.dataSource.createQueryRunner();
		await queryRunner.connect();
		await queryRunner.startTransaction('READ COMMITTED');

		const { ask_price, ask_size } = order;
		const { userId, account, typeGiven, typeReceived, krw, tradeId} = bidDto;
		//여기 시작부터 락을 걸었습니다.
		const tradeData = await this.tradeRepository.getTradeFindOne(tradeId, queryRunner)
    if (!tradeData) return false;
		
		let result = false;
		try {
			...거래 로직
		} catch (error) {
			await queryRunner.rollbackTransaction();
			console.log(error);
		} finally {
			await queryRunner.release();
			return result;
		}
	}
//trade.repository.ts
async getTradeFindOne(tradeId, queryRunner) {
		//미체결 데이터가 거래중에 삭제되면 안되기 때문에 락을 걸었습니다.
		const tradeData = await queryRunner.manager.findOne(Trade, {
			where: { tradeId: tradeId },
			lock: { mode: 'pessimistic_read' },
		});
    return tradeData
	}
	
//trade.service.ts
async deleteMyBidTrade(user, tradeId) {
		const queryRunner = this.dataSource.createQueryRunner();
		await queryRunner.connect();
		await queryRunner.startTransaction('READ COMMITTED');

		try {
			... 삭제 로직
			
			//여기서 삭제를 하려고 해도 락이 걸려있으면 기본 동작 방식인 waiting을 통해서 
			//진행중인 트랜잭션이 끝나면 동작을 하는 것을 확인했습니다.
			await this.tradeRepository.deleteTrade(tradeId, queryRunner);

			... 삭제 로직
		} catch (error) {
			await queryRunner.rollbackTransaction();
			throw new UnprocessableEntityException({
				statusCode: 422,
				message: '해당 미체결 거래를 찾을 수 없습니다.',
			});
		} finally {
			await queryRunner.release();
		}
	}

실시간 거래 구현에서의 락

실시간 거래의 느낌을 살리기 위해 각각의 호가창의 매물대를 독립적으로 처리하도록 구현했습니다. 예를 들어 1000원에 100개, 1100원에 200개가 있다면 1000원의 100개가 먼저 체결되고, 그 다음 1100원의 200개가 체결되는 방식입니다.

그렇게 하기 위해서는 각각의 호가창이 서로의 거래에 영향을 미치면 안되기 때문에 하나의 호가에서 거래가 완료되면 commit이나 rollback을 통해 트랜잭션을 완료한 후 다음 호가의 거래를 진행합니다.

이는 실제 거래소의 작동 방식과 유사합니다. 실제로 10,000개를 구매하는 주문이 들어와도 한 호가에서 모든 물량이 체결되는 경우는 드물고, 여러 호가에 걸쳐 순차적으로 체결되기 때문에 이러한 방식으로 실시간 거래소의 동작을 구현했습니다.

onLocked → nowait 적용 문제

문제 상황

처음에는 락이 걸려있는 데이터에 접근할 때 waiting을 하도록 설정했습니다. 하지만 락이 걸린 데이터에 접근하려 할 때는 대기하는 것보다 바로 취소하는 것이 더 좋은 UX라고 판단하여 nowait 옵션을 적용하기로 했습니다.

문제 해결 과정

  1. nowait 옵션을 적용했으나 여전히 약 50초간 대기하는 현상 발생
  2. 원인 파악을 위한 시도:
    • TypeORM queryBuilder 설정 검토
    • Lock 옵션 사용법 확인
    • MySQL 기본 설정 확인
  3. MySQL의 기본 설정에서 innodb_lock_wait_timeout이 50초로 설정되어 있는 것을 발견
  4. MySQL 설정 변경으로 문제 해결
// 초기 구현
async findOne() {
  return this.createQueryBuilder()
    .setLock('pessimistic_write')
    .getOne();
}

// nowait 옵션 추가 후
async findOne() {
  return this.createQueryBuilder()
    .setLock('pessimistic_write', { mode: 'nowait' })
    .getOne();
}

// 최종 구현 - 에러 처리 추가
async executeTrade() {
  try {
    const result = await this.repository.findOne({
      lock: { mode: 'pessimistic_write' },
      timeout: 0  // 즉시 실패
    });
  } catch (error) {
    if (error.message.includes('lock wait timeout')) {
      throw new ConflictException('Resource is locked');
    }
    throw error;
  }
}

//MySQL 설정 변경

-- MySQL의 기본 설정 확인
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

-- my.cnf 또는 my.ini 파일에서 설정 변경
innodb_lock_wait_timeout = 0

배운 점

  1. ORM 설정만으로는 충분하지 않을 수 있으며, 데이터베이스 엔진의 기본 설정을 확인하는 것이 중요합니다.
  2. 문제 해결 시 애플리케이션 레벨부터 데이터베이스 레벨까지 다양한 계층을 고려해야 합니다.
  3. 동시성 처리는 사용자 경험과 직결되므로, 적절한 전략 선택이 중요합니다.
  4. 때로는 긴 대기 시간보다 즉각적인 실패 처리가 더 나은 사용자 경험을 제공할 수 있습니다.

이 경험을 통해 문제 해결을 위해서는 시스템 전반에 대한 이해가 필요하며, 사용자 경험을 고려한 기술적 선택의 중요성을 배웠습니다.

💻 개발 일지

💻 공통

💻 FE

💻 BE

🙋‍♂️ 소개

📒 문서

☀️ 데일리 스크럼

🤝🏼 회의록

Clone this wiki locally