[JPA 기본편] 4. 연관관계 매핑 기초

HJ·2024년 2월 22일
0

JPA 기본편

목록 보기
4/10
post-custom-banner

김영한 님의 자바 ORM 표준 JPA 프로그래밍 - 기본편 강의를 보고 작성한 내용입니다.


1. 연관관계가 필요한 이유

객체는 참조로 연관관계를 표현하고, 테이블은 외래키로 연관관계를 표현합니다. 그래서 객체의 참조와 테이블의 외래키를 매핑하는 방법을 알아야 합니다.

예를 들어 아래와 같은 관계가 있다고 가정해보겠습니다.

회원과 팀이 있다
회원은 하나의 팀에만 소속될 수 있다
하나의 팀에는 여러 명의 회원이 존재한다
➜ 회원과 팀은 다대일 관계이다

해당 관계에서 객체를 테이블에 맞추어 모델링하면 아래와 같고 이는 연관관계가 없는 객체가 됩니다.

@Entity
public class Member {
  @Id @GeneratedValue
  private Long id;
  private String name;
  @Column(name = "TEAM_ID")
  private Long teamId;
  ...
}
// ------------------------
@Entity
public class Team {
  @Id @GeneratedValue
  private Long id;
  private String name;
  ...
}

테이블에 맞추어 모델링을 했기 때문에 Member 가 Team 에 대한 참조를 가진 것이 아닌 Team 의 ID 를 FK 로 가지게 됩니다.


만약 회원의 팀을 저장하고 조회하려면 서로 연관관계가 없기 때문에 아래처럼 해야 합니다.

// 저장
Team team = new Team();
team.setName("teamA"); 
em.persist(team);

Member member = new Member();
member.setName("memberA");
member.setTeamId(team.getId());
em.persist(member);
// ------------------------------------------------------
// 조회
Member findMember = em.find(Member.class, member.getId());

Long findTeamId = findMember.getId();
Team findTeam = em.find(Team.class, findTeamId);

연관관계가 없기 때문에 member 를 저장할 때 Team 의 참조가 아닌 ID 를 사용합니다. 또한 회원의 팀을 조회할 때도 member 에서 Team 의 id 를 조회하고, team 의 id 로 다시 Team 을 조회해야 합니다.

이렇게 객체를 테이블에 맞추어 데이터 중심으로 모델링하면 협력 관계를 만들 수 없습니다.




2. 단방향 연관관계

위에서 보았던 예제를 객체지향적으로 모델링하면 아래와 같습니다.

@Entity
public class Member {
  @Id @GeneratedValue
  private Long id;
  private String name;
  @ManyToOne
  @JoinColumn(name = "TEAM_ID")
  private Team team;
  ...
}
// ------------------------
@Entity
public class Team {
  @Id @GeneratedValue
  private Long id;
  private String name;
  ...
}

Member 를 보면 Team 의 ID 가 아닌 Team 의 참조를 그대로 들고 있습니다. 참조를 사용하면 아래 두 가지를 명시해주어야 합니다.

  1. JPA 에게 현재 엔티티와 참조 엔티티가 어떤 관계인지

  2. 현재 엔티티에서 참조가 어떤 FK 와 매핑되는지

1번의 경우 회원과 팀은 다대일 관계이기 때문에 @ManyToOne 을 사용합니다.

2번의 경우 Member 가 가진 Team 의 참조와 테이블 관점에서 보았을 때 Member 가 가진 FK 를 매핑해주어야 하기 때문에 @JoinColumn 을 사용해서 이를 지정해줍니다.


위처럼 모델링 했을 때 저장과 조회를 하는 코드는 아래처럼 변하게 됩니다.

// 저장
Team team = new Team();
team.setName("teamA");  // 단방향 연관관계 설정, 참조 저장
em.persist(team);

Member member = new Member();
member.setName("memberA");
member.setTeam(team);
em.persist(member);
// ------------------------------------------------------
// 조회
Member findMember = em.find(Member.class, member.getId());
Team findTeam = findMember.getTeam();   // 참조를 사용해서 연관관계 조회 ( 객체 그래프 탐색 )

