본문 바로가기

스프링

[JPA] 일반 조인 vs 패치 조인

시작하기에 앞서

이번 포스트에서는 일반 조인과 패치 조인의 차이점에 대해서 알아보려고 한다.

우리가 엔티티들을 생성하고 사용하다보면, 자연스럽게 다양한 연관 관계가 맺어진다.

우리는 맺어진 연관 관계 속에서 다양한 정보를 찾으려고 노력하는데, 우리는 이 때 조인을 사용하곤한다.

JPA에서는 특별한 조인인 패치 조인을 제공하는데 이 부분은 과연 조인과 어떤 차이점이 존재하는 것일까?

간단한 예제를 통해서 이 부분에 대해서 자세히 알아보자.

엔티티

이 부분에 대해서 알아보기 위해 사용한 예제는 다음과 같다.

게시글을 작성할 수 있는 학생이 존재한다.

한 학생은 여러 개의 게시글을 작성할 수 있고, 한 게시글은 한 작성자에 의해 소유된다.

학생과 게시글은 위와 같은 속성을 가진다.

이 부분에 대한 엔티티 코드는 다음과 같다.

@Entity
@Getter
@Setter
@SequenceGenerator(
        name="BOARD_SEQ_GENERATOR",
        sequenceName = "BOARD_SEQ"
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "BOARD_SEQ_GENERATOR")
    @Column(name = "board_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "student_id")
    private Student writer;

    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime writeTime;
    private Boolean isDeleted;
    private String title;
    private String content;
    private Integer hit;

    @Builder
    public Board(Student writer, List<Attachment> attachedFiles, LocalDateTime writeTime, Boolean isDeleted, String title, String content, Integer hit) {
        this.writer = writer;
        this.attachedFiles = attachedFiles;
        this.writeTime = writeTime;
        this.isDeleted = isDeleted;
        this.title = title;
        this.content = content;
        this.hit = hit;
    }
}

@Entity
@ToString
@Getter
@SequenceGenerator(
        name = "STUDENT_SEQ_GENERATOR",
        sequenceName = "STUDENT_SEQ"
)
public class Student {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "STUDENT_SEQ_GENERATOR")
    @Column(name = "student_id")
    private Long id;
    private String email;
    private String password;
    private String name;
    private String nickname;
    private String department;
    private String major;

    protected Student() {
    }

    @Builder
    public Student(String email, String password, String name, String nickname, String department, String major) {
        this.email = email;
        this.password = password;
        this.name = name;
        this.nickname = nickname;
        this.department = department;
        this.major = major;
    }
}

여기서, 유의깊게 봐야할 점은 Board 객체에서 Student 객체로의 Fetch Strategy를 Lazy로 설정했다는 것이다.

이 부분을 유의하여 두 엔티티에 대한 일반 조인과 패치 조인을 사용해보자.

일반 조인

테스트 코드로 JPQL을 작성해 실제로 조인을 적용해보자.

@Test
public void 조인테스트() throws Exception {
    // 학생 생성
    Student student = new Student("testID@gmail.com", "testPW", "테스터", "테스터", "컴공", "백엔드");
    Student joinStudent = studentService.join(student);

    // 게시물 작성
    BoardAddForm boardAddForm = new BoardAddForm("테스트 글", "테스트 글입니다.", null, null);
    BoardPostDto boardPostDto = boardAddForm.createBoardPostDto(joinStudent);
    boardService.post(boardPostDto);

    // 영속성 컨텍스트 플러쉬 후 초기화
    em.flush();
    em.clear();

    // 쿼리 조회
    List<Board> resultList = em.createQuery(
                    "select b from Board b " +
                    "join b.writer w", Board.class)
                    .getResultList();
}

위 코드의 쿼리에서 볼 수 있듯이, Board 엔티티와 Student 엔티티를 조인하여 Board 객체를 끌어오고있다.

이 부분에 대해 쿼리가 어떻게 나가는지 확인해보자.

쿼리를 보면 알 수 있듯이, Board에 대한 정보(값 타입)는 모두 끌어오나 Student 객체에 대한 정보는 ID를 제외하고 끌어오고 있지 않다.

그렇다면, Student 객체는 어떤 형태로 불러와지는 것일까?

실제로 다음 코드를 통해 확인해보자.

System.out.println("board1 = " + board1.getWriter().getClass().getName());

다음 코드를 진행해보면 Student 객체의 클래스에 대해 알 수 있다.

결과는 다음과 같다.

바로, 프록시이다. 비밀은 바로 LAZY에 있었다.

우리가 실제로 위에서 LAZY로 설정함에 따라 Student 객체를 실제로 사용하기 전까지 불러오지 않기 위해 프록시 객체로 설정해두었다.

이제 실제로 Student의 닉네임을 불러와보자.

System.out.println("board1 = " + board1.getWriter().getNickname());

위의 코드를 실행하면 다음과 같은 결과를 얻을 수 있다.

추가로, Student 객체를 따로 조회하여 이를 가져온다.

