개요

mysql을 먼저 접하고 JPA를 접한 케이스다보니 JPA의 개념이 낮설고 헷갈림의 연속이였다.
JPA를 공부해보고 뭘 위한 것인지 공부해보려한다.

JPA가 뭘까?

결국 Mysql이다. JPA에서 아무리 깊게 고민해서 코드를 짠다고 해도 결국 RDB의 쿼리로 변환되어 서버에 전송된다.
위의 한 줄을 가지고 JPA를 깊이 이해해보려고한다.

패러다임의 불일치

DB 테이블 설계는 PK를 가진 테이블을 먼저 설계하고 이 PK를 FK로 갖는 테이블을 설계한다.
이렇게 하면 PK를 가진 테이블에서 시작해서 FK를 갖는 테이블로 갈 수 있고 FK를 가진 테이블에서 PK를 가진 테이블로 갈 수 있다.

예를 들어 게시글과 댓글이 있다고 해보자.

--왼쪽 테이블인 post를 기준으로 join
SELECT post.id, post.title, post.content, comment.id AS comment_id, comment.content AS comment_contnet FROM post JOIN comment ON post.id = comment.post_id; 

-- 오른쪽 테이블인 comment를 기준으로 join
SELECT post.id, post.title, post.content, comment.id AS comment_id, comment.content AS comment_contnet FROM comment JOIN POST ON comment.post_id = post.id;

왼쪽 테이블을 기준으로 join을 해도, 오른쪽 테이블을 기준으로 join을 해도 조회가 가능하고 동일한 결과가 나온다.
이게 DB의 방식이다. 방향성이 없다고도 말한다.

그러나 자바의 방식은 조금 다르다. 자바에서는 방향성을 결정짓는 것이 어렵다. 이게 무슨말인가 하면…

자바에서는 한 클래스의 필드로 다른 클래스를 소유하는 방식(참조하는 방식)으로 연관관계를 맺는다.
그러므로 참조를 어떻게 갖도록 하느냐에 따라 게시글이 댓글을 참조하는 것도 가능하고 댓글이 게시글을 참조하는 것도 가능, 게시글이 댓글을 참조하고 댓글이 게시글을 참조하는 것도 가능하다.

@Data
public class Post {
  private String title;
  private String content;
  private String writer;
  private List<Comment> comments;
}
@Data
public class Comment {
    private String title;
    private String content;
    private String writer;
}

위의 코드는 게시글이 댓글을 참조하는 방식으로 댓글은 게시글의 존재를 모르고 댓글에서 게시글로의 접근이 불가능하다. post.getComments()는 가능하지만 comment.getPost()는 불가능하다. 어느 쪽에서든 다른 한쪽에 접근할 수 있는 RDB와는 다르다.
이렇게 연관관계를 설정하는 것이 바로 @OneToMany이다.

다음으로는 댓글이 게시글을 참조하는 방식으로 게시글은 댓글의 존재를 모르고 게시글에서 댓글로의 접근이 불가능하다.
이런 방식이 바로 @ManyToOne이다.

@Data
public class Post {
    private String title;
    private String content;
    private String writer;
}
@Data
public class Comment {
    private String title;
    private String content;
    private Post post;
}

ManyToOne에서 만약 새로운 댓글이 달렸다면 다음과 같이 할 수 있다.

Post post = findPost(1L);
Comment comment = new Comment();
comment.setCommentId(1L);
comment.setContent("Comment content");
comment.setPost(post);

이 둘을 조합하여 서로가 서로를 참조하는 방식으로 구성할 수도 있다.(양방향 연관관계)
즉, 여러가지의 방법이 존재한다. (앞에서 봤던 것처럼 DB에서는 어느 곳에서 접근해도 상관없고 동일한 결과를 리턴한다.)

여기서 DB와 객체지향 간의 패러다임의 불일치가 생긴다.
이러한 불일치를 해결하기 위한 개념이 바로 ORM이다.

ORM(Object Relation Mapping) 이란 객체지향으로 구성한 시스템을 데이터베이스에 매핑하는 패러다임이다.
쉽게 말해 이전에는 DB의 방식을 Java가 따랐다면 Java의 방식으로 DB를 사용하는 것이다. 주도권을 가져왔다고 표현해도 될 것 같다.

ORM을 통해 자바의 객체를 이용해서 프로그램을 작성하듯이 DB를 사용할 수 있는 것이다. 이 덕분에 우리는 DB에 대해 전혀 이해하지 못하고 있더라도 DB를 이용한 웹 서비스를 개발할 수 있다.

그래서 JPA 자체는 객체지향을 데이터베이스에 매핑하는 프레임워크지만 JPA에서 알아서 쿼리를 작성해서 실행시킨다는 편리함때문에 사용하는 의미도 크다고 생각한다.
예를 들어 반복되고 무의미한 작업들(반복되는 쿼리 작성)을 ORM에게 맡길 수 있다. (Spring JDBC의 SimpleJdbcInsert를 보면 이러한 고민의 흔적이 보인다.)

일반적으론 FK를 기준으로, 변화가 많은 쪽을 선택하여 연관관계를 설정해주는데 게시글과 댓글로 본다면 댓글에서 @ManyToOne으로 단방향 연관관계를 설정해주는 식이다.(단방향 연관관계란 두 개의 클래스에서 오직 하나만 다른 하나의 클래스의 존재를 알고있다는 의미이다.)

