Skip to main content

Command Palette

Search for a command to run...

양방향 바인딩 & N+1 문제

Published
6 min read

양방향 바인딩

상황 설정

게시판 서비스 제작 중

게시글 하나에 그 게시글에 소속된 다수에 댓글이 있는 상황(게시글 하나 <> 다수의 댓글)

댓글에 게시글 ID를 FK(외래키)설정을 하는 것이다.

Article 엔티티

@OrderBy("id")
@OneToMany(mappedBy = "article", cascade = CascadeType.ALL)
private final Set<ArticleComment> articleComments = new LinkedHashSet<>();
  • 댓글 엔티티 객체을 Set에 담은 객체를 필드로 선언한다.

  • @OneToMany 어노테이션을 통해 해당 게시글 엔티티에 연관된 다수의 객체(테이블)이 있다고 명시

  • @OneToMany 의 mappedBy 옵션을 통해 JPA가 해당 변수를 가진 엔티티를 찾아 해당 게시글 엔티티오 관계를 등록한다. 주의!! : 변수명으로 대상을 찾기에 다른 엔티티에서 양방향 바인딩에 쓰이는 변수와 똑같은 변수명을 쓰면 안된다. ****

  • cascade 설정을 통해서 부모인 게시글이 삭제되면 자식인 소속 댓글도 전부 삭제된다. 실제 운영할 때는 보통 댓글 기록을 남겨두는 방식으로 설정한다.

ArticleComment 엔티티

@ManyToOne(optional = false) 
private Article article; // 게시글 (ID)
  • @ManyToOne 어노테이션을 통해 부모가 되는 대상 엔티티가 있다는 것을 명시 (N:1 관계 명시)

  • 앞서 말했듯이 @OneToMany 의 mappedBy 옵션의 대상이 되는 변수명 article로 설정

이렇게 설정하면 실제 DB는 ArticleComment(댓글) 테이블에 Article(게시글) ID가 FK(외래키) 속성으로 생기게 되는 것이다.

게시글 객체안에 댓글 객체 목록이 저장되는 등의 문제는 Spring 내부에서의 문제이다.

서로 연관성이 높은 게시글, 댓글을 다루는 기능을 만들 때 상당히 편해보이는 방식이지만, 역시나 단점이 존재한다.

  • 불필요한 데이터 로딩: 양방향 연관 관계를 설정하면 한 쪽 엔티티를 로드할 때 연관된 다른 엔티티도 함께 로드된다. 예를 들어, 게시글을 가져올 때 해당 게시글에 연결된 모든 댓글도 함께 가져오게 된다. 이는 필요하지 않은 경우 불필요한 데이터베이스 쿼리와 데이터 로딩을 초래할 수 있다. 이는 심각한 성능이슈를 발생시킬 수 있다.

  • 복잡한 객체 그래프: 양방향 연관 관계를 사용하면 엔티티 객체 그래프가 복잡해질 수 있다. 게시글이 댓글을 가지고 있고, 댓글이 다시 게시글을 참조하면서 객체 간의 순환 참조가 발생할 수 있다. 이는 객체 그래프를 탐색하거나 직렬화할 때 문제를 발생시킬 수 있으며, 메모리 사용량을 증가시킬 수 있다.

  • 복잡성 증가: 엔티티 객체 간의 양방향 관계를 관리하려면 추가적인 코드와 주의가 필요하다. 관리하기 복잡한 코드로 이어질 수 있으며, 실수로 순환 참조나 무한 루프를 만들어 성능 문제를 유발할 수 있다.

위의 단점과 문제에 대한 해결책들이 완벽한 방법은 아니나 존재한다. 그런데 이 해결책을 사용하기 위한 추가 구현이 3번째 단점인 복잡성 증가를 유발하기 때문에, 역시나 마법의 말인 ‘그때그때 적절한 방법을 써야한다.’ 가 정답이다.

아래부터 각 문제에 대한 해결책에 대해 알아보자

N+1 문제

N+1 문제는 데이터베이스 쿼리 실행 횟수가 불필요하게 많아지는 상황을 가리킨다.

