다양한 연관관계 매핑

김준석·2021년 1월 23일
1
post-thumbnail

이번 장에서 공부할 것은 다중성과 단방향, 양방향을 고려한 가능한 모든 연관관계이다.

  • 다대일: 단방향, 양방향
  • 일대다: 단방향, 양방향
  • 일대일: 주 테이블 단방향, 양방향
  • 일대일: 대상 테이블 단방향, 양방향
  • 다대다: 단방향, 양방향

왼쪽이 연관관계 주인으로 생각하면서 설명하겠다. (예: 다대일 양방향이면 다(N)가 연관관계 주인)

1. 다대일

다대일 단방향 [N:1]

Member 엔티티

@Entity
public class Member {
    
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    private String username;
    
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    
    // ...
}

Team 엔티티

@Entity
public class Team {
    
    @Id
    @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
    
    private String name;
    
    // ...
}

Member.team 필드를 TEAM_ID 외래 키와 매핑했다. 따라서 Member.team 필드로 MEMBER 테이블의 TEAM_ID 외래키를 관리한다.

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

Member 엔티티

@Entity
public class Member {
    
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    private String username;
    
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    
    public void serTeam(Team team) {
        this.team = team;
        
        // 무한루프에 빠지지 않도록 체크
        if (!team.getMembers().contains(this)) {
            team.getMembers().add(this);
        }
    }
    
    // ...
}

Team 엔티티

@Entity
public class Team {
    
    @Id
    @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
    
    private String name;
    
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<Member>();
    
    public void addMember(Member member) {
        this.members.add(member);
        
        // 무한루프에 빠지지 않도록 체크
        if (member.getTeam() != this) {
            member.setTeam(this);
        }
    }
    
    // ...
}
  • 양방향은 외래 키가 있는 쪽이 연관관계 주인
    • 일대다와 다대일 연관관계는 항상 다(N)에 외래 키
    • JPA는 외래 키를 관리할 때 연관관계의 주인만 사용
    • 주인이 아닌 쪽은 조회를 위한 JPQL이나 객체 그래프 탐색에 사용
  • 양방향 연관관계는 항상 서로를 참조
    • 연관관계 편의 메서드 작성
    • 편의 메서드는 한 곳 또는 양쪽 다 작성할 수 있는데 양쪽에 작성 시 무한루프에 빠질 수 있으니 주의

2. 일대다

일대다 단방향 [1:N]

Team 엔티티

@Entity
public class Team {
    
    @Id
    @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
    
    private String name;
    
    @OneToMany
    @JoinColumn(name = "TEAM_ID") // MEMBER 테이블의 TEAM_ID (FK)
    private List<Member> members = new ArrayList<Member>();
    
    // ...
}

Member 엔티티

@Entity
public class Member {
    
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    private String username;
    
    // ...
}
  • 일대다 단방향 관계를 매핑할 때는 @JoinColumn을 명시
  • Team.members로 회원 테이블의 TEAM_ID를 관리

일대다 단방향 매핑의 단점

매핑한 객체가 관리하는 외래 키가 다른 테이블에 있다는 점이다. 다대일처럼 본인 테이블에 외래 키를 관리하고 있다면 연관관계 처리를 할 때 INSERT SQL 한 번으로 끝낼 수 있지만, 다른 테이블이 외래 키를 관리하고 있으면 UPDATE SQL을 추가로 실행한다.

Member member1 = new Member("member1");
Member member2 = new Member("member2");

Team team1 = new Team("team1");
team1.getMembers().add(member1);
team1.getMembers().add(member2);

em.persist(member1); // INSERT member1
em.persist(member2); // INSERT member2
em.persist(team1); // INSERT team1, UPDATE member1.fk, UPDATE member2.fk

transaction.commit();

Member 엔티티는 Team을 모른다. 따라서 Member가 저장될 때 MEMBER 테이블의 TEAM_ID 외래 키에 아무 값도 저장되지 않는다. 대신 Team 엔티티를 저장할 때 Team.members 참조하여 MEMBER 테이블에 있는 member1, member2의 TEAM_ID 외래 키를 업데이트한다.

성능 문제도 있지만 관리도 부담스러우니 일대다 단방향보다는 다대일 양방향 매핑을 사용하자.

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

일대다 양방향이나 다대일 양방향이나 같은 말이지만 앞의 예시와 다른 점은 연관관계 주인이 다르다.

앞에서 설명할 때 양방향 관계에서 연관관계 주인이 아니면 mappedBy 설정을 사용하여 주인이 아님을 나타내야 한다고 했다. 하지만 현재 주인이 아닌 쪽은 Member 엔티티인데 @ManyToOne 속성에는 mappedBy가 없다. 그럼 불가능한 것일까?

불가능한 것은 아니다. 일대다 단방향 매핑 반대편에 같은 외래 키를 사용하는 다대일 단방향 매핑을 읽기 전용으로 하나 추가하면 된다.

Team 엔티티

