[코드로 배우는 스프링부트 웹 프로젝트] - 연관관계(3) : 조인(Join)연산, Lazy Loading

Jongwon·2023년 1월 3일
0

JOIN

백엔드, 특히 데이터베이스 부분을 작업할 때 빼놓을 수 없는 개념, 그리고 가장 중요한 개념은 조인 연산이라고 생각합니다.

저도 대략 2년전부터 프로젝트로 계속 백엔드를 맡다보니 조인 연산을 많이 접해보긴 했지만 정리해두지 않으면 잊어버리기 쉬운 개념이라 이번 기회에 정리해보고자 합니다.

Left Join

먼저 개념을 설명하기 전, 앞서 하던 테스트 코드에 아래와 같이 작성해봅시다.

BoardRepositoryTests에 추가

    @Test
    public void testRead1() {
        Optional<Board> result = boardRepository.findById(100L);

        Board board = result.get();

        System.out.println(board);
        System.out.println(board.getWriter());
    }

이 코드의 실행결과로 다음의 SQL문이 실행이 실행되고, 결과도 아래와 같이 나옵니다.

여기서 중요한 것은 하이라이트친 left join입니다. 이전에 엔티티 생성 시기에 설정한 연관관계는 Member와 Board가 1:N의 관계를 가지는 것이었습니다.

Left Join(a.k.a Left Outer Join)은 from절의 table(b1_0)의 전체 레코드와 join절의 table(w1_0)의 레코드 중 on 조건과 일치하는 레코드들만 결과로 가져옵니다.

즉, 여기서는 게시판의 글 전체를 가져오는데, 게시판의 writer 이메일과 동일한 이메일을 가진 멤버에 대해 같이 결과로 반환하라는 SQL문입니다.

다이어그램으로 포함관계를 그려보면 아래와 같습니다.

출처 : W3Schools



Right Join

Right Join은 Left와 반대로 join절의 table 레코드 전체와, on 조건과 일치하는 where절의 table 레코드 일부를 결과로 가져옵니다.

출처 : W3Schools

Inner Join

앞의 left join과 right join은 다른 말로 left outer join혹은, right outer join이라고도 합니다. 그렇다면 반대로 inner join은 무엇일까요?

출처 : W3Schools

쉽게 생각하면 두 테이블 레코드의 교집합입니다. on 조건에 맞는 양쪽의 레코드만 가져옵니다.

Outer Join과 Inner Join의 개념을 응용하면 여집합의 개념도 SQL에서 구현할 수 있습니다.

Full (Outer) Join

Full Join은 Left Outer Join과 Right Outer Join의 합에서 중복된 데이터를 제거한 결과를 반환합니다. 다시 말하면 양쪽 레코드 전체를 반환합니다.

출처 : W3Schools

Self Join

Self Join은 따로 명령어가 있는 것은 아니고, 조건을 하나의 테이블에 대해서 하는 것입니다. 예를 들어 하나의 테이블에 애트리뷰트가 (ID, 이름, 도시)로 되어있을 때, 같은 도시에 살고 있는 사람을 묶고자 할때는

SELECT A.CustomerName AS CustomerName1, B.CustomerName AS CustomerName2, A.City
FROM Customers A, Customers B
WHERE A.CustomerID <> B.CustomerID
AND A.City = B.City
ORDER BY A.City;

하나의 Customers라는 테이블에 대해 위의 SQL과 같이 작성할 수 있습니다.




다시 프로젝트로 돌아와서, 이번에는 Reply에 대해 테스트를 진행해보겠습니다.

ReplyRepositoryTests

    @Test
    public void readReply1() {
        Optional<Reply> result = replyRepository.findById(1L);
        
        Reply reply = result.get();

        System.out.println(reply);
        System.out.println(reply.getBoard());
    }

Reply 레코드 하나를 부르는데, 조인 연산을 보면 Board와도 조인하고, Member와도 조인하고 있는 모습을 볼 수 있습니다.

Eager Loading

한 레코드를 조회할 때 그 레코드와 연관관계를 가지는 모든 레코드를 로딩하는 것을 eager loading이라고 합니다. 한번에 모든 엔티티를 가져올수는 있지만, 반대로 생각하면 복잡한 연관관계를 가지는 레코드들을 조인으로 부른다는 점에서 성능 저하의 원인이 됩니다.

Lazy Loading

