개발/JPA

[JPA] 연관관계 매핑 기초

highright96 2021. 12. 14.

JPA 스터디를 진행하며 작성한 글입니다.

 

객체의 참조와 테이블의 외래 키를 매핑하는 것이 이 글의 목표이다. 시작하기 전에 연관관계 매핑을 이하하기 위한 핵심 키워드를 정리해보았다.

  • 방향 : 단방향, 양방향이 있다. 회원 -> 팀 또는 팀 -> 회원 둘 중 한쪽만 참조하는 것을 단방향 관계라 하고, 양쪽 모두 서로 참조하는 것을 양방향 관계라 한다.
  • 다중성 : 다대일, 일대다, 일대일, 다대다 다중성이 있다. 예를 들어 회원과 팀이 관계가 있을 때 여러 회원은 한 팀에 속하므로 다대일 관계다.
  • 연관관계의 주인 : 객체를 양방향 연관관계로 만들면 연관관계의 주인을 정해야 한다.

 

단방향 연관관계

객체 연관관계 vs 테이블 연관관계

  • 객체는 참조(주소)로 연관관계를 맺는다.
  • 테이블은 외래 키로 연관관계를 맺는다.

이 둘은 비슷해 보이지만 매우 다른 특징을 가진다. 연관된 데이터를 조회할 때 객체는 참조를 사용하지만 테이블은 조인을 사용한다. 이때 참조를 사용하는 객체의 연관관계는 단방향(A -> B)이고 외래 키를 사용하는 테이블의 연관관계는 양방향(A -> B, B -> A)이다.

 

순수한 객체 연관관계

순수하게 객체만 사용한 연관관계를 살펴보겠다. 아래 코드는 JPA를 사용하지 않은 순수한 회원과 팀 클래스 코드다.

public class Member {

    private String id;
    private String username;
    private Team team;

    // Getter, Setter
}

public class Team {

    private String id;
    private String name;

    // Getter, Setter
}

회원1과 회원2를 팀1에 소속시키고 회원1이 속한 팀1을 조회해보자.

public static void main(String[]args){
    Member member1=new Member("member1","회원1");
    Member member2=new Member("member2","회원2");
    Team team1=new Team("team1","팀1");

    member1.setTeam(team1);
    member2.setTeam(team1);

    Team findTeam=member1.getTeam(); //팀1 조회
    }

이처럼 객체는 참조를 사용해서 연관관계를 탐색할 수 있는데 이것을 객체 그래프 탐색이라 한다.

 

테이블 연관관계

데이터베이스 테이블의 회원과 팀의 관계를 살펴보겠다. 순수한 객체 연관관계에서 보여준 회원, 팀 테이블을 생성하고 insert 쿼리를 실행해 회원1과 회원2를 팀1에 소속시켰다고 가정하겠다. 데이터베이스는 객체와는 다르게 아래와 같이 외래 키를 사용해서 특정 회원이 소속된 팀을 탐색할 수 있는데 이것을 조인이라 한다.

SELECT T.*
FROM MEMBER M
         JOIN TEAM T ON M.TEAM_ID = T.ID
WHERE M.MEMBER_ID = 'member1'

반대로 특정 팀에 소속된 회원들도 탐색할 수 있다. 이것이 양방향 연관관계인 이유다.

 

객체 관계 매핑

이번에는 JPA를 사용해서 회원과 팀을 매핑하겠다.

@Entity
public class Member {

    @Id
    @Column(name = "MEMBER_ID")
    private String id;
    private String username;

    // 연관 관계 매핑**
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    // Getter, Setter
}

@Entity
public class Team {

    @Id
    @Column(name = "TEAM_ID")
    private String id;
    private String name;

    // Getter, Setter
}

 

@ManyToOne

이름 그대로 다대일(N:1) 관계라는 매핑 정보다. 회원과 팀은 다대일 관계다. 연관관계를 매핑할 때 이렇게 다중성을 나타내는 어노테이션을 필수로 사용해야 한다.

 

@JoinColumn

조인 컬럼은 외래 키를 매핑할 때 사용한다. name 속성에는 매핑할 외래 키 이름을 지정한다. 회원과 팀 테이블은 TEAM_ID 외래 키로 연관관계를 맺으므로 이 값을 지정하면 된다. 이 어노테이션은 생략할 수 있다.

 

연관관계 사용

저장