그러나 한국어를 기준으로 생각해보면 의문이 생길 수도 있다. 게시판을 생각해보면 게시글을 클릭하면 게시글과 댓글이 같이 있으니 하나의 게시글이 여러개의 댓글을 가지는 것이 좀 더 자연스럽지 않을까?

여기에는 문제가 있다.

@Entity
public class Post {
    @Id
    private Long id;
    private String content;
    private String title;
    private String writer;
    private List<Comment> comments = new ArrayList<>();

    public void addComment(Comment comment) {
        this.comments.add(comment);
    }
}
@Entity
public class Comment {
  @Id
  private Long id;
  private String content;
  private String writer;
}

이렇게 작성하고 실행시켜보면 테이블은 정상적으로 생성된다.

그러나 이렇게 생성된 테이블과 엔티티간의 짝이 맞지 않게된다.
생성된 테이블에는 Comment에 FK인 post_id가 있지만 정작 엔티티 클래스에는 post_id가 없고 오히려 이 FK를 Post에서 관리한다.
이런 상황에서 Comment를 수정하게 된다면 정작 쿼리는 Post에서 나가게 된다.
이렇게 되면 엔티티와 DB간의 관계가 명확하지 않게된다.

이런 이유로 @OneToMany를 사용하여 단방향 연관관계를 맺는 것 보다 @ManyToOne을 사용하여 단방향 연관관계를 맺는 것이 좋다고 한다.

그래서 기본적으로 @ManyToOne 연관관계를 맺고 역참조가 필요할 경우 양방향 관계를 맺는 식으로 한다.
그러나 이렇게 하면 댓글의 관점에서의 해석이기 때문에 post에서는 comment의 존재를 모른다.
그렇기 때문에 Post엔티티를 통해 comment의 정보를 읽어올 수 없다.

이 것이 바로 단방향 참조가 가지는 단점이다.
필요한 정보에 하나의 엔티티를 통해서 접근할 수 없다는 것이다.

그래서 만약 post정보와 comment정보를 둘 다 사용하고 싶다면 단순히 객체를 이용해서 접근하는 것이 아니라 join을 사용해야하는데 일반 Join을 사용하면 안된다.
그 이유는 한쪽에만 데이터가 존재하는 상황이 있기 때문이다.

예를 들어 특정 게시물은 댓글이 없는 경우가 있을 수 있다. 그렇기 때문에 left (outer) join을 사용해야한다.

JPQLQuery<Post> query = from(post);
query.leftjoin(comment).on(comment.post.eq(post));

이렇게 할 수 있다.

JPA의 개념

JPA를 이용한 개발의 핵심은 객체지향을 통해서 영속계층을 처리하는데 있다.
앞에서 말했듯이 주도권을 Java가 가진다. 직접 테이블과 SQL을 다루는 것이 아니라 자바의 데이터 객체를 엔티티 객체로 다루고 JPA로 이를 DB에 매핑시킨다.

image

여기서 엔티티 객체 인스턴스란 DB로 따지면 테이블의 한 행이다.
Java로 따지면 PK를(고유한 번호를) 가지는 객체이다.

엔티티 클래스를 정의한다 -> 테이블을 정의한다.
엔티티 인스턴스를 만든다 -> DB의 새로운 한 행을 생성한다.

JPA의 사용은 단순히 JpaRepository 인터페이스를 구현하는 것만으로도 가능하다.

Spring Data JPA는 엔티티 객체를 이용해서 JPA를 사용하는 편리한 방법을 지원해주는 스프링 관련 라이브러리다. Spring Data JPA는 자동으로 객체를 생성하고 이를 통해서 여러 동작들을 자동으로 처리해주는데 이를 위해서 제공되는 인터페이스가 JpaRepository이다.

public interface PostRepository extends JpaRepository<Post, Long> {

}

이렇게 인터페이스를 선언하는 것만으로도 간단한 crud작업을 처리할 수 있다.

postRepository.findById(1L);
postRepository.save(post);
postRepository.deleteById(1L);

위에서부터 각각 SELECT, (INSERT, UPDATE), DELETE에 대응된다.

JPA를 사용하면 쿼리는 어떻게 쓰는걸까?

크게 3가지 방법이 있다.
쉽게 사용할 수 있는 방법으로는 메소드 이름이 쿼리가 되는 쿼리메소드이다.
많이 사용하는 기본적인 쿼리들을 위와 같은 방법으로 사용할 수 있다.

findById는 일반적인 select와 같고 update는 먼저 select 쿼리가 실행되고 update가 실행된다. delete를 실행시켜보면 먼저 select 쿼리가 실행되고 delete 쿼리가 실행된다.

여기서 바로 수정하고 삭제하면 되는데 굳이 select를 먼저 실행시키는 이유를 생각해보면 JPA를 이용한다는 것은 영속 컨텍스트와 데이터베이스를 동기화시켜서 관리한다는 의미이다. 즉 java 애플리케이션에서 DB를 다루겠다는 의미이다.

