먼저 연관 관계가 있는 두 엔티티에 대해 일대일 관계인지 일대다 관계인지 다중성을 판단해야 한다. 그리고 한쪽만 참조하는 단방향 관계인지 서로 참조하는 양방향 관계인지 판단하고, 만약 양방향 관계라면 누가 그 연관 관계의 주인인지를 정해야 한다.
다대일 매핑은 관계형 데이터베이스의 물리적 외래 키 위치와 객체 지향 모델의 논리적 참조 구조가 가장 완벽하게 조화를 이루는 아키텍처 표준이다. 회원과 팀의 예시처럼, 수많은 자식 레코드가 하나의 부모 레코드를 참조하는 구조에서는 데이터베이스 정규화 원칙상 필연적으로 자식 테이블(다 측)에 외래 키 컬럼이 부여된다.
먼저 다대일 단방향을 살펴보자.

package hellojpa;
import jakarta.persistence.*;
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
public Member() {};
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
package hellojpa;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Team {
@Id @GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
그림과 코드를 보다시피 Member 클래스에서 Member.team으로 팀 엔티티를 참조할 수 있지만, Team 클래스에는 아무 것도 없는 다대일 단방향 연관 관계다. 그리고 Member.team 필드를 TEAM_ID 외래키와 매핑했기 때문에 Member.team 필드로 회원 테이블의 TEAM_ID 외래키를 관리한다.
이번엔 양방향을 살펴보자.

객체 연관 관계에서 실선이 연관 관계의 주인, 즉 Member.team이고, 점선은 연관 관계의 주인이 아니다. 아래 코드를 보자.
package hellojpa;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@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<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
보다시피 반대 방향만 추가해주면 된다. 아무튼 중요한 점은 일대다 관계나 다대일 관계에서 외래키는 항상 “다” 쪽에 있고, “다” 쪽인 테이블이 외래키를 가지고 있기 때문에 연관 관계의 주인이 된다. 그리고 양방향 관계의 경우, 항상 서로 참조해야 한다. 서로 참조하게 하기 위해 편의 메서드를 사용하는 것이 권장된다. 하지만 주의할 점은 양쪽에 모두 편의 메서드를 작성하게 되면 무한 루프에 빠진다는 점이다.
먼저, 권장되는 모델은 아니지만 일대다 단방향 연관 관계에 대해 살펴보자. 그냥 Member와 Team을 뒤집은 상태다.

하나의 Team은 여러 Member를 참조할 수 있지만, 반대로 Member는 Team을 참조하지 않는다. 일단 그림을 보면 굉장히 특이한데, 보통 자신이 매핑한 테이블의 외래키를 관리하는데, 지금 Team.members로 MEMBER 테이블의 TEAM_ID 외래키를 관리하고 있다. 근데 이게 당연한 소리인게, 보다시피 현재 외래키가 “다” 쪽인 MEMBER 테이블에 있고, “다” 쪽인 Member 클래스에 TEAM_ID를 매핑할 수 있는 참조가 없다. 그래서 어쩔 수 없이 Team 클래스의 참조 필드인 members랑 매핑을 한 것이다.
실행 코드를 보면…
package hellojpa;
import jakarta.persistence.*;
import java.util.List;
public class JpaMain {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Member member = new Member();
member.setUsername("member 2");
em.persist(member);
Team team = new Team();
team.setName("team 2");
team.getMembers().add(member); // 생성한 Team에 멤버 추가
em.persist(team);
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
}
emf.close();
}
}

Team 엔티티를 손 댔는데 MEMBER 테이블로 UPDATE 쿼리가 나가는 것을 볼 수 있다. 일단 Member 엔티티는 Team 엔티티를 모른다. 그리고 연관 관계에 대한 정보는 Team 엔티티의 members 필드가 관리한다. 따라서 Member 엔티티를 저장할 때는 MEMBER 테이블의 TEAM_ID 외래키에 아무 값도 저장되지 않는다. 대신, Team 엔티티를 저장할 때 Team.members 참조값을 확인해서 MEMBER 테이블에 있는 TEAM_ID 외래키를 업데이트하는 것이다.
이처럼 일대다 단방향 매핑의 단점은 이처럼 매핑한 객체가 관리하는 외래키가 다른 테이블에 있다는 점이다. 본인 테이블에 외래키가 있으면 엔티티의 저장과 연관 관계 처리를 INSERT 쿼리 한 방으로 해결할 수 있지만, 다른 테이블에 외래키가 있으면 위와 같이 연관 관계 처리를 위한 UPDATE 쿼리가 추가로 나간다는 것이다. 따라서 다대일 양방향 매핑을 사용해야 한다.
일대다 양방향 매핑도 있을까? 양방향 매핑에서 @OneToMany는 연관 관계의 주인이 될 수 없다. 당연하게도 일대다 관계든, 다대일 관계든 항상 “다” 쪽에 외래키가 있기 때문에 항상 연관 관계의 주인에 @ManyToOne을 사용한 것이다. 그래서 일대다 양방향 매핑은 공식적으로 존재하지 않는다.
일대일 관계는 양쪽이 서로 하나의 관계만 가진다. 일대다와 다대일과는 달리, 주 테이블이나 대상 테이블 중 어느 곳이든 외래키를 가질 수 있다. 그럼 선택을 해야겠구만…
먼저, 주 테이블에 외래키가 있는 단방향 관계인 경우를 살펴보자. 회원과 락커 일대일 관계를 예시로 들었다.

