양방향 매핑을 왜 함부로 사용하면 안될까? 삭제 성능 최적화

2025. 9. 23. 04:33·프로젝트

최근 여러 프로젝트를 하면서 @OneToMany를 충분히 잘 알고 사용하고 있다고 생각했습니다.

양방향 매핑과 Cascade를 통해서 삭제를 편리하게 하려고 했는데 또 쿼리를 까보니 양방향 매핑은 그렇게 사용하면 안 된다는 것을 깨달았습니다. 그 경험에 대해서 소개를 하고 @OneToMany를 어떻게 활용할 수 있는지 다뤄보겠습니다.

✅ 예전에 공부한 @OneToMany를 사용함으로써 발생한 문제들 

https://coding-self-study.tistory.com/25

 

JPA 최적화는 어떻게 해야할까?

이번 게시물에서는 JPA 최적화와 여러 가지 배경 지식을 알아보도록 하겠습니다.정답은 아니지만 저의 기준과 나름대로 얻었던 인사이트를 공유하고자 합니다.📌 N+1 문제와 해결법에 대해서 정

coding-self-study.tistory.com

 

예전에 Clokey Legacy를 다룰 때에는 애초부터 단방향 매핑만을 사용했습니다. 양방향의 필요성을 크게 느끼지 못했고 처음부터 사용하지 않는 것이 좋다는 것을 익히 많이 들었기 때문입니다. 또한, 위의 게시물에서도 언급한 것처럼 잘못 사용할 경우 다음과 같은 문제가 발생할 수 있습니다.

  • MultipleBagFetchException 발생 가능
  • Paging과 함께 잘못 사용할 경우 메모리에서 페이징이 진행되는 문제 발생 가능
당시에는 이런 것들이 있다는 것 정도로 알고만 넘어갔습니다.

Clokey v2.0.0을 리팩토링하고 Cherrypic등의 프로젝트를 개발하면서 삭제 시 양방향 매핑의 장점이 있다고 생각했기 때문에 도입을 했었고, 결과적으로 잘 사용하고 있다고 생각했습니다. 쿼리에서 심각한 비효율이 발생한 다는 것을 알기 전까지 말이죠.

✅ @OneToMany를 사용함으로써 발생했던 추가적인 문제들

1️⃣ @OneToMany와 (CascadeType.ALL+orphanRemoval = true)을 함께 사용하는 경우 삭제 쿼리의 비효율성

@Override
public void deleteAlbum(Long albumId) {
    final Member currentMember = memberUtil.getCurrentMember();
    final Album album = getAlbumById(albumId);

    validateAlbumHost(currentMember.getId(), album.getId());
    validateSubscriptionInactive(album);
    validateRemainingParticipants(album, currentMember);

    final List<Event> events = eventRepository.findAllByAlbumId(album.getId());

    eventPublisher.publishEvent(
            ImagesDeleteEvent.of(events.stream().map(Event::getCoverUrl).toList()));

    if (imageRepository.existsByAlbumId(album.getId())) {
        eventPublisher.publishEvent(AlbumImagesDeleteEvent.of(album.getId()));
    }

    if (album.getCoverUrl() != null) {
        eventPublisher.publishEvent(ImageDeleteEvent.of(album.getCoverUrl()));
    }

    log.info(">>> About to delete album id={} (this triggers delete SQL)", album.getId());

    albumRepository.delete(album);

    log.info(">>> Album delete finished (delete SQL executed)");
}

실제로 제가 Cherrypic 프로젝트에서 사용한 코드인데요,

 

간단히 맥락을 설명하자면

  • 하나의 앨범에는 여러 이벤트가 존재합니다. (Album:Event는 1:N)
  • 하나의 앨범에는 여러 참가자가 존재합니다. (Album:Participant는 1:N)
  • 하나의 앨범에는 여러 사진이 존재합니다. (Album:Image는 1:N)