따라서 엔티티 객체가 추가되면 영속 컨텍스트에 추가하고 데이터베이스와 동기화가 이루어져야한다. 따라서 수정이나 삭제를 한다면 일단 영속컨텍스트에 해당 엔티티 객체가 존재해야하기에 먼저 select를 실행해 엔티티 객체를 영속성 컨텍스트에 저장해서 이를 삭제한 후에 실제 delete가 이루어지게된다.

이러한 쿼리 외에도 다양한 쿼리메소드를 정의하고 사용할 수 있다. 예를 들면 findByNameContains와 같은 식이다. 이 쿼리메소드는 해당 문자열을 포함하고 있는 결과를 조회해서 리턴한다.

만약 간단히 검색기능을 구현하고 싶다면 이렇게 작성할 수 있다.(별도의 쿼리 메소드는 아래와 같이 인터페이스에서 정의한 뒤 사용해야한다.)

public interface PostRepository extends JpaRepository<Post, Long> {
    List<Post> findByPostTitleContains(String keyword);
}

다른 방법들은 천천히 알아보자.

주의점

JPA의 모든 데이터 변경은 트랜잭션 안에서 실행된다.
즉 트랜잭션 밖에서의 데이터 변경은 반영되지 않는다.

양방향 관계

양방향 관계란 Post에선 OneToMany를 사용하고 댓글에선 ManyToOne을 사용하여 관계를 설정하는 것을 말한다.

양방향 OneToMany는 기본적으로 각 엔티티에 해당하는 테이블을 독립적으로 생성하고 중간에 이들을 매핑해주는 테이블을 생성한다.
하나의 댓글이 하나의 게시물에 종속된 것이 아닌 하나의 댓글이 여러 개의 게시물에 사용될 수 있는 구조를 가정하고 생성하기 때문이다.

이를 사용하지 않는 방법으로는 단방향으로 OneToMany를 사용하는 방법과 mappedBy속성을 사용하는 방법이 있다.

흔히 mappedBy를 연관관계의 주인이라고 해석하기도 한다.

예를 들어 게시물 관점에서 보면 각 댓글들은 모두 별개의 존재로 하나의 댓글이 여러 개의 게시물에 사용될 수 있는 구조를 가정하고 생성하게 되고
반대로 댓글의 관점에서는 하나의 게시물을 참조하는 구조를 가정하기 때문에 중간에 매핑 테이블이 생긴다 mappedBy를 이용하여 댓글쪽의 해석을 적용할 것을 명시한다.

@Entity
public class Post {
...
    @OneToMany(mappedBy = "post")
    private Set<PostImage> imageSet = new HashSet<>();
...
}

이렇게 하면 댓글이 연관관계의 주인이므로 매핑 테이블이 사라지고 @ManyToOne의 구조처럼 테이블이 생성된다.

로딩 방식

Post 엔티티에서 Comment의 리스트를 필드로 가지고 있는 상황에서

Post post = postRepository.findById(1L);

Post 인스턴스를 가져온다면 post에 comments의 데이터는 존재할수도 안할수도 있다.
이는 로딩방식에 따라 다르다.
만약 어떤 API에서는 게시글 정보만 사용하고 어떤 API에서는 게시글 정보와 댓글도 같이 처리한다면 Post 인스턴스를 받아올 때 항상 댓글 리스트도 같이 받아오는 것은 낭비가 될 수 있다. 생각해보면 개발자가 엔티티를 어떻게 사용할 지 JPA의 입장에서 알 수가 없다.
따라서 JPA에서는 두 가지 로딩 방식을 지원하고 있다. 그게 바로 지연 로딩 방식과 즉시 로딩 방식이다.

지연 로딩은 post.getComments()와 같이 해당 필드에 접근할 때 쿼리가 발생한다. 따라서 지연 로딩 방식을 사용할 경우 post에는 comments 정보가 존재하지 않는다.
즉시 로딩은 Post 인스턴스를 가져올 때 조회하는 김에 연관된 정보들도 같이 조회해오는 방식으로 이 경우 post에 comments정보가 존재한다.

OneToMany는 지연 로딩이 디폴트, ManyToOne은 즉시 로딩이 디폴트로 설정되어있고. 이는 예상되는 사용 패턴과 성능상의 이점 때문이다.

N+1 문제

JPA에서 굉장히 유명한 문제가 있는데 바로 N+1문제이다.
N+1문제를 재현하기 위해 코드를 작성한다.

public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    private String title;
    @Column(columnDefinition = "TEXT")
    private String content;
    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @Builder.Default
    private List<Comment> comments = new ArrayList<>();
    private String writer;

    public void addComment(String content, String writer) {
        Comment comment = Comment.builder()
                .post(this)
                .content(content)
                .writer(writer)
                .build();
        this.comments.add(comment);
    }
}
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    @Column(columnDefinition = "TEXT")
    private String content;
    private String writer;
    @ManyToOne
    private Post post;
}
@Test
public void testInsertAll() {
  for (int i = 1; i <= 100; i++) {
      Post post = Post.builder()
              .title("Title..." + i)
              .content("Content..." + i)
              .writer("writer..." + i)
              .build();
      for (int j = 0; j < 3; j++) {
          if(i % 5 == 0) {
              continue;
          }
          post.addComment("test" + j, "testser" + j);
      }
      postRepository.save(post);
  }
}
Page<Post> findAll(Pageable pageable);
@Test
@Transactional
public void testRead() {
  Pageable pageable = PageRequest.of(0, 10, Sort.by("id").descending());
  Page<Post> page = postRepository.findAll(pageable);
  page.forEach(post -> {
      System.out.println("Post Id: " +post.getId());
      post.getComments().forEach(comment -> {
          System.out.println(comment.getContent());
      });
      System.out.println("------------------------");
  });
}