저장할 때는 Team 의 참조를 넣어주면 되고, 조회할 때는 Member 에서 바로 Team 을 꺼내면 됩니다. 변경할 때도 setTeam() 에 Team 에 대한 참조만 넣어주면 됩니다.




3. 양방향 연관관계

위에서 단방향 관계로 설정했기 때문에 Member ➜ Team 은 가능하지만 Team ➜ Member 는 불가능합니다. 이를 양방향 연관관계로 변경하면 아래와 같습니다.

테이블 연관관계를 보면 이전과 동일합니다. 왜냐하면 회원이 속한 팀을 알고 싶을 때도 TEAM_ID 로 조인하면 되고, 팀에 속한 회원을 알고 싶을 때도 TEAM_ID 로 조인하면 되기 때문입니다.

즉, 테이블의 연관관계는 외래키 하나에 양방향이 다 있다는 것이고, 테이블에서는 외래키 하나 만으로 양쪽의 데이터를 가져올 수 있게 됩니다.

하지만 객체 연관관계에서 Team 을 보면 List Member 가 추가된 것을 볼 수 있는데 이렇게 해야 Team 에서 Member 로 갈 수 있기 때문에 추가된 것입니다.

이것이 객체 참조와 테이블의 외래키의 가장 큰 차이점인데 참조를 통한 객체 연관관계는 단방향, 외래키를 통한 테이블 연관관계는 양방향입니다.


@Entity
public class Member {
  @Id
  @Column(name = "MEMBER_ID")
  private String id;
  private String username;

  @ManyToOne
  @JoinColumn(name="TEAM_ID")
  private Team team;
  ...
}
// ------------------------
@Entity
public class Team {
  @Id
  @Column(name = "TEAM_ID")
  private String id;
  private String name;

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

Team 에 입장에서 일대다 관계이기 때문에 @OneToMany 를 사용하고 이를 List 로 묶습니다. 이때 미리 초기화 시켜야 add() 를 호출했을 때 NullPointerException 이 발생하지 않습니다.

@OneToMany 를 보면 mappedBy 라는 속성이 사용된 것을 볼 수 있는데, team 이라는 변수명에 연결되어 있음을 표시함과 동시에 연관관계의 주인이 아님을 명시합니다.

이렇게 하면 Member ➜ Team, Team ➜ Member 가 둘 다 가능한 양방향 연관관계가 완성됩니다.




4. 양방향 연관관계의 주인

4-1. 객체와 테이블이 관계를 맺는 차이

객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단뱡향 관계 2개입니다. 그래서 객체를 양방향으로 참조하려면 단방향 연관관계를 2개 만들어야 합니다.

하지만 테이블은 외래키 하나로 두 테이블의 연관관계를 관리하고, 외래키 하나로 양방향 연관관계를 가져 양쪽으로 조인할 수 있습니다.

결국 엔티티와 테이블의 차이점은 엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데 테이블에서는 외래키 하나라는 점입니다.

2번에서 객체에서 연관관계를 표현하기 위해 참조에 FK 를 매핑해주었습니다. 단방향일 때는 참조가 1개였지만, 양방향일 때는 참조가 2개입니다. 그래서 어떤 참조에 외래키를 매핑해야 할 지 문제가 발생하는데 이때 등장하는 개념이 연관관계의 주인입니다.


4-2. 연관관계의 주인

정리하자면 위의 그림처럼 양방향 관계일 때는 Member 에 있는 Team 참조에 FK 를 매핑할 것인지, Team 에 있는 Member 참조에 FK 를 매핑할 것인지를 결정해야 합니다.

연관관계의 주인을 정할 때는 외래키가 있는 곳을 주인으로 정하면 됩니다. 위의 예시에서는 Member.team 이 연관관계의 주인이 됩니다.

그래서 연관관계의 주인이 아닌 Team 에 mappedBy 가 붙었으며, Member 의 team 필드와 매핑되어 있다고 표시합니다.

[ 양방향 매핑 규칙 ]

  1. 객체의 두 관계 중 하나를 연관관계의 주인으로 지정합니다.

