Side Project/Study Together

[JPA] 일대다(OneToMany) 단방향 매핑의 성능 이슈

우니wooni 2024. 9. 25. 17:50

서론

프로젝트에 JPA를 처음 적용했을 땐 너무 편리하다며 감탄했다. 최근에는 제대로 이해하지 않고 사용하면 오히려 독이 될 수 있다는 것을 깨닫고 있다.

 

특히 연관관계 매핑에서는 세심한 주의가 필요하며, 양방향 매핑과 단방향 매핑이 각각 가져오는 차이를 이해하는 것이 중요하다. 이번 글에서는 양방향 매핑과 단방향 매핑에서 발생하는 문제들을 분석하고, 이러한 문제를 해결해 나가는 과정을 정리해보려 한다.


양방향 매핑의 문제점

✔️ 상태 변경에 대한 관리 복잡성이 증가

 

A객체가 B객체에 대해 직접 참조가 가능한 것은 B객체의 비즈니스 로직 노출, 상태 변경에 대한 가능성이 열려있는 구조이다.

양방향 매핑을 사용할 경우, 양쪽 객체가 서로를 참조하게 되면서 상태 관리가 복잡해지고 불필요한 비즈니스 로직 노출 문제가 발생할 수 있다.

A객체가 B객체에 대해 알아야 할 필요가 없고, 직접 참조가 필요하지 않은 경우라면 굳이 A→B의 연관관계를 맺을 필요가 없다.

 

따라서, StudyGroup(부모 객체)과 Participant(자식 객체)는 일방적인 관계만 필요하다고 판단하여 StudyGroup → Participant OneToMany 단방향 매핑으로 설계하였다.


OneToMany  단방향 매핑에서의 주의할 점

1. 조인 테이블 방식의 OneToMany 단방향 매핑

🚨 개별 행 DELETE 불가능

 

조인 테이블 방식으로 매핑 시, 개별적으로 특정 Participant를 삭제할 때 성능 문제가 발생할 수 있다.

@Entity
public class StudyGroup {
	@Id
  	@GeneratedValue(strategy = GenerationType.IDENTITY)
  	private Long id;
  
  	@OneToMany(cascade = CasCadeType.ALL, orphanRemoval=true)
  	List<Participant> participants = new ArrayList<>();
  
  	// ...
  
  	public void addParticipant(ParticipantV2 participant) {
        participants.add(participant);
        
  	public void removeParticipant(Long participantId) {
    	ParticipantV2 removeParticipant = participants.stream()
            .filter(participant -> participantId.equals(participant.getId()))
            .findFirst()
            .orElseThrow(() -> new NoSuchElementException("현재 StudyGroup에 존재하지 않는 참여자입니다."));

    	participants.remove(removeParticipant);
   }
}
@Entity
public class Participant {
	@Id
	@GeneratedValue(strategy = GenertationType.IDENTITY)
	private Long id;
	
