Springboot 조인과 연관관계

niireymik·2024년 7월 15일

🧶 JPA 연관 관계

연관 관계 정의 규칙

연관 관계를 매핑할 때, 생각해야 할 것은 크게 3가지가 있다.

방향 : 단방향, 양방향 (객체 참조)
연관 관계의 주인 : 양방향일 때, 연관 관계에서 관리 주체
다중성 : 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M)

하나 하나 짚어보자.

1.단방향 / 양방향

데이터베이스 테이블은 외래 키 하나로 양쪽 테이블 조인이 가능하다. 반면 객체참조용 필드가 있는 객체만 다른 객체를 참조하는 것이 가능하다. 따라서 두 객체 사이에 하나의 객체만 참조용 필드를 갖고 참조하면 단방향 관계, 두 객체 모두가 각각 참조용 필드를 갖고 참조하면 양방향 관계라고 한다.
(정확히는 양방향 관계는 없고, 두 객체가 단방향 참조를 각각 가져서 양방향 관계처럼 사용되는 것이다.)

무조건 양방향 관계를 하면 쉽지 않을까?
아니다. 객체입장에서 양방향 매핑을 했을 때 오히려 복잡해질 가능성이 크다.
일반적인 비즈니스 애플리케이션의 사용자(User) 엔티티를 생각해 보자면, 이는 괸장히 많은 엔티티와 연관 관계를 갖는다. 이런 경우에는 모든 엔티티를 양방향 관계로 설정하게 되면 사용자 엔티티는 엄청나게 많은 테이블과 연관 관계를 맺게 되고 User 클래스는 매우 복잡해진다.
-> 구분하기 좋은 기준은 기본적으로 단방향 매핑으로 하고 나중에 역방향으로 객체 탐색이 꼭 필요하다고 느낄 떄 추가하는 것으로 한다. 양방향 관계 설정의 자세한 것은 아래에서 다룬다.

2.연관 관계의 주인

두 객체가 양방향 관계를 맺을 때에는 연관 관계의 주인을 설정해 주어야 한다.

연관 관계의 주인은 연관 관계를 갖는 두 객체 사이에서 조회, 저장, 수정, 삭제를 할 수 있지만, 연관 관계의 주인이 아니면 조회만 가능하다. 연관 관계의 주인이 아닌 객체에서는 mappedBy 속성을 사용해서 주인 객체를 지정해 줘야 한다.

왜 연관 관계의 주인을 지정해야 하는가?
두 객체(Board, Post)가 있고 양방향 관계를 갖는다고 생각해 보자.
이 상황에서 게시글(Post)의 게시판을 다른 게시판(Board)로 수정하려고 할 때, Post 객체에서 setBoard(...)같은 메서드를 이용해서 수정하는 게 맞는지, Board 객체에서 Post의 List를 수정하는게 맞는지 헷갈릴 수 있다.
물론 두 객체 입장에서는두 방법 다 옳지만, 이렇게 양방향 연관 관계 관리 포인트가 두 개일때는 테이블과 매핑을 담당하는 JPA 입장에서 혼란을 주게 된다. 따라서 두 객체 사이의 연관 관계의 주인을 정해서 명확하게 Post에서 Board를 수정할 때만 FK를 수정하겠다! 라고 정하는 것이다.

3.다중성

데이터베이스를 기준으로 다중성을 결정한다.

연관관계는 대칭성을 가진다.
일대다 <-> 다대일
일대일 <-> 일대일
다대다 <-> 다대다




📝 @JoinColumn 어노테이션

@JoinColumn을 마스터하면 JPA 연관관계를 어느정도는 다 알 수 있다는 말을 참고해, 앞서 살펴본 연관관계에 대해 @ManyToOne, @OneToOne 등의 어노테이션과 함께 자세히 파헤쳐 보자.

1.@JoinColumn이란?

JoinColumn 어노테이션 클래스에는 아래와 같은 주석이 존재한다.

맨 윗부분의 내용을 요약하면 다음과 같다.
Entity 연관관계 또는Element Collection을 연결하기 위한 Column을 지정한다.

즉 JoinColumn이 사용되는 용도는 주로 Entity의 연관관계에서 외래키를 지정, 매핑하기 위해 사용된다.

1-2.@JoinColumn의 속성 : name

@JoinColumn 어노테이션에서 중요한 속성은 name, referencedColumnName 정도가 있다.

name 속성

  • name 속성은 매핑할 외래 키의 이름을 지정하는 속성이다.
  • name을 지정하지 않는다면 기본적으로 참조하는 Entity 필드명 + "_" + 참조되는 Entity의 PK 이름 으로 설정된다.

기본값이 있는데 name을 지정해주는 이유?
-> 유지 보수 시에 필드명이 바뀔 수 있으므로, 필드명이 바뀌더라도 정해진 name으로 연결되어 기존 코드에 영향이 없도록 하기 위함이다.

2-1. 다대일(N:1)

다대일(N:1) 단방향

@Entity
public class Post {
	@Id
    @Column(name = "POST_ID)
    UUID id;
    
    @ManyToOne
    @JoinColumn(name = "BOARD_ID")
    Board board;
    
    //... getter, setter
}

@Entity
public class Board {
	@Id
    UUID id;
    String title;
    //... getter, setter
}

다대일 단방향에서는 다 쪽인 Post에서 @ManyToOne만 추가해준 것을 알 수 있다. 반대로 Borad에서는 참조하지 않는다. (단방향이기 때문)

다대일(N:1) 양방향

@Entity
public class Post {
	@Id
    @Column(name = "POST_ID)
    UUID id;
    
    @ManyToOne
    @JoinColumn(name = "BOARD_ID")
    Board board;
    
    //... getter, setter
}

@Entity
public class Board {
	@Id
    UUID id;
    String title;
    