@Entity
public class Team {
    
    @Id
    @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
    
    private String name;
    
    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<Member>();
    
    // ...
}

Member 엔티티

@Entity
public class Member {
    
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    private String username;
    
    @ManyToOne
    @JoinColumn(name = "TEAM_ID", insertable = false, updatable = false)
    private Team team;
    
    // ...
}

일대다 단방향 매핑 반대편에 다대일 단방향 매핑을 추가하게 되면 둘 다 같은 키를 관리하게 된다. 그러면 문제가 발생될 수 있다. 따라서 다대일 쪽은 insertable = false, updatable = false 속성을 추가하여 읽기 전용으로 만든다.

일대다 양방향 매핑 역시 일대다 단방향 매핑의 단점을 가지고 있기 때문에 될 수 있으면 다대일 양방향 매핑을 사용하도록 하자.

3. 일대일 [1:1]

특징

  • 일대일 관계는 그 반대도 일대일 관계
  • 일대일 관계는 주 테이블이나 대상 테이블 둘 중 어느 곳이나 외래 키를 가질 수 있음(일대다, 다대일 관계에서는 항상 다(N)쪽이 외래 키를 가짐).

주 테이블이 외래 키를 가질 때

  • 주 테이블에 외래 키를 두고 대상 테이블을 참조
  • 객체지향 개발자들이 선호
  • 주 테이블이 외래 키를 가지고 있으므로 주 테이블만 확인해도 대상 테이블과 연관관계가 있는지 알 수 있음

대상 테이블이 외래 키를 가질 때

  • 전통적인 데이터베이스 개발자들이 선호
  • 테이블 관계를 일대일에서 일대다로 변경할 때 테이블 구조를 그대로 유지할 수 있음

주 테이블에 외래 키

단방향

Member 엔티티

@Entity
public class Member {
    
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    private String username;
    
    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;
    
    // ...
}

Locker 엔티티

@Entity
public class Locker {
    
    @Id
    @GeneratedValue
    @Column(name = "LOCKER_ID")
    private Long id;
    
    private String name;
    
    // ...
}

일대일 관계에서는 @OneToOne을 사용한다.

양방향

Member 엔티티

@Entity
public class Member {
    
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    private String username;
    
    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;
    
    // ...
}

Locker 엔티티

@Entity
public class Locker {
    
    @Id
    @GeneratedValue
    @Column(name = "LOCKER_ID")
    private Long id;
    
    private String name;
    
    @OneToOne(mappedBy = "locker")
    private Member member;
    
    // ...
}

양방향이기에 연관관계 주인을 정해야 한다. MEMBER 테이블이 외래 키를 가지고 있으므로 Member.locker가 연관관계 주인이고, Locker.member는 mappedBy를 사용해서 주인이 아님을 나타냈다.

대상 테이블에 외래 키

단방향

JPA에서 일대일 관계 중 대상 테이블에 외래 키가 있는 단방향 관계는 지원하지 않는다. 단방향 관계를 Locker → Member 또는 Locker를 연관관계 주인으로 설정한 양방향 관계로 수정해야 한다.

양방향

Member 엔티티

@Entity
public class Member {
    
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    private String username;
    
    @OneToOne(mappedBy = "member")
    private Locker locker;
    
    // ...
}

Locker 엔티티

@Entity
public class Locker {
    
    @Id
    @GeneratedValue
    @Column(name = "LOCKER_ID")
    private Long id;
    
    private String name;
    
    @OneToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;
    
    // ...
}

일대일 매핑에서 대상 테이블에 외래 키를 두고 싶으면 이렇게 하면 된다. 대상 엔티티 Locker를 연관관계의 주인으로 만들고 LOCKER 테이블의 외래 키를 관리하도록 했다.

4. 다대다 [N:N]

관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다. 그래서 보통 다대다 관계를 일대다, 다대일 관계로 풀어내는 연결 테이블을 사용한다.

테이블과 달리 객체는 @ManyToMany 어노테이션과 컬렉션을 사용해서 회원은 상품, 상품은 회원을 참조하게 만들 수 있다.

다대다: 단방향

Member 엔티티

@Entity
public class Member {
    
    @Id
    @Column(name = "MEMBER_ID")
    private String id;
    
    private String username;
    
    @ManyToMany
    @JoinTable(name = "MEMBER_PRODUCT", // 다대다 관계를 풀어내기 위해 필요한 연결 테이블
            joinColumns = @JoinColumn(name = "MEMBER_ID"),
            inverseJoinColumns = @JoinColumn(name = "PRODUCT_ID"))
    private List<Product> products = new ArrayList<Product>();
    
    // ...
}

Product 엔티티

@Entity
public class Product {
    
    @Id
    @Column(name = "PRODUCT_ID")
    private String id;
    
    private String name;
    
    // ...
}

