ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • N + 1 정복하기 <1. N + 1?>
    프레임워크/Spring Boot 2024. 2. 20. 16:13

     

     

    (소스코드: https://github.com/blog-example/-JPA-N_Plus_1

    ORM을 사용하면 만나는 흔한 이슈 중 하나인 N + 1 !

    명확하게 설명할 수 있을 정도로 머리속에 집어넣어보겠습니다.

     

    이번글에서는 N + 1이 무엇이고 왜 발생하고 어떤 코드에서 발생하는지 살펴보겠습니다.

    1.  N + 1이 무엇?

    2. 프로젝트 세팅하기

    3. N + 1 왜 발생하는가?

     

     


     

     

    1. N + 1 이 무엇?

    gpt는 n + 1 이슈를 무엇이라고 정의하는지 살펴보겠습니다.

     

    명쾌하게 잘 정리해준 것 같습니다! 면접 때 질문을 받으면 이 정도 내용으로 답변을 하면 좋겠다는 생각이 듭니다.

     

    # 데이터베이스 쿼리 최적화 이슈

    # 한 번의 쿼리로 N개의 엔티티를 가져오지만 해당 엔티티에 접근할 때 추가로 N번의 쿼리가 발생하는 이슈

     

     

    2.  프로젝트 세팅하기

    위의 문장 자체는 이해했으나, 역시 직접 만나보는게 가장 확실하게 이해하는 방법일 것이라 생각됩니다.

    가장 간단한 예제인 게시글과 댓글을 사용하는 케이스를 가지고 N + 1을 발생시켜보겠습니다.

     

    간단하게 ERD를 하나 그려봤습니다.

     

    SQL도 안쓰면 자꾸 잊어버리기 때문에 SQL로 테이블도 작성해줍니다.

    DROP DATABASE np1;
    CREATE DATABASE np1;
    
    use np1;
    
    CREATE TABLE IF NOT EXISTS posts (
    	post_id BIGINT PRIMARY KEY AUTO_INCREMENT,
        title VARCHAR(255) NOT NULL,
        content VARCHAR(255) NOT NULL
    );
    
    
    CREATE TABLE IF NOT EXISTS comments (
    	comment_id BIGINT PRIMARY KEY AUTO_INCREMENT,
        comment VARCHAR(255) NOT NULL,
        post_id BIGINT NOT NULL,
        FOREIGN KEY (post_id) REFERENCES posts(post_id)
    );

     

    간단하게 더미도 stored procedure 사용해서 만들어 주겠습니다.

    중요한 내용은 아니니 위의 sql과 아래의 sql을 모두 복사해서 mysql workbench에서 사용하면 편하게 더미데이터를 삽입할 수 있습니다.

    CREATE PROCEDURE InsertDummyData()
    BEGIN
        DECLARE maxPost INT DEFAULT 11;
        DECLARE maxComment INT DEFAULT 11;
        
    	DECLARE i INT DEFAULT 1;
        DECLARE j INT;
    
        -- 게시글 더미 데이터 삽입
        WHILE i <= maxPost DO
            INSERT INTO posts (title, content) VALUES (CONCAT('title', i), 'content for title');
            SET i = i + 1;
        END WHILE;
        
        SET i = 1;
        -- 댓글 더미 데이터 삽입
        WHILE i <= maxPost DO
            SET j = 1;
            WHILE j <= maxComment DO
                INSERT INTO comments (comment, post_id) VALUES (CONCAT('comment', j), i);
                SET j = j + 1;
            END WHILE;
            SET i = i + 1;
        END WHILE;
    END$$
    
    DELIMITER ;
    
    CALL InsertDummyData();

     

    spring cli 사용해서 프로젝트도 하나 만들어 줍니다.

     $ spring init --dependencies=web,jpa,mysql,lombok np1;

     

    Post와 cmment 엔티티를 추가합니다.

    import jakarta.persistence.*;
    import lombok.AllArgsConstructor;
    import lombok.Getter;
    
    import java.util.ArrayList;
    import java.util.List;
    
    @Getter
    @AllArgsConstructor
    @Entity
    @Table(name = "posts")
    public class Post {
      @Id
      @GeneratedValue(strategy = GenerationType.IDENTITY)
      @Column(name = "post_id")
      private long postId;
    
      @Column(name = "title")
      private String title;
    
      @Column(name = "content")
      private String content;
    
      @OneToMany(mappedBy = "post")
      private List<Comment> comments = new ArrayList<>();
    
      public void addComment(Comment comment) {
        if (!comments.contains(comment)) {
          comments.add(comment);
          comment.addPost(this);
        }
      }
    }
    import jakarta.persistence.*;
    import lombok.AllArgsConstructor;
    import lombok.Getter;
    
    @Getter
    @AllArgsConstructor
    @Entity
    @Table(name = "comments")
    public class Comment {
      @Id
      @GeneratedValue(strategy = GenerationType.IDENTITY)
      @Column(name = "comment_id")
      private long commentId;
    
      @Column(name = "comment")
      private String comment;
    
      @ManyToOne(fetch = FetchType.LAZY)
      @JoinColumn(name = "post_id")
      private Post post;
    
      public void addPost(Post post) {
        this.post = post;
      }
    }

     

     

    그리고 http 응답을 위해서 dto들을 생성해 주겠습니다.

     

    import com.example.np1.entity.Post;
    import lombok.AccessLevel;
    import lombok.AllArgsConstructor;
    import lombok.Getter;
    
    import java.util.List;
    
    @Getter
    @AllArgsConstructor(access = AccessLevel.PRIVATE)
    public class PostDto {
      private long postId;
      private String title;
      private String content;
      private List<CommentDto> comments;
    
      public static PostDto of (Post post, List<CommentDto> comments) {
        return new PostDto(post.getPostId(), post.getTitle(), post.getContent(), comments);
      }
    }
    import com.example.np1.entity.Comment;
    import lombok.AccessLevel;
    import lombok.AllArgsConstructor;
    import lombok.Getter;
    
    @Getter
    @AllArgsConstructor(access = AccessLevel.PRIVATE)
    public class CommentDto {
      private long commentId;
      private String comment;
      private long postId;
    
      public static CommentDto of (Comment comment, long postId) {
        return new CommentDto(comment.getCommentId(), comment.getComment(), postId);
      }
    }

     

     

    위의 dto를 사용해서 게시글 목록을 생성하는 서비스 코드 입니다.

    import com.example.np1.dto.CommentDto;
    import com.example.np1.dto.PostDto;
    import com.example.np1.entity.Comment;
    import com.example.np1.entity.Post;
    import com.example.np1.repository.PostRepository;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Service;
    
    import java.util.ArrayList;
    import java.util.List;
    
    @Slf4j
    @RequiredArgsConstructor
    @Service
    public class PostService {
    
      private final PostRepository postRepository;
    
      public List<PostDto> getPosts() {
        List<Post> posts = postRepository.findAll(); // N + 1에서 1에 해당
    
        List<PostDto> postDtos = new ArrayList<>();
        for (int i = 0; i < posts.size(); i++) {  
          Post post = posts.get(i);
          List<Comment> comments = post.getComments(); // N + 1에서 N에 해당
    
          List<CommentDto> commentDtos = new ArrayList<>();
          for (Comment comment: comments) {
            commentDtos.add(CommentDto.of(comment, post.getPostId()));
          }
    
          postDtos.add(PostDto.of(post, commentDtos));
        }
    
        return postDtos;
      }
    
    }

     

    마지막으로 요청을 받아서 서비스를 실행시켜주는 컨트롤러를 작성해줍니다.

    import com.example.np1.dto.PostDto;
    import com.example.np1.service.PostService;
    import lombok.RequiredArgsConstructor;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import java.util.List;
    
    @RequiredArgsConstructor
    @RestController
    public class PostController {
    
      private final PostService postService;
    
      @GetMapping("/post")
      public List<PostDto> getOrder() {
        return postService.getPosts();
      }
    }

     

    이제 /post로 get요청을 보내고 쿼리를 확인해보겠습니다.

     

    초록색으로 표시된 1건의 게시글 조회 쿼리와 빨간색으로 표시된 N건의 댓글 조회 쿼리를 확인할 수 있습니다.

    이제 왜 이렇게 실행되고, 왜 문제가 되는지 살펴보겠습니다.

     

     

    3. N + 1 왜 발생하는 것인가?

    N + 1을 개선하기 이전에, 왜 이런 상황이 발생하는 것인지에 대해서 살펴보겠습니다.

     

     

    아래는 위에서 작성한 서비스 코드의 일부입니다. 정확히 어디에서 N번의 쿼리가 추가로 발생될까요?

    public List<PostDto> getPosts() {
      List<Post> posts = postRepository.findAll(); // N + 1에서 1에 해당
    
      List<PostDto> postDtos = new ArrayList<>();
      for (int i = 0; i < posts.size(); i++) {
        Post post = posts.get(i);
        List<Comment> comments = post.getComments(); // N + 1에서 N에 해당
    
        List<CommentDto> commentDtos = new ArrayList<>();
        for (Comment comment: comments) {
          commentDtos.add(CommentDto.of(comment, post.getPostId()));
        }
    
        postDtos.add(PostDto.of(post, commentDtos));
      }
    
      return postDtos;
    }

     

    디버거를 사용해서 추가 쿼리가 발생하는 시점을 찾아보겠습니다.

    post.getComments()를 지나면서 새로운 쿼리가 발생하는 것을 확인할 수 있습니다.

    posts 사이즈만큼 순회하면서 post.getComment를 호출할 것이므로 posts의 사이즈만큼 새로운 쿼리가 발생합니다. 

     

    그렇다면 왜 getComments를 하는 순간 새로운 쿼리가 발생하는 것일까요?

    이유는 JPA가 엔티티를 생성할 때 연관관계가 있는 엔티티의 데이터를 로딩하는 방식에 있습니다.

    class Post {
    
       ... 생략
         
       @OneToMany(mappedBy = "post")
       private List<Comment> comments = new ArrayList<>();

     

    JPA는 엔티티의 데이터를 데이터베이스로부터 가져올 때 연관관계가 있는 경우 fetch 타입을 설정할 수 있도록 기능을 제공합니다.

    엔티티를 생성할 때, 모든 데이터를 다 가져오는 EAGER 타입과, 필요한 시점에 가져오는 LAZY 2가지 속성을 가지고 있습니다.

    제가 게시글과 댓글의 연관관계를 맺을 때 사용한 @OneToMany는 default로 LAZY 타입을 사용하고 있습니다.

     

     


    EAGER 하게 모든 것을 생성 시점에 가져오면 1번의 쿼리로 연관 데이터를 모두 가져올 수 있다는 장점이 있지만, 반대로 사용하지 않을 수도 있는 데이터까지 쿼리하기 때문에 데이터베이스에 더 많은 부하를 주고 가져온 데이터를 모두 메모리에 올려두기 때문에 메모리 사용량을 증가시키는 단점을 가지고 있습니다.

    그래서 JPA 는 이런 단점을 최적화하기 위해서 LAZY라는 방법을 제공합니다.
    LAZY는 연관관계가 있는 데이터는 필요한 시점에 찾을 수 있도록 reference를 제공하고, 즉시 필요한 데이터만 데이터베이스에서 쿼리를 합니다. 따라서 불필요한 데이터를 쿼리하면서 소모하는 데이터베이스 리소스를 아낄 수 있고, 메모리에도 꼭 필요한 데이터만 올라가게 만드는 최적화가 가능합니다.

     

     

    우리 코드를 살펴보겠습니다.

    22번 줄에서 post 엔티티의 정보를 가져오기를 희망하고 있습니다.

    fetch type이 Lazy 로 설정되어 있기 때문에 우선 post 관련 데이터만 로딩할 것입니다.

     

    하지만 우리는 28번 줄에서 comments 역시 바로 필요한 상황입니다.

    이를 위해서 post에서 제공한 reference를 사용해서 해당 post에 연관된 comment를 다시 데이터베이스에서 조회하는 상황이 발생하는 것입니다. (Lazy는 연관관계의 데이터에 접근하는 순간 데이터베이스에서 정보를 가져옵니다.)

     

    아이러니하게 최적화를 위해 사용한 Lazy 타입이 또 다른 비효율을 만들어 내는 것입니다.

     

     

    지금은 11개의 게시글과 각 게시글에 11개의 댓글 정도의 데이터를 다루고 있지만, 데이터 숫자가 늘어날 수록 N + 1 현상이 가져오는 성능 저하는 심각할 것입니다. 1번의 쿼리는 1번의 데이터베이스와의 통신을 의미하기 때문에 데이터베이스와 통신에 사용되는 리소스도 선형적으로 증가할 것이고, 메모리에 많은 양의 데이터가 적재되면서 메모리 리소스에도 문제가 발생할 것입니다.

     

     


     

     

     

    이번 글에서는 N + 1 을 발생시키는 코드를 직접 작성하면서, N + 1이 어떤 현상이고 어떤 이유에서 발생하는지를 확인해보았습니다.

    이어지는 글에서 이 문제를 어떻게 해결해나갈 수 있는지 살펴보겠습니다.

     

    감사합니다.

     

     

     

Designed by Tistory.