JPA

[JPA] 연관관계 매핑 - 연관 관계를 매핑할 때 어떻게 해야하는 가(단방향, 양방향)

뚜코맨 2024. 7. 20. 19:15

 

JPA를 공부하면서 학습 했던 내용을 정리해보고자 한다.

 

일단, RDB에서의 연관관계 매핑은 FK(외래키)로 이루어진다. 그치만 객체에서의 관계는 참조로 이루어진다. (연관관계의 주인이 꼭 있어야한다.) 여기서 RDB와 ORM의 패러다임의 불일치가 발생한다. 객체와 테이블의 연관관계에 대한 차이가 분명하게 있기 때문에, JPA에서는 이러한 연관관계를 매끄럽게 이용할 수 있도록 다양한 어노테이션를 제공해준다.

 

RDB에서에의 관계는 일대일, 일대다, 다대다가 존재한다. 테이블간의 연관관계가 1:1이냐, 1:N이냐, N:M이냐에 따라 외래키를 필드를 추가하여 사용한다.

 

객체를 테이블에 맞추어서 모델링을 하게 되는 경우를 보면 참조 대신에 외래 키를 직접 필드에 주입을 하게 된다.

 

Member라는 엔티티 안에 teamId(FK) 필드가 추가 되어있다. 이는 객체지향적인 설계가 아닌 테이블에 초점을 맞춘 모델링 방식이다.

이럴 경우 RDB에서는 연관관계가 지정이 되겠지만, ORM 입장에서 생각해보면 teamId 라는 필드는 아무런 연관관계가 지정 되어있지 않은 이름 모를 필드가 되어버린다.

 

이럴 경우에 외래 키 식별자를 직접 다루게 된다. 아래의 코드를 보자.

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.setTeamId(team.getId()) 이 부분이다. 이 부분이 무엇인가 문제가 있어보인다. RDB적인 관점에서 보면 외래키에 해당 팀 id를 주입했기 때문에 문제가 전혀 없어보이지만, ORM의 특징은 전혀 살리지 못한 개발 방식이다. member에 team을 추가하고 싶을 경우엔 객체지향적으로 생각해보면 member.setTeam(team)이라는 코드가 들어가야하지 않는가.

 

조회를 할 때 생각해보자.

Member findMember = em.find(Member.class, member.getId());

Long findTeamId = findMember.getTeamId();

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

 

멤버를 가져오려면 member.getId()로 조회를 해서 멤버를 찾아온다. 근데 찾아온 멤버가 어느 팀 소속인지 알려면, 찾아온 멤버의 getTeamId()를 가져와서 변수에 저장한 후 해당 teamId로 team 객체에서 찾아 와야 한다. 이것부터 테이블과 객체 사이에는 이렇게 간격이 차이가 나게 된다. 

 

객체테이블에 맞추어 데이터베이스 중심적으로 모델링을 하면 협력관계를 만들수가 없다. 테이블외래키로 조인을 사용해서 연관된 테이블을 찾지만, 객체참조를 통해서 연관된 객체를 찾기 때문이다.

 

그럼 개선된 객체지향적 모델링을 알아보자

 

아까와 다르게 Member 엔티티의 필드가 아닌 Team 객체 전체가 들어가게끔 모델링을 하게 된다. 코드로 보자.

package jpabook.jpashop.domain;

import jakarta.persistence.*;

import java.util.ArrayList;
import java.util.List;

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

   //getter and setter
}

 

엔티티가 이렇게 설계가 될 것이다. private Team team만 하게 되면 JPA 입장에선 전혀 연관이 없기 때문에 연관관계 매핑을 위한 어노테이션을 추가해준다. @ManyToOne, @JoinColumn(name = "team_id")을 지정해주어야 단방향으로 매핑이 완료된다.

 

