결과
JPA Batch Size + Database INDEX 적용하여
10초 → 2초 → 0.1초 순으로 API 조회 성능을 개선했다.
배경
핵심 API에 데이터가 많아지면 어떻게 될지 궁금함으로 시작됐다.
120개 → 300만개로 증가시켜 조회를 해보니 조회가 오래 걸리는 문제가 발생했다.
PoolMark 테이블에서 Pool을 참조하는 형태, Pool : PoolMark = 1 : M 관계로 설계됐다.
문제 상황
2025-05-26T20:21:26.157+09:00 WARN 9396 --- [swim] [io-8080-exec-10] org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
Hibernate: select p1_0.id,p1_0.additional_info,p1_0.address,p1_0.latitude,p1_0.link,p1_0.longitude,p1_0.name,p1_0.parking,pm1_0.pool_id,pm1_0.id,pm1_0.user_id,p1_0.section from pools p1_0 left join pool_marks pm1_0 on p1_0.id=pm1_0.pool_id where p1_0.section=?
Hibernate: select count(p1_0.id) from pools p1_0 left join pool_marks pm1_0 on p1_0.id=pm1_0.pool_id where p1_0.section=?
300만개의 데이터로 조회를 하니 SQL은 기존과 동일하게 동작했으나 경고가 출력되었다.
쿼리의 결과를 모두 가져와 메모리에서 다시 정제했을 때 확인할 수 있는 문구다.
DB에서 조회의 결과를 모두 가져와서 객체와 매핑 시키는 작업을 다시했다는 의미이다.
public interface PoolRepository extends JpaRepository<Pool, Long> {
@Query("""
SELECT p
FROM Pool p
LEFT JOIN FETCH p.poolMarks pm
WHERE p.section = :section
""")
Page<Pool> findBySection(@Param("section") String section, Pageable pageable);
}
JOIN FETCH를 사용했다. DB에서 연관데이터까지 한번에 가져와 달라는 JPQL 문법이다.
그래서 모든 데이터를 가져와 메모리에서 페이징(LIMIT/OFFSET)을 수행한 모양이다.
1번 Pool에 연관된 데이터(PoolMark) 10개, 2번 Pool에도 연관된 데이터 10개로 총 20개의 레코드가 있다고 하자.
10개 단위로 페이징을 하면 2개의 페이지가 생성된다. 조회 결과를 들여다 보면 PoolMark는 서로 다를지 몰라도 1번 Pool 10개, 2번 Pool 10개 조회된다.
하지만 원하는 페이징의 기준은 전체 결과가 아닌 Pool이다.
다시 말해, 원하는 결과로 1페이지로 1번 Pool 1개 + PoolMark 10개, 2번 Pool 1개 + PoolMark 10개의 형태를 바라고 있어 Hibernate가 다시 정리를 위한 자원과 시간이 필요하다.
그 결과, 위 이미지에 Waiting (TTFB, Time To First Byte), 첫 정보가 수신되는 시간이 10초 소요되었다.
이는 성능 저하 + 메모리 낭비를 야기하고 사용자 경험에게 느린 서비스를 제공하게 된다.
@BatchSize / default_batch_fetch_size 적용 (1)
public interface PoolRepository extends JpaRepository<Pool, Long> {
@Query(value = """
SELECT p
FROM Pool p
LEFT JOIN p.poolMarks pm
WHERE p.section = :section
""",
countQuery = """
SELECT COUNT(p)
FROM Pool p
WHERE p.section = :section
""")
Page<Pool> findBySection(@Param("section") String section, Pageable pageable);
}
### application.properties ###
# JPA Batch Size
spring.jpa.properties.hibernate.default_batch_fetch_size=100
기존 JPQL에서 FETCH를 제거하고 Batch Size가 추가되었다.(countQuery는 뒤에서 설명하겠다)
지연 로딩(LAZY)를 사용하고 있기 때문에 PoolMark는 프록시로 아직 데이터가 담겨 있지 않다.
PoolMark의 데이터를 코드로 확인할 때마다 SQL을 한번 더 보내 진짜 데이터를 가져온다. 이렇게 되면 PoolMark에 접근할 때마다 쿼리가 보내지기 때문에 N + 1이 발생해 DB를 여러번 호출한다.
이를 방지하는 것이 Batch Size이다.
PoolMark 접근 시 프록시인 경우 해당 Pool.id를 Batch에 담아 쿼리 IN절에 사용하게 된다. Batch Size 보다 많다면 쿼리를 보내고 나머지는 다시 Batch에 담아서 쿼리를 수행한다.
결과 (1)
Hibernate: select p1_0.id,p1_0.additional_info,p1_0.address,p1_0.latitude,p1_0.link,p1_0.longitude,p1_0.name,p1_0.parking,p1_0.section from pools p1_0 left join pool_marks pm1_0 on p1_0.id=pm1_0.pool_id where p1_0.section=? limit ?
Hibernate: select count(p1_0.id) from pools p1_0 where p1_0.section=?
Hibernate: select pm1_0.pool_id,pm1_0.id,pm1_0.user_id from pool_marks pm1_0 where pm1_0.pool_id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Batch가 적용되며 3번째 쿼리에 IN절이 추가되었다.
쿼리가 1개 증가 했지만 메모리 자원을 사용하거나 N + 1이 발생하지 않았고 결과적으로 응답시간이 2초로 단축되었다.
추가로 countQuery의 설명을 보충하자면, 작성하지 않더라도 Page처리를 위해 적용되는 부분이지만 명시해 보았다.
Spring Data JPA의 Page<T> 반환 페이징 기능에서, 전체 페이지 수 계산을 위해 전체 row 개수(total count)가 반드시 필요하기 때문이다.
DataBase INDEX (2)
모바일 페이지 속도가 사용자 경험과 수익에 미치는 영향을 강조하는 글이다.
- 속도가 수익을 좌우: 빠른 페이지일수록 전환율이 높음
- 최적화 필요: 자동차 및 소매업 등 일부 산업의 웹페이지가 유독 느림
- 페이지 크기 줄이기: 이미지를 압축하고 불필요한 요소를 줄여 성능 개선
- 딥러닝 분석 결과: 로딩 시간이 증가할수록 이탈률 급증
구현한 프로젝트는 웹 기반이긴 하나 화면단에 보여질 데이터가 아직 2초 정도 소요된다. 자료에 따른다면 데이터가 전달되는 속도가 느리다는 이유 하나만으로 최소 30%의 사용자가 이탈될 여지가 있어 개선할 필요가 있다.
SELECT p
FROM Pool p
LEFT JOIN p.poolMarks pm
WHERE p.section = :section
JPQL을 살펴보면, WHERE 절에 section을 조건으로 필터링을 한다. section 필드는 자주 사용되어 분석을 해보았다.
EXPLAIN
SELECT *
FROM pools p
WHERE p.section = '강남'
limit 10 offset 0;
select_type | table | partitions | type | possible_keys |
SIMPLE | p | null | ALL | null |
EXPLAIN으로 실행 계획을 확인하니 type Column이 ALL(모든 행 처음부터 끝까지 탐색)방식으로 조회를 한다.
INDEX를 적용해 자료구조를 이용하면 탐색속도를 높여 개선이 가능하다.
아래 표는 section에 INDEX 추가 결과이다.
select_type | table | partitions | type | possible_keys |
SIMPLE | p | null | ref | idx_pools_section |
결과 (2)
ms는 1/1000초다. 즉, 조회 시간이 0.1초이다.
처음과 비교하면 99% 감소한 수치이다.
결과를 위해 INDEX가 생성되는 시간은 10초 정도 소요가 되었다.
이 10초로 사용자들에게 원할한 서비스를 제공하고 구글 자료처럼 비즈니스 모델이 있다면 수익까지 고려해 볼 수 있지 않겠는가
다른 방법은 없었나?
방법 | 연관 엔티티 조회 가능 | 페이징 가능 | 장점 | 단점 |
JOIN FETCH | ✅ 가능 (즉시 로딩) | ❌ 불가능 (컬렉션 join fetch 시) |
간단하고 직관적 | 컬렉션 join 시 페이징 불가, 중복 데이터 |
@EntityGraph | ✅ 가능 | ✅ 가능 (단, 컬렉션 X) |
선언적으로 깔끔하게 fetch 설정 | 복잡한 fetch 경로 많으면 번거로움 |
@BatchSize / default_batch_fetch_size | ✅ 가능 (지연 로딩 상태에서) | ✅ 가능 | Lazy 유지하면서도 N+1 완화 | 정확한 쿼리 제어 어려움 |
DTO Projection (new Dto(...)) | ❌ 불가능 (엔티티 아님) | ✅ 가능 | 필요한 데이터만 가져와 성능 최적화 | 연관 객체 직접 사용 불가, 재사용 어려움 |
2단계 조회 (ID 목록 → 연관 쿼리) | ✅ 가능 | ✅ 가능 | 유연하게 커스터마이징 가능 | 복잡한 로직, 코드량 증가 |
- JOIN FETCH: 기존에 사용했던 방식으로 메모리 과부화와 시간 소요가 발생하여 적합하지 않음
- @EntityGraph: JPQL을 작성하지 않고 간략하게 애노테이션으로 정리할 수 있으나 @XXXToOne 관계의 조회가 아니므로 적합하지 않음
- @BatchSize / default_batch_fetch_size: 지연 로딩(LAZY) 상태, 페이징, N + 1 완화 가능하여 적합!
- DTO Projection: 연관 엔티티와 같이 조회가 불가하여 적합하지 않음
- 2단계 조회 (ID 목록 → 연관 쿼리): 코드 변경을 최소화해야 하므로 적합하지 않음
따라서, @BatchSize / default_batch_fetch_size 방식이 가장 적합했다.
- 쾌적한 서비스 제공
- API 성능 99% 개선 (10초 → 0.1초)
- LAZY, Batch 개념 확립
'Spring' 카테고리의 다른 글
Spring 3.1.1 RELEASE CORS Filter (0) | 2024.07.23 |
---|---|
Spring MVC Project 안보일 때 (0) | 2024.06.21 |
Mybatis mapper xml 파일 위치 지정 (0) | 2024.06.10 |
Spring Legacy pom.xml 초기설정 (0) | 2024.06.03 |
moa(3) (0) | 2024.02.22 |