다양한 연관관계 매핑

지난번 포스팅에 이어서 다양한 연관관계 매핑 방법에 대해서 포스팅 하겠습니다.
엔티티의 연관관계를 매핑할 때는 다음 3가지를 고려해야 합니다.

  • 다중성
  • 단방향, 양방향
  • 연관관계의 주인

먼저 연관관게가 있는 두 엔티티가 일대일 관계인지 일대다 관계인지 다중성을 고려해야 합니다. 두 엔티티 중 한쪽만 참조하는 단방향 관계인지 서로 참조하는 양방향 관계인지도 생각을 해봐야 하고, 마지막으로 양방향 관계이면 누가 연관관계의 주인인지도 정해야 합니다. 생각보다 간단해 보이지만… JPA를 사용할때 고려해야 할점이 한두가지가 아닌거 같습니다.

단방향, 양방향

테이블은 객체와는 다르게 외래 키 하나로 조인을 사용해서 양방향으로 쿼리가 가능하므로 사실상 방향이라는 개념이 없습니다. 반면에 객체는 참조용 필드를 가지고 있는 객체만 연관된 객체를 조회할 수 있습니다. 객체 관계에서 한쪽만 참조하는 것을 단방향 관계라고 하고, 양쪽이 서로 참조하는 것을 양방향 관계라고 합니다.

연관관계의 주인

데이터베이스는 외래 키 하나로 두 테이블이 연관관계를 맺습니다. 따라서 테이블의 연관관계를 관리하는 포인트는 외래키 하나입니다. 반면에 엔티티를 양방향으로 매핑하면 A -> B, B -> A 2곳에서 서로를 참조합니다. 따라서 객체의 연관관계를 관리하는 포인트는 2곳 입니다.
JPA에서는 두 객체 연관관계 중 하나를 정해서 데이터베이스 외래 키를 관리하는데 이것을 연관관계의 주인이라고 합니다. 따라서 A -> B 또는 B -> A 둘 중 하나를 정해서 외래 키를 관리해야 합니다. 외래 키를 가진 테이블과 매핑한 엔티티가 외래키를 관리하는게 효율적이므로 보통 이곳을 연관관계의 주인으로 선택합니다. 주인이 아닌 방향은 외래 키를 변경할 수 없고 읽기만 가능합니다.
즉, 객체 그래프 탐색만 가능하다는 거죠. 연관관계의 주인은 mappedBy 속성을 사용하지 않습니다.

다대일

대다일 관계의 반대 방향은 항상 일대다 관계고 일대다 관계의 반대 방향은 항상 다대일 관계입니다. 데이터베이스 테이블의 일(1), 다(N)관계에서 외래 키는 항상 다쪽에 있습니다. 따라서 객체 양방향 관계에서 연관관계의 주인은 항상 다쪽입니다.

다대일 단방향[N:1]

아래 코드를 통해서 회원 엔티티와 팀 엔티티의 다대일 단방향 연관관계를 간단하게 살펴보겠습니다.

@Entity
@NoArgsConstructor
@ToString(exclude = "team")
@Getter @Setter
public class Member {

    @Id
    @Column(name = "MEMBER_ID")
    private String id;

    private String username;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team; //팀의 참조를 보관
}
@Entity
@NoArgsConstructor
@Getter @Setter
public class Team {

    @Id
    @Column(name = "TEAM_ID")
    private String id;

    private String name;
}

회원은 Member.team으로 팀 엔티티를 참조할 수 있지만 반대로 팀에는 회원을 참조하는 필드가 없습니다. 따라서 회원과 팀은 다대일 단방향 연관관계를 가집니다.

@ManyToOne
@JoinColumn(name = "TEAM_ID") // TEAM_ID 외래 키와 매핑
private Team team;

Member.team 필드로 회원 테이블의 TEAM_ID 외래 키를 관리합니다.

다대일 양방향[N:1, 1:N]

이번엔 위의 예제를 다대일 양방향 관계로 살펴보겠습니다.

@Entity
@NoArgsConstructor
@Getter
public class Member {

    @Id
    @Column(name = "MEMBER_ID")
    private String id;

    private String username;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team; 