그 결과는….

Hibernate: 
    select
        p1_0.id,
        p1_0.content,
        p1_0.title,
        p1_0.writer 
    from
        post p1_0 
    order by
        p1_0.id desc 
    limit
        ?, ?
Hibernate: 
    select
        count(p1_0.id) 
    from
        post p1_0
------------------------
Post Id: 100
Hibernate: 
    select
        c1_0.post_id,
        c1_0.id,
        c1_0.content,
        c1_0.writer 
    from
        comment c1_0 
    where
        c1_0.post_id=?
[]
------------------------
Post Id: 99
Hibernate: 
    select
        c1_0.post_id,
        c1_0.id,
        c1_0.content,
        c1_0.writer 
    from
        comment c1_0 
    where
        c1_0.post_id=?
[Comment(id=238, content=test0, writer=testser0, post=Post(id=99, title=Title...99, content=Content...1, writer=writer...99)), Comment(id=239, content=test1, writer=testser1, post=Post(id=99, title=Title...99, content=Content...1, writer=writer...99)), Comment(id=240, content=test2, writer=testser2, post=Post(id=99, title=Title...99, content=Content...1, writer=writer...99))]
------------------------
Post Id: 98
Hibernate: 
    select
        c1_0.post_id,
        c1_0.id,
        c1_0.content,
        c1_0.writer 
    from
        comment c1_0 
    where
        c1_0.post_id=?
[Comment(id=235, content=test0, writer=testser0, post=Post(id=98, title=Title...98, content=Content...1, writer=writer...98)), Comment(id=236, content=test1, writer=testser1, post=Post(id=98, title=Title...98, content=Content...1, writer=writer...98)), Comment(id=237, content=test2, writer=testser2, post=Post(id=98, title=Title...98, content=Content...1, writer=writer...98))]
------------------------

이렇게 목록을 조회하는 쿼리(1)와 목록의 각 엔티티를 조회하는 쿼리(N)개가 발생한다.

데이터베이스 접속은 비용이 매우 비싼 작업으로, 만약 조회해야하는 엔티티가 더 많아진다면 성능적으로 큰 손해를 볼 수 있다. 예를 들어 지금은 댓글만 연관관계가 있지만 게시글 첨부 이미지라거나 좋아요를 누른 유저의 리스트라던가 연관관계가 설정되어있다면 데이터베이스를 엄청 많이 사용하게 되기 때문에 문제가 된다.

이를 해결하기 위해선 쿼리를 보다 적게 사용하면 된다.

우선 JPA에서 생각한 쿼리는 이렇다.

SELECT * FROM post LIMIT 0, 10;
SELECT * FROM comment WHERE post_id = 1;
SELECT * FROM comment WHERE post_id = 2;
...
SELECT * FROM comment WHERE post_id = 10;

연관된 데이터를 한 번에 같이 불러오는 방법은..? -> join!

SELECT p.*, c.* FROM post p LEFT JOIN comment c ON p.id = c.post_id ORDER BY p.id DESC LIMIT 0, 10;

이런식으로 쿼리를 줄일 수 있다. 이렇게 해결하는 방법이 Fetch Join이다.

그러나 Join을 이용하여 페이징하는 방법의 경우 문제가 있다.

+-----+------+---------+---------+----------+
| id  | id   | post_id | content | writer   |
+-----+------+---------+---------+----------+
| 100 | NULL |    NULL | NULL    | NULL     |
|  99 |  238 |      99 | test0   | testser0 |
|  99 |  239 |      99 | test1   | testser1 |
|  99 |  240 |      99 | test2   | testser2 |
|  98 |  235 |      98 | test0   | testser0 |
|  98 |  236 |      98 | test1   | testser1 |
|  98 |  237 |      98 | test2   | testser2 |
|  97 |  232 |      97 | test0   | testser0 |
|  97 |  233 |      97 | test1   | testser1 |
|  97 |  234 |      97 | test2   | testser2 |
+-----+------+---------+---------+----------+

바로 Join을 사용하면 위와 같이 중복된 결과와 누락된 데이터가 생긴다는 것이다.

따라서 JPA에서는 우선 모든 조회 결과를 다 불러온 다음 인메모리에서 이를 잘라서 사용하는 방식으로 동작한다. 그러나 SELECT 결과의 수를 제한하여 성능 저하를 피하기 위한 것이 페이징 처리일 것인데 모든 데이터가 처리 대상이라면 성능이 저하되어 사실상 페이징 처리의 이점을 얻지 못하게 된다. 따라서 페이징 처리를 하기 위해선 Batch size가 적합하다고 할 수 있다.

BatchSize는 SQL의 IN구문을 이용하는 방법으로 다음과 같은 쿼리를 사용하는 방법이다.

