-
Notifications
You must be signed in to change notification settings - Fork 1
[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개를 구매하는 주문이 들어와도 한 호가에서 모든 물량이 체결되는 경우는 드물고, 여러 호가에 걸쳐 순차적으로 체결되기 때문에 이러한 방식으로 실시간 거래소의 동작을 구현했습니다.
처음에는 락이 걸려있는 데이터에 접근할 때 waiting을 하도록 설정했습니다. 하지만 락이 걸린 데이터에 접근하려 할 때는 대기하는 것보다 바로 취소하는 것이 더 좋은 UX라고 판단하여 nowait 옵션을 적용하기로 했습니다.
- nowait 옵션을 적용했으나 여전히 약 50초간 대기하는 현상 발생
- 원인 파악을 위한 시도:
- TypeORM queryBuilder 설정 검토
- Lock 옵션 사용법 확인
- MySQL 기본 설정 확인
- MySQL의 기본 설정에서
innodb_lock_wait_timeout
이 50초로 설정되어 있는 것을 발견 - 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
- ORM 설정만으로는 충분하지 않을 수 있으며, 데이터베이스 엔진의 기본 설정을 확인하는 것이 중요합니다.
- 문제 해결 시 애플리케이션 레벨부터 데이터베이스 레벨까지 다양한 계층을 고려해야 합니다.
- 동시성 처리는 사용자 경험과 직결되므로, 적절한 전략 선택이 중요합니다.
- 때로는 긴 대기 시간보다 즉각적인 실패 처리가 더 나은 사용자 경험을 제공할 수 있습니다.
이 경험을 통해 문제 해결을 위해서는 시스템 전반에 대한 이해가 필요하며, 사용자 경험을 고려한 기술적 선택의 중요성을 배웠습니다.
- [FE] TailwindCSS @apply
- [FE] 캐러셀 구현
- [FE] 사이드 바 상태관리 도전기
- [FE] axios interceptor로 로그인 필요한 api 개선하기
- [FE] Tanstack Query API 최적화 도전기
- [FE] Tanstack Query로 구현하는 무한 스크롤 차트 도전기
- [FE] 차트 무한 스크롤링 최적화 도전기
- [FE] 차트 실시간 등락 구현 도전기
- [FE] 검색 구현 및 검색 API 호출 최적화 도전기
- [FE] 고차 컴포넌트를 활용한 인증 접근 제어
- [FE] 코드 스플릿팅으로 최적화 도전기
- [BE] Server 생성
- [BE] CI/CD
- [BE] GitAction 학습 정리
- [BE] ssh터널링으로 db연결
- [BE] 배포환경에서 DB 연결 및 테스트 완료
- [BE] https 적용
- [BE] upbit api 연결 및 SSE api
- [BE] SSE 구현
- [BE] SSE 에러
- [BE] redis 설치 및 연동
- [BE] 트랜잭션 락 구현과 최적화
- [BE] Oauth CORS
- [BE] QueryRunner 사용 시 발생한 문제점과 해결방안
- [BE] Git Action 학습 정리
- [BE] NestJS 학습 정리
- [BE] 로그인 기능 및 리프레시토큰
- [BE] 비회원 체험 기능
- [BE] Nginx 학습 정리
- [BE] Mixed Content와 HTTPS 보안 구현하기
- [BE] 매수/매도 로직 구현 및 개선 과정
- [BE] Queue, Load Balancing, Redis