6장. 다양한 연관관계 mapping

6.1 N:1

  • 항상 N:1 의 반대 방향은 1:N, 1:N의 반대 방향은 N:1
  • 1:N, N:1 관계에서 foreign key는 항상 N 쪽에 위치
    • ∴ owner는 N 쪽



6.1.1 N:1 단방향 [N:1]

코드
// member entity
@Entity
public class Member {
  @Id
  @GeneratedValue
  private Long id;

  private String username;

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

  ...
}

...

// team entity
@Entity
public class Team {
  @Id
  @GeneratedValue
  private Long id;

  private String name;

  ...
}

  • member 에서는 Member.team 으로 team entity 참조 가능, team 에서는 member 참조 불가능 -> N:1 단방향 연관관계
    • Member entity 에서 Member.team 으로 member table team_id foreign key 관리



6.1.2 N:1 양방향 [N:1 & 1:N]

코드
// member entity
@Entity
public class Member {
  @Id
  @GeneratedValue
  private Long 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);
    }
  }

  ...
}

...

// team entity
@Entity
public class Team {
  @Id
  @GeneratedValue
  private Long id;

  private String name;

  @OneToMany(mappedBy = "team")
  private List<Member> members = new ArrayList<Member>();

  public void setMembers(Member member) {
    this.members.add(member);
    
    // 무한루프 방지
    if (member.getTeam() != this) {
      member.setTeam(this);
    }
  }

  ...
}

  • 위 N:1 양방향 예제에서 실선이 연관관계 owner side(Member.team), 점선이 non-owning side(Team.members)
  • 양방향에선 foreign key 있는 쪽이 owner
    • N:1, 1:N 에선 항상 N 쪽에 foreign key 위치
    • 여기선 N 쪽인 member 의 Member.team 이 owner
    • non-owning side인 Team.members 는 조회를 위한 JPQL 또는 객체 그래프 탐색 시 사용
  • 양방향 연관관계 에선 항상 서로 참조해야 함
    • 어느 한 쪽만 참조시 양방향 성립 X
    • 이를 위해 연관관계 편의 method 작성하는게 좋음
      • 위 예제에서의 setTeam, setMembers




6.2 1:N

  • 1:N 에서는 entity를 하나 이상 참조할 수 있음
    • 자바 컬렉션인 Collection, List, Set, Map 중 하나 사용



6.2.1 1:N 단방향 [1:N]

  • 1:N 단방향 관계는 JPA 2.0부터 지원
  • 1:N, N:1 관계에서는 항상 N 쪽 table에 fk 있기 때문에 1:N 단방향 관계는 좀 특수
  • 아래 예제처럼 N쪽 table에 있는 fk를 반대쪽 entity 에서 관리하게 됨
  • 1:N 단방향 mapping 시에는 @JoinColumn 명시해야 함
    • 명시하지 않을 경우 JPA는 JoinTable 전략을 기본으로 사용하여 mapping
      • JoinTable 전략: 연결 테이블을 중간에 두고 연관관계를 관리하는 방식

코드
@Entity
public clas Team {
  @Id
  @GeneratedValue
  private Long id;

  private String name;

  @OneToMany
  @JoinColumn(name = "team_id") // member table 에 있는 fk 사용
  private List<Member> members = new ARrayList<Member>();
  
  ...
}

...

@Entity
public class Member {
  @Id
  @GeneratedValue
  private Long id;

  private String username;

  ...
}

  • 1:N 단방향 mapping 단점
    • 다른 테이블에 있는 fk를 관리하게 되기 때문에 쿼리 추가로 필요해짐
      • INSERT 한번으로 끝날 게 연관관계 처리를 위해 UPDATE 쿼리를 추가로 실행해야 함
코드
Member member1 = new Member("Alice");
Member member2 = new Member("Bob");

Team team1 = new Team("Alpha");
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

...



6.2.2 1:N 양방향 [1:N & N:1]

  • 원칙적으로 1:N 양방향은 존재 X
  • 대신 N:1 양방향 mapping 사용
  • 굳이 1:N 양방향 mapping 구현하려면 1:N 단방향과 함께 반대편에서 같은 fk 쓰는 N:1 단방향 mapping 을 read only로 추가하면 됨

코드
@Entity
public class Team {
  @Id
  @GeneratedValue
  private Long id;

  private String name;

  // 1:N 단방향
  @OneToMany
  @JoinColumn(name = "team_id")
  private List<Member> members = new ARrayList<Member>();

  ...
}

...

@Entity
public class Member {
  @Id
  @GeneratedValue
  private Long id;