SELECT * FROM post LIMIT 0, 10;
SELECT * FROM comment WHERE id IN (100, 99, 98, 97, 96, 95, 94, 93, 92, 91) ORDER BY id DESC;

join을 이용한 방법에 비해 쿼리가 하나 더 나가지만 쿼리를 압축시킬 수 있는 효과적인 방법이다.
OneToMany관계에 있는 필드에 @BatchSize(size = n)과 같이 작성해서 사용하면 된다.

결국 SQL이다.

@EntityGraph

로딩이란 하나의 엔티티를 조회할 때 연관되어있는 엔티티들을 어떻게 가져올 것이냐를 말한다.
JPA에선 객체와 필드를 보고 쿼리를 생성하는데 다른 객체가 필드로 명시되어있으면 그 객체들까지 조회한다.
가장 간단한 방법은 즉시(eager)로딩을 적용하는 것이지만 위에서 살펴본 대로 성능적인 이슈가 생길 수 있다.

@OneToMany의 경우 기본적으로 지연 로딩을 한다.
게시물을 조회하는 경우 Post객체와 Comment객체들을 생성해야하므로 2번의 SELECT가 수행된다.

테스트 메소드를 작성해서 확인해보면…

Optional<Post> result = postRepository.findById(1L);

Post post = result.orElseThrow();

log.info(post);
log.info(post.getCommentSet());
@EntityGraph(attributePaths = {"commentSet"})
@Query("select p from Post p where p.id = :postId")
Optional<Post> findByIdWithComments(Long postId);

이렇게 작성하고 테스트 메소드를 실행해보면…

Optional<Post> result = postRepository.findById(1L);

Post post = result.orElseThrow();

log.info(post);
for (Comment comment : post.getCommentSet()) {
  log.info(comment);
}

실행 결과를 보면 post테이블과 comment 테이블의 조인처리가 된 상태로 select가 실행되면서 post엔티티와 comment엔티티를 한 번에 처리할 수 있게 되었다.

영속성의 전이

상위 엔티티와 하위 엔티티의 연관관계를 싱위의 엔티티에서 관리했을 때 중요한 것은 상위 엔티티의 상태가 변경되었을 때 하위 엔티티 객체들 역시 같이 영향을 받는다는 것이다.
이를 영속성의 전이라고 한다.
예를 들어 Comment객체가 JPA에 의해 관리되면 이를 참조하고 있는 Post객체 역시 같이 처리되어야 한다.
이러한 경우 연관관계에 cascade 속성을 부여해서 이를 제어하도록 한다.

단방향 관계를 적용했을 경우 데이터베이스상의 연관관계를 생각하면 된다. 게시글의 pk를 댓글이 fk로 사용하고 있다면 삭제할 수 없으니 해당하는 댓글들을 모두 삭제한 뒤 게시글을 삭제하면 된다.

commetRepository.deleteByPostId(1L);
postRepository.deleteById(1L);

페이징 처리

조회 결과가 여러 건인 경우 조건에 맞는 데이터를 조회한 결과가 천 건, 만 건이 넘어간다면 매번 필요도 없는 데이터를 불러와야 할 것이다. 성능 저하를 피할 수 없게된다. 따라서 목록을 조회할 경우 페이징이라는 기법을 적용한다.
우리가 웹 사이트를 이용하며 많이 접해본 것이다. 게시판을 들어가면 제한된 숫자의 게시글이 보이고 다음 페이지를 클릭하여 다른 페이지를 볼 수 있다.

페이징에서 가장 중요한 개념은 바로 SQL의 limit 문법이다. 아무리 복잡하게 페이징을 구현한다고 해도 결국 limit문법으로 가져올 데이터를 제한하는 것이다.

SELECT * FROM ARTICLES LIMIT 10;

그리고 그 다음 10개의 데이터를 불러 올 땐 이렇게 작성할 수 있다.

SELECT * FROM ARTICLES LIMIT 10, 10;

JPA에서는 이를 매우 간단하게 처리할 수 있다.
바로 Page<E>Pageable 타입이다.

페이징 처리는 이러한 Pagealbe 인터페이스 타입의 객체를 구성해서 파라미터로 전달하면 된다.

일반적으로는 PageRequest.of()기능을 사용한다.
Pageable pageable = PageRequest.of(페이지 번호, 사이즈, Sort) 와 같은 식으로 사용한다.
주의할 점은다른 자료구조와 마찬가지로 우리가 생각하는 1이 여기선 0이다. 즉 가장 첫 번째 페이지를 불러오고 싶다면 1이 아니라 0을 입력해야한다.
Page<Board> result = boardRepository.findAll(pageable);

리턴 타입인 Page는 페이징 처리에 필요한 정보들을 담고있다. 예를 들어 다음 페이지가 존재하는지, 이전페이지가 존재하는지 등의 정보이다.

PageRequest.of(0, 10); // 첫 번째 페이지, 페이지 크기가 10인 페이지 요청
PageRequest.of(0, 10, Sort.by("id").ascending()); // 첫 번째 페이지, 페이지 크기가 10인 페이지, id를 기준으로 오름차순 정렬
Sort multiSortDesc = Sort.by("field1").descending().and(Sort.by("field2").ascending());
// 위와 같이 여러 값들을 정렬 조건으로 넣을 수도 있다.

