관리 메뉴

너와 나의 스토리

[SpringBoot] JPA 양뱡향 매핑은 불필요한 것인가? Fetch 전략과 N+1 문제 본문

개발/Spring Boot

[SpringBoot] JPA 양뱡향 매핑은 불필요한 것인가? Fetch 전략과 N+1 문제

노는게제일좋아! 2025. 9. 27. 09:48
반응형

본 포스팅은 "[NHN Cloud 2019] Spring JPA의 사실과 오해" 강연 내용을 정리한 글입니다.

 

 

연관관계 매핑 - 단방향 vs 양방향

  • 사실상 단방향 매핑만으로 연관관계 매핑은 이미 완료 
    • 어차피 연관관계 매핑은 내부적으로 foreign key를 이용하게 되는데, foreign key는 결국 하나이기 때문에
  • 양방향 매핑은 양쪽에서 서로에 대한 설정을 해줘야 하기 때문에 복잡해짐.
    • 그에 비해 반대쪽 방향으로 객체 그래프 탐색 기능 추가된 게 유일한 이점임.
  • 결론
    • 대개의 경우 단방향 매핑이면 충분하다
    • 우선은 단방향 매핑을 사용하고 반대 방향으로의 객체 그래프 탐색이 필요할 때 양방향을 사용

 

다대일(N:1) 단방향 연관관계 매핑

@Entity
public class Member {
    @Id
    @Column(name ="member_id")
    private Long memberId;
    
    ...   
}


@Entity
public class MemberDetail {
    @Id
    @Column(name ="member_detail_id")
    private Long memberDetailId;
    
    @ManyToOne(cascade = CascadeType.ALL)
    @JoinColumn(name = "member_id")
    private Member member;
    
    ...
}

 

 

Cascade: 영속성 전이

  • Entity의 영속성 상태 변화를 연관된 Entity에도 함께 적용하는 것
  • Cascade Type
    • PERSIST
      • Entity를 영속 객체로 추가할 때 연관된 Entity도 함께 영속 객체로 추가한다.
    • REMOVE
      • Entity를 삭제할 때 연관된 Entity도 함께 삭제한다.
    • DETACH
      • Entity를 영속성 컨텍스트에서 분리할 때 연관된 Entity도 함께 분리 상태로 만든다.
    • REFRESH
      • Entity를 데이터베이스에서 다시 읽어올 때 연관된 Entity도 함께 다시 읽어온다.
    • MERGE
      • Entity를 준영속 상태에서 다시 영속 상태로 변경할 때 연관된 Entity도  함께 변경한다.
    • ALL
      • 모든 상태 변화에 대해 연관된 Entity에 함께 적용한다.

 

Case1: 연속성 전이를 통한 insert

@Transactional
public void createMemberWithDetails() {
    Member member = new Member("member1", LocalDateTime.now());
    
    MemberDetail memberDetail1 = new MemberDetail(member, "type1", "description1");
    MemberDetail memberDetail2 = new MemberDetail(member, "type2", "description2");

    membserDetailRepository.saveall(Arrays.asList(memberDetail1, memberDetail2));
}
  • 실제 수행 결과
    • members 테이블에 member INSERT
    • member_details 테이블에 member_details1 INSERT
    • member_details 테이블에 member_details2 INSERT

 

 

일대다(1:N) 단방향 연관관계 매핑

@Entity
public class Member {
    @Id
    @Column(name ="member_id")
    private Long memberId;
    
    @OneToMany(casecade = CascadeType.ALL)
    @JoinColumn(name = "member_id")
    private List<MemberDetail> details;
    
    ...   
}


@Entity
public class MemberDetail {
    @Id
    @Column(name ="member_detail_id")
    private Long memberDetailId;
 
    ...
}

 

 

Case2: 연속성 전이를 통한 insert

@Transactional
public void createMemberWithDetails() {
    Member member = new Member("member1", LocalDateTime.now());
    Member savedMember = memberRepository.save(member);
    
    MemberDetail memberDetail1 = new MemberDetail(member, "type1", "description1");
    MemberDetail memberDetail2 = new MemberDetail(member, "type2", "description2");

    member.getDetails().add(memberDetail1);
    member.getDetails().add(memberDetail2);
}
  • 실제 수행 결과
    • members 테이블에 member INSERT
    • member_details 테이블에 member_details1 INSERT
    • member_details 테이블에 member_details2 INSERT
    • member_details 테이블에서 member_id UPDATE
    • member_details 테이블에서 member_id UPDATE
  • 추가적으로 업데이트 쿼리가 추가됨
  • 즉, 일대다(1:N) 단방향 연관관계 매핑에서 연속성 전이를 통해 insert를 하게 되면 
    • 일대다 관계의 외래 키(FK) 지정을 위해 추가적인 update 쿼리가 발생하는 문제가 생김
    • 이 경우에는 오히려 일대다 양방향 연관관계로 변경하면 추가적인 update 쿼리가 없어짐.

 

 