위에 이어서 게시글과 댓글의 관계를 예시로 보자면 N은 부모 엔티티(예: 게시글)의 수를 나타내며, 1은 각 부모 엔티티마다 연관된 자식 엔티티(예: 댓글)를 가져오기 위한 추가적인 쿼리를 나타낸다.

좀 더 예시에 빗대어 설명하자면, 게시글 목록 요청으로 하나의 쿼리문을 보내 DB 에게 받아온 게시글 N개, 그리고 게시글 별로 댓글을 가져오는 쿼리문이 N번 발생했다고 하여, N개의 게시글의 1개씩의 댓글 요청 쿼리가 발생한다하여 N+!이라한다.

둘이 양방향 바인딩이 되어, 부모인 게시글 엔티티에 안에 댓글 엔티티 목록이 필드로 있기에 해당 엔티티르 구성하기 위해 과도한 데이터베이스에 대한 쿼리 요청이 발생하는 것이 문제인 것이다.

이 N+1 문제에 대한 대표적인 해결방법은 두 가지가 있다.

  • 지연로딩(FetchType.Lazy)

  • 배치 읽기 (Batch Fetching)

보통 이 두 가지 방법을 같이 쓴다.

지연로딩(FetchType.LAZY)

양방향 바인딩이 되어 있어도, 지연로딩 되어 있다면 필요한 경우에만 연관 엔티티를 로딩한다.

즉, 게시글 목록이 호출 되면 게시글 관련 쿼리만 요청되고, 댓글 쿼리문을 요청되지 않는다. 그리고 나중에 필요에 인해서 게시글 객체를 통한 관련 댓글 목록에 접근 할 때 관련 쿼리문이 요청되는 것이다.

예시 코드를 봐보자