페이징 조건이 얼마 없어서 간단히 구현하고 싶다면 JpaRepository에는 fildAll()이라는 메소드를 사용할 수 있다. 인자로는 Pageable타입을 받는다. fildAllByPost_Id와 같이 작성하면 JPA에서 알아서 쿼리를 날려준다.

그리고 검색 조건을 추가할 수도 있다.

예를 들어 게시글을 조회하는데 검색 결과를 페이징 처리하고싶다면 쿼리 메소드로 다음과 같이 작성할 수 있다.

Page<Post> findByTitleContaining(String title, Pageable pageable);

정적인 쿼리의 한계

위의 방법들을 사용하면 편하지만 쿼리가 정적으로 고정된 형태라는 한계가 있다.
예를 들어 검색기능을 생각해보자.

조건이 없을 수도 있고 하나일 수도 있고 두 개 세 개일 수도 있다.
만약 그렇다면 경우의 수를 고려하여 별도의 쿼리를 작성해야한다.

이러한 문제를 해결하는 방법중 하나가 바로 Querydsl이다. Querydsl은 JPA의 구현체인 Hibernate 프레임워크가 사용하는 HQL을 동적으로 생성할 수 있는 프레임워크로 JPA를 지원하고 있다.

Querydsl은 HQL(Hibernate Query Language) 쿼리를 타입에 안전하게 생성 및 관리할 수 있게 해주는 프레임워크다.
Querydsl의 장점은 다음과 같다.

  1. 동적 쿼리 작성 가능
  2. 타입 안전성 확보

Querydsl

Querydsl은 Query + DSL로 DSL이란 Domain Specific Languages의 약자로 도메인의 문제를 해결하기 위해 특화된 언어라는 의미다.
뜻풀이를 해보자면 SQL을 Type-safe하게 작성할 수 있는 라이브러리라고 할 수 있다.

자바 코드를 이용하면 런타임 시점이 아니라 컴파일 시점에 오류를 찾아낼 수 있기 때문에 타입의 안정성을 확보할 수 있다는 장점이 있다. 이 이점은 생각보다 크다. 예를 들어 JPQL로 작성한 쿼리에 오류가 있는데 해당 경로가 호출되는 빈도가 굉장히 낮고 이로 인해 테스트를 소홀히 했다면 오류가 있다는 것을 알기까지 시간이 얼마나 걸릴지 모른다. 그러나 Querydsl을 사용하면 이런 오류를 빠르게 인지할 수 있다. 심리적 안정감도 얻을 수 있다.

Querydsl에서는 Q도메인 이라는 것이 필요한데 이는 Querydsl의 설정을 통해서 기존의 엔티티 클래스를 Querydsl에서 사용하기 위해 별도의 코드로 생성하는 클래스이다.

Querydsl을 사용하는 방법은 여러가지다 가장 기본적인 방법은 다음과 같다.

  1. 쿼리를 만들고 JPQLQuery<Post> query
  2. 실행한다 List<Post> list = query.fetch();
@Override
public Page<Post> searchAll(Pageable pageable) {
    QPost post = QPost.post;
    JPQLQuery<Post> query = from(post);

    query.where(post.title.contains("test"));

    List<Post> list = query.fetch();
    Long count = query.fetchCount();

    return new PageImpl<>(list, pageable, count);
}

다른 블로그나 강의를 듣다보면 JPQLQueryFactory를 사용하는 것을 볼 수 있다.
JPQLQueryFactory는 말 그대로 JPQLQuery를 만드는 Factory로 내부 코드를 보면… 스크린샷 2024-06-20 시간: 01 42 40 이렇게 JPQLQuery를 리턴하고 있는 걸 볼 수 있다.

결국 어떤 방법을 사용해도 JPQLQuery를 사용하지만 사용 방법이 조금 다르다. 위의 JPQLQuery는 from 절 부터 시작하지만 JPQLQueryFactory는 메소드 체이닝 방식으로 메소드가 SELECT부터 시작한다. 실제 우리가 CLI에서 쿼리를 작성할 때를 생각해보면 SELECT부터 입력한다. 따라서 조금 더 이해하기 쉬운 코드를 작성할 수 있고 재사용성이 높다는 장점이 있다. 이러한 이유로 JPQLQuery보다 JPQLQueryFactory를 사용하는 것을 권장하는 편이다.

JPQLQueryFactory는 빈으로 등록하여 스프링이 관리하도록 한다.

@Configuration
@EnableJpaAuditing
public class JpaConfig {
    @PersistenceContext
    private EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(em);
    }
}

이렇게 설정하고 Querydsl을 사용하는 클래스에서 주입받아서 사용한다.

private final JPQLQueryFactory queryFactory
...
public List<SampleDTO> search(){
    List<Sample> result = jpaQueryFactory.selectFrom(sample).fetch();
}

쿼리 메소드를 사용할 경우 엔티티를 리턴하기 때문에 만약 이를 DTO로 받고 싶을 땐 Projections를 사용할 수 있다.

public List<SampleDTO> search(){
    List<SampleDTO> result = jpaQueryFactory
    .select(
        Projections.constructor(
            SampleDTO.class,
            sample.id,
            sample.title))
    .from(sample)
    .fetch();

    return result;
}