이 부분은 성능상 문제가 발생할 수 있다.

이것이 바로 JPA에서 잘 알려진 N+1문제를 초래할 수 있는 부분인 것이다.(N+1 부분에 대해 잘 모르겠다면, 다음 포스트를 참고)

그에 따라, 우리는 다양한 해결법 중 하나인 패치 조인을 사용하는 것이다.

패치 조인을 실제로 사용해보자.

패치 조인

모든 조건은 일반 조인에서 사용한 테스트 코드와 동일하다.

단지, 일반 조인에서 패치 조인으로 변경한 것 뿐이다.

@Test
public void 조인테스트() throws Exception {
    // 학생 생성
    Student student = new Student("testID@gmail.com", "testPW", "테스터", "테스터", "컴공", "백엔드");
    Student joinStudent = studentService.join(student);

    // 게시물 작성
    BoardAddForm boardAddForm = new BoardAddForm("테스트 글", "테스트 글입니다.", null, null);
    BoardPostDto boardPostDto = boardAddForm.createBoardPostDto(joinStudent);
    boardService.post(boardPostDto);

    // 영속성 컨텍스트 플러쉬 후 초기화
    em.flush();
    em.clear();

    // 쿼리 조회
    List<Board> resultList = em.createQuery(
                    "select b from Board b " +
                    "join fetch b.writer w", Board.class)
                    .getResultList();
}

위 코드에 대한 결과는 어떤지 살펴보자.

위와 상당히 다른 결과를 보인다.

일반 조인에서는 단순히 Student의 식별자 값만 들고있었다면, 이번에는 Student 객체 자체를 끌어왔다.

그렇다면 Student는 객체는 원본 객체일까? 실제로 확인해보자.

System.out.println("board1 = " + board1.getWriter().getClass().getName());

위 코드의 실행 결과에서 확인할 수 있듯이 원본 객체를 끌고온다.

그에 따라, 다음 코드를 실행해도 더 이상 Student 객체에 대한 Select 쿼리를 보낼 필요가 없어진다.

System.out.println("board1 = " + board1.getWriter().getNickname());

그렇다면, 일반 조인으로는 무조건 N+1의 문제를 발생시킬까?

다음을 살펴보자.

우리는 단순히 어떤 게시글의 작성자의 이름이 필요하다.

이 때, fetch join을 사용한다면 당연히 Student 객체를 모두 끌어와 사용하기 때문에 N+1의 문제를 방지할 수 있다.

하지만, Board의 모든 정보와 Student의 모든 정보를 끌어오기 때문에 상당히 불필요한 정보를 가져온다.

이를 일반 조인으로 해결해보자.

단순히, 다음 코드와 같이 진행한다면 결과는 어떻게 될까?

@Test
public void 조인테스트() throws Exception {
    //given
    Student student = new Student("testID@gmail.com", "testPW", "테스터", "테스터", "컴공", "백엔드");
    Student joinStudent = studentService.join(student);

    //when
    BoardAddForm boardAddForm = new BoardAddForm("테스트 글", "테스트 글입니다.", null, null);
    BoardPostDto boardPostDto = boardAddForm.createBoardPostDto(joinStudent);
    boardService.post(boardPostDto);

    em.flush();
    em.clear();

    //then
    String nickname = em.createQuery(
                    "select w.nickname from Board b " +
                            "join b.writer w", String.class)
                            .getSingleResult();
}

다음과 같이, 엔티티를 끌어오는 것이 아니라 단순 조인된 대상 엔티티의 일부 속성을 꺼내오면 어떻게 될까?

이는 단순히 추가 쿼리 없이 끌고와진다.

당연하다고 느껴질 수 있지만, 이 부분은 확실하게 알아두면 좋은 부분이다.

우리가 보통 레이어 간의 데이터 전송을 위해 Dto를 많이 사용하기 때문에 Query 자체를 Dto로 Select하는 경우가 종종 있다.

이 경우에 무작정 Lazy로 설정된 엔티티에 대해서의 join은 N+1이다라고 치부하는 착각을 없애기 위해 적어보았다.

정리

이렇듯, 위에서 살펴봤던 것처럼 일반 조인과 패치 조인의 차이에 대해서 살펴보면 다음과 같다.

  1. 일반 조인
    • 연관 엔티티에 join을 하게되면 Select 대상의 엔티티는 영속화하여 가져오지만, 조인의 대상은 영속화하여 가져오지 않는다.
    • 연관 엔티티가 검색 조건에 포함되고, 조회의 주체가 검색 엔티티뿐일 때 사용하면 좋다.
  2. 패치 조인
    • 연관 엔티티에 fetch join을 하게되면 select 대상의 엔티티뿐만 아니라 조인의 대상까지 영속화하여 가져온다.
    • 연관 엔티티까지 select의 대상일 때, N+1의 문제를 해결하여 가져올 수 있는 좋은 방법이다.

이 부분에 대해서는 조금 더 경험을 해보고 사용하면서 더 느껴보면 좋을 것 같다.