엔티티들은 대부분 다른 엔티티와 연관관계가 있다. 하지만 ORM vs SQL Mapper vs JDBC에서 볼 수 있듯이 객체와 테이블은 서로 다른 방식으로 연관관계를 맺는다. 객체의 경우 참조를 통해 관게를 맺고 테이블은 외래 키를 사용해서 관계를 맺는다. ORM에서는 객체 연관관계와 테이블 연관관계 매핑의 패러다임 불일치를 해결해주는 역할을 한다.
연관관계의 주요 개념
연관관계 매핑을 위한 3가지의 주요 개념이 있다.
- 방향: 단방향, 양방향이 있다. 예를 들어 회원과 팀이 있다면 회원 팀 또는 팀 → 회원 또는 회원 → 팀 둘 중 한 쪽만 참조하고 있는 것을 단방향 관계라고 한다. 그리고 팀 → 회원 과 회원 → 팀 서로 참조하고 있는 것을 양방향 관계라 한다. 방향은 객체관계에만 존재하고 테이블 관계는 외래키로 관계를 맺기 때문에 항상 양방향이다. 예를 들어 회원과 팀이 있을 때 객체의 경우
member.getTeam()
으로 회원은 팀을 알 수 있지만team.getMembers()
로 팀은 회원을 알 수 없기 때문에 단방향이다. 테이블의 경우TEAM_ID
외래 키 하나로MEMBER JOIN TEAM
과TEAM JOIN MEMBER
가 가능하기 때문에 양방향이다. - 다중성: 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:N)가 있다. 예를 들어 회원과 팀이 관계가 있을 때 여러 회원은 한 팀에 속하므로 다대일 관계다. 반대로 한 팀에는 여러 회원이 있기 때문에 일대다 관계다.
- 연관관계의 주인: 두 객체 연관관계 중 하나를 정해서 테이블의 외래 키를 관리하는데 외래키를 관리하는 객체를 연관관계의 주인이라 한다.
객체의 양방향 연관관계
사실 객체에는 양방향 연관관계라는 것이 존재하지 않는다. 서로 다른 단방향 연관관계 2개를 애플리케이션 로직으로 잘 묶어서 양방향인 것처럼 보이게 하는 것이다. 반면에 데이터베이스 테이블은 외래 키 하나만으로 양방향 연관관계를 맺을 수 있다. 그럼 객체의 서로 다른 단방향 연관관계 2개 중 어떤 것을 사용해서 외래키를 관리해야 될까? 이를 해결하기위해 JPA는 두 연관관계 중 하나를 정해서 테이블의 외래키를 관리하게 되는데 이를 연관관계의 주인이라한다. 이제 연관관계의 주인이 어떤 것인지 좀 더 이해할 수 있을 것이다. 연관관계의 주인을 정한다는 것을 쉽게 표현하면 외래 키 관리자를 선택한다고도 표현할 수 있을 것이다.
그럼 연관관계의 주인은 누가 되어야할까? 아래의 예시를 보자.
class Member {
@ManyToOne
@JoinColumn(name="TEAM_ID")
private Team team;
}
class Team {
@OneToMany
private List<Member> members = new ArrasyList<Member>();
}
아직 어노테이션의 의미는 이해하지 않아도 된다. 중요한 것은 두 객체가 서로를 참조하여 단방향 연관관계를 2개를 만들었다는 점이다. 여러 회원은 하나의 팀이 될 수 있고 하나의 팀은 여러 회원이 있을 수 있다. 데이터베이스의 테이블은 항상 다대일에서 다 쪽에 외래 키를 가지게 되므로 위 예시의 경우에는 Member
가 외래 키를 가지게된다. 즉 항상 ‘다’ 쪽이 연관관계의 주인이 된다. 연관관계의 주인이 정해지면 반대편은 가짜 매핑이 되기때문에 읽기만 가능하고 외래 키 변경이 불가능하다. 연관관계의 주인쪽만 외래 키 관리가 가능하다. 즉, 위 예시의 경우 아래의 코드를 작성해도 데이터베이스에 저장할 때 무시된다.
team1.getMembers().add(member1);
team1.getMembers().add(member2);
아래는 데이터베이스에 저장된다.
member1.setTeam(team1);
member2.setTeam(team1);
그러면 연관관계의 주인만 값을 저장하고 주인이 아닌 곳에는 값을저장하지 않아도 될까? 사실 객체 관점에서 양쪽 방향에 모두 값을 입력해주는 것이 가장 안전하다. JPA를 사용하지 않는 순수한 객체 상태에서 심각한 문제가 발생할 수 있기 때문이다. 메번 두 메소드를 모두 호출하면 실수로 하나만 호출할 수도 있기때문에 다음과 같이 setTeam()
메소드를 정의한다. 이렇게 한 번에 양방향 관계를 설정하는 메소드를 연관 관계 편의 메소드라고 부른다.
public void setTeam(Team team) {
if (this.team != null) {
this.team.getMembers().remove(this);
}
this.team = team;
team.getMembers().add(this);
}
처음 조건문의 의미는 기존에 연관관계가 연결된 팀이 있었다면 Team
입장에서도 연관관계를 지우는 과정이다. 만약 이 과정을 하지 않는다면 영속성 컨텍스트가 아직 살아있는 상태에서 Team
의 getMembers()
메소드를 호출한다면 기존에 있는 회원도 포함되어 반환된다.
매핑해보기
연관관계 매핑에 필요한 개념을 모두 이해했으니 직접 매핑해보자.
다대일
단방향
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "TEAM_ID")
private Long id;
private String name;
}
@JoinColumn(name = "TEAM_ID")
를 사용해서 Member.team
필드가 TEAM_ID
외래 키와 매핑되었다.
양방향
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
public Team getTeam() {
return team;
}
public void setTeam(Team team) {
if (this.team != null) {
this.team.getMembers().remove(this);
}
this.team = team;
if (!team.getMembers().contains(this)) { // 무한루프에 빠지지 않도록 체크
team.getMembers().add(this);
}
}
}
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "member")
private List<Member> members = new ArrayList<>();
public List<Member> getMembers() {
return members;
}
public void addMember(Member member) {
this.members.add(member);
if (member.getTeam() != this) { // 무한루프에 빠지지 않도록 체크
member.setTeam(this);
}
}
}
@OneToMany(mappedBy = "member")
를 사용해서 자신이 연관관계 주인이 아님을 알려주고, 연관관계 주인에 해당하는 필드를 명시한다. 양방향 연관과계는 항상 서로 참조된 상태어야 하도록 유의해야된다. setTeam()
과 addMember()
메소드를 참고해서 무한루프에 빠지지않게 주의하며 서로 참조되도록 구현을 한다.
일대다
단방향
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_ID")
private Long id;
private String username;
}
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany
@JoinColumn(name = "TEAM_ID")
private List<Member> members = new ArrayList<>();
}
다대일 양방향때와는 다르게 mappedBy
가 아닌 @JoinColumn(name = "TEAM_ID")
로 반대쪽 테이블의 외래 키를 지정한다. 일대다인 경우, @JoinColumn
을 붙이지 않으면 JPA는 연결 테이블을 중간에 두고 연관관계를 관리하는 조인 테이블 전략을 기본으로 사용해서 매핑하기때문에 반드시 붙여야된다. 일대다 단방향의 경우 반대쪽 테이블의 외래 키를 지정하기때문에 단점이 있다. 연관관계를 처리하기 위해 UPDATE SQL이 추가로 실행해야 하기 때문이다. 성능 문제와 관리 문제가 발생하는 것이다. 따라서 가능하면 일대다 단방향 매핑보다는 다대일 양방향 매핑을 사용하는 것이 좋다.
양방향
일반적으로 일대다 양방향이 아닌 다대일 양방향을 사용한다. 억지로 일대다 양방향을 만든다면 일대다 단방향 매핑 반대편에 같은 외래 키를 사용하는 다대일 단방향 매핑을 읽기 전용으로 하나 추가하면 된다. 하지만 앞에서 설명한 일대다 단반향의 성능 문제와 관리 문제로인해 다대일 양방향을 사용하는 것을 권장한다.
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID", insertable = false, updatable = false)
private Team team;
}
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany
@JoinColumn(name = "TEAM_ID")
private List<Member> members = new ArrayList<>();
}
일대일
일대일의 경우는 주 테이블과 대상 테이블 중 어디든 외래 키를 둘 수 있다. 주 테이블에 외래 키를 두면 주 테이블만 확인해도 대상 테이블과 연관관계가 있는지 알 수 있다. 외래 키를 객체 참조와 비슷하게 사용할 수 있어서 객체지향 개발자들이 선호한다. 대상 테이블에 외래 키를 두면 테이블 관계를 일대일에서 일대다로 변경할 때 테이블 구조를 그대로 둘 수 있다는 장점이 있다. 전통적인 데이터베이스 개발자들은 보통 대상 테이블에 외래 키를 두는 것을 선호한다.
주 테이블에 외래 키
- 단방향
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@OneToOne
@JoinColumn(name = "LOCKER_ID")
private Locker locker;
}
@Entity
public class Locker {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "LOCKER_ID")
private Long id;
private String name;
}
@JoinColumn(name = "LOCKER_ID")
로 인해 MEMBER 테이블에 LOCKER_ID
를 외래 키로 가지게 된다.
양방향
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@OneToOne
@JoinColumn(name = "LOCKER_ID")
private Locker locker;
}
@Entity
public class Locker {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "LOCKER_ID")
private Long id;
private String name;
@OneToOne(mappedBy = "locker")
private Member member;
}
@OneToOne(mappedBy = "locker")
를 통해 자신이 연관관계의 주인이 아니라고 설정하면 된다.
대상 테이블에 외래 키
일대일 관계 중 대상 테이블에 외래 키가 있는 단방향 관계는 JPA에서 지원하지 않는다.
양방향
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@OneToOne(mappedBy = "member")
private Locker locker;
}
@Entity
public class Locker {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "LOCKER_ID")
private Long id;
private String name;
@OneToOne
@JoinColumn(name = "MEMBER_ID")
private Member member;
}
주 테이블에 외래 키의 양방향을 반대로 지정하면 된다.
다대다
관계형 데이터베이스에서는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다. 그래서 보통 다대다 관계를 일대다, 다대일 관계로 풀어내는 열결 테이블을 사용한다.
단방향
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_ID")
private Long 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<>();
}
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "PRODUCT_ID")
private Long id;
private String name;
}
@ManyToMany
와 @JoinTable
을 사용해서 연결 테이블을 바로 매핑했다. @JoinTable
의 속성은 다음과 같다.
@JoinTable.name
: 연결 테이블의 이름을 지정한다.@JoinTable.joinColumns
: 현재 방향의 매핑할 조인 컬럼 정보를 지정한다.@JoinTalble.inverseJoinColumns
: 반대 방향의 조인 컬럼 정보를 지정한다.
@ManyToMany
을 사용하면 연결 테이블을 자동으로 처리해주므로 신경쓸 필요가 없어진다.
양방향
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_ID")
private Long 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<>();
}
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "PRODUCT_ID")
private Long id;
private String name;
@ManyToMany(mappedBy = "products")
private List<Member> members = new ArrayList<>();
}
다대다의 경우도 mappedBy
속성으로 연관관계의 주인이 아니라는 것을 지정할 수 있다. 이 경우에도 연관관계 편의 메소드를 추가하는 것이 좋다. 하지만 @ManyToMany
를 사용하는 방법에는 한계가 있다. 연결 테이블에 추가적인 컬럼을 넣을 수 없다. 이런 경우는 별도의 엔티티를 만들어서 다대다에서 일대다, 다대일 관계로 풀어야된다. 일단 Member
는 아래와 같다.
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_ID")
private String id;
private String username;
@OneToMany(mappedBy = "member")
private List<MemberProduct> products = new ArrayList<>();
}
연결 테이블을 나타내는 엔티티는 식별자를 어떻게 구성할지에 선택하여 2가지 방법이 있다.
-
식별 관계: 받아온 식별자를 기본 키 + 외래 키로 사용한다.
@Entity @IdClass(MemberProductId.class) public class MemberProduct { @Id @ManyToOne @JoinColumn(name = "MEMBER_ID") private Member member; @Id @ManyToOne @JoinColumn(name = "PRODUCT_ID") private Product product; private int orderAmount; } public class MemberProductId implements Serializable { private String member; private String product; @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } MemberProductId that = (MemberProductId) o; return Objects.equals(member, that.member) && Objects .equals(product, that.product); } @Override public int hashCode() { return Objects.hash(member, product); } }
@IdClass
를 사용해서 복합 기본 키를 매핑했다. JPA에서는 복합 기본 키를 사용하려면 별도의 식별자 클래스를 만들어야한다. 복합 기본 키를 위한 식별자 클래스는 다음과 같은 특징이 있다.public
인 빈 생성자가 있어야한다.Serializable
을 구현해야 한다.equals()
와hashCode()
메소드를 구현해야 한다.- 클래스가
public
이어야 한다. @IdClass
를 사용하는 방법 외에@EmbeddedId
를 사용하는 방법도 있다.
위와 같이 복합 키를 사용하면 식별자 클래스를 만드는 증의 번거로움이 있다.
-
비식별 관계: 받아온 식별자는 외래 키로만 사용하고 새로운 식별자를 추가한다.
@Entity
public class MemberProduct {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "MEMBER_ID")
private Member member;
@ManyToOne
@JoinColumn(name = "PRODUCT_ID")
private Product product;
private int orderAmount;
}
이전의 식별 관계 방법보다 매핑이 단순하고 이해하기 쉬워졌다. 일반적으로 비식별 관계가 복합 키를 위한 식별자 클래스를 만들지 않다도 되므로 단순하고 편리하게 ORM 매핑할 수 있어 이 방법을 추천한다.
참고 자료
자바 ORM 표준 JPA 프로그래밍(김영한)
https://docs.jboss.org/hibernate/jpa/2.1/api/javax/persistence/JoinColumn.html
https://docs.oracle.com/cd/E16439_01/doc.1013/e13981/cmp30cfg001.htm#BCGGGDDC
댓글남기기