페이징 처리시 QuerydslRepositorySupport라는 클래스의 기능을 이용할 수 있다.

예를 들어 게시글을 검색하는데 작성일자가 최신인 것부터 먼저 보고싶다던지 하는 경우 OrderSpecifier를 리턴하는 메소드를 작성해주어야한다. 이렇게 하는 방법도 있지만 구현을 단순화하고 싶다면 기본 Pageable 타입의 sortBy라던가 정렬 순서라던가 하는 기본 기능을 사용할 수도 있다.

QuerydslRepositorySupport는 Spring Data JPA와 Querydsl을 함께 사용할 때 유용한 추상 클래스로 기본적인 설정과 기능을 제공한다.
그중 핵심적인 메소드인 getQuerydsl()은 QueryRepositorySupport에서 JPAQueryFactory 인스턴스를 얻는 메소드로 간단히 페이징을 적용할 수 있다.

List<SampleDTO> result = getQuerydsl().applyPagination(pageable, query.fetch());
query.fetchCount();

위와 같이 사용하고 Page타입으로 감싸서 리턴해주면 된다.

return new PageImpl<>(result, pageable, count);
  • fetchcount()’ is deprecated
    이전 버전까지 사용하던 fetchCount메소드의 지원이 5.0버전부터 중단되었다.
    이는 groupby ~ having 조건이 있는 경우 쿼리에 대해 카운트 쿼리를 생성할 수 없기 때문이라고 한다.
    따라서 다음과 같이 별도의 쿼리를 작성하여 사용해야한다.
    long totalCount = jpaQueryFactory
      .select(post.count())
      .from(post)
      .fetchOne();
    

심화

불필요한 확장 및 계층 구조 사용하지 않는 방법

일반적으론 JpaRepository 인터페이스를 먼저 선언하고 Querydsl의 인터페이스를 선언한다. 일반적으로 이름은 SampleRepositoryCustom과 같이 작성한다. 그리고 이를 SampleRepository에서 상속받는다. 이렇게 하면 sampleRepository.customMethod();와 같이 사용할 수 있다.

여기에 인터페이스를 작성하고 이를 구현하는 SampleRepsitoryCustomImpl를 작성하여 JPQLQueryFactory를 사용하여 쿼리를 사용하는 것이 일반적이다.

Querydsl은 JPQLQueryFactory만 있으면 사용할 수 있다. 주로 특정 엔티티를 메인으로 확정할 수 없는 경우 사용한다고 한다.

그러나 이렇게 사용하는 경우 상속이나 구현 관계가 아니기 때문에 기존 JpaRepository 인터페이스를 통해 접근이 불가능하고 별도의 객체를 사용해야한다. 상속 구조를 통해 사용하는 것도 나쁘지 않다고 본다. 왜냐하면 레포지토리를 사용하는 곳인 서비스에서는 자신이 사용하는 것이 querydsl을 사용한 것인지, 쿼리 메소드를 사용한 것인지 알 필요가 없기 때문이다.
일원화된 인터페이스를 사용한다는 점에서 기존 방식도 충분히 사용할 이점이 있다고 생각한다.

동적 쿼리 생성

Querydsl에서는 2가지 방법으로 동적 쿼리를 생성할 수 있다.

  1. BooleanBuilder
  2. BooleanExpression
BooleanBuilder builder = new BooleanBuilder();

switch (type) {
    case: "t":
        booleanBuilder.or(board.title.contains(keyword));
        break;
    case: "c":
        booleanBuilder.or(board.content.contains(keyword));    
        break;
    case: "w":
        booleanBuilder.or(board.writer.contains(keyword));
        break;
}

return queryFactory
        .selectFrom(sample)
        .where(booleanBuilder)
        .fetch();

어떤 쿼리가 나갈지 한눈에 파악하기 힘들고 조건이 늘어날 수록 코드가 복잡해지게된다.
이럴 경우 BooleanExpression을 사용할 수 있다.

BooleanExpression whereClause = eqTitle(type, keyword)
        .or(eqContent(type, keyword))
        .or(eqWriter(type, keyword));

queryFactory
    .selectFrom(sample)
    .where(whereClause)
    .fetch();

private BooleanExpression eqTitle(type, keyword) {
    if(type == null || type != "t") {
        return null;
    }

    return board.title.contains(keyword);
}
private BooleanExpression eqContent(type, keyword) {
    if(type == null || type != "c") {
        return null;
    }

    return board.content.contains(keyword);
}
private BooleanExpression eqWriter(type, keyword) {
    if(type == null || type != "w") {
        return null;
    }

    return board.writer.contains(keyword);
}

이렇게 작성하면 where절에 null이 들어갈 경우 쿼리에서 해당 조건을 자동으로 제거된다.
만약 모든 조건이 null이라면 where절의 조건이 없어지면서 모든 테이블이 조회 대상이기 때문에 성능상의 문제가 생기기 때문에 조심해야한다.

JPA의 구조