package hellojpa;
import jakarta.persistence.*;
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
@OneToOne
@JoinColumn(name = "LOCKER_ID")
private Locker locker;
public Member() {};
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
package hellojpa;
import jakarta.persistence.*;
@Entity
public class Locker {
@Id @GeneratedValue
private Long id;
private String name;
}
일대일 관계이므로 객체 매핑에 @OneToOne을 사용했고, DB에는 LOCKER_ID 외래키에 UNIQUE 제약 조건을 추가했다. 다대일 단방향 매핑과 유사하다.

이제 양방향에 대해 살펴보자.

package hellojpa;
import jakarta.persistence.*;
@Entity
public class Locker {
@Id @GeneratedValue
private Long id;
private String name;
// 코드 추가
@OneToOne(mappedBy = "locker")
private Member member;
}
양방향이므로 연관 관계의 주인을 정해야 하는데, MEMBER 테이블에 LOCKER_ID라는 외래키가 있으므로 Member 엔티티에 있는 Member.locker를 연관 관계의 주인으로 설정했다. 따라서 반대인 Locker 엔티티에 mappedBy를 선언해줬다.
반대로 대상 테이블에 외래키가 있다면 어떨까? 일대일 관계 중 대상 테이블에 외래키가 있는 단방향 관계일 경우에는 JPA에서 지원하지 않는다.

이럴 경우, Locker 엔티티에서 Member 엔티티로 방향을 수정하거나, 양방향 관계로 만든 다음 Locker 엔티티를 연관 관계의 주인으로 정해야 한다. 그럼 양방향일 경우를 생각해보자.

package hellojpa;
import jakarta.persistence.*;
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
@OneToOne
@JoinColumn(mappedBy = "member")
private Locker locker;
public Member() {};
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
package hellojpa;
import jakarta.persistence.*;
@Entity
public class Locker {
@Id @GeneratedValue
private Long id;
private String name;
@OneToOne
@JoinColumn(name = "MEMBER_ID")
private Member member;
}
이렇게 일대일 매핑에서 대상 테이블에 외래키를 두고 싶다면 양방향으로 설계하도록 하자.
<일대일 정리>
관계형 DB는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다. 대신, 연결 테이블을 추가해서 일대다와 다대일 관계로 풀어낸다.

하지만, 객체는 컬렉션을 이용해서 객체 2개로 다대다 관계로 표현할 수 있다. @ManyToMany 애노테이션을 사용해서 편리하게 매핑 가능하다. 여기서 딜레마가 오는 것이다.
일단 다대다 단방향 관계인 Member와 Product를 코드로 살펴보자.
package hellojpa;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
@OneToOne
@JoinColumn(name = "LOCKER_ID")
private Locker locker;
@ManyToMany
@JoinTable(name = "MEMBER_PRODUCT")
private List<Product> products = new ArrayList<>();
public Member() {};
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
/*
Hibernate:
create table MEMBER_PRODUCT (
Member_MEMBER_ID bigint not null,
products_id bigint not null
)
...
Hibernate:
alter table if exists MEMBER_PRODUCT
add constraint FKc6hsxwm11n18ahnh5yvbj62cf
foreign key (products_id)
references Product
Hibernate:
alter table if exists MEMBER_PRODUCT
add constraint FK4ibylolqmostllrjdc147aowv
foreign key (Member_MEMBER_ID)
references Member
*/
package hellojpa;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToMany;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
// 양방향일 경우
@ManyToMany(mappedBy = "products")
private List<Member> members = new ArrayList<>();
}
보다시피 회원 엔티티랑 상품 엔티티를 @ManyToMany로 매핑했다. 실행 결과를 보면, @ManyToMany와 @JoinTable로 연결 테이블을 바로 매핑한 것이다. 이처럼 겉으로 보기에는 연결 테이블을 자동으로 처리해줘서 도메인 모델이 단순해지고 편리해보인다. "하지만 실무에서 사용할 수 없다!" 예를 들면, 회원이 상품을 주문하면 연결 테이블에 단순히 주문한 회원 아이디와 상품 아이디만 담고 끝나지 않는다. 보통 추가 정보를 더 넣어야 할 일이 많을 것이다. 이렇게 어떤 컬럼을 연결 테이블에 추가하게 되면 더 이상 @ManyToMany를 사용할 수 없다. 왜냐하면 주문 엔티티나 상품 엔티티에는 추가한 컬럼을 매핑할 수 없기 때문이다. 그렇기 때문에 연결 테이블을 엔티티로 승격시켜야 한다. 추가로 다대다 관계도 일대다, 다대일 관계로 풀어내야 한다.

public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
@OneToOne
@JoinColumn(name = "LOCKER_ID")
private Locker locker;
@OneToMany(mappedBy = "member")
private List<MemberProduct> memberProducts = new ArrayList<>();
public Member() {};
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
package hellojpa;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
public class MemberProduct {
@Id @GeneratedValue
private Long id;
@ManyToOne
@JoinColumn(name = "MEMBER_ID")
private Member member;
@ManyToOne
@JoinColumn(name = "PRODUCT_ID")
private Product product;
// 엔티티로 승격했기 때문에 추가 가능
private int orderAmount;
private LocalDateTime orderDate;
}
package hellojpa;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "product")
private List<MemberProduct> memberProducts = new ArrayList<>();
}
회원 관점에서 보든, 상품 관점에서 보든, “다” 관계인 MemberProduct가 각각의 연관 관계의 주인이다. 따라서 주인이 아닌 Member.memberProducts와 Product.memberProducts에 mappedBy를 설정해줬다.