본문으로 건너뛰기
pjh-climb-dev

TypeORM getOne과 getMany의 차이점, 그리고 Lock의 범위

작성 기준 TypeORM 버전은 0.3.28이다.

개요

보통 쿼리 빌더를 작성하는 이유는 raw 쿼리보다 더 가독성이 있고, 파라미터를 기반으로 처리하기 때문에 Sql Injection등에 강하다. 또한, orm 메서드만큼 안전하며 복잡한 쿼리를 작성할 때 사용한다는 특징이 있기 때문에 많이 사용한다.

하지만, TypeORM의 쿼리 빌더의 getOne()과 getMany() 함수를 사용할 때 주의할 점이 있다. 바로 쿼리 생성 결과와 내부 로직에 대한 차이가 있다.

필자는 이것을 MySQL Skip Locked로 이것저것 테스트하다가 알아냈다. 테이블 Row 1개씩 Skip Locked를 걸어두어 처리하는 배치 프로그램을 실행하는데, 약 10대의 멀티 인스턴스를 띄워 실행하고자 했다. 예상한 동작은 각 인스턴스가 row를 하나씩 가져가 병렬로 빠르게 처리하는 흐름을 생각했지만, 1대의 인스턴스만 처리하는 흐름을 볼 수 있었다. 해결하며 알아낸 getOne()과 getMany() 동작의 차이를 간단하게 정리하려고 한다.

img.png

pm2 fork 모드로 인스턴스를 10대 띄웠지만, 7번 인스턴스만 열심히 처리하고 있는 모습이다.


1. getOne과 getMany의 차이?

결론적으로 말하자면, getOne은 조회 결과 하나를 가져오는 것이고 getMany는 다수의 결과를 가져오는 것이다.

함수 이름으로만 봤을때 무슨 생각이 들었냐면

  • getOne은 LIMIT 1을 사용해서 결과를 가져오겠구나

하지만, getOne 함수는 LIMIT 절을 사용하지 않고 실행하고 있었음을 알 수 있었다.
이것을 내부 소스를 보면 알 수 있다.


2. getMany() 로직

img.png

위는 TypeORM 깃허브에서 작성되어 있는 getMany 함수이다. 코드는 간단하다. 문제가 있으면 에러를 발생시키고, 없으면 getRawAndEntities()라는 함수를 실행시켜 쿼리 결과를 리턴하는 것이다. entities 속성을 가져와 반환하는 듯 하다.

https://github.com/typeorm/typeorm/blob/0.3.28/src/query-builder/SelectQueryBuilder.ts#L1757


3. getOne() 로직

img.png

전체 소스는 아래의 링크를 통해 확인할 수 있지만, 위의 로직만 봐도 알 수 있다. getMany 함수처럼 getRawAndEntities() 함수를 실행시키고 쿼리 결과를 가져오는데, 여기서 단순히 entities의 첫 번째 원소만 가져오고 있다. LIMIT으로 제어하는 로직이 아니었던 것이다.

https://github.com/typeorm/typeorm/blob/0.3.28/src/query-builder/SelectQueryBuilder.ts#L1697


4. Lock의 범위를 모르면 어떻게 될까

그래서 ‘개요’의 사진처럼 1개의 인스턴스만 열심히 처리하고 있던 것이다. Lock의 범위가 모든 row에 영향을 끼쳤기 때문에 다른 인스턴스들이 각 Row의 권한을 얻을 수 없었던 것이다. ‘개요’의 상황뿐만 아니라 Lock의 범위를 모르면 발생하는 상황은 다음과 같을 것이다.

  • 너무 넓은 범위(테이블 전체)를 잠그면 다른 프로세스의 동시 접근을 과도하게 막아 시스템이 느려짐
  • 여러 트랜잭션이 잠긴 자원을 기다리며 데드락이 발생할 수 있음. 또는 DB Connection Pool 고갈

이처럼 본인이 사용할 자원만 Lock을 걸어두는 것이 가장 이상적인 처리 방식이라 할 수 있다.


5. LIMIT으로 Lock 범위를 조절하자

const place = await queryRunner.manager
  .getRepository(Place)
  .createQueryBuilder('place')
  .setLock('pessimistic_write')
  .setOnLocked('skip_locked')
  .where('place.processedDate IS NULL')
  .getOne();

위의 기존 쿼리를 아래와 같이 변경했더니 의도한대로 처리가 되었다.

const [place] = await queryRunner.manager
  .getRepository(Place)
  .createQueryBuilder('place')
  .setLock('pessimistic_write')
  .setOnLocked('skip_locked')
  .where('place.processedDate IS NULL')
  .take(1) // LIMIT 1
  .getMany();

사실 getOne, getMany 로직만 보면 첫 번째 원소를 가져오냐 아니냐의 단순 차이기 때문에, 무엇을 사용하든 상관없다.

img.png


Reference