relationLoadStrategy, join 대신 query로 카티션 곱 폭발 잡기여러 개의 OneToMany 관계를 한 번에 조회하는 API가 유독 느렸다. 데이터 양 자체는 얼마 되지 않는데도 응답이 눈에 띄게 지연됐고, DB 부하도 높았다. 원인은 TypeORM의 관계 로딩 전략(relationLoadStrategy)에 있었다. 이 글은 왜 기본값인 join이 특정 상황에서 성능을 무너뜨리는지, 그리고 query로 바꾸면 무엇이 달라지는지 정리한 기록이다.
하나의 Entity에서 형제 관계인 여러 컬렉션을 함께 불러오는 구조였다. 대략 이런 관계망이다.
assignLine
├─ salesOrder
│ ├─ salesOrderSizes (OneToMany)
│ ├─ salesOrderColors (OneToMany)
│ └─ salesOrderBreakdowns (OneToMany)
├─ purchaseOrders → breakdowns (OneToMany)
└─ breakdowns (OneToMany)
단일 assignLine 하나를 조회하는 요청인데도 응답이 느리고, DB에서 오가는 데이터가 비정상적으로 컸다.
join이 느린 이유TypeORM의 기본 관계 로딩 전략은 join이다. 요청한 모든 관계를 하나의 SQL로 LEFT JOIN해서 가져온다. 관계가 단순할 때는 이 방식이 왕복 한 번으로 끝나 효율적이다.
문제는 형제 관계인 여러 OneToMany를 한 쿼리에서 동시에 조인할 때 발생한다. 서로 독립적인 컬렉션들을 하나의 결과 테이블로 표현하려면, DB는 모든 조합을 행으로 만들어내는 카티션 곱(Cartesian product) 을 수행한다.
salesOrderSizes (S) × salesOrderColors (C) × purchaseOrders·breakdowns (P·B) × assignLine.breakdowns (A)
각 컬렉션의 크기를 곱한 만큼 행이 만들어진다. 예를 들어 사이즈 8개, 컬러 5개, 발주 breakdown 40개, assign breakdown 40개라면,
8 × 5 × 40 × 40 ≈ 64,000행
실제 의미 있는 데이터는 8 + 5 + 40 + 40 = 93개뿐인데, 결과는 6만 행으로 부풀어 오른다. 이 폭발이 세 군데에서 동시에 비용을 발생시킨다.
salesOrder 정보가 6만 번 반복 전송되는 식이다.즉 실데이터는 몇십 건인데, 곱셈만큼 DB·네트워크·앱이 헛일을 하는 구조다.
query로 바꾸면 달라지는 것relationLoadStrategy를 query로 지정하면, TypeORM은 하나의 거대한 조인 대신 관계마다 별도의 작은 쿼리를 날리고 메모리에서 이어붙인다.
1) assignLine 본체 → 1행
2) salesOrder → 1행
3) salesOrderSizes → S행 (id IN (...))
4) salesOrderColors → C행
5) purchaseOrders → P행
6) salesOrderBreakdowns → B행
7) assignLine.breakdowns → A행
핵심은 조회량의 성격이 바뀐다는 점이다.
S × C × P·B × A에서 S + C + P·B + A 수준으로 떨어진다. 앞의 예시 기준 64,000행이 93행으로 줄어든다.sales_order_id, buyer_purchase_order_id 등)를 IN (...)으로 조회하므로 인덱스를 타기 좋다.쿼리 개수는 1개에서 여러 개로 늘어난다. 하지만 각 쿼리가 실데이터 크기만큼만 반환하기 때문에, 왕복 몇 번의 비용이 6만 행 폭발보다 압도적으로 저렴하다. 다중 컬렉션 조회에서 TypeORM이 query 전략을 권장하는 이유가 이것이다.
query 전략은 세 가지 범위로 지정할 수 있다.
DataSource 전역에 설정하려면 다음과 같이 한다.
const dataSource = new DataSource({
// ...
relationLoadStrategy: 'query',
});
특정 조회에서만 적용하고 싶다면 find 옵션에 넘긴다.
const assignLine = await repository.findOne({
where: { id },
relations: {
salesOrder: {
salesOrderSizes: true,
salesOrderColors: true,
salesOrderBreakdowns: true,
},
purchaseOrders: { breakdowns: true },
breakdowns: true,
},
relationLoadStrategy: 'query',
});
QueryBuilder를 쓴다면 setRelationLoadStrategy로 지정한다.
const assignLine = await repository
.createQueryBuilder('assignLine')
.setRelationLoadStrategy('query')
// ...
.getOne();
query가 항상 정답은 아니다. 관계가 얕고 조회하는 컬렉션이 하나뿐이라면, 조인은 왕복이 한 번으로 끝나 오히려 더 빠를 수 있다. 이 경우 카티션 곱이 발생하지 않으므로 join의 단점이 없다.
반대로 이번 사례처럼 형제 컬렉션이 여러 개인 케이스에서는 query가 정답이다. 판단 기준은 단순하다. 한 번에 로딩하는 독립적인 OneToMany / ManyToMany 컬렉션이 둘 이상이라면, 카티션 곱을 의심하고 query 전략을 검토할 만하다.
join 전략은 형제 컬렉션을 여러 개 조인할 때 카티션 곱으로 행이 폭발한다.query 전략은 관계마다 별도 쿼리를 날려 곱셈을 덧셈으로 바꾼다.query, 단일·얕은 관계에서는 join이 유리하다.데이터 양은 그대로인데 조회가 느리다면, 먼저 실제로 몇 행이 오가고 있는지 확인해보자. 곱셈이 범인일 가능성이 높다.