  private String username;

  // N:1 단방향 read only
  @ManyToOne
  @JoinColumn(name = "team_id", insertable = false, updatable = false)
  private Team team;
  ...
}

  • 위 예제처럼 1:N, N:1 모두 동일한 fk 사용하게 하고 N:1 쪽에서 insertable = false, updatable = false 로 설정해서 read only 만들면 됨




6.3 1:1

  • 1:1 관계에서는 양쪽이 서로 하나의 관계만 가짐
  • 어느 쪽이던 fk 가질 수 있음
    • 주 테이블이랑 대상 테이블 중 어느쪽이 fk 가질 지 선택해야 함
  • 대상 테이블에 fk



6.3.1 주 테이블에 fk

  • 주 객체가 대상 객체 참조하듯 주 테이블에 fk 두고 대상 테이블을 참조
  • fk를 객체 참조와 유사하게 사용할 수 있음
    • 주로 객체지향 개발자들이 선호
    • JPA에서도 좀 더 편리하게 mapping
  • 주 테이블만 확인해도 대상 테이블과 연관관계 파악 가능


단방향

코드
@Entity
public class Member {
  @Id
  @GeneratedValue
  private long id;

  private String username;

  @OneToOne
  @JoinColumn(name = "locker_id")
  private Locker locker;

  ...
}

...

@Entity
public class Locker {
  @Id
  @GeneratedValue
  private Long id;

  private String name;

  ...
}

  • 1:1 이므로 @OneToOne 으로 객체 mapping
  • DB member table locker_id foregin key에 유니크 제약조건 UNI 추가


양방향

코드
@Entity
public class Member {
  @Id
  @GeneratedValue
  private long id;

  private String username;

  @OneToOne
  @JoinColumn(name = "locker_id")
  private Locker locker;

  ...
}

...

@Entity
public class Locker {
  @Id
  @GeneratedValue
  private Long id;

  private String name;

  @OneToOne(mappedBy = "locker")
  private Member member;
  ...
}

  • 양방향이므로 연관관계 owner 필요
    • member table이 fk 가지고 있으므로 Member entitiy의 Member.locker 가 owner
    • 반대쪽인 Locker entity 의 Locker.member 는 mappedBy 설정



6.3.2 대상 테이블에 fk

  • 테이블 관계를 1:1에서 1:N으로 변경할 때에도 테이블 구조 유지 가능
  • DB 개발자들이 주로 선호


단방향

  • JPA 에서는 위와 같이 대상 테이블에 fk 있는 1:1 단방향 관계 지원 안함


양방향

코드
@Entity
public class Member {
  @Id
  @GeneratedValue
  private long id;

  private String username;

  @OneToOne
  @JoinColumn(name = "member")
  private Locker locker;

  ...
}

...

@Entity
public class Locker {
  @Id
  @GeneratedValue
  private Long id;

  private String name;

  @OneToOne(mappedBy = "member_id")
  private Member member;
  ...
}

  • 1:1 mapping 시 대상 테이블에 fk 두려면 이렇게 양방향으로 해야 함

!!주의사항!!
proxy 사용할 때 fk 직접 관리하지 않는 1:1 관계는 lazy loading 설정이 안먹힘
예를 들어 위 예제에서 Locker.member 는 lazy loading 가능, Member.locker 는 불가능
proxy 대신 bytecode instrumentation 사용하면 해결할 수 있음




6.4 N:N

  • 관계형 DB는 정규화된 테이블 2개로 N:N 표현 불가
  • ∴ 일반적으로는 N:N을 1:N, N:1로 풀어내는 연결 테이블을 사용

  • 객체의 경우엔 객체 2개만으로 N:N 관계 표현 가능
  • @ManyToMany 사용하여 N:N mapping



6.4.1 N:N 단방향

코드
@Entity
public class Member {
  @Id
  private String id;

  private String username;

  @ManyToMany
  @JoinTable(name = "member_product",
            joinCoulmns = @JoinColumn(name = "member_id")
            inverseJoinColumns = @JoinColumn(name = "product_id"))
  private List<Product> products = new ArrayList<Product>();
  
  ...
}

...

@Entity
public class Product {
  @Id
  priavte String id;

  private String name;

  ...
}

  • @ManyToMany와 @JoinTable 을 사용하여 연결 테이블 매핑
  • @JoinTable.name: 연결 테이블을 지정
  • @JoinTable.joinColumns: 현재 방향 테이블과 매핑할 join column 정보 지정
  • @JoinTable.inverseJoinColumns: 반대 방향 테이블과 매핑할 join column 정보 지정