    public void setTeam(Team team){

        this.team = team;
        //무한루프에 빠지지 않도록 체크합니다.
        if(!team.getMembers().contains(this)){
            team.getMembers().add(this);
        }
    }
}
@Entity
@NoArgsConstructor
@Getter @Setter
public class Team {

    @Id
    @Column(name = "TEAM_ID")
    private String id;

    private String name;

    //컬렉션은 필드에서 바로 초기화 하는것이 안전합니다.
    //nul 문제에서 안전합니다. 하이버네이트는 엔티티를 영속화 할때, 컬렉션을 감싸서 하이버네이트가 제공하는 내장컬렉션으로 변경합니다. 
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    public addMember(Member member){
        this.members.add(member);
        if(member.getTeam() != this){ // 무한루프에 빠지지 않도록 체크함
            member.setTeam(this);
        }
    }
}
  • 양방향은 외래 키가 있는 쪽이 연관관계의 주인입니다.
    일대다와 다대일 연관관계는 항상 다(N)에 외래 키가 있습니다. 여기서는 다쪽인 MEMBER 테이블이 외래 키를 가지고 있으므로 Member.team이 연관관계의 주인입니다. JPA는 외래 키를 관리할 때 연관관계의 주인만 사용합니다. 주인이 아닌 Team.members는 조 회를 위한 JPQL이내 객체 그래프를 탐색할 때 사용합니다.

  • 양뱡향 연관관계는 항상 서로를 참조해야 합니다.
    어느 한 쪽만 참조하면 양방향 연관관계가 성립하지 않습니다. 항상 서로 참조하게 하려면 연관관계 편의 메소드를 작성하는게 좋은데 회 원의 setTeam() , addMember() 메소드가 이런 편의 메소드들입니다. 편의 메소드는 한 곳에만 작성하거나 양쪽 다 작성할 수 있는 데, 양쪽에 다 작성하면 무한루프에 빠지므로 주의해야 합니다. 위의 코드에서는 편의 메소드를 양쪽에다 작성해서 둘 중 하나만 호출하면 됩니다.

일대다

일대다 관계는 다대일의 관계의 반대 방향입니다. 일대다 관계는 엔티티를 하나 이상 참조할 수 있으므로 자바 컬렉션인 Collection, List, Set, Map 중에 하나를 사용해야 합니다.

일대다 단방향 1:N

@Entity
@Getter @Setter
public class Team {

    @Id @GeneratedValue
    @Column(name = "TEAM_ID")
    private String id;

    private String name;

    @OneToMany
    @JoinColumn(name = "TEAM_ID") //MEMBER 테이블의 TEAM_ID (FK)
    private List<Member> members = new ArrayList<>();
}
@Entity
@Setter
@Getter
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private String id;

    private String username;
}

코드를 보면 일대다 단방향 관계는 약간 특이합니다. 팀 엔티티의 Team.members로 회원 테이블의 TEAM_ID 외래 키를 관리합니다. 보통 자신이 매핑한 테이블의 외래 키를 관리하는데, 이 매핑은 반대쪽 테이블에 있는 외래 키를 관리합니다. 그럴 수밖에 없는 것이 일대다 관계에서 외래 키는 항상 다쪽 테이블에 있습니다. 하지만 다 쪽인 Member 엔티티에는 외래 키를 매핑할 수 있는 참조 필드가 없습니다. 대신에 반대쪽인 Team 엔티티에만 참조 필드인 members가 있습니다. 따라서 반대편 테이블의 외래 키를 관리하는 특이한 모습입니다.

일대다 단방향 관계를 매핑할 때는 @JoinColumn을 명시해야 합니다. 그렇지 않으면 JPA는 연결 테이블을 중간에 두고 연관관계를 관리하는 조인 테이블 전략을 기본으로 사용해서 매핑합니다.

일대다 단방향 매핑의 단점은 매핑한 객체가 관리하는 외래 키가 다른 테이블에 있다는 점입니다. 본인 테이블에 외래 키가 있으면 엔티티 의 저장과 연관관계 처리가를 INSERT SQL 한 번으로 끝낼 수 있지만, 다른 테이블에 외래 키가 있으면 연관관계 처리를 위한 UPDATE SQL을 추가로 실행해야 합니다.

