BOOK 3 - 자바 ORM 표준 JPA 프로그래밍(5)

연관관계 매핑 기초

  • 엔티티들은 대부분 다른 엔티티와 연관관계가 있다.
  • 객체는 reference를 사용해서 관계를 맺고 테이블은 foreign key를 사용해서 관계를 맺는다.
  • 객체의 연관관계는 단방향이고, 테이블의 연관관계는 양방향이다.

단방향 연관관계

  • 다음과 같은 다대일(N:1) 단방향 관계의 예가 있다.
    • 회원과 팀이 있다.
    • 회원은 하나의 팀에만 소속될 수 있다.
    • 회원과 팀은 다대일 관계다.

image

객체 연관관계

  • 회원 객체는 Member.team 필드로 팀 객체와 연관관계를 맺는다.

  • 회원 객체와 팀 객체는 단방향 관계다.

    • 회원은 team 필드를 통해서 팀을 알 수 있지만 팀은 회원을 알 수 없다.
    • 즉, team.getMember() 가 불가능하다.
  • 객체를 양방향으로 참조하려면 단방향 연관관계를 2개 만들어야 한다.

    • 양쪽에서 서로 참조하도록 필드를 추가해야 한다.
  • 객체는 참조를 이용해서 member.getTeam() 과 같이 연관관계를 탐색할 수 있는데 이것을 객체 그래프 탐색이라 한다.

테이블 연관관계

  • 회원 테이블은 TEAM_ID 외래 키로 테이블과 연관관계를 맺는다.

  • 회원 테이블과 팀 테이블은 양방향 관계다.

    • 회원 테이블의 TEAM_ID 를 통해서 팀 테이블을 조인할 수 있고, 반대로도 조인할 수 있다.
  • 데이터베이스는 다음과 같이 외래 키를 사용해서 연관관계를 탐색할 수 있는데 이것을 조인이라 한다.

    SELECT T.*
    FROM MEMBER M
    JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
    WHERE M.MEMBER_ID = "member1";
    

객체 관계 매핑

@Entity
public class Member {
  ...
  // 연관관계 매핑
  @ManyToOne
  @JoinColumn(name="TEAM_ID")
  private Team team;
}
@Entity
public class Team {
  @Id
  @Column(name="TEAM_ID")
  private String id;
}
  • @ManyToOne : 다대일(N:1) 관계라는 매핑 정보

    • 연관관계를 매핑할 때 다중성을 나타내는 어노테이션을 필수로 사용해야 한다.

    • optional : false 로 설정하면 연관된 엔티티가 항상 있어야 한다.

    • fetch : 글로벌 페치 전략을 설정한다.

    • cascade : 영속성 전이 기능을 사용한다.

    • targetEntity : 연관된 엔티티의 타입 정보를 설정한다.

      • 컬렉션을 사용해도 generic으로 타입 정보를 알 수 있기 때문에 거의 사용하지 않는다.

        @OneToMany
        private List<Member> members;  // generic으로 타입 정보를 알 수 있다.
        
  • @JoinColumn(name="TEAM_ID") : 외래 키를 매핑할 때 사용한다.

    • name : 매핑할 외래 키 이름 지정

    • referencedColumnName : 외래 키가 참조하는 대상 테이블의 컬럼명
    • nullable , insertable , updatable 등의 DDL 제약 조건 속성은 @Column 속성과 동일하게 사용 가능하다.
    • @JoinColumn 을 생략하면 기본 전략을 사용하여 외래 키를 찾는다.
      • 기본 전략 : 필드명(team) + _ + 참조하는 테이블의 컬럼명(TEAM_ID)

연관관계 사용

저장

  • JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태여야 한다.
Team team1 = new Team("team1", "팀1");
em.persist(team1);

Member member1 = new Member("member1");
member1.setTeam(team1);
em.persist(member1);
  • JPA는 참조한 팀의 식별자를 외래 키로 사용해서 적절한 등록 쿼리를 생성한다.

    INSERT INTO TEAM(TEAM_ID, TEAM_NAME) VALUES('team1', "팀1");
    INSERT INTO MEMBER(MEMBER_ID, TEAM_ID) VALUES('member1', 'team1');
    

조회

  • 연관관계가 있는 엔티티를 조회하는 방법은 크게 2가지 이다.
    • 객체 그래프 탐색
    • 객체지향 쿼리 사용(JPQL)

객체 그래프 탐색

  • member.getTeam() 과 같이 객체를 통해 연관된 엔티티를 조회할 수 있다.

    Member member = em.find(Member.class, "member1");
    Team team = member.getTeam();
    

