Hyeok의 웹 개발 블로그

<2025.05.07> 연관관계 N+1 본문

TIL/Spring

<2025.05.07> 연관관계 N+1

Yhyeok 2025. 5. 7. 20:32

✅단방향 연관관계

  • 단방향 연관관계는 아래와 같이 연관관계에 있는 두 객체 사이에 한 방향으로의 참조가 존재하는 상태
    - Team -> Member
    - Member -> Team
    Team 입장에서는 Member 와 1:N 연관관계를 가지지만, Member 입장에서는 Team 과 N:1 연관관계이고 어떤 방향이든 두 객체 사이의 한 방향의 참조만 존재한다면 모두 단방향 연관관계라 할 수 있다.

✔ @OneToOne

  • @OneToOne 연관관계는 두 테이블 / 객체가 1:1 연관관계일 때 사용
    - 1:1연관관계에 있는 객체간 참조 방향을 어떻게할지 잘 결정해야함.
    - 물리적으로 분리되어야하는 테이블인지 고민 (데이터의 생명주기 혹은 사용 패턴을 토대로 고민 가능)

✔ @ManyToOne

  • 테이블상의 외래키 위치와 객체상의 참조 위치가 동일해 헷갈리지 않음
  • 별다른 설정 없이도 우리가 생각한대로 SQL 쿼리가 잘 동작한다.
@Entity
public class Team {
	
	@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "team_id")
	private Long id;
	
	private String name;	
	private LocalDateTime createdAt;
	private LocalDateTime updatedAt;
}

@Entity
public class Member {

	@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "member_id")
	private Long id;
	
	private String name;
	private int age;
	
	@ManyToOne
	@JoinColumn(name = "team_id")
	private Team team;
	
	private LocalDateTime createdAt;
}

 

✔ @OneToMany

  • RDB 의 외래키의 위치와 객체 참조의 위치가 다르다.
  • 1번 문제로 인해 INSERT 쿼리 요청 시, 불필요한  UPDATE 쿼리가 발생.
  • 단방향 연관관계로 사용할 때, @JoinColum을 반드시 사용해야함. 사용 안했을땐, 불필요한 연결 테이블이 생성될 가능성있음
  • 연관관계에 있는 Entity(member)를 save() 해주지 않으면 제대로 저장 X
    이런 경우 반드시 cascade 옵션 중 PERSIST 를 사용해야함.
    • 단방향 연관관계의 방향으로 @OneToMany 를 사용했을 때 주의해야할 것들이 존재할 뿐이지 명확한 단점이 있는 것은 아니라고 생각한다.
    • @OneToMany 연관관계는 사용하면 안돼! 가 아니라 이런 포인트에서의 주의해야할 부분이 있으니 Trade-Off 하자! 로 접근하는게 더 좋을 것 같다.
      1. 도메인을 정의할 때 더 자연스러운 방향으로 설계하는게 비즈니스 로직을 개발하는 과정에서 더 자연스럽고 더 잘 읽히는 코드를 만들 수 있다.
      2. 항상 같이 조회되는 상황에서는 @OneToMany 로 조회했을 때 코드가 자연스러워 진다.
      3. Post - Hashtag 처럼 Hashtag → Post 로 참조할 일이 없고 항상 Post → Hashtag 로만 참조하는 조회패턴을 가진다면 @OneToMany 를 적극 고려할 수 있다.

✅ 양방향 연관 관계

- 객체 사이에는 "양방향"이라는 개념 X
 Team -> Member, Member -> Team 두 단방향 연관관계가 존재하는 상황을 양방향 연관관계라고 함.

 

✔ 양방향 연관관계를 지양해야하는 이유

  • 양방향 연관관계를 지양해야한다는 말은 많이 들어봤을거라 생각한다! 그럼 왜 그럴까?
    1. 불필요한 연관관계로 인해 설계의 복잡도가 증가한다.
    2. 양방향 의존성은 순환참조 및 데이터 동기화 등에서 문제가 생길 가능성이 존재한다.
    3. 객체 사이의 과도한 결합으로 인해 서로 변경을 전파하게되어 변경에 유연하지 못한 구조가 된다. ”OOP 의 의존성” 에 대한 이야기!
    4. 객체의 독립성이 저하되어 테스트 코드를 작성하기 어렵다.