자바에서 데이터베이스와 연동을 지원하는 API는 JDBC API로 java.sql의 여러 인터페이스를 이용하면 실제로는 인터페이스를 구현한 드라이버가 동작하여 구체적인 작업을 수행한다.
JPA도 마찬가지로 JPA에서 제공하는 API를 이용하여 데이터베이스 연동을 구현하면 실제로는 그 구현체가 구체적인 작업을 처리한다.
여러 구현체가 있는데 Hibernate, EclipseLink, DataNucleus가 있다. 이 중에서 스프링 부트가 디폴트로 사용하는 구현체는 Hibernate다. 간단한 프로젝트를 만들고 실행해보면 로그에서 Hibernate를 찾아볼 수 있다.

JPA의 동작

JPA를 이용해 CURD작업을 하려면 EntityManager가 필요하다. 이를 생성하기 위한 존재는 EntityManagerFactory로 말 그대로 엔티티 매니저를 생산하는 공장이다.

EntityManagerFactory emf = Persistence.createEntityManagerFactory("Sample");
EntityManager em = emf.createEntityManager();

이런식으로 객체를 얻었다면 persist메소드를 통해 엔티티에 설정된 값을 테이블에 저장하면 된다.

EntityTransaction tx = em.getTransaction();

try {
    User user = new User();
    user.setId(1L);
    user.setUsername("user1");
    
    tx.begin();
    em.persist(user);
    tx.commit();
} catch(Exception e) {
    log.info(e);
    tx.rollback();
} finally {
    em.close();
    emf.close();
}

이런 식으로 동작한다.
EntityManager의 persist 메소드를 호출하면 EntityManager와 연결된 영속 컨텍스트에 엔티티 객체가 등록되고
영속 컨텍스트는 등록된 엔티티를 분석하여 쿼리를 만들어 전송한다.
이 과정에서 JDBC API가 호출된다.

컨텍스트란 맥락이라는 사전적인 정의가 있지만 실제로 쓰일 때는 무언가가 생성되고 라이프사이클을 수행하는 공간을 말한다.
세션 컨텍스트, 애플리케이션 컨텍스트 등등.. 모두 이러한 의미로 사용된다.

영속 컨텍스트는 사실상 엔티티 매니저와 동일한 개념으로 영속 컨텍스트가 관리하는 엔티티는 생성, 관리, 분리, 삭제와 같은 상태로 존재한다.
생성 상태는 비영속 상태라고도 하고 단지 엔티티 객체를 생성했을 뿐인 상태를 말한다. 위의 코드에서 User user = new User()만 선언했을 때의 상태다. 이 상태에선 그냥 일반적인 자바 객체와 다를 것이 없다.

그 다음으론 관리 상태에 들어간다.
이는 엔티티 객체가 영속 컨텍스트에 의해 관리받고있는 상태를 말한다.
관리 상태로 전환시키는 방법은 persist 메소드를 사용하는 방법과 find메소드를 사용하는 방법이 있다.
영속 컨텍스트는 persist 메소드에 의해 관리 상태로 전환된 엔티티에 대해서 insert처리를 한다.

find메소드는 조회하고자 하는 엔티티가 영속 컨텍스트에 존재하면 엔티티를 반환하고 없다면 데이터베이스에서 조회해온 뒤 새로운 엔티티를 생성한다.
그리고 이를 영속 컨텍스트에 등록한다.
그러니 find메소드를 호출했다고 해서 무조건 데이터베이스에 SELECT쿼리를 내보내는 것이 아니라 만약 영속 컨텍스트에 있다면 이 정보를 사용하는 것이다.

그 다음으론 분리 상태 혹은 준영속 상태가 있다.
이는 영속 컨텍스트에 존재하던 엔티티가 특정 작업에 의해 영속 컨텍스트에서 벗어난 상태를 의미한다.
이렇게 영속 컨텍스트의 관리에서 벗어났기 때문에 엔티티를 수정하더라도 데이터베이스에 쿼리가 나가지 않는다.
이렇게 보면 비영속 상태와 다를 것이 없어보이지만 생성 상태의 엔티티는 한 번도 영속상태에 들어간 적이 없기 때문에 식별자 값을 가지지 않는다는 차이가 있다.

또한 알아두어야하는 개념이 있는데 바로 엔티티 캐시다.
persist메소드를 호출하여 영속 상태로 만든다고 해도 그 즉시 INSERT쿼리가 나가지 않는다.
JPA의 영속 컨텍스트 내부에 엔티티 캐시라는 공간을 만들어서 엔티티들을 관리한다.
캐시는 키와 벨류의 쌍이며 엔티티가 영속 상태로 전환될 때 자신이 가지고 있는 캐시에 해당 엔티티를 등록한다. 만약 캐시에 등록된 엔티티를 INSERT하려면 Flush가 수행되어야한다. 이는 캐시에 등록된 엔티티의 상태를 데이터베이스와 동기화 시키는 작업을 의미한다.
직접 flush()를 호출해도 되고 트랜잭션을 커밋시켜도 된다.

만약 플러시가 실행되면 영속 컨텍스트는 캐시에 등록된 모든 엔티티들의 상태를 체크하고 쿼리를 생성한다. 예를 들어 remove로 제거된 엔티티에 대해서는 DELETE쿼리가 나가고 값이 변경된 엔티티에 대해서는 UPDATE쿼리를 만든다. 그리고 이렇게 만들어진 쿼리를 여러 번의 커넥션을 통해 전송하는 것이 아니라 하나의 커넥션 안에서 처리한다.