본문 바로가기
JPA

[JPA] 1 : N 연관관계에서 limit 사용 시 OutOfMemory가 발생하는 문제 해결

by doodoom 2023. 10. 5.

0. 이 글을 쓰게된 이유

팀원분이 성능 최적화를 하려고 짧은 시간에 많은 요청을 보내던 중 OutOfMemory 예외가 발생했다. 평소에는 괜찮다가 왜 이런 문제가 발생했는지 알아보던 중에 배웠던 것을 기록하기위해 이 글을 쓰게 되었다.

1. Entity 연관관계

다음과 같이 Roadmap Entity와 RoadmapTag Entity가 있고, 이 둘은 1 : N 연관관계를 가지고있었다.

 

로드맵

실제로는 이것보다 훨씬 연관관계가 많고 복잡한 엔티티이지만 해당 연관관계만 보여주겠다.

로드맵 태그

2. 문제 상황

로드맵과 로드맵 태그를 같이 조회하는 상황이다. 이때, 로드맵 태그가 Lazy Loading이 설정이 되어있으니, 각 로드맵의 태그에 접근할때마다 쿼리가 나갈것이다. 그래서 fetch join을 사용하여 이 문제를 해결하고 싶었다. 

하지만 이 쿼리는 문제가 있다. 문제는 다음과 같다.

 

1. 만약 로드맵이 1개이고 태그가 3개이다. 

2. 그럼 Join으로 인해 결합된 스키마는 3개가 나올 것이다. 즉, 로드맵 데이터는 중복을 피할 수 없다.

여기까지는 우리가 일반적으로 아는 조인 데이터 중복문제이다. 이는 fetchJoin으로 해결이 가능하다. 하지만, 위 쿼리는 limit이라는 키워드를 사용하고있다. 

 

2.1 JPA에서 limit 키워드를 처리하는법

 

조인 데이터 중복문제가 발생하지 않는 쿼리에서 limit 키워드를 사용하면 JPA에서는 다음과 같이 LIMIT 쿼리가 나간다.

SELECT
    ...
FROM
    roadmap r1_0
JOIN
    roadmap_category c1_0 ON c1_0.id = r1_0.category_id
JOIN
    member c2_0 ON c2_0.id = r1_0.member_id
WHERE
    r1_0.status = ?
ORDER BY
    r1_0.created_at DESC
LIMIT ?;

 

여기서 중요한 것은 쿼리에 Limit이 있는것이다. Join이 완료된 결합된 스키마에 데이터 중복 문제가 발생하지 않기 때문에 해당 스키마 자체에 LIMIT을 걸어도 상관이 없다.

 

 

하지만 데이터 중복 문제가 발생하는 Join 스키마에서는 어떨까? 

위와 같이 조인 데이터 스키마가 있을 때, 중복된 데이터를 포함해서 LIMIT을 걸게된다. 그럼 사용자가 의도하지 않은 데이터가 나올 것이다.

 

이를 처리하기위해 JPA에서는 1 : N 관계에서 limit을 사용할때, 전체를 모두 가져온후, java 어플리케이션에서 자르게 된다. 

 

위 경우 나가는 쿼리

select
        ...
    from
        roadmap r1_0 
    join
        roadmap_category c1_0 
            on c1_0.id=r1_0.category_id 
    join
        member c2_0 
            on c2_0.id=r1_0.member_id 
    left join
        roadmap_tag v1_0 
            on r1_0.id=v1_0.roadmap_id 
    where
        r1_0.status=? 
    order by
        r1_0.created_at desc

 

이와 같이 모두 가져온 후, JPA 영속성 계층에서 데이터를 limit에 맞게 자른다. 

 

즉, 전체 데이터가 어플리케이션 메모리에 올라가다보니 OutOfMemory가 발생할 여지가 발생한다.

 

3. 해결 방법

결론부터 말하자면 우리는 Roadmap을 조회하는 쿼리와 RoadmapTag를 조회하는 쿼리를 분리하자는 결론이 나왔다. 
그리고 RoadmapTag를 조회할때 각 로드맵마다 쿼리를 날리는 문제점은 @BatchSize를 통해서 IN절을 활용해 해결했다.

 

3.1 수정된 Entity

Roadmap

Roadmap.roadmapTag에 @BatchSize를 사용해서 로드맵마다 여러번 나가는 쿼리를 IN절을 활용해 1번의 쿼리로 최적화 하였다.

 

3.2 수정된 쿼리

위 그림과 같이 로드맵만 조회하는 쿼리를 날린다. 그리고 RoadmapTag에 설정되어있는 Lazy Loading 전략으로 인해, RoamapTag에 접근할때 RoadmapTag를 조회하는 쿼리가 날라가게된다.

 

Roadmap 쿼리

SELECT
        ... 
    FROM
        roadmap r1_0 
    JOIN
        roadmap_category c1_0 
            on c1_0.id=r1_0.category_id 
    JOIN
        member c2_0 
            on c2_0.id=r1_0.member_id 
    WHERE
        r1_0.status=? 
    ORDER BY
        r1_0.created_at desc
    LIMIT ?;

RoadmapTag 조회 쿼리

SELECT
    v1_0.roadmap_id,
    v1_0.id,
    v1_0.name
FROM
    roadmap_tag v1_0
WHERE
    v1_0.roadmap_id IN (?);

 

4. 정리

이렇게 되면 어플리케이션에서는 LIMIT을 활용해 제한된 개수의 데이터만 받아서 메모리를 아낄 수 있게된다. 1 : N과 limit을 같이 활용할때에는 이 부분을 꼭 신경쓰자!

'JPA' 카테고리의 다른 글

[JPA] 기본키 생성 전략  (0) 2023.07.07
[JPA] 영속성 전이와 고아 객체  (0) 2023.06.29
[JPA] N + 1 문제 2 (fetch join 최적화)  (0) 2022.09.28
[JPA] N + 1 문제 1  (0) 2022.09.28