    @OneToMany(mappedBy = "board")
    List<Post> posts = new ArrayList<>();
    //... getter, setter
}

다대일 양방향으로 만드려면 일(1) 쪽에 @OneToMany를 추가하고 양방향 매핑을 사용햇으니 연관관계의 주인을mappedBy로 지정해 준다.

2-2. 일대다(1:N)

*실무에서 일대다(1:N) 단방향은 거의 쓰지 않도록 한다.

일대다(1:N) 단방향
데이터베이스 입장에서는 무조건 다(N) 쪽에서 외래키를 관리한다.
그런데 이는 일(1)쪽 객체에서 다(N)쪽 객체를 조작(생성/수정/삭제)하는 방법이다.

@Entity
public class Post {
	@Id
    @Column(name = "POST_ID")
    UUID id;
    
    @JoinColumn(name = "BOARD_ID")
    Board board;
    
    //... getter, setter
}

@Entity
public class Board {
	@Id
    UUID id;
    String title;
    
    @OneToMany
    @JoinColumn(name = "POST_ID")
    List<Post> posts = new ArrayList<>();
    //... getter, setter
}

양방향이므로 @OntToMany에서 mappedBy는 없다.
대신 @JoinColumn을 이용해서 조인을 한다.
실제 사용은 아래와 같다.

//...
Post post = new Post();
post.setTitle("가입인사");

entityManager.persist(post); // post 저장

Board board = new Board();
board.setTitle("자유게시판");
board.getPosts().add(post);

entityManager.persist(board); // board 저장
//...

board를 저장할 때 문제가 발생한다.
Borad 엔티티는 Board 테이블에 매핑되기 때문에 Board 테이블에 직접 저장할 수 있으나, Post 테이블의 FK(BOARD_ID)를 저장할 방법이 없기 때문에 조인 및 업데이트 쿼리를 날려야 하는 문제가 있다. 정리하자면 다음과 같다.

단점

  • 일을 수정하려 하는데 다에 관해 쿼리가 발생하는 것

따라서 일대다(1:N) 단방향 연관 관계 매핑이 필요한 경우는 그냥 다대일(N:1) 양방향 연관 관계를 매핑해버리는 게 추후 유지보수에 유리하기에 추천하는 방식이다.

일대다(1:N) 양방향 (실무 사용 금지)
일대다 양방향은 공식적으로 존재하는 건 아니다. 키워드는 `JoinColumn(updatable = false, insertable = false) 정도로 볼 수 있겠으나, 일대다 양방향을 사용해야 할 때는 다대일 양방향을 사용하도록 하는 게 더 좋을 것이다.

✏️ 결과적으로, (특수한 경우가 아니라면) 일대다(1:N) 단방향, 양방향보다는 다대일(N:1) 양방향을 지향하는 것이 좋겠다.

2-3. 일대일(1:1)

게시글(Post)에 첨부파일(Attach)을 반드시 1개만 첨부할 수 있다고 가정한다.

일대일(1:1) 양방향

@Entity
public class Post {
	@Id
    @Column(name = "POST_ID")
    UUID id;
    
    @OneToOne
    @JoinColumn(name = "ATTACH_ID")
    Attach attach;
    //... getter, setter
}

@Entity
public class Attach {
	@Id
    @Column(name = "ATTACH_ID")
    UUID id;
    String name;
    
    @OneToOne(mappleBy = "attach")
    Post post;
    //... getter, setter
}

앞에서 살펴본 것과 비슷하게 단순하다. @OneToOne을 양방향으로 설정하고 mappedBy만 설정해서 읽기 전용으로 만들어주면 된다.

일대일(1:1) 단방향
이는 위에서 정의한대로 한 쪽에서만 관계를 처리하면 되지만, 문제의 여지가 있다.
외래 키를 Post에서 관리하는 게 좋을 것인지 Attach에서 관리하는 게 좋을 것인지 정해야 한다. 즉 테이블 어디에 둘 것인지를 생각해야 한다.

그런데 테이블은 한 번 생성되면 굳어져 변경이 어려운 반면, 비즈니스는 언제든 바뀔 수 있다. 예를 들어, 게시글이 여러 개의 첨부파일을 첨부할 수 있도록 비즈니스가 변경되면 어떻게 될까? 이 경우를 생각하면 다(N)쪽인 Attach 테이블에 외래 키가 있는 것이 변경에 유연할 것이다.
하지만 이것이 무조건 좋지도 않다. 객체 입작에서, Post쪽(1)에서 외래 키를 갖게 되면 Post를 조회할 때마다 이미 Attach의 참조를 갖고 있기 때문에 성능 상 이득이 있다.

그러므로 확장 가능성 등 종합적인 판단에 따라 결정해야 한다.

2-4. 다대다(N:N)

-> 실무 사용 금지 !

  • 중간 테이블이 숨겨져 있기 때문에 자기도 모르는 복잡한 조인의 쿼리가 발생할 수 있다.
  • 다대다로 자동생성된 중간테이블은 두 객체의 외래 키만 저장되기 때문에 문제가 될 확률이 높다. 일반적으로 JPA의 사용에서, 중간 테이블에 외래 키 외에 다른 정보가 들어가는 경우가 많이 때문에 다대다를 일대다 또는 다대일로 풀어서 만드는 것이 추후 변경에도 유연하게 대처할 수 있다.



💡 Join 연산

@Entity
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;
}

위와 같은 Board가 있다고 가정하고, Board의 입장에서 join의 두 가지 케이스를 보자.

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

    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를 사용한 것도 확인할 수 있다.

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

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

    @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);

0개의 댓글