위처럼 저는 Album과 N관계를 가지는 모든 필드에 양방향 매핑을 걸어두었고, 이로 인해서 Album 하나를 삭제하는 것으로 나머지 연관된 모든 것들에 대한 삭제가 가능합니다. (참 편하죠!?~)

  • 위의 코드처럼 삭제 직전과 이후에 로그를 찍고 테스트 코드에서 어떤 쿼리들이 나가는지 확인해 보았습니다.
2025-09-19 20:08:03.109 [Test worker] INFO  o.c.d.album.service.AlbumServiceImpl - >>> About to delete album id=1 (this triggers delete SQL)
2025-09-19 20:08:03.110 [Test worker] DEBUG org.hibernate.SQL - select (필드 내용) from event e1_0 where e1_0.album_id=?
2025-09-19 20:08:03.113 [Test worker] DEBUG org.hibernate.SQL - select (필드 내용) from image i1_0 where i1_0.album_id=?
2025-09-19 20:08:03.114 [Test worker] DEBUG org.hibernate.SQL - select (필드 내용) from notification n1_0 left join member r1_0 on r1_0.id=n1_0.receiver_id left join member s1_0 on s1_0.id=n1_0.sender_id where n1_0.album_id=? // 알림
2025-09-19 20:08:03.121 [Test worker] DEBUG org.hibernate.SQL - select (필드 내용) from participant p1_0 left join favorites f1_0 on p1_0.id=f1_0.participant_id where p1_0.album_id=? // 앨범 좋아요
2025-09-19 20:08:03.122 [Test worker] DEBUG org.hibernate.SQL - select (필드 내용) from payment p1_0 where p1_0.album_id=? //결제
2025-09-19 20:08:03.124 [Test worker] INFO  o.c.d.album.service.AlbumServiceImpl - >>> Album delete finished (delete SQL executed)
2025-09-19 20:08:03.127 [Test worker] DEBUG org.hibernate.SQL - delete from event where id=?
2025-09-19 20:08:03.130 [Test worker] DEBUG org.hibernate.SQL - delete from event where id=?
2025-09-19 20:08:03.130 [Test worker] DEBUG org.hibernate.SQL - delete from event where id=?
2025-09-19 20:08:03.131 [Test worker] DEBUG org.hibernate.SQL - delete from event where id=?
2025-09-19 20:08:03.132 [Test worker] DEBUG org.hibernate.SQL - delete from image where id=?
2025-09-19 20:08:03.133 [Test worker] DEBUG org.hibernate.SQL - delete from participant where id=?
2025-09-19 20:08:03.134 [Test worker] DEBUG org.hibernate.SQL - delete from album where id=?

 

(저도 몰랐는데 연관관계가 더 있었네요 아무튼) 해당 album과 관련된 모든 엔티티들을 where로 찾아온 이후에 하나씩 삭제 쿼리가 나가게 됩니다. 사실 이는 크게 놀라운 일은 아닙니다. 여러분들이 사용하는 deleteAll과 같은 메서드도 저런 식으로 하나씩 삭제를 하게 되거든요(나중에 이야기하겠습니다).

 

어떤 부분이 비효율적인가?

  • 삭제를 해야 하는 엔티티들을 굳이 먼저 조회를 해야 한다는 점.
  • 조회를 해온 엔티티들을 하나씩 삭제해야 한다는 점.

이런 부분들에 문제가 있다고 생각했습니다.

물론 여러분들이 굳이 어떤 엔티티를 가져온 뒤에 deleteAll(Iterable<T> entities)를 사용하고 계시다면 위와 별반 다르지 않은 쿼리가 나가고 있겠지만요, 이는 최적화될 수 있는 부분입니다.

 

여기서 잠깐 짚고 넘어갈 부분이 있다면, 상황적으로 이미 찾아온 것을 삭제하는 것과 찾아올 필요가 없는데 찾아와서 삭제하는 것은 다릅니다.

 

