이전 게시글에서 AuditLog의 N + 1 문제를 해결하여 성능을 개선하였다.
하지만 10,000건의 데이터는 너무 적다.
여러 조언을 구해본결과 1000만건은 해야 유의미한 결과를 얻을 수 있다는 판단이 들었다.
그렇게 해서 데이터 1000만건 삽입!
audlitLog는 1000만건
INSERT INTO vivim.audit_log (actor_id, target_type, target_id, action_type, logged_at)
WITH RECURSIVE numbers AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM numbers WHERE n < 10000000 -- 1000만 건
)
SELECT
FLOOR(1 + RAND() * 100000) AS actor_id, -- 1부터 100000 사이의 랜덤 actor_id
ELT(FLOOR(1 + RAND() * 5), 'PROJECT', 'POST', 'COMMENT', 'USER', 'COMPANY') AS target_type, -- 예시 TargetType 값
FLOOR(1 + RAND() * 1000000) AS target_id, -- 1부터 1000000 사이의 랜덤 target_id
ELT(FLOOR(1 + RAND() * 3), 'CREATE', 'MODIFY', 'DELETE') AS action_type, -- 예시 ActionType 값
FROM_UNIXTIME(UNIX_TIMESTAMP('2023-01-01 00:00:00') + FLOOR(RAND() * (UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP('2023-01-01 00:00:00')))) AS logged_at -- 2023년 1월 1일부터 현재까지의 랜덤 시간
FROM
numbers;
audlitLogDetail는 200만건 삽입하였다.
INSERT INTO vivim.audit_log_detail (audit_log_id, field_name, old_value, new_value)
SELECT
al.id,
CONCAT('field_', FLOOR(1 + RAND() * 10)),
CONCAT('old_value_', FLOOR(1 + RAND() * 100)),
CONCAT('new_value_', FLOOR(1 + RAND() * 100))
FROM
vivim.audit_log al
WHERE
RAND() < 0.2;
근데 문제가 발생했다.
10000건일땐 아무런 문제 없던 API가
2025-08-08 16:51:07.248 [https-jsse-nio-443-exec-9] ERROR org.apache.catalina.core.ContainerBase.[Tomcat].[localhost].[/].[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception │
│ [Request processing failed: java.lang.RuntimeException: java.lang.OutOfMemoryError: Java heap space] with root cause │
│ java.lang.OutOfMemoryError: Java heap space
데이터가 1000만건이 되니 out of memory가 발생하는 것이였다..
OutOfMemoryError: Java heap space 오류는 애플리케이션이 너무 많은 데이터를 한 번에 메모리에 로드하려고 할 때 발생한다.
특히 HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory 경고는 AuditLog 엔티티를
조회하면서 AuditLogDetail과 같은 컬렉션(@OneToMany 관계)을 함께 가져올 때, Hibernate가 페이징을 메모리에서 처리하기 위해 모든 관련 데이터를 먼저 로드하기 때문에 발생한다고 한다.
1000만 건의 데이터에서는 이 방식이 메모리 부족이 발생했던 것이다..
이러한 문제를 해결하기위해 기존 커스텀쿼리에 추가한 fetch join을 제거하고
AuditLog.java에 @BatchSize를 추가하였다.
AuditLog 엔티티의 details 필드에 @BatchSize(size = N) 어노테이션을 추가하여, Hibernate가 AuditLogDetail을 가져올 때 N개의 AuditLog에 대한 상세 정보를 한 번에 가져오도록 하였다.
1. JOIN FETCH와 OutOfMemoryError의 원인
JOIN FETCH는 N+1 쿼리 문제를 해결하기 위해 매우 유용한 기능입니다. AuditLog와 AuditLogDetail의 경우, JOIN FETCH a.details를 사용하면 AuditLog를 조회할 때 연관된 모든 AuditLogDetail도 한 번의 SQL 쿼리로 함께 가져옵니다.
문제점 (페이징과 함께 사용될 때):
* 카테시안 곱 (Cartesian Product): AuditLog 하나에 여러 개의 AuditLogDetail이 있을 수 있습니다. JOIN FETCH를 사용하면 데이터베이스에서 AuditLog 레코드 수 * AuditLogDetail 레코드 수 만큼의 결과 행이 생성될 수 있습니다 (정확히는 DISTINCT
키워드를 사용하더라도 내부적으로는 더 많은 행이 생성될 수 있습니다).
* Hibernate의 메모리 내 페이징: 더 중요한 문제는 Hibernate가 OneToMany 관계를 FETCH JOIN으로 가져오면서 LIMIT 또는 OFFSET과 같은 페이징을 적용할 때 발생합니다.
* Hibernate는 HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory 경고를 띄우면서, 데이터베이스에서 모든 `AuditLog`와 그에 연결된 모든 `AuditLogDetail`을 먼저 가져온 다음, 애플리케이션 메모리에서
`LIMIT`와 `OFFSET`을 적용합니다.
* 즉, 1000만 건의 AuditLog와 그에 연결된 수천만 건의 AuditLogDetail이 있다면, 이 모든 데이터를 애플리케이션의 JVM 힙 메모리에 한꺼번에 로드하려고 시도합니다.
* 이 과정에서 JVM 힙 메모리가 부족해지면 java.lang.OutOfMemoryError: Java heap space가 발생하게 됩니다.
2. @BatchSize를 통한 해결
@BatchSize는 FETCH JOIN과는 완전히 다른 방식으로 N+1 문제를 해결하고 메모리 효율성을 높입니다.
* 지연 로딩 (Lazy Loading) 기반: @BatchSize는 기본적으로 연관 관계가 지연 로딩(Lazy Loading)으로 설정되어 있을 때 작동합니다. 즉, AuditLog 엔티티를 조회할 때는 AuditLogDetail 컬렉션을 즉시 가져오지 않습니다.
* 별도의 쿼리 실행:
1. 1단계 쿼리 (메인 엔티티): AuditLogRepositoryImpl.findByConditions에서 LEFT JOIN FETCH a.details를 제거했기 때문에, 이제 쿼리는 AuditLog 엔티티만 가져옵니다. 이 쿼리는 데이터베이스에서 LIMIT와 OFFSET을 효율적으로 적용하여 요청된 페이지의
`AuditLog` 레코드만 가져옵니다. 이 과정에서 대량의 데이터가 메모리에 로드되지 않습니다.
2. 2단계 쿼리 (컬렉션 배치 로딩): 애플리케이션 코드에서 가져온 AuditLog 객체들의 getDetails() 메서드를 호출하여 AuditLogDetail 컬렉션에 접근할 때, Hibernate는 @BatchSize(size = 100) 설정에 따라 작동합니다.
* 예를 들어, 한 페이지에 10개의 AuditLog를 가져왔다면, Hibernate는 이 10개의 AuditLog에 해당하는 AuditLogDetail을 한 번의 `IN` 쿼리로 가져옵니다.
* 만약 한 페이지에 100개의 AuditLog를 가져왔다면, Hibernate는 이 100개의 AuditLog에 해당하는 AuditLogDetail을 한 번의 `IN` 쿼리로 가져옵니다.
* 이렇게 하면 N+1 쿼리(각 AuditLog마다 AuditLogDetail을 가져오는 쿼리)를 방지하면서도, 한 번에 메모리에 로드되는 데이터의 양을 FETCH JOIN처럼 전체가 아닌, 현재 페이지의 `AuditLog`에 해당하는 `AuditLogDetail`만으로 제한할 수 있습니다.
요약:
JOIN FETCH는 페이징 쿼리에서 OneToMany 관계를 처리할 때 모든 데이터를 메모리에 로드하여 OutOfMemoryError를 유발했습니다. 반면, JOIN FETCH를 제거하여 메인 엔티티의 페이징을 데이터베이스에 맡기고, @BatchSize를 통해 연관된 컬렉션을 필요할
때마다 효율적인 배치 쿼리로 가져옴으로써, 애플리케이션의 메모리 사용량을 크게 줄여 OutOfMemoryError를 해결할 수 있었습니다.
이제 outOfMemory 문제는 해결했다.
근데 또 하나의 문제가 있다.
오프셋 기반 페이징의 단점은 마지막 페이지로 갈 수록 불필요한 조회가 진행되어 성능이 저하된다는 점이다.
첫번째 페이지 조회의 경우 2898ms의 시간이 소모되지만
2025-08-08 18:09:50.590 [https-jsse-nio-443-exec-1] INFO com.welcommu.moduleapi.aop.ExecutionTimeLogger - ⏱️ Page com.welcommu.moduleservice.logging.AuditLogSearchService.searchLogs(ActionType,TargetType,String,String,Long,Pageable) executed in 2898 ms
2025-08-08 18:09:50.590 [https-jsse-nio-443-exec-1] INFO com.welcommu.moduleapi.aop.ExecutionTimeLogger - ⏱️ ResponseEntity com.welcommu.moduleapi.logging.AuditLogController.searchAuditLogs(ActionType,TargetType,String,String,Long,Pageable) executed in 2898 ms
마지막 페이지의 경우 4538ms의 시간이 소모되는 것을 확인할 수 있다.
2025-08-08 18:10:02.860 [https-jsse-nio-443-exec-1] INFO com.welcommu.moduleapi.aop.ExecutionTimeLogger - ⏱️ Page com.welcommu.moduleservice.logging.AuditLogSearchService.searchLogs(ActionType,TargetType,String,String,Long,Pageable) executed in 4538 ms
2025-08-08 18:10:02.860 [https-jsse-nio-443-exec-1] INFO com.welcommu.moduleapi.aop.ExecutionTimeLogger - ⏱️ ResponseEntity com.welcommu.moduleapi.logging.AuditLogController.searchAuditLogs(ActionType,TargetType,String,String,Long,Pageable) executed in 4538 ms
이러한 문제는 어떻게 해결하여야 할까?
이 문제에 대한 해답으로 커서기반페이징을 적용하기로 했다.
커서 기반 페이징을 적용하고 마지막 페이지의 응답속도를 체크해보니
2025-08-08 18:27:53.733 [https-jsse-nio-443-exec-9] INFO com.welcommu.moduleapi.aop.ExecutionTimeLogger - ⏱️ LogsWithCursor com.welcommu.moduleservice.logging.AuditLogSearchServiceImpl.searchLogs(ActionType,TargetType,String,String,Long,LocalDateTime,Long,int) executed in 2309 ms
2025-08-08 18:27:53.734 [https-jsse-nio-443-exec-9] INFO com.welcommu.moduleapi.aop.ExecutionTimeLogger - ⏱️ ResponseEntity com.welcommu.moduleapi.logging.AuditLogController.search(ActionType,TargetType,String,String,Long,LocalDateTime,Long,int) executed in 2311 ms
2309ms의 시간이 소모되는 것을 확인할 수 있었다.
오프셋 기반 페이징은 100번째페이지를 조회할때 99번쨰 페이지까지 조회를 한 후 폐기한다
그리고 남은 100번쨰 페이지를 리턴한다.
그에 비해 커서 기반 페이징은 필요한 부분부터 조회를 시작해 리턴하기에 이러한 성능 차이를 가져올 수 있었다.
'Spring' 카테고리의 다른 글
Audit Log 조회 API 성능 개선기 - 3 (1) | 2025.08.08 |
---|---|
Audit Log 조회 API 성능 개선기 - 1 (4) | 2025.07.19 |
Github Action 환경에서 모든 테스트코드가 실패한다. (0) | 2025.07.19 |
테스트 코드는 왜 짜야할까.. (0) | 2025.07.17 |
DB에 인덱싱을 적용하면 왜 빨라질까? (0) | 2025.06.09 |