JPA (5) 연관 관계 매핑

김영한님의 자바 ORM 표준 JPA 프로그래밍 강의 정리

단방향 연관 관계

아래의 테이블 관계가 있다고 가정하면 객체도 이에 맞춰서 모델링을 하게 된다.

Member Team
member_id (PK) team_id (PK)
team_id (FK) Name
// Member
@Entity
public class Member {
    @Id @GeneratedValue @Column(name = "member_id")
    private Long id;
    private Long teamId;
    
    // getter & setter
}

// Team
@Entity
public class Team {
    @Id @GeneratedValue @Column(name = "team_id")
    private Long id;
    private String name;
    
    // getter & setter
}

Member 클래스에서 team_id 외래키를 테이블에 맞추어서 모델링 하다보니 Team 클래스를 참조하는 것이 아닌 team_id 값에 맞춰서 Long 타입을 쓰게 됐다. 이렇게 사용할 경우, member에서 team 정보를 가져온다면 아래처럼 될 것이다.

// member의 team 저장할 때
Team team = new Team();
team.setName("A");
em.persist(team);

Member member = new Member();
member.setTeamId(team.getId());
em.persist(member);

// member의 team 정보를 가져올 때
Member member = em.find(Member.class, 1L);
Team team = em.find(Team.class, member.getTeamId());

테이블은 FK로 조인을 해서 연관된 테이블을 찾지만, 객체는 참조를 이용해서 연관된 객체를 찾는다. DB의 테이블과 객체는 이런 차이가 있기 때문에 객체를 테이블에 맞추어 모델링 하는 게 객체지향적으로 모델링을 할 수 없다. 문제가 되었던 teamId를 Team 객체를 참조하도록 바꿔보도록 한다. 그리고 JPA에서 연관 관계를 알 수 있도록 어노테이션을 추가해주어야 한다. 테이블에서 member와 team은 다대일의 관계이다. Member 클래스가 N이고 Team 클래스가 1의 관계를 가지게 되므로 Member 클래스의 Team 필드에 @ManyToOne 어노테이션을 추가해주도록 한다. 그리고 @JoinColumn 어노테이션도 추가해서 조인할 컬럼을 알 수 있도록 한다.

@Entity
public class Member {
    @Id @GeneratedValue @Column(name = "member_id")
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
    
    // getter & setter
}

테이블을 기반한 모델링에서 객체 지향 모델링으로 바꾼 후에는 객체 그래프 탐색이 훨씬 간단해진다. Team 클래스를 참조하기 때문에 member 객체를 찾으면 관계가 있는 team 객체 또한 바로 조회할 수 있다. team을 변경할 때도 setter 메서드로 변경만 하면 된다.

// 조회
Member member = em.find(Member.class, 1L);
Team team = member.getTeam();

// 수정
member.setTeam(otherTeam);

양방향 연관 관계

member에서 연관관계를 설정했기 때문에 member에서는 team을 참조할 수 있다. DB에서는 team에서도 연관 관계에 있는 member 테이블의 정보를 얻을 수 있지만, team 객체는 연관 관계가 있는 member 객체를 알 수 없다. 여기서 양방향 연관관계가 필요하게 된다. member와 team 클래스 간에 양방향으로 참조할 수 있도록 team 클래스도 수정해보도록 한다. List로 members 필드를 추가해주도록 하고 NPE를 방지하도록 초기화도 해주도록 한다. team과 member과 일대다의 관계이므로 team에서도 어노테이션을 추가해주어야 한다. @OneToMany 어노테이션을 추가해주고 mappedBy에서 member 클래스에서 Team 필드의 변수명을 적어주도록 한다.

@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<>();
    
    // getter & setter
}

Member에서 team을 조회한 것처럼 Team에서도 연관된 member를 조회해서 객체 그래프 탐색을 할 수 있다.

Team team = em.find(Team.class, 1L);
List<Member> findMembers = team.getMembers();

연관 관계의 주인 & mappedBy

연관 관계, 단방향, 양방향라는 표현이 나오면서 객체 모델링을 어떻게 해야할지 헷갈린다. @ManyToOne과 @OneToMany 어노테이션을 설정하는 것도 이해하기 어려울 수 있다. 연관관계와 모델링을 이해하기 위해서는 객체와 테이블의 차이를 먼저 이해해야 한다. Member와 Team을 예로 들면, DB에서는 member에 있는 외래키만으로도 두 테이블끼리 연결이 가능하다. FROM 뒤에 어떤 테이블이 오든간에 JOIN을 통해 연관된 테이블의 데이터를 가져올 수 있다.