ex) 제가 로직적으로 어떤 엔티티들을 이미 찾아왔고 그것을 삭제한다고 생각해 봅시다. 그렇다면 여기서 조회하는 소요 자체는 크게 비효율적이지 않을 수 있습니다.

 

하지만, 위와 같은 상황에서는 사실 삭제하는 앨범 ID를 가진 엔티티들을 굳이 조회해 올 필요 없이 삭제하면 그만인 상황입니다.

 

delete (필드 내용) from event e1_0 where e1_0.album_id=?

바로 이렇게 하면 되는데 굳이 찾아와서 하나씩 삭제한다 이 말이죠.

 

물론 이는 JDBC Batch Size를 활용하면 조금 완화할 수 있는 문제긴 합니다. 하지만, 그것을 활용한다고 해도 한 번에 delete 하는 쿼리만큼 성능이 나올 수는 없습니다. (참고로, identity 방식으로 ID값을 생성한다고 해도 update와 delete에서는 JDBC Batch Size가 동작합니다)

 

2️⃣ @OneToOne 조회 시 발생하는 N+1 문제

 

위에서 발생하는 문제는 편의성을 위해서 성능을 조금 희생한다고 생각할 수는 있지만 (학생 레벨 플젝에서 서버 스펙도 좋지 않은데 저는 이것도 용납하면 안 된다고 생각하긴 합니다), @OneToOne에서 발생하는 N+1 문제는 완전히 양방향 매핑을 사용하고자 하는 마음이 없어지게 만들었습니다.

 

⭐ 여러분, 그거 아시나요? JPA에서는 @OneToOne의 양방향 매핑 시 Lazy Loading이 작동하지 않습니다.

 

어째서 저럴까?라고 생각을 한다면 답이 없습니다. 많은 사람들이 불편함을 느끼고 있는 부분인 것도 같습니다.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToOne(mappedBy = "member", fetch = FetchType.LAZY)
    private Profile profile;
}

@Entity
public class Profile {
    @Id @GeneratedValue
    private Long id;
    private String bio;

    @OneToOne
    @JoinColumn(name = "member_id")
    private Member member;
}

예를 들면 다음과 같은 상황이 있다고 생각한다면,

 

Member를 조회해 오는 순간 모든 Member마다 Profile을 조회하는 N+1문제가 발생하게 됩니다. 

이를 해결하기 위해서는 3가지 방법이 존재한다고 김영한 강사님이 말하신 레퍼런스가 있습니다.

  • fk를 Member에 둘 것 
  • 1:1을 1:N N:1 관계로 풀어낼 것
  • 그냥 단방향 매핑을 사용할 것

저는 그냥 단방향 매핑을 그대로 사용하는 것이 좋은 방법이라고 생각했습니다.

✅  그래서 양방향 매핑을 어떻게 사용하라는 겁니까?

양방향 매핑은 말 그대로 편의 기능으로만 사용한다면 큰 문제가 없게 됩니다. 정리하면,

  • 1:N 관계에서 양방향 엔티티들을 조회하는 용도로 사용할 것.
  • 1:1에서는 걸어 두는 것도 안됩니다!

정도입니다.

@OneToMany(mappedBy = "album", cascade = CascadeType.PERSIST)
private List<Participant> participants = new ArrayList<>();

추가적으로, 이런 식으로 cascadeType.PERSIST를 걸어두고 부모를 저장하면 자식들도 한 번에 저장하는 것 정도로 사용할 수 있습니다.

"엇 그런데 사실 저 편의 메서드조차도 JPQL로 빼서 

select (필드들) from participants where member_id = 1

이렇게 하면 더 효율적이지 않나요? "

 

맞습니다! 그런데 위 정도의 차이는 성능의 차이가 정말 미미합니다. 
그래서 사용할만한 곳에 도입하여 편하게 사용하자라는 취지입니다.

