현재 콜드체인 플랫폼 서비스를 운영하는 팀에서 근무하고 있습니다.
물류/유통 도메인 특성 상 재고 관련 데이터를 사용해 집계/통계성 기능(재고 조회, 재고수불부 생성 및 조회 등)을 다룰 일이 많습니다.
대규모 재고 관리 시스템에서 1000만 건의 데이터 조회
로 인한 성능 저하 문제를 SQL 쿼리 최적화를 통해 해결한 사례를 소개합니다. 특히 재고 조회와 관련된 기능의 개선 과정에 초점을 맞추어 설명하겠습니다.
결과적으로 약 10초에서 약 3초로 3배 가량 조회 성능을 개선
했습니다.
데이터베이스는 MySQL8.0.34(RDS, db.r6g.large)을 사용하고 있습니다.
아래의 쿼리 예시는 서비스 보안상 어느 정도 각색한 예시이고,
실행 속도는 실제 시간을 가져왔습니다.
기존 쿼리 입니다.
select g.name, i.total_count, l.lct_name, ob.outb_ymd
from inventory i
inner join goods g
on g.goods_id = i.goods_id
inner join location l
on l.lct_seq = i.lct_seq
inner join outbound ob
on ob.goods_id = g.goods_id
inner join center c
on i.center_id = c.center_id
where c.center_id = 'DC001'
and ob.outb_ymd >= 20240901
and ob.outb_ymd <= 20240930
위 쿼리의 실행계획 입니다.(필요한 컬럼만 정리했습니다.)
+--+-----------+-----+------+---------------+-----------+
|id|select_type|table|type |ref |Extra |
+--+-----------+-----+------+---------------+-----------+
|1 |SIMPLE |c |const |const |null |
|1 |SIMPLE |i |ALL |null |Using where|
|1 |SIMPLE |l |eq_ref|rdms.i.lct_seq |null |
|1 |SIMPLE |g |eq_ref|rdms.i.goods_id|null |
|1 |SIMPLE |ob |ref |rdms.i.goods_id|Using where|
+--+-----------+-----+------+---------------+-----------+
위의 실행계획에서 type, Extra
를 중점으로 보시면 됩니다.
c : center table(물류 센터 관련)
i : inventory table(재고 관련)
l : location table(로케이션: 적재된 위치 관련)
g : goods table(상품 관련)
ob : outbound table(출고 관련)
위의 실행계획에서 join 방식만을 해석해보면,
[inventory 테이블]
[location, goods 테이블]
type
컬럼은 MySQL 옵티마이저가 각 테이블에 접근하는 방식을 나타냅니다.Nested Loop Join
입니다.약 10초 정도 걸립니다.
Nested-Loop Join은 관계형 데이터베이스에서 두 개 이상의 테이블을 조인할 때 사용되는 가장 기본적인 조인 알고리즘 중 하나입니다. 이 알고리즘은 한 테이블의 각 행에 대해 다른 테이블을 반복적으로 검색하여 조인 조건을 만족하는 행을 찾는 방식으로 작동합니다.
Hash Join
은 데이터베이스에서 두 개 이상의 테이블을 조인할 때 사용되는 조인 알고리즘 중 하나로, 조인 조건이 동등(equal) 조건일 때
주로 사용됩니다. 이 알고리즘은 한 테이블의 데이터를 메모리에 해시 테이블로 만들고, 다른 테이블의 데이터를 순회하면서 해시 테이블
을 통해 빠르게 매칭하는 방식으로 작동합니다.
직접 Join 하지 않고, subquery를 사용해 한 번 필터링 한 후, 그 결과물을 Join에 활용하는 방법
SELECT *
FROM
(SELECT *
FROM inventory
WHERE center_id = 'DC001') i
INNER JOIN goods g ON g.goods_id = i.goods_id
INNER JOIN location l ON l.lct_seq = i.lct_seq
INNER JOIN
(SELECT *
FROM outbound
WHERE outb_ymd >= 20240901 AND outb_ymd <= 20240930) ob ON ob.goods_id = g.goods_id
-- 이미 inventory에서 center_id를 필터링했으므로 center 테이블과의 조인을 제거합니다.
;
with 구문을 통해 데이터를 미리 필터링한 임시 테이블을 생성하고, 이 임시 테이블을 사용하여 join에 활용하는 방법
-- 필요한 데이터만 미리 필터링하는 CTE(Common Table Expression)를 사용합니다.
WITH
FilteredInventory AS (
SELECT *
FROM inventory
WHERE center_id = 'DC001'
),
FilteredOutbound AS (
SELECT *
FROM outbound
WHERE outb_ymd >= 20240901 AND outb_ymd <= 20240930
)
SELECT *
FROM FilteredInventory i
INNER JOIN goods g ON g.goods_id = i.goods_id
INNER JOIN location l ON l.lct_seq = i.lct_seq
INNER JOIN FilteredOutbound ob ON ob.goods_id = g.goods_id
-- 이미 inventory에서 center_id를 필터링했으므로 center 테이블과의 조인을 제거합니다.
;
+--+-----------+-----+------+---------------+------------------------------------------+
|id|select_type|table|type |ref |Extra |
+--+-----------+-----+------+---------------+------------------------------------------+
|1 |SIMPLE |c |const |const |null |
|1 |SIMPLE |i |ALL |null |Using where |
|1 |SIMPLE |l |eq_ref|rdms.i.lct_seq |Using where; Using join buffer (Hash Join)|
|1 |SIMPLE |g |eq_ref|rdms.i.goods_id|Using where; Using join buffer (Hash Join)|
|1 |SIMPLE |ob |ref |rdms.i.goods_id|Using where; Using join buffer (Hash Join)|
+--+-----------+-----+------+---------------+------------------------------------------+
위의 실행계획에서 type, Extra
를 중점으로 보시면 됩니다.(필요한 컬럼만 정리했습니다.)
c : center table(물류 센터 관련)
i : inventory table(재고 관련)
l : location table(로케이션: 적재된 위치 관련)
g : goods table(상품 관련)
ob : outbound table(출고 관련)
약 3초로 Nested-Loop Join을 사용한 것(약 10초)보다 약 3배의 조회 성능을 개선했습니다.
SELECT /*+ HASH_JOIN(i, g) HASH_JOIN(i, l) HASH_JOIN(g, ob) HASH_JOIN(i, c) */ *
FROM inventory i
INNER JOIN goods g ON g.goods_id = i.goods_id
INNER JOIN location l ON l.lct_seq = i.lct_seq
INNER JOIN outbound ob ON ob.goods_id = g.goods_id
INNER JOIN center c ON i.center_id = c.center_id;
Hibernate를 JPA 구현체로 사용하는 경우, SQL 주석을 활용하여 옵티마이저 힌트를 SQL 쿼리에 포함시킬 수 있습니다.
1) Hibernate 설정에서 SQL Comments 활성화
// properties
hibernate.use_sql_comments=true
2) setComment() 메서드를 사용하여 힌트 추가
import org.hibernate.Session;
import org.hibernate.query.Query;
// 엔티티 매니저에서 Hibernate 세션 가져오기
Session session = entityManager.unwrap(Session.class);
// HQL 쿼리 작성
String hql = "SELECT e FROM YourEntity e WHERE e.field = :value";
// 쿼리 생성 및 힌트 추가
Query<YourEntity> query = session.createQuery(hql, YourEntity.class)
.setParameter("value", someValue)
.setComment("+ HASH_JOIN");
// 결과 조회
List<YourEntity> result = query.getResultList();
실행되는 SQL 예시
/*+ HASH_JOIN */
SELECT e.field1, e.field2 FROM your_entity e WHERE e.field = ?
JPA의 네이티브 쿼리를 사용하여 직접 SQL을 작성하고 힌트를 포함시킬 수 있습니다.
String sql = "SELECT /*+ HASH_JOIN */ * FROM your_entity e WHERE e.field = :value";
Query query = entityManager.createNativeQuery(sql, YourEntity.class);
query.setParameter("value", someValue);
List<YourEntity> result = query.getResultList();
Spring Data JPA를 사용하는 경우 @QueryHints를 활용하여 힌트를 추가할 수 있습니다.
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.QueryHints;
import javax.persistence.QueryHint;
public interface YourEntityRepository extends JpaRepository<YourEntity, Long> {
@Query("SELECT e FROM YourEntity e WHERE e.field = :value")
@QueryHints({
@QueryHint(name = "org.hibernate.comment", value = "+ HASH_JOIN")
})
List<YourEntity> findWithHint(@Param("value") String value);
}
팀 내에서 복잡한 조회 쿼리를 사용할 때 QueryDsl을 활용하고 있습니다.
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.querydsl.jpa.JPAExpressions;
JPAQueryFactory queryFactory = new JPAQueryFactory(entityManager);
QYourEntity e = QYourEntity.yourEntity;
List<YourEntity> result = queryFactory.selectFrom(e)
.where(e.field.eq(someValue))
.setHint("org.hibernate.comment", "+ HASH_JOIN")
.fetch();