일대다(1:N) 양방향 매핑

@Entity
public class Member {
    @Id
    @Column(name ="member_id")
    private Long memberId;
    
    @OneToMany(casecade = CascadeType.ALL, mappedBy = "member")
    private List<MemberDetail> details;
    
    ...   
}


@Entity
public class MemberDetail {
    @EmbeddedId
    private PK pk;
    
    private String description;
    
    @ManyToOne
    @MapsId("memberId")  // 복합키의 memberId를 member 연관관계와 매핑 -> pk.memberId와 member.memberId는 항상 동일한 값이 됨.
    private Member member;
    
    @Embeddable
    public static class Pk implements Serializable {
    	@Column(name = "member_id")
        private Long memberId;
        
        private String type;
    }
    ...
}
  • 양방향으로 설정하는 경우, 연관관계의 주인을 설정하는 게 중요하다. 내부적으로는 FK를 사용하기 때문이다. 
  • 결과적으로 FK를 가지고 있는쪽이 연관관계의 주인이 된다.
  • 예제에서 보면 MemberDetail에서 MapsId
    • Member는 연관관계의 주인이 아니기 때문에 join column을 쓰면 안 된다.
    • @JoinColumn: 이 필드가 FK임을 명시.
    • mappedBy: "나는 주인이 아니다"라는 선언 
    • @MapsId: 자식 엔티티의 PK와 FK를 공유할 때 사용
  • 즉, member_detail의 PK는 (member_id, type) 복합키이고, 그 중 member_id는 member 테이블의 FK 역할을 동시에 한다.
  • 이렇게 구현 후, 아까 예제를 수행하면, 업데이트 쿼리가 추가적으로 발생하지 않게 된다.

 

그렇다면 항상 ManyToOne 단방향만 쓰는 게 정답일까?

상황 추천 방식
대부분 MemberDetail 중심으로 접근하고, Member에서 details를 잘 안 쓰는 경우 ManyToOne 단방향
Member에서 details를 자주 조회해야하는 경우 양방향 (mappedBy 설정)
OneToMany 단방향 비추 (쿼리 비효율적)
  • FK는 항상 "다"쪽(@ManyToOne)에서 관리하는 게 정석이고, "일"쪽(@OneToMany)는 필요할 때만 컬렉션으로 접근할 수 있게 양방향을 열어주는 게 가장 실용적이다. 

 

Fetch 전략

  • 전략
    •  FetchType.EAGER
      • 하나의 entity를 가져올 때, 연관관계에 있는 entity를 즉시 가져오는 것
      • @OneToOne, @ManyToOne
    • FetchType.LAZY
      • 실제 참조가 이뤄졌을 때 연관관계에 있는 entity 값을 가져오는 것
      • @OneToMany, @ManyToMany

 

 

N+1 문제

  • 연관된 entity를 가지고 올 때, 우리가 의도한 것과 달리 추가적으로 쿼리를 N번 추가적으로 수행하는 문제
  • 해결 방법
    • Fetch Join
    • Entity Graph

 

오해1: N+1 문제는 EAGER Fetch 전략 때문에 발생하는가?

  • Fetch 전략을 LAZY로 설정했더라도 연관 Entity를 참조하면 그 순간 추가적인 쿼리가 수행됨. 
  • Fetch 전략은 시점 차이지, 똑같이 문제 발생.

 

오해2: findAll() 메서드 N+1 문제가 발생하지 않는가?

  • fetch 전략을 적용해서 연관 entity를 가져오는 것은 오직 단일 레코드에서만 적용
  • 단일 레코드 조회가 아닌 경우 해당 JPQL을 먼저 수행하고 반환된 레코드 하나 하나에 대해 entity에 설정된 fetch 전략을 적용해서 연관 entity 가져옴.
    • 예를 들어, findAll()로 Member 목록을 가져온 직후, JPA는 단순히 Member 객체들만 영속성 컨텍스트에 올린다. 
    • 이후, member.getDetails()를 호출하면 그때, fetch 전략을 수행함. 
    • 즉, 연관 entity를 조회하는 시점에서 결국 N+1이 발생.
  • 그렇기 때문에 findAll() 메서드 호출도 역시 이 과정에서 N+1 문제 발생 가능

 

 

N+1 해결을 위해 Fetch Join 사용

  • 흔히 하는 실수 1: Pagination + Fetch JOIN
    • Pagination 쿼리에 Fetch Join을 적용하면 실제로는 모든 레코드르 가져와서 조인한 후에 Pagination처리가 됨. 
    • 즉, 분리해서 실행해야한다. 
반응형
Comments