✅  그럼 이제 삭제는 어떻게 처리하죠?

이를 위해서는 먼저 

  • deleteAll(Iterable<T> entities)
  • deleteAllInBatch(Iterable<T> entities)& deleteInBatch(Iterable<T> entities)

의 차이를 알아야 합니다.

 

deleteAll(Iterable<T> entities) 같은 경우에는 배치 삭제 처리를 하지 않습니다. 

select
    user0_.id as id1_0_,
    user0_.name as name2_0_,
    user0_.email as email3_0_
from
    user user0_
where
    user0_.id in (?, ?);

삭제해야 하는 것들을 다음과 같이 In절로 찾아오고 

delete
from user
where id=?;

delete
from user
where id=?;

하나씩 삭제를 하게 됩니다.

 

deleteAllInBatch(Iterable<T> entities)& deleteInBatch(Iterable<T> entities) 같은 경우는

delete
from user
where id in (?, ?);

별도의 찾아오는 과정 없이 즉시 In절을 통해서 삭제하게 됩니다.

 

따라서, deleteAllInBatch 같은 메서드를 사용하면 비교적 더 효율적인 삭제를 할 수 있습니다.

하지만요, 이것 보다도 더 효율적인 JPQL 메서드를 사용하는 방법이 존재합니다!

 

바로 In 절을 기반으로 한 deleteAllInBatch가 아닌 Where절을 이용한 JPLQ 삭제 메서드를 만드는 것입니다.

@Modifying
@Query("delete from Event e where e.album.id = :albumId")
void deleteAllByAlbumId(@Param("albumId") Long albumId);

이렇게 말이죠..? 

 

그럼 여기서 궁금한 점이 생깁니다.

"아니 그럼 모든 상황에서 JPQL 메서드를 만들어서 한 번에 삭제하라고요?"

 

이건 너무 불편한 경우가 많습니다. 그래서 저는 다음과 같은 방법을 생각했습니다.

 

상황적으로 이미 찾아온 것을 삭제할 때는 deleteAllInBatch를 사용하고  찾아올 필요가 없는데 찾아와서 삭제하는 경우는 JPQL을 사용하는 것입니다.

 

이미 찾아온 경우에는 deleteAllInBatch를 사용하는 것도 JPQL 만큼이나 성능이 좋기 때문입니다.

 

단, 처음 예시를 들었던 앨범을 기준으로 이벤트들을 삭제하는 경우, 삭제할 것들을 모두 찾아오게 된다면 불필요한 리소스를 사용하게 됩니다. 이 경우에는 JPQL을 사용해야 하는 것이죠!

 

⭐ 마지막으로 deleteAllInBatch와 where절을 기반으로 한 JPQL의 삭제 성능을 비교하며 마무리하겠습니다. (deleteAllInBatch도 충분히 좋다고요!)

 

이를 위해서는 EXPLAIN ANAYLZE 쿼리를 사용했는데요, 이는 레포지토리 레벨의 메서드를 Jmeter 같은 부하 테스트 도구를 사용할 경우 JVM Warm-Up 문제등으로 제대로 된 답을 얻을 수 없기 때문입니다.

 

https://coding-self-study.tistory.com/30

 

어째서 Jmeter로 메서드 단위 성능을 반복 측정하면 안되는가? (JVM Warm-up & DB Cache)

이전에 작성했던 게시글에서 Jmeter로 한 메서드의 성능을 반복 측정하면 안 되는 이유를 JVM Warm-up과 DB Cache와 관련해서 설명을 드리겠습니다. https://coding-self-study.tistory.com/26 Clokey 리펙토링 : JPA

coding-self-study.tistory.com

그래서 EXPLAIN ANALZE 쿼리를 사용하기로 결심했지만, 

현재 MySQL에서는 EXPLAIN ANALZE는 Delete 쿼리에는 적용할 수 없기 때문에 유사한 상황에서 조회를 하는 경우를 확인해 보겠습니다.

 