@OrderBy("id")
@OneToMany(mappedBy = "article", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private final Set<ArticleComment> articleComments = new LinkedHashSet<>();

양방향 바인딩의 부모 엔티티 클래스(Article) 필드에 쓰이는 @OneToMany 어노테이션 옵션에 fetch = FetchType.LAZY 가 추가된것을 볼 수 있다.

fetch 옵션은 JPA(Java Persistence API)에서 엔티티 간의 관계를 로딩하는 방식을 설정하는 옵션이다.

  1. EAGER (즉시 로딩): EAGER 옵션을 사용하면 관계된 엔티티가 항상 즉시 로딩된다. 이는 부모 엔티티를 조회할 때 연관된 자식 엔티티도 즉시 로딩되어 함께 가져온다는 의미이다. EAGER 로딩을 사용하면 성능 문제가 발생할 수 있으므로 신중하게 사용해야 한다.

  2. LAZY (지연 로딩): LAZY 옵션을 사용하면 연관된 엔티티가 필요한 시점에만 로딩된다. 부모 엔티티를 조회할 때 연관된 자식 엔티티는 초기에 로딩되지 않고, 실제로 필요한 시점에 로딩된다. LAZY 로딩은 성능 최적화와 메모리 사용을 향상시킬 수 있는 방법 중 하나다. 이것이 Default로 되어있으니 별도로 건드릴 필요가 없다. 정확히 알고 쓰자는 개념으로 알고 있자.

필요할 때만 요청을 보내 관련 데이터 전부를 가져오는 쿼리 요청을 보내 발생하는 N+1 문제를 해결할 수 있다. 이 뜻은 프로그램의 성능, 효율에도 좋다는 것이다.

배치 읽기 (Batch Fetching)

먼저 배치라는 개념을 알 필요가 있는데 간단히 말하자면, 여러 작업을 한 번에 처리하는 방식으로서 요청을 묶어서 한번에 보내고, 응답을 모아서 한 번에 처리하는 것이다.

예를 들어 10개의 요청이 있고 DB에 보내야 한다고 치면, 10개의 요청을 묶어 DB에 1개의 요청만 보내고 DB는 그 요청에 따라 10개의 응답을 보내준다. 그 10개의 응답을 Spring이 임시 메모리에 모았다가 응답이 끝나면 한 번에 처리하는 건데, 이렇게 함으로서 네트워크와 프로그램의 동작 부담을 감소 시키는 것이다.

이 이상의 배치의 자세한 개념은 따로 알아보자, 배치 하나 만으로도 중요하고 제법 양이 되는 부분이다. 그리고 여기서 적용하는 방법은 진짜 배치가 아닌 JPA에서 제공하는 DB에 대한 요청을 묶어서 전달하는 유사 배치이니 진짜 배치와는 구별하자.

배치를 적용하는 다양한 방법이 있지만 여기선 간단한 방법만 알아 보겠다. 이것은 완벽한 배치 기능을 쓰는 것은 아니다.

yml 설정 파일을 통한 배치 적용

application.yml

spring:
    jpa:
      defer-datasource-initialization: true
    hibernate.ddl-auto: create
    show-sql: true
    properties:
        hibernate.format_sql: true
            hibernate.default_batch_fetch_size: 100

보통의 JPA 관련 설정인데 여기서 중요한 부분은

hibernate.default_batch_fetch_size: 100

정확하게는

spring.jpa.properties.hibernate.default_batch_fetch_size: 100

spring 의 jpa의 속성의 hibernate 설정인 default_batch_fetch_size 이다.

default_batch_fetch_size 는 JPA가 한 번의 쿼리로 가져올 데이터 개수를 지정하는 설정이다. 이 설정은 JPA가 데이터베이스에 보내는 요청에 담는 쿼리문의 개수를 제어하게 된다.

예를 들어, **hibernate.default_batch_fetch_size**를 100으로 설정하면 JPA는 한 번의 쿼리로 최대 100개의 엔티티를 가져오려고 시도한다. 즉, DB에게 보내는 쿼리문 요청을 최대 100개씩 묶어서 보내어 관련 최대 100개 엔티티의 정보를 채워 넣으려고 하는 것이다.

이 설정으로는 보편적으로 말하는 배치가 많은 응답을 임시 저장소에 모아서 한 번에 처리함으로써 프로그램 부담을 줄이는 기능이 없다. 그래서 진짜 배치 기능이라고는 볼 수 없다.

단순히 DB에게 쿼리문을 묶어서 하나의 요청으로 보내주는 것이다. 그런데 이것이 쿼리 요청이 과도하게 발생하는 문제인 N+1을 해결해주니 이에 대한 해법으로 볼 수 있다.

만약 전체적인 요청, 응답으로 인한 프로그램 부하를 컨트롤하고 싶다면 제대로된 배치 기능을 써야한다.

N+1 정리

게시글 - 댓글 관계를 바탕으로 정리

  • 양방향 바인딩으로 게시글(임의의 엔티티)에 댓글(다른 엔티티)가 연동된 경우 N+1 문제가 발생하여 불필요하고 과도한 쿼리문 요청이 발생한다.

  • 게시글안의 댓글을 접근하지 않는 이상 댓글이 필요하지 않다고 판단하고 지연로딩을 통해서 게시글 목록 요청시 각 게시글의 댓글에 대한 요청쿼리를 발생시키지 않는다. \>> 불필요한 쿼리 제한

  • 지연 로딩으로 일단 댓글까지 한 번 DB에서 가져오는 것은 막았지만, 만약 게시글 목록과 관련 댓글 정보가 한 번에 필요한 경우에는 N+1 상황이 강제된다. 이때 배치(이 글에서는 hibernate.default_batch_fetch_size 설정)를 활용하면 각 게시글의 댓글을 가져오는 쿼리문을 한번에 요청을 보냄으로서 과도한 요청 쿼리가 발생하는 현상인 N+1에 대한 해결책이 된다. \>> 쿼리 묶어서 한 번에 처리

사실상 지연로딩으로 N+1 이 발생할 상황을 전부 막았지만, 게시글 목록과 각 게시글의 댓글 정보가 전부 필요한 상황이 강제 되었을 때의 방책으로 hibernate.default_batch_fetch_size 같은 배치 관련 설정이 필요한 것이다.

총 정리

위의 내용으로 양방향 바인딩과 그로 인해 발생하는 N+1의 개념, 그리고 해결법을 알아 보았다. 위의 내용은 정말 일부분이고 문제에 대한 완벽한 해결법은 되지 못한다. 아직 나의 수준에서는 충분하지만, 큰 규모의 복잡한 프로젝트에서는 Batch, DB 쿼리 튜닝, 캐싱, 인덱스 등등의 정교한 설정이 필요할 것이다.