여기서 주의할 점은 @ManyToOne을 어디에 붙혀야할지가 가장 중요하다. 모델링을 보면 하나에 팀에 여러명의 멤버가 들어갈 수 있기에 Team(1) : Member(N) 이다. @ManyToOne은 일대다 관계에서 무조건 "다" 쪽에 해당하는 엔티티에 붙혀준다. 그래서 Member 객체에 @ManyToOne을 붙혀준다. 이러면 단방향 매핑이 완료된다.

 

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

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

Member findMember = em.find(Member.class, member.getId());
Team findTeam = em.findMember.getTeam();

System.out.println("findTeam = " = findTeam.getName());

tx.commit();

 

member.setTeam(team) 이부분이 중요하다. 단방향 연관관계 설정하고 참조를 저장한다. 이제 객체지향적인 설계가 되었다.

 

그럼 단방향 연관관계는 이렇게 알아보았다. JPA에서 가장 중요한 양방향 연관관계를 알아보자.

 

위쪽의 단방향 연관관계에서는 멤버에서는 팀에 접근 할 수있지만, 팀에서는 멤버에 대해 접근을 할 수가 없다. 

 

 

테이블의 연관관계를 보면 외래키 하나로 사실 멤버, 팀 모두 접근이 가능하다. 양방향으로 되어있다. 사실상 테이블에 연관관계엔 방향이라는 개념이 존재하지 않는다. 외래키 하나로 서로에 대한 정보를 다 알 수 있기 때문이다.

 

하지만 객체는 다르다. 멤버에서는 팀을 갈 수 있지만, 팀에서는 멤버를 갈 수 없다. 그래서 팀 엔티티에서도 지정을 해줘야한다.

 

양방향 매핑은 Member 엔티티는 동일하지만 Team 엔티티가 달라진다.

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;

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

 

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

 

이렇게 해두면 팀에서도 멤버에 대한 list를 가져올 수 있다.

 

그럼 일대다의 관계에서 과연 양방향으로 매핑 관계를 잡는 것이 과연 무조건적으로 좋지는 않다. 객체 입장에서는 단방향이 좋다.

 

가장 중요한 것은 "mappedBy"가 가장 중요하다. 이것이 연관관계의 주인을 분명하게 정해준다. mappedBy = "team"의 의미는 일대다 매핑에서 "나는 team에게 관리 받고 있다" 라는 의미이다.

그렇기에 연관관계의 주인을 Member임을 암시하는 것이다. 이 부분이 정말 JPA의 기초적인 부분이지만 가장 중요한 내용이다.

 

객체와 테이블간의 연관관계를 맺는 차이를 이해해야 한다. 사실 양방향 연관관계지만 객체적인 입장에서 생각해보면 단방향이 2개가 양쪽으로 있는 것 뿐이다.

 

객체 연관관계 = 2개이다. 

- 회원 -> 팀 연관관계 1개(단방향)

- 팀 -> 회원 연관관계 1개(단방향)

 

테이블 연관관계 = 1개

- 회원 <-> 팀 연관관계(양방향)

 

객체의 양방향 관계는 양방향 관계가 아니라 서로 다른 방향 관계가 2개 있는 것이다. 객체를 양방향으로 참조하기 위해서는 단방향 연관관계를 2개를 만들어야한다.

 

양방향의 매핑 규칙은 다음과 같다.

  1. 객체의 두 관계중 하나를 연관관계의 주인으로 지정한다.(@ManyToOne)
  2. 연관관계의 주인만이 외래 키를 관리한다.(등록, 수정)
  3. 주인이 아닌쪽(@OneToMany)은 읽기(readOnly)만 가능하다.
  4. 주인은 mappedBy 속성 X
  5. 주인이 아니면 mappedBy 속성으로 주인이 누구인지를 알려줘야한다.

🙋🏻‍♂️ "그럼 새로운 멤버가 생겨서 팀에 넣고 싶을 때 어디에 추가해야돼?"

 

 