객체지향 쿼리 사용

  • JPQL에서 지원하는 조인 기능을 사용하여 연관된 엔티티를 조회할 수 있다.

  • 팀1에 소속된 회원만 조회하려면 회원과 연관된 팀 엔티티를 검색 조건으로 사용해야 한다.

    String jpql = "select m from Member m join m.team t where t.name=:teamName";
      
    List<Member> resultList = em.createQuery(jpql, Member.class)
      													.setParameter("teamName", "팀1")
      													.getResultList();
    
    • 회원이 팀과 관계를 가지고 있는 필드(m.team)를 통해서 MemberTeam 을 조인했다.
    • where 절에서 t.name 을 검색조건으로 사용해서 팀1에 속한 회원만 검색했다.

수정

Team team2 = new Team("team2", "팀2");
em.persist(team2);

Member member = em.find(Member.class, "member1");
member.setTeam(team2);
  • 위와 같이 팀 참조 값을 변경했을 때 실행되는 SQL은 다음과 같다.

    UPDATE MEMBER
    SET TEAM_ID='team2', ...
    WHERE ID='member1';
    
  • 단순히 불러온 엔티티의 값만 변경해두면 트랜잭션 커밋시에 flush가 일어나면서 dirty checking으로 변경사항을 데이터베이스에 자동으로 반영한다.

  • 연관관계를 제거하는 작업도 팀 참조값을 null 로 변경하는 것 외에 모두 동일하다.

양방향 연관관계

  • 팀에서 회원으로 접근하는 관계를 추가하면 회원 -> 팀, 팀 -> 회원 의 양방향 접근이 가능해진다.
  • 팀에서 회원은 일대다 관계이므로 컬렉션을 사용해야 한다.
    • 따라서 Team.membersList 컬렉션으로 추가한다.
  • 테이블은 외래 키 하나로 양방향 관계를 조회할 수 있으므로 데이터베이스에 추가할 내용은 없다.
@Entity
public class Team {
  ...
  @OneToMany(mappedBy="team")
  private List<Member> members = new ArrayList<Member>();
}
  • @OneToMany : 일대다(1:N) 관계라는 매핑 정보
    • mappedBy : 양방향 매핑일 때 사용하는 반대쪽 매핑 필드 이름

연관관계의 주인

  • 엄밀히 이야기하면 객체에는 양방향 연관관계라는 것이 없다.
    • 서로 다른 2개의 단방향 연관관계를 어플리케이션 로직으로 묶어서 양방향 연관관계인 것처럼 보이게 할 뿐이다.
  • 테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다.
  • 엔티티를 단방향으로 매핑하면 참조를 하나만 사용하므로 이 참조로 외래 키를 관리하면 된다.
    • 엔티티를 양방향으로 매핑하면 두 엔티티는 서로를 참조하므로 객체의 연관관계를 관리하는 포인트는 2곳이 된다.
    • 즉, 객체의 참조는 둘인데 외래 키는 하나다.
    • 둘 사이의 차이로 인해 JPA에서는 두 객체 연관관계 중 하나를 정해서 테이블의 외래 키를 관리해야 하는데, 이것을 연관관계의 주인(owner)이라 한다.

양방향 매핑의 규칙 : 연관관계의 주인

  • 양방향 연관관계 매핑 시 두 연관관계 중 하나를 연관관계의 주인으로 정해야 한다.
  • 연관관계의 owner만이 데이터베이스 연관관계와 매핑되고, 외래 키를 관리(등록, 수정, 삭제)할 수 있다.
    • owner가 아닌 쪽은 read만 가능하다.
  • 어떤 연관관계를 owner로 정할지는 mappedBy 속성을 사용한다.
    • owner는 mappedBy 속성을 사용하지 않는다.
    • owner가 아니면 mappedBy 속성을 사용해서 속성의 값으로 연관관계의 owner를 지정해야 한다.

둘 중 어떤 것을 연관관계의 주인으로 정해야 할까?

  • 연관관계의 주인을 정한다는 것은 외래 키 관리자를 선택하는 것이다.
  • 예를 들어 회원 - 팀 연관관계의 경우, MEMBER 테이블에 있는 TEAM_ID 외래 키를 관리할 관리자를 선택해야 한다.
    • Member 엔티티에 있는 Member.team 을 주인으로 선택하면 자기 테이블(MEMBER)에 있는 외래 키를 관리하면 된다.
    • Team 엔티티에 있는 Team.members 를 주인으로 선택하면 물리적으로 다른 테이블에 있는 외래 키를 관리해야 한다.