Case : Album 하나에 event를 1000개 넣고 조회하는 상황

 

1번: Where를 통한 조회

EXPLAIN ANALYZE SELECT *
                FROM event
                WHERE album_id = 1;
-> Index lookup on event using fk_event_album (album_id = 1) (cost=173 rows=1000) (actual time=0.299..4.21 rows=1000 loops=1)

 

여러 번 돌려봤는데 0.3ms 정도로 빠른 성능을 자랑합니다.

 

2번: In을 통한 조회

EXPLAIN ANALYZE
SELECT *
FROM event
WHERE id IN (
             1,중략
             991,992,993,994,995,996,997,998,999,1000
    );
-> Index range scan on event using PRIMARY over (id = 1) OR (id = 2) OR (998 more) (cost=451 rows=1000) (actual time=1.47..1.47 rows=0 loops=1)

어떤 블로그에서 In절을 사용하면 인덱스를 안 탄다고 말하신 분이 있었던 것 같은데 그렇지 않습니다.

 

평균적으로 1.47ms 정도의 성능이 나왔고요. 1000개 정도의 데이터에서도 큰 차이가 없었습니다.

 

정말 대용량의 데이터를 다루지 않는 이상 deleteAllInBatch와 Where 기반 JPQL 삭제를 혼용해도 괜찮을 것 같습니다! 적어도 "학생 레벨의 프로젝트에서는 안심하고 사용해도 되지 않을까"라고 생각하고 있습니다.

 

또한, 위의 두 메서드를 사용하면 조회해 오는 과정 없이 삭제를 하기 때문에 delete 과정에서 동시성 문제도 발생하지 않구요!

 

+9월 24일 내용 추가 - JPQL로 삭제하는 것에도 주의 사항이 존재합니다. 다음 게시물에 정리했습니다.

https://coding-self-study.tistory.com/32

 

@Modifying의 문제점을 알고 사용하자

이전 게시글에서는 삭제의 용도로 사용하던 양방향 매핑을 제거하게 된 계기와 그로 인해서 deleteAllInBatch와 JPQL 삭제 메서드를 사용하게 된 이유를 소개했습니다. https://coding-self-study.tistory.com/31

coding-self-study.tistory.com

 

'프로젝트' 카테고리의 다른 글

모니터링 구축기 : Logback + PLG  (0) 2025.09.26
@Modifying의 문제점을 알고 사용하자  (0) 2025.09.24
테스트 코드는 어떻게 작성해야 할까? - 좋은 테스트 코드 작성 TMI  (1) 2025.07.01
JPA 최적화는 어떻게 해야할까?  (0) 2025.05.28
배포의 모든 것 - 5. Blue-Green 무중단 배포 적용  (2) 2025.05.01
'프로젝트' 카테고리의 다른 글
  • 모니터링 구축기 : Logback + PLG
  • @Modifying의 문제점을 알고 사용하자
  • 테스트 코드는 어떻게 작성해야 할까? - 좋은 테스트 코드 작성 TMI
  • JPA 최적화는 어떻게 해야할까?
나는 정말 개발이 하고 싶었다
나는 정말 개발이 하고 싶었다
개발 혼자 공부하기
  • 나는 정말 개발이 하고 싶었다
    감자밭
    나는 정말 개발이 하고 싶었다
  • 전체
    오늘
    어제
    • 분류 전체보기 (32)
      • ETC (3)
      • 알고리즘 (0)
      • Java (0)
      • DB (2)
      • Spring (0)
      • 프로젝트 (18)
      • Server (3)
      • CS (0)
        • 운영체제 (0)
      • Infra (4)
        • IAC (1)
        • AWS (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • hELLO· Designed By정상우.v4.10.3
나는 정말 개발이 하고 싶었다
양방향 매핑을 왜 함부로 사용하면 안될까? 삭제 성능 최적화
상단으로

티스토리툴바