JPA 프로그래밍 입문 - chapter10 엔티티 콜렉션 매핑

1. 엔티티 콜렉션 매핑과 연관 관리

  • 엔티티 콜렉션 매핑은 가능하면 사용을 자제하는게 좋다. 코드를 복잡하게 만들고 얻을 수 있는 장점은 많지 않기 때문이다. 게다가 잘못 사용하면 성능에 영향을 줄 수 있다.
  • 양방향 연관은 서로 올바르게 연관을 유지하도록 코드를 작성해야한다.
  • M:N 연관은 1:N 연관과 유사하지만 더 복잡하다.
  • 1:N, M:N 양방향 연관을 관리하기 위해 코드가 복잡해진다. 복잡한 코드는 변경을 어렵게 만드는 요인이 되므로 가능하면 양방향 연관은 다른 방식으로 해결해서 구현의 복잡도를 낮추는 것이 코드 관리에 유리하다.

2. 1:N 단방향 엔티티 Set 매핑

@Data
@Entity
public class Team {
    @Id
    private String id;
    private String name;
    
    @OneToMany
    @JoinColumn(name = "team_id")
    private Set<Player> players = new HashSet<>();
}

// 단방향 연관이므로 Player 엔티티는 Team 엔티티에 대한 연관을 갖지 않는다.
@Data
@Entity
public class Player {
    private String id;
    private String name;
}
  • 클래스 구조에서의 연관 방향은 Team에서 Player를 향한다. 하지만 테이블의 참조방향은 player 테이블에서 team으로 향한다. 즉 team과 player을 매핑할 때 사용할 칼럼이 team 테이블에 존재한다.
  • @OneToMany : 1:N 연관을 설정
  • @JoinColumn : 연관을 매핑할 때 사용할 칼럼을 지정
    • 위 예에서 Player 클래스와 매핑할 player 테이블에서 Team의 식별자를 저장할 때 사용할 컬럼을 지정한다. (즉 player 테이블의 team_id 컬럼을 사용해서 Team의 식별자를 저장한다.)
  • Player 클래스는 Set에 저장되므로 equals와 hashCode 메서드를 구현하는게 좋다.
    • 엔티티 클래스는 equals와 hashCode를 구현할 때 식별자만 사용해서 구현하는 경우도 많다.

2.1 1:N 연관의 저장과 변경

  • 1:N 연관에서 주의할 점은 @OneToMany 연관에 저장되는 대상이 관리 대상의 엔티티이어야 한다는 것이다.
em.getTransaction().begin();

Player p1 = em.find(Player.class, "P1");
Player p2 = new Player("P2", "선수2");  // 영속이 아님

Team team1 = em.find(Team.class, "T1");
team1.add(p1);
team1.add(p2); // 관리 상태가 아닌 p2를 @OneToMany 연관에 추가

em.getTransaction().commit();
  • 여기서 p2는 디비에 저장되지 않는다. 그런데 디비에 저장되지 않은 p2를 team1의 @OneToMany에 추가했다. 이 경우 참조하는 엔티티가 관리상태가 아니므로 트랜잭션 커밋 시점에 익셉션이 발생한다.

2.2 1:N 연관의 조회

  • @OneToMany의 기본 로딩 방식은 지연 로딩이다.

2.3 연관에서 제외하기

  • Team 엔티티에 속한 Player 엔티티를 Team에서 제외하고 싶다면 단순히 콜렉션에서 삭제하면 된다.
em.getTransaction().begin();

Team team = em.find(Team.class, "T1");
Optional<Player> pOpt = 
    team.getPlayers().stream().filter(p -> p.getId().equals("P2")).findFirst();
pOpt.ifPresent(p -> team.removePlayer(p));

em.getTransaction().commit();
  • 위의 코드를 실행하면 하이버네이트는 update 쿼리를 실행해서 콜렉션에서 삭제한 엔티티에 매핑되는 레코드의 team_id를 널로 할당한다.
  • 주의할 점은 연관에서 제외했다고 해서 엔티티가 삭제되는것은 아니다. 단지 연관 관계만 삭제될 뿐이다.

2.4 콜렉션 지우기

  • Team 엔티티의 players 콜렉션을 모두 삭제하면 연관된 Player 엔티티와의 연관이 끊긴다.
em.getTransaction().begin();

Team team = em.find(Team.class, "T1");
team.clear(); // 또는 team.setPlayers(new HashMap<>()); team.setPlayer(null);