  2. 연관관계의 주인만이 외래키를 관리합니다. ( 등록, 수정이 가능합니다 )

  3. 주인이 아닌 쪽은 읽기만 가능합니다.

  4. 주인은 mappedBy 속성을 사용하지 않습니다.

  5. 주인이 아니면 mappedBy 속성으로 주인을 지정합니다.


4-3. 양방향 매핑 시 주의점

1. 연관관계 주인에 값을 입력하지 않음

[ 잘못된 코드 ]

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

Member member = new Member();
member.setUsername("memebrA");

team.getMembers().add(member);  // 연관관계의 주인이 아닌 값만 설정
em.persist(member);

위의 코드를 실행하고 Member 와 Team 을 조회하면 아래와 같습니다. Member 테이블의 TEAM_ID 를 보면 NULL 인 것을 알 수 있습니다.

MEMBER_IDUSERNAMETEAM_ID
1member1null

TEAM_IDNAME
2TeamA

Member 를 보면 Team 참조가 연관관계의 주인이고, Team 의 List Member 는 mappedBy 로 설정되어 읽기 전용입니다. 그래서 JPA 는 Insert 나 Update 를 할 때 mappedBy 가 붙은 부분은 확인하지 않습니다.

하지만 위의 코드에서는 연관관계의 주인인 Member.team 에 값을 세팅하지 않았기 때문에 MEMBER 의 TEAM_ID 값이 null 이 됩니다. 올바르게 하려면 양방향 매핑 시, 연관관계의 주인에 값을 입력하는 것이 맞습니다.

[ 올바른 코드 ]

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

Member member = new Member();
member.setUsername("memebrA");

member.setTeam(team); // 연관관계 주인
em.persist(member);

2. 순수 객체 관계를 고려해서 항상 양쪽에 값을 세팅해야 한다

순수한 객체 관계를 고려하면 연관관계의 주인에도 세팅을 해주고, 주인이 아닌 쪽에도 값을 세팅해주는 것이 맞는 방법입니다. 아래 예시를 보겠습니다.

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

Member member = new Member();
member.setUsername("memebrA");
member.setTeam(team); // 연관관계 주인
em.persist(member);

Team findTeam = em.find(Team.class, team.getId());
List<Member> members = findTeam.getMembers();

for(Member m : members) {
  System.out.println("m = " + m.getName());
}

tx.commit();

위의 코드를 실행시키면 아무것도 출력되지 않습니다. 왜냐하면 Member 와 Team 이 지연로딩이 설정되어 있어, 조회를 하려면 DB 에 select 쿼리가 나가야 하는데 1차 캐시에 있는 값을 가져왔기 때문입니다.

persist() 로 인해 Team 이 영속성 컨텍스트에 존재하게 되고, find() 를 실행할 때 1차 캐시에서 가지고 오게 됩니다. 그래서 DB에 쿼리가 나가지 않아 가져온 Team 의 List 에는 값이 존재하지 않게 됩니다. 그래서 아래처럼 양쪽 모두에 값을 세팅하는 것이 올바른 방법입니다.

Team team = new Team();
team.setName("teamA");

Member member = new Member();
member.setUsername("memebrA");

member.setTeam(team); // 연관관계 주인
team.getMembers().add(member);  // 주인이 아닌 관계
em.persist(team);
em.persist(member);

영한님이 추천하는 방식

public class Member {
  ...
  public void setTeam(Team team) {
    this.team = team;
    team.getMember().add(this);
  }
}

연관관계의 주인에 값을 세팅하는 것과, 연관관계의 주인이 아닌 곳에 값을 세팅하는 코드 두 개를 작성해야 하는데 이를 연관관계 편의 메서드를 생성해서 하나로 묶는 방식을 추천하셨습니다.


4-4. 양방향 매핑 정리

단방향 매핑만으로도 이미 연관관계 매핑은 완료된 것입니다. 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색)기능이 추가된 것 뿐입니다.

그래서 단방향 매핑을 잘 하고, 양방향은 필요할 때 추가해도 테이블에 영향을 주지 않기 때문에 상관없습니다.

post-custom-banner

0개의 댓글