코드
Product productA = new Product();
productA.setId("productA");
productA.setName("A");
em.persist(productA);

Member member1 = new Member();
member1.setId("member1");
member1.setUsername("Alice");
member1.getProducts().add(productA);    // 연관관계 설정
em.persist(member1);

sql
INSERT INTO product(id, name) VALUES("productA", "A");
INSERT INTO member(id, username) VALUES("member1", "Alice");
INSERT INTO member_product(member_id, product_id) VALUES("productA", "member1");

  • 위와 같이 @ManyToMany, @JoinTable 로 연관관계 설정에둔 entity 저장할 때 연결 테이블에도 같이 값이 저장됨
코드
Member member = em.find(Member.class, "member1");
List<Product> products = member.getProducts();    // 객체 그래프 탐색
for (Product product: products) {
  System.out.println("product.name = " + product.getName());
}

sql
SELECT *
FROM member_product mp
  INNER JOIN product p 
  ON mp.product_id = p.id
WHERE mp.member_id=?

  • 위 예제 처럼 연관관계 설정된 Member::getProducts() 호출하면 연결 테이블을 join 하여 조회하는 쿼리가 실행됨



6.4.2 N:N 양방향

  • N:N 양방향의 경우에도 owner 정해야 하며, 마찬가지로 mappedBy 속성을 사용한다
    • 1:N, N:1과 마찬가지로 non-owning side entity 에 mappedB 설정
코드
@Entity
public class Member {
  @Id
  private String id;

  private String username;

  @ManyToMany
  @JoinTable(name = "member_product",
            joinCoulmns = @JoinColumn(name = "member_id")
            inverseJoinColumns = @JoinColumn(name = "product_id"))
  private List<Product> products = new ArrayList<Product>();
  
  ...

  // N:N 양방향도 아래와 같이 연관관계 편의 method 추가해서 사용하는게 좋음
  public void addProduct(Product product) {
    ...
    products.add(product);
    product.getMembers().add(this);
  }

  ...
}

...

@Entity
public class Product {
  @Id
  priavte String id;

  private String name;

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

  ...
}



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

  • @ManyToMany 를 사용하면 편리하긴 하지만 연결 테이블에 id 외에 다른 컬럼들을 추가적으로 사용할 때 매핑이 불가능
  • ∴ 이러한 경우엔 연결 테이블을 매핑하는 연결 엔티티를 만들고 여기에 추가 컬럼들을 매핑 후 1:N, N:1 관계로 entity 연결해야 함

코드
@Entity
public class Member {
  @Id
  private String id;

  // Member 와 MemberProduct N:1 양방향 관계
  // 여기선 fk (member_id) 가지고 있는 MemberProduct 가 owner
  @OneToMany(mappedBy = "member")   // 역방향
  private List<MemberProduct> memberProducts;

  ...
}

...

@Entity
public class Product {
  @Id
  private String id;

  private String name;

  ...
}

...

@Entity
@IdClass(MemberProductId.class)   // identifier clas 설정정
public class MemberProduct {
  @Id
  @ManyToOne
  @JoinColumn(name = "member_id")
  private Member member;    // MemberProductId.member와 연결 

  @Id
  @ManyToOne
  @JoinColumn(name = "product_id")
  private Product product;    // MemberProductId.product와 연결

  private int orderAmount;

  @Temporal(TemporalType.TIMESTAMP)
  private Date orderDate;

  ...
}

...

// MemberProduc 식별자 클래스
public class MemberProductId implements Serializable {
  private String member;    // MemberProduct.member 와 연결
  
  private String product;   // MemberProduct.product 와 연결

  @Override
  public boolean equals(Object o) { ... }

  @Override
  public int hashCode() { ... }
}


  • pk를 매핑하는 @Id와 fk를 매핑하는 @JoinColumn 을 같이 사용하여 pk + fk 한번에 매핑
  • @IdClass 를 사용하여 복합 기본키를 매핑


복합 기본키 (복합키)

  • 연결 엔티티는 pk가 두 fk로 이루어진 복합 기본키임
    • 위 예제의 경우 MemberProduct entity 의 pk는 member_id 와 product_id 로 이루어진 복합키
  • JPA 에서 복합키 사용하려면 별도의 식별자 클래스 만들어야 함
    • 식별자 클래스는 @IdClass 로 연결 엔티티에 지정
    • 위 예제의 경우 MemberProductId 클래스가 식별자 클래스
  • 식별자 클래스의 특징
    • 복합 키는 별도의 식별자 클래스로 만들어야 함
    • Serializable 을 implement 해야 함
    • equals, hashCode method 구현해야 함
    • 기본 생성자 있어야 함
    • 식별자 클래스는 반드시 public
    • @IdClass 대신 @EmbeddedId 사용하는 방법도 있음