내가 만약 새로운 멤버가 생겨서 팀에 넣고 싶을때 양방향으로 관계를 지정해두면 나는 Member에 Team을 수정해야하는가? Team에 List members에 수정해야하는가? 라는 딜레마가 오게 된다. 이의 해답은 연관 관계 주인에 있다. 

 

연관관계의 주인은 Member.team에 있다. 주인의 반대편은 Team.members이다. Team에서 list member를 아무리 수정 해봐야 데이터베이스엔 적용되지 않는다. 왜냐, 주인이 아닌쪽은 member에 대한 읽기 권한만 있기 때문이다. 

 

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

em.persist(team);
 
Member member = new Member();
member.setName("member1");
team.getMembers().add(member);

em.persist(member);

 

자, 이 코드를 보자. new Member()로 새로운 멤버를 생성했다. 그 후 team.getMembers()로 멤버 리스트를 가져온 후 멤버 리스트에 add로 새로운 멤버인 member를 추가했다. 실행 결과를 보자. 

 

분명 insert 쿼리가 2번이 나갔고, 디비안에 결과를 보자. teamId가 왜 null이지? (연관 관계가 성립되지 않았다.)

 

이부분이 JPA를 사용할 때 가장 많이 하는 실수이다. 지금 이 코드는 연관관계의 주인(member)에 데이터를 넣은게 아니라, team(가짜매핑)에 데이터를 넣었다. 그렇기에 연관관계 지정이 되지 않고 서로다른 insert 쿼리가 2개가 나가게 된것이다.

 

그럼 반대로 연관관계 주인(member)에게 값을 넣어보겠다.

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

Member member = new Member();
member.setName("member1");
//연관관계의 주인에 값 설정
member.setTeam(team);

em.persist(member);

 

member.setTeam(team)으로 연관관계의 주인인 member에서 team을 지정해주었다. 이렇게 해야 정상적으로 연관관계가 성립이 된다.

 

그럼 member에서 setTeam(team)으로 값을 넣어줬는데 그럼 반대쪽에 객체(team)에는 값을 안넣어줘도 되는가? 사실 JPA 입장에서는 team에서 member를 넣지 않아도 정상적으로 조회가 가능하다. 하지만, 객체지향적으로 생각을 해보면 양쪽에 값을 다 넣어주는 것이 맞다. 

 

다음의 상황을 보자

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

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

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

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

tx.commit();

 

팀을 등록하고, 팀에 멤버를 등록하고, 해당 팀에 존재하는 member list를 조회를 하는 코드이다. 실행결과를 보자.

 

 

setTeam()으로 분명히 member, team을 연관관계 매핑을 해두었는데 iter를 돌려보면 아무런 member 정보가 나오지 않는다.

이유는 1차캐시에 있다. team이라는 엔티티는 이미 1차캐시에 등록되어있는 순수 객체기 때문에 조회를 하면 아주 순수한 객체만의 반환되기때문에 size()를 찍어보아도 0이 나올것이다. 이렇기 때문에, 양방향 매핑 관계에서도 연관관계의 주인에 값을 설정하는 것 뿐만아니라 가짜매핑쪽인 team에도 값을 넣어줘야한다.

 

...
team.getMembers().add(member);

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

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

 

team.getMembers().add(member); 이렇게 team쪽에서도 값을 넣어주면, 잘 나오는 걸 볼 수 있다.

 

하지만 1차 캐시에서 조회를 하지 않고 em.flush(), em.clear()을 통해 1차 캐시를 비워주고 db에서 조회를 하면 연관관계의 주인에서만 값을 정해주어도 값은 잘 나오게 된다.

 

결론

  • 단방향 매핑만으로도 이미 연관관계 매핑은 끝이다
  • 양방향 매핑은 반대방향으로 조회(객체 그래프 탐색) 기능이 추가 된 것 뿐이다
  • 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하자
  • 단방향 매핑을 우선 잘해두고, 양방향은 필요할 때 추가하자(테이블의 영향을 주지 않는다)