em.getTransaction().commit();
  • 커밋 시점에 아래 쿼리를 실행해서 연관을 위해 사용한 team_id 컬럼의 값을 널로 바꾼다.
  • 컬렉션을 삭제한다는 것은 컬렉션을 통한 연관을 삭제하는 것이지 컬렉션에 포함된 엔티티를 삭제하는 것이 아니다.
update Player set team_id=null where team_id = ?

3. 1:N 양방햔 Set 매핑

@Data
@Entity
public class Team {
    @Id
    private String id;
    private String name;
    @OneToMany(mappedBy = "team")
    private Set<Player> players = new HashSet<>();
}

@Data
@Entity
public class Player {
    private String id;
    private String name;
    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
}
  • 1:N 연관은 N:1 연관과 쌍을 이룬다. 1:N 단방향 연관을 1:N 양방향 연관으로 바꾸면, 1:N 단방향 연관과 N:1 연관을 함께 설정하면 된다.
    • 단방향 연관과의 차이점이 있다면 @JoinColumn 대신에 @OneToMany의 mappedBy 속성을 사용한다는 것이다.
  • mappedBy 속성을 사용해서 연관을 소유한 엔티티의 속성을 지정한다.
    • 연관을 소유한 쪽은 참조키를 들고 있는 엔티티이다.
    • 1:1 양방햔 연관에서도 DB 테이블에서 참조키를 갖는 쪽이 연관을 소유한다.
em.getTransaction().begin();

Team t3 = new Team("T3", "팀3");
Player p3 = em.find(Player.class, "P3");

t3.addPlayer(p3);
p3.setTeam(t3);

em.persist(t3);
em.getTransaction().commit();
  • 연관 소유 주체가 Player이기 때문에 연관을 설정할 때는 Player에서 Team으로의 연관도 설정해주어야 한다. Team에서 Player로의 연관만 추가하면 DB에 연관 데이터가 올바르게 반영되지 않는다.
  • 연관을 소유한 쪽은 Player이기 때문에 Player의 연관만 알맞게 지정해도 DB 테이블에는 연관을 위한 데이터가 반영된다.

코드 상에서 논리적인 양방향 연관이 올바르게 존재하지 않으면 콜렉션을 사용하는 기능이 비정상적으로 동작하므로 코드 상의 양방향 연관을 올바르게 유지해야 한다.

  • 양방햔 연관을 소유를 Player가 갖고 있으므로 Team과 Player의 연관을 제거하려면, 컬렉션에 속한 모든 Player에서 Team으로의 연관을 제거해야 한다.
em.getTransaction().begin();

Team t1 = em.find(Team.class, "T1");
for (Player p: t1.getPlayers()) {
	p.setTeam(null);
}
t1.getPlayers().clear();

em.getTransaction().commit();

4. M:N 단방향 연관

@Entity
public class Performance {
    @Id
    private String id;
    private String name;
    @ManyToMany
    @JoinTable(
    	name = "perf_person",
        joinColumns = @JoinColumn(name = "performance_id"),
        inverseJoinColumns = @JoinColumn(name = "person_id")
    )
    private Set<Person> cast = new HashSet<>();
}
  • M:N 연관은 조인 테이블을 사용해서 연관을 지정한다.
  • @ManyToMany 애노테이션을 사용한다.
  • @JoinTable 애노테이션의 각 속성은 다음과 같다.
    • name : 조인 테이블의 이름을 지정
    • joinColumns : 조인 테이블에서 Performance 엔티티를 참조할 때 사용할 칼럼
    • inverseJoinColumns : 조인 테이블에서 콜렉션에 포함될 Person 엔티티를 참조할 때 사용할 칼럼

5. M:N 양방향 연관

  • 양방향 연관은 단방향 연관과 유사하다. 단 연관의 소유를 누가 할지 결정하고 연관을 소유한 쪽에 @JoinTable을 설정해주면 된다.
  • 아래는 M:N 연관의 소유가 Performance에 있는 매핑 설정이다.
@Entity
public class Performance {
    @Id
    private String id;
    private String name;
    @ManyToMany
    @JoinTable(
    	name = "perf_person",
        joinColumns = @JoinColumn(name = "performance_id"),
        inverseJoinColumns = @JoinColumn(name = "person_id")
    )
    private Set<Person> cast = new HashSet<>();
}

@Entity
public class Person {
    @Id
    private String id;
    private String name;
    
    @ManyToMany(mappedBy = "cast")
    private Set<Performance> perfs = new HashSet<>();
}
  • Person의 @ManyToMany는 mappedBy 속성을 사용하고 있다. 이 코드는 연관의 소유를 Performance의 cast가 가진다고 설정한 것이다. 따라서 연관을 변경하려면 Performance의 cast 값을 변경해주면 된다.