식별 관계

  • 부모 테이블의 pk 받아서 자신의 pk & fk 로 사용하는 것을 identifying relationship 이라고 함
코드
public void save() {
  // Member save
  Member member1 = new Member("member1", "Alice");
  em.persist(member1);


  // Product save
  Product productA = new Product("productA", "A");
  em.persist(productA);

  // MemberProduct save
  MemberProduct memberProduct = new MemberProduct();
  memberProduct.setMember(member1);
  memberPRoduct.setProduct(productA);
  memberProduct.setOrderAmount(2);
  em.persist(memberProduct);
}

...

public void find() {
  // pk 생성
  MemberProductId memberProductId = new MemberProductId();
  memberProductId.setMember("member1");
  memberProductId.setProduct("productA");

  MemberProduct memberProduct = em.find(MemberProduct.class, memberProductId);

  Member member = memberProduct.getMember();
  Product product = memberProduct.getProduct();

  System.out.println("member = " + member.getUsername());
  System.out.println("product = " + product.getName());
  System.out.println("orderAmount = " + memberProduct.getOrderAmount());
}

  • 위 예제에서 MemberProduct는 member, product 의 pk 를 받아서 MemberProductId 식별자 클래스로 묶어 복합 기본 키로 사용하고, 각각 member, product 와의 관계를 위한 fk 로도 사용함
  • MemberProduct entity 는 DB 에 저장될 때 연관된 member 와 product 의 식별자 가져와서 자신의 pk 로 사용
  • 조회시에는 식별자 클래스를 만들어서 사용



6.4.4 N:N 새로운 기본 키 사용

  • 6.4.3절에서 처럼 복합 기본 키를 사용하면 복잡해짐
  • 대신 DB에서 자동으로 생성해주는 대리 키를 사용하는 방법이 있음
    • 간편하고 비즈니스 의존성 X
    • ORM 매핑 시에 복합 키 만들지 않아도 돼서 매핑도 간단

코드
@Entity
public class Order {
  @Id
  @Column
  private Long id;

  @ManyToOne
  @JoinColumn(name = "member_id")
  private Member member;

  @ManyToOne
  @JoinColumn(name = "product_id")
  private Product product;

  private int orderAmount;

  @Temporal(TemporalType.TIMESTAMP)
  private Date orderDate;

  ...
}

... 

@Entity
public class Member {
  @Id
  @Column
  private Long id;

  @OneToMany(mappedBy = "member")
  private List<Order> order = new ARrayList<Order>();

  ...
}

...

@Entity
public class Product {
  @Id
  @Column
  private Long id;

  @OneToMany(mappedBy = "product")
  private List<Order> order = new ArrayList<Order>();
}

  • MemberProduct 대신 Order table 을 만들고, 기본키를 member_id + product_id 복합키 대신 id 로 설정. member_id 와 product_id 는 fk 로만 활용
코드
public void save() {
  // Member save
  Member member1 = new Member("member1", "Alice");
  em.persist(member1);

  // Product save
  Product productA = new ProductA("productA", "A");
  em.persist(productA);

  // Order save
  Order order = new Order();
  order.setMember(member1);
  order.setProduct(productA);
  order.SetOrderAmount(2);
  em.persist(order);    // {id: 1L, member_id: "member1", product_id: "productA", order_amount: 2}
}

...

public void find() {
  Long orderId = 1L;
  Order order = em.find(Order.class)

  Member member = order.getMember();
  Product product = order.getProduct();

  System.out.println("member = " + member.getUsername());
  System.out.println("product = " + member.getProduct());
  System.out.println("orderAmount = " + order.getOrderAmount());
}

  • 대리 키를 사용하여 N:N 관계를 매핑하면 위와 같이 좀 더 단순하게 저장 및 조회할 수 있음



6.4.5 N:N 연관관계 정리

  • N:N 관계를 1:N & N:1 관계로 풀어내기 위해 연결 테이블 만들 때 식별자 구성 방법을 선택해야 함
    • 식별 관계: 받아온 식별자를 pk(복합 기본키) & fk 로 활용
    • 비식별 관계: 받아온 식별자는 단순히 fk로만 쓰고 새로운 식별자 추가해서 사용
  • ORM 매핑 편리성 때문에 비식별 관계로 구성하는 게 좋음