Member 엔티티는 Team 엔티티를 모릅니다. 그리고 연관관계에 대한 정보는 Team 엔티티의 members가 관리합니다. 따라서 Member 엔티티를 저장할 때는 MEMBER 테이블의 TEAM_ID 외래 키에 아무 값도 저장되지 않습니다. 대신 Team 엔티티를 저장할 때 Team.members의 참조 값을 확인해서 회원 테이블에 있는 TEAM_ID 외래 키를 업데이트 합니다.

이것만 보면… 굳이 이렇게 일대다 단방향 매핑보다는 다대일 양방향 매핑을 하는게 훨씬 편하고 간편하니… 다대일 양방향 매핑을 사용합시다.

일대다 양방향 1:N, N:1

일대다 양방향 매핑은 존재하지 않습니다. 대신 다대일 양방향 매핑을 사용해야 합니다. 더 정확히 말하자면 양방향 매핑에서 @OneToMany는 연관관계의 주인이 될 수 없습니다.
관계형 데이터베이스 특성상 일대다. 다대일 관계는 항상 다 쪽에 외래 키가 있습니다. 이런 이유로 @ManyToOne에는 mappedBy 속성이 없습니다.

일대일

일대일 관계는 양쪽이 서로 하나의 관계만 가집니다. 예를 들어 회원은 하나의 사물함만 사용하고 사물함도 하나의 회원에 의해서만 사용됩니다.

일대일 관계의 특징

  • 일대일 관계는 그 반대도 일대일 관계입니다.
  • 테이블 관계에서 일대다, 다대일은 항상 다(N)쪽이 외래 키를 가집니다. 반면에 일대일 관계는 주 테이블이나 대상 테이블 둘 중 어느곳 이나 외래 키를 가질 수 있습니다.
  1. 주테이블의 외래 키
    주 객체가 대상 객체를 참조하는 것처럼 주 테이블에 외래 키를 두고 대상 테이블을 참조합니다. 외래 키를 객체 참조와 비슷하게 사용 할 수 있어서 객체지향 개발자들이 선호합니다. 이 방법의 장점은 주 테이블이 외래 키를 가지고 있으므로 주 테이블만 확인해도 대상 테이블과 연관관계가 있는지 알 수 있습니다.

  2. 대상 테이블에 외래 키
    전통적인 데이터베이스 개발자들은 보통 대상 테이블에서 외래 키를 두는 것을 선호합니다. 이 방법의 장점은 테이블의 관계를 일대일에 서 일대다로 변경할 때 테이블 구조를 그대로 유지할 수 있습니다.

일대일 단방향 1:1

// 일대일 주 테이블에 외래 키, 단방향 매핑 코드
@Entity
@Getter
@Setter
public class User {

    @Id @GeneratedValue
    @Column(name = "user_id")
    private String id;

    private String username;

    @OneToOne
    @JoinColumn(name = "locker_id")
    private Locker locker;   
}
@Entity
@Getter @Setter
public class Locker {

    @Id @GeneratedValue
    @Column(name = "locker_id")
    private Long id;

    private String name;

}

일대일 관계이므로 객체 매핑에 @OneToOne을 사용했습니다. 참고로 이 관계는 다대일 단방향과 거의 비슷합니다.

일대일 양방향 1:1

// 일대일 주 테이블에 외래 키, 양방향 매핑 코드
@Entity
@Getter
@Setter
public class User {

    @Id @GeneratedValue
    @Column(name = "user_id")
    private String id;

    private String username;

    @OneToOne
    @JoinColumn(name = "locker_id")
    private Locker locker;   
}
@@Entity
@Getter @Setter
public class Locker {

    @Id @GeneratedValue
    @Column(name = "locker_id")
    private Long id;

    private String name;

    @OneToOne(mappedBy = "locker") // 연관관계의 주인이 아님을 선언
    private Member member;
}

양방향이므로 연관관계의 주인을 정해야 합니다.

다대다

관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없습니다. 그래서 보통 다대다 관계를 일대다, 다대일 관계로 풀어내는 연결 테이블을 사용합니다.
예를 들어 회원들은 상품을 주문합니다. 반대로 상품들은 회원들에 의해 주문됩니다. 둘은 다대다 관계입니다.

스크린샷 2019-10-04 오전 2 42 15