public void testSave(){
    Team team1=new Team("team1","팀1");
    em.persist(team1);

    Member member1=new Member("member1","회원");
    member1.setTeam(team1);
    em.persist(member1);

    Member member2=new member("member2","회원2");
    member2.setTeam(team1);
    em.persist(member2);
    }

 

조회

연관관계가 있는 엔티티를 조회하는 방법은 크게 2가지다.

  • 객체 그래프 탐색
  • 객체지향 쿼리 사용(JPQL)

 

객체 그래프 탐색

member.getTeam()을 사용해서 member와 연관된 team 엔티티를 조회할 수 있다.

Member member=em.find(Member.class,"member1");
    Team team=member.getTeam(); //객체 그래프 탐색
    System.out.println("팀 이름 = "+team.getTeam());

//출력 결과: 팀 이름 = 팀1

 

객체지향 쿼리 사용(JPQL)

select m from Member m join m.team where t.name=:teamName

자세한 내용은 10장에서 다루겠다.

 

수정

private static void updateRelation(EntityManager em){
    Team team2=new Team("team2","팀2");
    em.persist(team2);

    Member member=em.find(Member.class,"member1");
    member.setTeam(team2);
    }

트랜잭션을 커밋할 때 플로시가 일어나면서 변경 감지 기능이 작동한다. 그리고 변경사항을 데이터베이스에 자동으로 반영한다.

 

연관관계 제거

private static void deleteRelation(EntityManager em){
    Member member1=em.find(Member.class,"member1");
    member1.setTeam(null);
    }

연관관계를 null로 설정하면 변경 감지 기능이 동작해 변경사항이 데이터베이스에 자동으로 반영된다.

 

연관된 엔티티 삭제

member1.setTeam(null);
    member2.setTeam(null);
    em.remove(team);

연관된 엔티티 자체를 삭제하려면 기존에 있던 연관 관계를 먼저 제거하고 삭제해야 한다. 그렇지 않으면 외래 키 제약 조건으로 인해, 데이터베이스에서 오류가 발생한다.

 

양방향 연관관계

회원에서 팀으로 접근하고 반대 방향인 팀에서도 회원으로 접근할 수 있도록 양방향 연관관계로 매핑해보겠다.

@Entity
public class Member {

    @Id
    @Column(name = "MEMBER_ID")
    private String id;
    private String username;

    // 연관 관계 매핑**
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    // Getter, Setter
}

@Entity
public class Team {

    @Id
    @Column(name = "TEAM_ID")
    private String id;
    private String name;

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

    // Getter, Setter
}

팀과 회원은 일대다 관계다. 따라서 팀 엔티티에 List<Member> members를 추가했다. 그리고 일대다 관계를 매핑하기 위해 @OneToMany 매핑 정보를 사용했다. mappedBy 속성은 양방향 매핑일 때 사용하는데 반대쪽 매핑의 필드 이름을 값으로 주면 된다. 자세한 내용은 다음에 나오는 연관관계의 주인에서 설명하겠다.

 

연관관계의 주인

테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다. 엔티티를 단방향으로 매핑하면 참조를 하나만 사용하므로 이 참조로 외래 키를 관리하면 된다. 그런데 엔티티를 양방향으로 매핑하면 회원 -> 팀, 팀 -> 회원 두 곳에서 서로를 참조한다. 따라서 객체의 연관관계를 관리하는 포인트는 2곳으로 늘어난다.

엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데 외래 키는 하나다. 따라서 둘 사이에 차이가 발생한다. 이런 차이로 인해 JPA에서는 두 객체 연관관계 중 하나를 정해서 테이블의 외래 키를 관리해야 하는데 이것을 연관관계의 주인이라 한다.

 

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

양방향 연관관계 매핑 시 지켜야할 규칙이 있는데 두 연관관계 중 하나를 연관관계의 주인으로 정해야 한다. 연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래 키를 관리할 수 있다. 반면에 주인이 아닌 쪽은 읽기만 할 수 있다.

 

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

연관관계의 주인은 테이블에 외래 키가 있는 곳으로 정해야 한다. 회원 테이블이 외래 키를 가지고 있으므로 Member.team이 주인이 된다. 주인이 아닌 Team.members에는 mappedBy="team" 속성을 사용해서 주인이 아님을 설정해야 한다.

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

 

양방향 연관관계 저장

아래와 같이 팀1, 회원1, 회원2를 저장할 수 있다.

