이번 장에서 공부할 것은 다중성과 단방향, 양방향을 고려한 가능한 모든 연관관계이다.
왼쪽이 연관관계 주인으로 생각하면서 설명하겠다. (예: 다대일 양방향이면 다(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;
// ...
}
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 외래키를 관리한다.
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);
}
}
// ...
}
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
을 명시일대다 단방향 매핑의 단점
매핑한 객체가 관리하는 외래 키가 다른 테이블에 있다는 점이다. 다대일처럼 본인 테이블에 외래 키를 관리하고 있다면 연관관계 처리를 할 때 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 외래 키를 업데이트한다.
성능 문제도 있지만 관리도 부담스러우니 일대다 단방향보다는 다대일 양방향 매핑을 사용하자.
일대다 양방향이나 다대일 양방향이나 같은 말이지만 앞의 예시와 다른 점은 연관관계 주인이 다르다.
앞에서 설명할 때 양방향 관계에서 연관관계 주인이 아니면 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
속성을 추가하여 읽기 전용으로 만든다.
일대다 양방향 매핑 역시 일대다 단방향 매핑의 단점을 가지고 있기 때문에 될 수 있으면 다대일 양방향 매핑을 사용하도록 하자.
특징
주 테이블이 외래 키를 가질 때
대상 테이블이 외래 키를 가질 때
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 테이블의 외래 키를 관리하도록 했다.
관계형 데이터베이스는 정규화된 테이블 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 속성
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
로 매핑한다.
식별자 클래스 특징
@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;
// ...
}
연결 테이블을 어떻게 구성할지 선택
이집 잘하네