필자가 아이패드로(깨알자랑) 다대다 관계를 그려봤습니다… 악필이라… 이해해주세요.

그래서 아래 그림처럼 중간에 연결 테이블을 추가해야 합니다. Member_Product 연결 테이블을 추가했습니다. 이 테이블은 사용해서 다대다 관계를 일대다, 다대일 관계로 풀어 낼 수 있습니다. 이 연결 테이블은 회원이 주문한 상품을 나타냅니다.

스크린샷 2019-10-04 오전 2 42 23

그런데 객체는 테이블과 다르게 객체 2개로 다대다 관계를 만들 수 있습니다. 예를들어 회원 객체는 컬렉션을 사용해서 상품들을 참조하면 되고 반대로 상품들도 컬렉션을 사용해서 회원들을 참조하면 됩니다.
@ManyToMany를 사용하면 다대다 관계를 편리하게 매핑할 수 있습니다.

다대다 단방향 N:N

마찬가지로 예제로 한번 보겠습니다.

@Entity
@Getter
@Setter
public class User {

    @Id @GeneratedValue
    @Column(name = "user_id")
    private String id;

    private String username;

    @ManyToMany
    @JoinTable(name = "MEMBER_PRODUCT", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns =     @JoinColumn(name = "product_id"))
    List<Product> products = new ArrayList<Product>();
}
@Entity
@Getter
@Setter
public class Product {

    @Id
    @JoinColumn(name = "product_id")
    private String id;

    private String name;
}

위의 코드에서 회원 엔티티와 상품 엔티티를 @ManyToMany로 매핑했습니다. 여기서 중요한 점은 @ManyToMany와 @JoinTable을 사용해서 연결 테이블로 바로 매핑한 것입니다. 따라서 회원과 상품을 연결하는 회원_상품 엔티티 없이 매핑을 완료할 수 있습니다.

  • @JoinTable.name: 연결 테이블을 지정합니다. 여기서는 MEMBER_PRODUCT 테이블을 선택했습니다.

  • @JoinTable.joinColumns: 현재 방향인 회원과 매핑할 조인 컬럼 정보를 지정합니다. MEMBER_ID로 지정했음

  • @JoinTable.inverseJoinColumns:반대 방향인 상품과 매핑할 조인 컬럼 정보를 지정합니다. PRODUCT_ID로 지정했음

MEMBER_PRODUCT 테이블은 다대다 관계를 일대다, 다대일 관계로 풀어내기 위해 필요한 연결 테이블 뿐입니다. @ManyToMany로 매핑한 덕분에 다대다 관계를 사용할 때는 이 연결 테이블에 신경을 쓰지 않아도 됩니다.

@Test
// 각 메소드 마다 트랜잭션을 걸어준다.
// 시작점에 transaction.begin(), 끝점에 transaction.commit()이 달린다고 생각하자.
@Transactional
@Rollback(false)
public void manyToManyTest() {

    Product product = new Product();
    product.setId("productA");
    product.setName("상품A");
    em.persist(product);

    User user = new User();
    user.setId("member1");
    user.setUsername("회원1");
    user.getProducts().add(product);
    em.persist(user);
}

결과 값:

스크린샷 2019-10-04 오전 2 42 35

// 탐색
    @Test
    @Transactional
    @Rollback(false)
    public void find() {
        User user = em.find(User.class,"member1" );
        List<Product> products = user.getProducts();
        for (Product product : products) {
            System.out.println("Product.name = " +product.getName());
        }
 }

다대다 양방향 N:N

다대다 양방향은 역방향에도 @ManyToMany를 사용합니다. 그리고 양쪽 중 원하는 곳에 mappedBy로 연관관계의 주인을 지정합니다.

@Entity
public class Product{

    @Id
    private String id;

    @ManyToMany(mappedBy = "products") // 역방향 추가
    private List<Member> members;

}

다대다 양방향 연관관계는 다음처럼 설정하면 됩니다.
member.getProducts().add(product);
product.getMembers().add(member);

또한 연관관계 편의 메소드를 추가해서 관리하는 것이 편리합니다.

public void addProduct(Product product){

    products.add(product);
    product.getMembers().add(this);
}
출저: 자바 ORM 표준 JPA 프로그래밍

+ Recent posts