Lazy Loading은 필요한 시점까지 데이터를 로딩하지 않는 것입니다. 이 eager loading과 lazy loading의 개념은 JPA에서만 사용되는 것이 아니라 웹 디자인, 기타 프로그래밍에서도 사용되는 개념입니다. 웹 페이지를 수정하고 이를 반영할 때, 필요한 순간이 올 때까지 반영을 미루는 등 성능 향상을 위해 Lazy Loading의 개념은 필수적입니다.

Lazy Loading을 적용하기 위해 Board 엔티티에 fetchType을 지정하겠습니다.

Board

@Entity
@Builder
@AllArgsConstructor
@Getter
@ToString(exclude = "writer")
@NoArgsConstructor
public class Board extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bno;

    private String title;

    private String content;

//수정
    @ManyToOne(fetch = FetchType.LAZY)
    private Member writer;
}

여기까지만 수정 후 BoardTest의 testRead를 실행하면 아래와 같은 에러를 만나게 됩니다.

testRead1()에서는 getWriter(), 즉 Member가 필요하기 때문에 no Session에러가 발생합니다. 이를 해결하기 위해 @Transactional을 지정해주면 필요시 다시 데이터베이스에 접근하여 데이터를 가져옵니다.

위와 같이 처음 Board를 출력할 때는 Board 테이블만 select하고 결과를 출력했는데, 뒤에 다시 member의 데이터가 필요해지면서 데이터베이스에서 조회한 SQL을 확인할 수 있습니다.



하지만 Lazy Loading역시 단점이 존재합니다. 필요한 순간마다 쿼리문을 실행하기 때문에 연관관계가 복잡하면 오히려 더 많은 쿼리문을 실행하게 될 수도 있습니다.


그렇다면 단순히 Lazy Loading을 사용하는 방식도 때로는 좋지 않다면 어떤 방식을 사용해야 할까요? 기본적으로 Lazy Loading을 사용하되, 상황에 맞게 필요한 방법을 사용한다는 것이 정석입니다.

JPQL을 이용하여 쿼리문을 직접 지정해준다면 좀 더 원하는 방식으로 DB접근이 가능할 것입니다.



Board의 입장에서 두가지 케이스를 보겠습니다.

1. 연관관계가 설정된 두 엔티티

Board는 writer를 통해 Member와 이미 연관관계를 가지고 있습니다.

**BoardRepository**  
    public interface BoardRepository extends JpaRepository<Board, Long> {
    
    @Query("select b, w from Board b left join b.writer w where b.bno=:bno")
    Object getBoardWithWriter(@Param("bno") Long bno);
	}

쿼리문에서 Left Join 옆에 On 조건이 없는 것을 확인할 수 있는데, 이미 Board 엔티티에서 @OneToMany로 지정해주었기 때문에 불필요한 것입니다.

b.bno = :bno에서 :bno 는 동적 변수를 지정해줄 때 사용하는 기호입니다. bno라는 파라미터에 사용자가 값을 넣어줄 수 있습니다.

Join에 Member 대신 Member와 동일하고, 자신과 연관되어있음을 알려주는 애트리뷰트인 b.writer를 사용한 것도 확인할 수 있습니다.


BoardRepositoryTests에 테스트 코드를 작성해보겠습니다.

BoardRepositoryTests

    @Test
    public void testReadWithWriter() {
        Object result = boardRepository.getBoardWithWriter(100L);

        Object[] arr = (Object[]) result;

        System.out.println("-----------------------");
        System.out.println(Arrays.toString(arr));
    }

Join 연산이 실행된 것을 확인할 수 있습니다.




2. 연관관계가 설정되지 않은 두 엔티티

Board와 Reply간의 관계를 생각하면, Reply는 Board에 연관되어 있지만, Board입장에서는 Reply를 알지 못합니다. 이러한 경우에는 JPQL을 작성할 때, Join에 On 조건을 명시해야 합니다.

BoardRepository

    @Query("select b, r from Board b left join Reply r on r.board = b where b.bno = :bno")
    List<Object[]> getBoardWithReply(@Param("bno") Long bno);

BoardRepositoryTests

	@Test
    public void testGetBoardWithReply() {
        List<Object[]> result = boardRepository.getBoardWithReply(100L);

        for(Object[] arr : result) {
            System.out.println(Arrays.toString(arr));
        }
    }

profile
Backend Engineer

0개의 댓글