✔ 단점만 있는 기술은 없다!

  • 양방향 연관관계는 그럼 절대 쓰지말아야하는 걸까?
  • 그렇지 않다! 그럼 어떤 상황에서 양방향 연관관계를 고려해볼 수 있을까?
    1. 이미 @ManyToOne 연관관계를 사용하고 있는 상황에서 @OneToMany 방향으로의 조회(객체 참조)패턴이 많이 필요한 상황 ⇒ “단순 조회 목적!”
    2. @OneToMany 단방향 연관관계 매핑을 사용할 때 발생하는 “INSERT 쿼리시 연관관계 처리를 위한 UPDATE 쿼리가 함께 발생하는 문제” 를 해소하기 위한 상황 (대안 존재함!)
  • 양방향 연관관계는 “필수” 가 아니라 “필요” 에 의해서 추가되어야한다는 것을 꼭 기억하자!
  • 우선 하나의 단방향 연관관계로 시작하자!! 이후에 반대 방향의 객체 참조가 필요하다면 양방향 연관관계를 추가해도 테이블에 영향을 주지 않기 때문에 충분히 유연하다

✅ N+1

  • Todo 와 Comment 는 1:N 연관관계를 가지는 테이블이다.
  • Todo 와 Comment 객체는 양방향 연관관계 매핑을 맺고 있다.
    • 양방향 연관관계를 사용한 이유는 @OneToMany, @ManyToOne 에 대한 N+1 문제를 하나의 예제코드에서 모두 살펴보기 위함이다!!
  • 두 연관관계 매핑에서 모두 FetchType.EAGER 를 적용했다.
@Entity
public class Todo {
	
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "todo_id")
  private Long id;
  
  private String contents;
  private LocalDate date = LocalDate.now();
  private boolean isComplete = false;
  
  @OneToMany(mappedBy = "todo", fetch = FetchType.EAGER)
  public List<Comment> comments = new ArrayList<>();
}

@Entity
public class Comment {

  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "comment_id")
  private Long id;
  
  @ManyToOne(fetch = FetchType.EAGER)
  @JoinColumn(name = "todo_id")
  private Todo todo;
  private String contents;
}

 

✔ N+1 문제

N+1 문제는 JPA 를 이용해 연관관계에 있는 두 테이블의 데이터를 조회할 때 JOIN 을 이용한 SELECT 쿼리 1개가 발생할거라 기대했지만, JOIN 을 이용하지 않은 1개의 SELECT 쿼리 + 연관관계에 있는 데이터를 조회하기 위한 N개의 SELECT 쿼리로 총 N+1 개의 SELECT 쿼리가 발생하는 문제.

 

▶ ORM 이 가지는 고질적인 문제인 N+1 문제 발생 이유?

 - JPA 에서 연관관계에 있는 두 테이블 JOIN 해서 가지고오고 싶은지 확신 X
 - "연관관계 = JOIN" 이라고 가정, 너무 많은 연관관계 그래프가 한번에 조회 될 가능성 Up!!
 - 어떤 시점에  어떤 연관관계 그래프를 한번에 조회해야하는지 JPA 입장에서 알 수 없다.
    N+1 문제가 발생하고, Fetch Join, Entity Graph와 같은걸 통해 명시적으로 알려줌.

 

- N+1 문제는 JPA가 객체인 Entity 기반으로 쿼리를 자동으로 만들어주기 때문에 발생하는 문제.

 

 ✔ N+1 문제가 발생하는 이유는 EAGER ?

  • 예제 코드에서 Todo, Comment 는 서로에 대한 연관관계 Fetch 전략을 모두 EAGER 로 설정했다.
  • 경우에 따라 Lazy Loading 이 발생하지 않는 로직일 경우, 즉 연관관계를 참조하지 않는 경우 N개의 쿼리가 생략될 수는 있다.
  • 하지만, 연관관계에 있는 Entity 를 사용하는 시점에 결국 N개의 SELECT 쿼리가 발생해 N+1 문제가 여전히 발생하는걸 알 수 있다.
  • 즉, LAZY Fetch 전략을 통해 N+1 문제를 완전히 해결할 수는 없다!

 ✔ N+1 문제 해결

  • Batch Size 조절
  • Fetch Join
  • EntityGraph

- Batch Size

 - Batch Size 를 조절하는 것은 근본적인 해결 방법 X

 - N개의 쿼리가 많아 특정 개수씩 묶어 In Query를 날려서 쿼리 개수를 줄임.


 

- Fetch Join 

 - Fetch Join은 JPA에서 지원하는 JOIN 방식
 - Fetch Join을 사용할 때, 연관관계에 있는 Entity 까지 한번에 조회해 영속화 해줌.
 - Fetch Join은 JPQL로 JOIN FETCH 구문 사용

@Repository
public interface TodoRepository extends JpaRepository<Todo, Long> {

  @Query("SELECT todo FROM Todo todo JOIN FETCH todo.comments")
  List<Todo> findAllWithFetchJoin();
}

 


- EntityGraph
 - @EntityGraph 를 이용해 해당 Entity의 연관관계에 대한 정보를 넣어주는 방식

@Repository
public interface TodoRepository extends JpaRepository<Todo, Long> {

	@EntityGraph(attributePaths = ["comments"])
  @Query("SELECT todo FROM Todo todo")
  List<Todo> findAllWithEntityGraph();
}