	// ...
}

 

위와 같이 코드를 작성하면 Hibernate는 study_group_participants 조인 테이블을 자동으로 생성한다.

 


 

✔️ 삭제 시나리오 : N에 속하는 엔티티 DELETE

StduyGroup studyGroup = studyGroupRepository.findByID(1L);
studyGroup.removeParticipant(1L);
studyGroupRepository.save(studyGroup);

 

StudyGroup에서 Participant를 삭제하는 코드를 작성할 때 코드 실행 시 기대하는 동작은 아래와 같다.

 

1️⃣ study_group_participants 테이블에서 (study_group_id : 1, participants_id : 1)인 개별 행을 삭제 한다.

2️⃣ participant 테이블에서 ID가 1인 레코드를 삭한다.

 

그러나 실제 동작 방식은 기대와 다르다.

 

1️⃣ study_group_participants 테이블에서 study_group_id : 1인 행 전부 DELETE

2️⃣ participants_id가 1인 것을 제외한 나머지 레코드에 갯수만큼 INSERT

3️⃣ participant 테이블에서 id가 1인 행에 대한 DELETE

하나의 레코드를 삭제하기 위해 조인 테이블에서 모든 레코드를 삭제한 후 남은 데이터를 다시 삽입하는 방식으로 처리한다.

 

 

🧐 조인 테이블(study_group_participants)에서 개별 행만 삭제 하면 될것을,
왜 전부 DELETE하는 방식으로 동작하는 걸까?

 

 

✔️ 이유 : 

 

Parent 객체에서 일부 Child 객체를 삭제하면, Hibernate는 조인 테이블에서 어떻게 해당 관계만을 부분적으로 업데이트 해야 하는지 판단하지 못한다.

왜냐하면 OneToMany 단방향에서는 Child가 Parent를 알지 못하기 때문이다. 이로 인해, Hibernate는 모든 parent_id에 대한 레코드를 삭제한 후 다시 삽입하는 방식을 선택한다.

 


2. 조인 컬럼 방식의 OneToMany 단방향 매핑

@OneToMany(cascade = CasCadeType.ALL, orphanRemoval=true)
@JoinColumn(name = "study_group_id")
List<Participant> participants = new ArrayList<>();

 

@JoinColumn을 사용하면 Participant 테이블에 외래 키(study_group_id)가 추가되어 조인 테이블 대신 이 외래 키를 통해 매핑이 이루어진다.

하지만, 삭제 및 추가 시에 예상치 못한 쿼리 패턴이 발생할 수 있다.


 

✔️ 엔티티 제거 시나리오 : N에 속하는 엔티티 삭제

 

🚨 DELETE 쿼리를 기대했지만 UPDATE 쿼리가 발생한다.

StduyGroup studyGroup = studyGroupRepository.findByID(1L);
studyGroup.removeParticipant(1L);
studyGroupRepository.save(studyGroup);

 

위와 같이 코드를 작성할 경우 DELETE 쿼리를 통해 특정 Participant만 삭제할 것을 기대한다. 그러나 실제 동작은 다음과 같다.

 

1️⃣ UPDATE 쿼리가 먼저 실행되어 study_group_id 컬럼을 null로 업데이트.

2️⃣ 이후에 DELETE 쿼리 실행(단, orphanRemoval=true 설정 덕분에 수행).

✔️ 이유 :

 

N에 속하는 엔티티를 삭제한다는것은, 엔티티를 DELTE하는것이 아닌 부모와의 관계를 지우는것을 의미한다.

그렇기에 UPDATE 쿼리를 사용하여 조인 컬럼을 null로 변경시킨다.

 


 

✔️ 엔티티 추가 시나리오 : N에 속하는 엔티티 추가

 

🚨 INSERT 쿼리만을 기대했지만 UPDATE 쿼리가 추가적으로 발생한다.

StduyGroup studyGroup = studyGroupRepository.findByID(1L);
Participant participant = new Participant(...);
studyGroup.addParticipant(participant);
studyGroupRepository.save(studyGroup);

 

participant 테이블에 INSERT 쿼리만 발생하길 기대하지만, 실제 동작은 다르다.

 

1️⃣ INSERT 후 외래 키(study_group_id)를 null로 삽입한 후,

2️⃣ UPDATE 쿼리로 외래 키를 업데이트

즉, 외래키를 null로 삽입 시킨 후 다시 업데이트 하는 과정이 발생한다.

 

 

일대다 단방향 매핑의 단점은 매핑한 객체가 관리하는 외래 키가 다른 테이블에 있다는 점이다. 본인 테이블에 왜래 키가 있으면 엔티티의 저장과 연관관계 처리를 INSERT SQL 한 번으로 끝낼 수 있지만, 다른 테이블에 외래 키가 있으면 연관관계 처리를 위한 UPDATE SQL을 추가로 실행해야 한다.

김영한 저, 자바 ORM 표준 JPA 프로그래밍

 

1:N 단방향으로, 자식 객체는 여전히 부모 객체를 모르는 상태이다. 외래키는 자식 테이블(Participant)에 저장되지만, 부모 엔티티에서 자식들을 관리하는 상황이므로, 외래 키 값을 제때 삽입하지 못하고, 먼저 insert 후 외래키를 따로 update하는 것이다.


해결 방법

"성능과 설계의 트레이드 오프"

단방향 OneToMany 매핑에서는 성능 문제(특히, 불필요한 DELETE 및 INSERT, 또는 UPDATE 쿼리)가 발생할 수 있지만, 양방향 매핑을 선택하면 더 나은 성능을 얻을 수 있는 반면, 설계의 복잡성이 증가할 수 있습니다. 특히, 양방향 매핑에서는 객체 간의 순환 참조와 상태 관리 문제를 신경 써야 하므로 이를 적절히 처리할 수 있는 설계가 요구된다.

 

현재 프로젝트에서 양방향 매핑을 선택하였다. 그 이유는 성능 문제가 더 큰 비효율을 초래할 가능성이 있기 때문이다. 단방향 매핑에서 발생하는 성능 문제는 불필요한 쿼리 실행과 이로 인한 시스템 부하로 이어질 수 있다. 따라서, 양방향 매핑으로 전환하여 성능을 개선하는 것이 더 나은 선택이라고 판단하였다.