SELECT * FROM member m JOIN team t on m.team_id = t.team_id;
SELECt * FROM team t JOIN member m on t.team_id = m.team_id;

하지만 객체에서는 그렇지 않다. member 클래스에서 team 클래스에 관한 필드가 있다면 member에서 team으로 단방향의 연관관계가 생기고 team 클래스에서 member 클래스를 참조할 수 있는 필드가 있으면 team에서 member로 단방향 연관관계가 생겨 테이블과는 다르게 두 개의 연관관계가 생기게 된다. 결국 두 개의 단방향 관계는 한 개의 양방향 관계이다. 각 클래스가 서로를 참조하기 위해 연관 관계를 나타내는 필드가 필요하다는 것을 이해했지만, 테이블의 외래 키에 해당되는 것을 정해야 한다. 그렇지 않으면 데이터를 업데이트 할 때마다 두 클래스를 모두 해야한다. 연관 관계의 주인이라 표현하는데 그 중에 어떤 것을 연관 관계의 주인으로 정해야 할까? 연관 관계의 주인을 정하는 것은 간단하다. 테이블에서 외래 키를 가지는 테이블에 해당하는 객체를 연관 관계의 주인으로 정하면 된다. 예제에서는 member 클래스의 team 필드가 연관 관계의 주인이 되면 된다.

연관 관계의 주인 주인의 반대편
외래 키가 있는 곳 일대다에서 일에 해당하는 곳
@ManyToOne @OneToMany
@JoinColumn mappedBy 속성
등록, 수정 가능 읽기 전용

주의 1

양방향 매핑을 한 후에 많이 하는 실수 중에 하나는 연관 관계의 주인이 아닌 반대편에 값을 입력하는 것이다. 아래 코드처럼 한 후 DB에서 확인하면 member 테이블의 team_id가 null로 나온다.

Team team = new Team();
team.setName("A");
em.persist(team);

Member member = new Member();

team.getMembers().add(member); // 연관 관계의 주인이 아닌 반대편에 값을 저장

em.persist(member);

양방향으로 매핑할 때는 항상 연관 관계의 주인에 값을 입력하도록 해야한다.

Team team = new Team();
team.setName("A");
em.persist(team);

Member member = new Member();
member.setTeam(team);
em.persist(member);

주의 2

양방향 매핑을 하게 되면서 member, team 두 클래스 모두 서로를 참조할 수 있는 필드를 갖게 되었다. 연관 관계의 주인에 값을 입력하는 것이 맞지만 반대 방향의 객체의 상태도 일치하도록 양쪽 다 값을 설정하는 게 좋다.

Team team = new Team();
team.setName("A");
em.persist(team);

Member member = new Member();
member.setTeam(team);
em.persist(member);

team.members().add(member);

하지만 양쪽 다 값을 입력하게 되면 실수할 가능성도 있으니 한 쪽 객체를 정하고 메서드를 생성해서 양방향 객체의 값을 모두 입력하도록 하는 것이 좋다. 아래의 코드처럼 member에서 추가하거나 team에서 추가할 수 있는데 둘 다 메서드를 만드는 것보다 한 쪽을 정하고 값을 입력하도록 하는 것이 좋다.

// Member
public void updateTeam(team) {
    this.team = team;
    team.members().add(this);
}

// Team
public void addMember(member) {
    members().add(member);
    member.setTeam(this);
}

또한, 양방향 매핑할 때는 toString(), lombok, JSON 생성 라이브러리 등에서 무한 루프를 주의하도록 해야한다. toString() 메서드를 생성할 때 연관 관계 필드가 들어가지 않도록 주의해야 한다.

정리

JPA에서는 단방향 매핑만으로도 연관 관계 매핑은 완료된 것이다. 양방향 매핑은 반대 방향으로 조회하기 위함이지 필수로 해야하는 것이 아니다. 그러므로 기본적으로는 단방향 매핑을 먼저 잘 설계하도록 하고 양방향 매핑은 필요할 때 추가하도록 하는 것이 좋다.