연관관계의 주인은 외래 키가 있는 곳

  • 연관관계의 주인은 테이블에 외래 키가 있는 곳으로 정해야 한다.
    • 즉, 회원 테이블이 외래 키를 가지고 있으므로 Member.team 이 주인이 된다.
    • 주인이 아닌 Team.members 에는 mappedBy="team" 속성을 이용해서 주인을 지정해야 한다.
  • 연관관계의 주인만 데이터베이스 연관관계와 매핑되고 외래 키를 관리할 수 있다.
  • 주인이 아닌 반대편은 읽기만 가능하고 외래 키를 변경하지는 못한다.

양방향 연관관계의 주의점

  • 양방향 연관관계를 설정했을 때 연관관계의 주인에는 값을 입력하지 않고, 주인이 아닌 곳에만 값을 입력하면 데이터베이스에 외래 키 값이 정상적으로 저장되지 않는다.

    Member member1 = new Member("member1");
    em.persist(member1);
      
    Team team1 = new Team("team1", "팀1");
    team1.getMembers().add(member1);
    em.persist(team1);
    
    • 위 코드를 실행하면 MEMBER 테이블의 member1TEAM_ID 값은 null 이 될 것이다.
    • 연관관계의 주인이 아닌 Team.members 에만 값을 저장했기 때문이다.
  • 연관관계의 주인만이 외래 키 값을 변경할 수 있으므로 주인에 값을 입력해주어야 데이터베이스에 값이 정상적으로 저장된다.

순수한 객체까지 고려한 양방향 연관관계

  • JPA에서는 연관관계의 주인에만 값을 입력해주면 데이터베이스에 외래 키가 저장되기 때문에 member.setTeam(team) 과 같이 주인에만 값을 저장하면 된다.

  • 그러나 객체 관점에서는 양쪽 방향에 모두 값을 입력해주는 것이 가장 안전하다.

    • 양방향 모두 값을 입력하지 않으면 JPA를 사용하지 않는 순수한 객체 상태에서 심각한 문제가 발생할 수 있다.
    Team team1 = new Team("team1", "팀1");
    Member member1 = new Member("member1");
    Member member2 = new Member("member2");
      
    member1.setTeam(team1);
    member2.setTeam(team1);
      
    List<Member> members = team1.getMembers();
    System.out.println("members.size = " + members.size());
    // 출력 : members.size = 0
    
    • 위 코드는 JPA를 사용하지 않는 순수한 객체다.
    • Member.team 에만 연관관계를 설정하고 반대방향은 연관관계를 설정하지 않았다.
      • 따라서 team.Members 를 조회했을 때 원하는 결과를 얻지 못한다.
    • 이런 경우를 방지하기 위해 양쪽 다 관계를 설정해야 한다.
    team1.getMembers().add(member1);
    team1.getMembers().add(member2);
    
    • 이렇게 양쪽에 연관관계를 설정할 경우 순수한 객체 상태에서도 동작하며, 테이블의 외래 키도 정상 입력된다.

연관관계 편의 메서드

  • 양방향 연관관계는 양쪽 다 신경 써야 한다.

  • 위처럼 member.setTeam(team)team.getMembers().add(member) 를 각각 호출하다 보면 둘 중 하나만 호출해서 양방향이 깨질 수 있다.

    • 두 코드를 하나인 것처럼 사용하는 것이 안전하다.
    • Member 엔티티의 setter 를 리팩토링하여 편의 메서드를 작성할 수 있다.
    public class Member {
      ...
      public void setTeam(Team team) {
        if(this.team != null) {
          this.team.getMembers().remove(this);
        }
        this.team = team;
        team.getMembers().add(this);
      }
    }
    
    • 기존에 해당 인스턴스가 다른 참조 값을 가지고 있었을 경우, 해당 연관관계를 끊고 새로운 연관관계를 연결해야 하므로 if 문으로 참조 값을 확인하고 제거 한 후 새로운 연관관계를 매핑해준다.

정리

  • 단방향 매핑은 언제나 연관관계의 주인이므로 단방향 매핑만으로 테이블과 객체의 연관관계의 매핑은 이미 완료되었다.
  • 단방향을 양방향으로 만들면 반대방향으로 객체 그래프 탐색 기능이 추가된다.
  • 양방향 연관관계를 매핑하려면 객체에서 양쪽 방향을 모두 관리해야 한다.