@JoinTable 속성

  • name
    • 연결 테이블 지정 (MEMBER_PRODUCT)
  • joinColumns
    • 현재 방향인 회원과 매핑할 조인 컬럼 정보 지정 (MEMBER_ID)
  • inverseJoinColumns
    • 반대 방향인 상품과 매핑할 조인 컬럼 정보 지정 (PRODUCT_ID)

다대다: 양방향

Member 엔티티

@Entity
public class Member {
    
    @Id
    @Column(name = "MEMBER_ID")
    private String id;
    
    private String username;
    
    @ManyToMany
    @JoinTable(name = "MEMBER_PRODUCT",
            joinColumns = @JoinColumn(name = "MEMBER_ID"),
            inverseJoinColumns = @JoinColumn(name = "PRODUCT_ID"))
    private List<Product> products = new ArrayList<Product>();
    
    // ...
}

Product 엔티티

@Entity
public class Product {
    
    @Id
    @Column(name = "PRODUCT_ID")
    private String id;
    
    @ManyToMany(mappedBy = "products") // 역방향 추가
    private List<Member> members;
    
    private String name;
    
    // ...
}

양방향이니까 무엇이 필요할까? 앞에서 설명했던 편의 메서드다.

public void addProduct(Product product) {
    // ...
    products.add(product);
    product.getMembers().add(this);
}

다대다: 매핑의 한계와 극복, 연결 엔티티 사용

@ManyToMany를 사용하면 연결 테이블을 자동으로 처리해주므로 도메인 모델이 단순해지고 여러 가지로 편리하다. 하지만 이 매핑을 실무에서 사용하기에는 한계가 있다.

이처럼 주문 수량과 주문 날짜 컬럼을 추가한다고 하자. 그러면 더는@ManyToMany를 사용할 수 없을 것이다. Member 엔티티나 Product 엔티티에는 주문 수량과 주문 날짜 컬럼을 매핑할 수 없기 때문이다. 그러면 엔티티 관계도 테이블 관계처럼 풀어야 한다.

Member 엔티티

@Entity
public class Member {
    
    @Id
    @Column(name = "MEMBER_ID")
    private String id;
    
    // 역방향
    @OneToMany(mappedBy = "member")
    private List<MemberProduct> memberProducts;
    
    // ...
}

Product 엔티티

@Entity
public class Product {
    
    @Id
    @Column(name = "PRODUCT_ID")
    private String id;
    
    private String name;
    
    // ...
}

MemberProduct 엔티티

@Entity
@IdClass(MemberProductId.class)
public class MemberProduct {
    
    @Id
    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;
    
    @Id
    @ManyToMany
    @JoinColumn(name = "PRODUCT_ID")
    private Product product;
    
    // ...
}

MemberProduct 식별자 클래스

public class MemberProductId implements Serializable {
    
    private String member;
    private String product;
    
    // hashCode and equals
}

MemberProduct 식별자 클래스는 무엇일까? MemberProduct 엔티티는 기본 키가 MEMBER_ID와 PRODUCT_ID로 이루어진 복합 기본 키(줄여서 복합 키)다. JPA에서 복합 키를 사용하려면 별도의 식별자 클래스(MemberProductId.class)를 만들어야 한다. 복합 키 매핑은 @IdClass로 매핑한다.

식별자 클래스 특징

  • 복합 키는 별도의 식별자 클래스로 만듦
  • Serializable을 구현
  • hashCode와 equals 메서드 구현
  • 기본 생성자가 있음
  • 식별자 클래스는 public
  • @IdClass이외에 @EmbeddedId도 있음

회원상품은 회원과 상품의 기본 키를 받아서 자신의 기본 키로 사용한다. 이렇게 부모 테이블의 기본 키를 받아서 자신의 기본 키 + 외래 키로 사용하는 것을 데이터베이스 용어로 식별 관계(Identifying Relationship)라 한다.

다대다: 새로운 기본 키 사용

추천하는 기본 키 생성 전략은 데이터베이스에서 자동으로 생성해주는 대리 키를 Long 값으로 사용하는 것이다. 이것의 장점은 간편하고 거의 영구히 쓸 수 있으며 비즈니스에 의존하지 않는다.

새로운 기본 키를 사용해보자. MemberProduct보다 Order가 더 자연스러운 것 같아서 바꿨다.

Order 엔티티

@Entity
public class Order {
    
    @id
    @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;
    
    @ManyToOne
    @JoinColumn(name = "PRODUCT_ID")
    private Product product;
    
    // ...
}

다대다 연관관계 정리

연결 테이블을 어떻게 구성할지 선택

  • 식별 관계
    • 받아온 식별자를 기본 키 + 외래 키
  • 비식별 관계
    • 받아온 식별자는 외래 키로만 사용
    • 새로운 식별자 추가
profile
내 몸에는 꼰대의 피가 흐른다.

5개의 댓글

comment-user-thumbnail
2021년 2월 4일

이집 잘하네

2개의 답글
comment-user-thumbnail
2021년 2월 5일

이집 맛집이네

1개의 답글