public void testSave(){
    Team team1=new Team("team1","팀1");
    em.persist(team1);

    Member member1=new Member("member1","회원");
    member1.setTeam(team1);
    em.persist(member1);

    Member member2=new member("member2","회원2");
    member2.setTeam(team1);
    em.persist(member2);
    }

Team.members는 연관관계의 주인이 아니므로 외래 키에 영향을 주지 않는다. 따라서 아래 코드는 데이터베이스에 저장할 때 무시된다.

team1.getMembers().add(member1);
    team1.getMembers().add(member2);

 

양방향 연관관계의 주의점

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

객체 관점에서 아래와 같이 양쪽 방향에 모두 값을 입력해주는 것이 가장 안전하다. 양쪽 방향 모두 값을 입력하지 않으면 JPA를 사용하지 않는 순수한 객체 상태에서 심각한 문제가 발생할 수 있다.

public void test순수한객체_양방향(){
    Team team1=new Team("team1","팀1");
    em.persist(team1);

    Member member1=new Member("member1","회원1");

    //양방향 연관관계 설정
    member1.setTeam(team1);
    team1.getMembers().add(member1);
    em.persist(member1);

    //양방향 연관관계 설정
    Member member2=new Member("member2","회원2");
    member2.setTeam(team2);
    team1.getMembers().add(member2);
    em.persist(member2);
    }

 

연관관계 편의 메소드

양방향 연관관계는 결국 양쪽 다 신경 써야 하기 때문에 실수하기 쉽다. 따라서 다음과 괕이 두 코드를 하나인 것처럼 사용하는 것이 안전하다.

public class Member {

    private Team team;

    public void setTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}

 

연관관계 편의 메소드 작성 시 주의사항

member1.setTeam(teamA);
    member1.setTeam(teamB);
    Member findMember=teamA.getMember();

teamA를 teamB로 변경할 때 teamA -> member1 관계를 제거하지 않았다. 따라서 연관관계를 변경할 때는 기존 팀이 있으면 삭제하는 코드를 추가해야 한다.

public void setTeam(Team team){
      if(this.team!=null){
      this.team.getMembers().remove(this);
      }
      this.team=team;
      team.getMembers().add(this);
}

 

기타 알게된 사항

어디에 연관관계 편의 메소드를 두는게 좋을까?

우선 3가지 선택지가 있다.

  1. 엔티티 A에 둔다.
  2. 엔티티 B에 둔다.
  3. 엔티티 A, B에 둘다 둔다.

둘다 두는 것은 혼란을 가중하기 때문에 제외하고, A와 B 중 하나를 선택해서 사용하는 것이 좋다. 그러면 여기서 A, B 중에 하나를 선택해야 하는데 사실 이 부분은 정답이 없다. JPA의 영역이라기 보다는 오히려 객체지향 설계의 영역이기 때문이다.

 

예를 들어, Order와 Delivery 중에서 우리 팀의 핵심 비즈니스가 주문이라면 Order에 연관관계 편의 메서드를 두는 것이 더 나은 선택일 확률이 높다. 그런데 만약 우리 팀이 배달을 책임지는 팀이고 Order 엔티티는 있지만, 관련된 정보는 크게 의미가 없다면 Delivery 를 중심으로 비즈니스 로직이 진행되므로, Delivery 에 연관관계 편의 메서드를 두는 것이 나은 선택이 된다.

 

도메인 주도 설계(DDD)에 나오는 Aggregate Root 라는 개념을 사용한다면, Aggregate Root 에 연관관계 편의 메서드를 두는 것이 좋은 선택일 수도 있다.

 

정리하자면 실제 엔티티를 사용하는 비즈니스 로직을 구현할 때, A에 연관관계 편의 메서드를 두고 개발을 해보고 바꿔서 B에 연관관계 편의 메서드를 두어 보면, 어디에 두는 것이 더 유지보수하기 쉬운지 느껴질 것이다. 둘다 두는 것은 혼란을 가중하기 때문에 권장하지 않는다.

 

참고

'개발 > JPA' 카테고리의 다른 글

[JPA] 고급 매핑  (0) 2021.12.21
[JPA] 다양한 연관관계 매핑  (0) 2021.12.14
[JPA] 엔티티 매핑  (0) 2021.12.08
[JPA] 영속성 관리  (0) 2021.12.08
[JPA] JPA 소개  (0) 2021.12.08

댓글