Programming-[Backend]/JPA

[JPA기본] 6. 양방향 연관관계

컴퓨터 탐험가 찰리 2021. 9. 28. 22:07
728x90
반응형

 

1. 양방향 연관관계의 필요성

이전 글에서는 Member 객체에 teamId 대신 Team 객체 자체를 넣는 것이 효율적이고, 이렇게 함으로써 단방향 연관관계를 맺을 수 있다는 것을 배웠다. 그리고 이를 이용하여 특정 Member가 속한 Team을 얻어낼 수 있었다.

 

테이블은 외래키를 통해 이미 양방향 관계가 있는 것과 같은 구조이다

그런데 만약 특정 Team에 해당하는 Member 들을 조회하고 싶다면?

테이블의 연관 관계에서는 그냥 TEAM 테이블과 MEMBER 테이블을 조인하여, TEAM.TEAM_ID = MEMBER.TEAM_ID인 항목을 MEMBER 테이블에서 조회하면 된다. 이전 글에서 살펴본 것처럼 Member가 소속된 Team을 찾는 경우도 이런 방식으로 하면 된다. 다시 말해, 테이블은 말하자면 외래키를 이용하여 양방향 연관관계를 맺고 있는 구조이다.

 

객체는 양방향 연관관계가 필요한 경우, 직접 관계를 맺어주어야 한다

그런데 객체 연관 관계에서 특정 Team에 해당하는 Member들을 조회해보고 싶다면?

Team 객체는 em.find(Team.class, team.getId())로 불러올 수 있다. 그러나 Team 객체 내에 Member 정보가 없기 때문에 불러올 방법이 없다. 테이블처럼 Team과 Member를 조인할 수도 없다. 그래서 만약 이런 상황이 요구된다면, Team 객체에도 Member 객체의 정보를 설정해주는 양방향 연관 관계를 맺어준다.

 

 


 

2. 양방향 연관관계 실습해보기

 

@OneToMany, mappedBy 속성

Team 객체에 Member를 List로 추가해준다. OneToMany라는 용어는 Member 객체에서 ManyToOne과 개념적으로 대칭된다. 1개의 Team 객체에 여러 명의 Member가 List 형태로 오게되므로 OneToMany로 표현한다.

 

mappedBy 속성은 'Member' 객체가 Team 객체를 조회할 때의 필드명을 의미한다. 아래 Member 객체 코드에서 굵게 표시한 필드명이다. 헷갈리지 않도록 유의하자.

 

src/main/java/hellojpa/Team.java

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
@Getter
@Setter
@DynamicInsert
public class Team {
 
  @Id
  @GeneratedValue
  @Column(name = "TEAM_ID")
  private Long id;
 
  private String name;
 
  @OneToMany(mappedBy="team")
  private List<Member> members = new ArrayList<>();
 
}
cs

 

src/main/java/hellojpa/Member.java

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Entity
@Getter
@Setter
@DynamicInsert
public class Member {
 
  @Id
  @GeneratedValue
  private Long id;
 
  @Column(name="USERNAME")
  private String username;
 
  @ManyToOne
  @JoinColumn(name = "TEAM_ID")
  private Team team;
 
}
cs

 

실습 코드

flush, clear 후 member를 조회하고, 이 멤버와 연관된 team에서 다시 member를 조회한다. 양방향 연관관계로 맺어져 있기 때문에 서로 조회를 해도 조회가 되는 것을 확인할 수 있다.

 

src/main/java/hellojpa/JpaMain.java

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
...
try {
      Team teamA = new Team();
      teamA.setName("teamA");
      em.persist(teamA);
 
      Member member1 = new Member();
      member1.setUsername("member1");
      member1.setTeam(teamA);
      em.persist(member1);
 
      em.flush();
      em.clear();
 
      //member1의 team을 조회
      Member foundMember = em.find(Member.class, member1.getId());
      Team team = foundMember.getTeam();
 
      List<Member> members = team.getMembers();
        System.out.println("=================");
      for (Member m : members) {
        System.out.println("m = " + m.getUsername());
      }
        System.out.println("=================");
 
 
      tx.commit();
...
cs

 


 

연관 관계의 주인과 mappedBy

 

실제 테이블에서 MEMBER 테이블에 있는 TEAM_ID값을 바꾸는 상황을 가정해보자. 어떤 Member의 소속 team을 변경하고자 하는 것이다. 양방향 연관관계로 맺어진 경우, Member 객체의 Team 필드를 바꾸는 것이 맞을까 아니면 Team 객체에 있는 members 객체에서 member를 찾아서 team을 변경해주는 것이 맞을까?

 

연관 관계의 주인 부분을 업데이트 한다

테이블 입장에서는 양 쪽 어디가 됬든, 어떻게든 TEAM_ID만 업데이트되면 된다. 여기서 중요한 JPA의 규칙은 양방향 연관관계의 주인 객체를 업데이트 해야하고, 주인이 아닌 쪽은 읽기만 가능하다는 것이다. 그리고, mappedBy되는 쪽은 읽기만 가능하다. 상식상으로도 추측이 가능하지만 @ManyToOne이 있는 Member쪽이 주인이 되는 것이라고 생각하면 된다. 이런 규칙에 의해 Team 객체에 있는 members에 업데이트를 해도, 실제 DB에 반영되지 않는다!

 

실제 제대로 Member의 team 부분을 member.setTeam 으로 지정해주어도, TEAM 테이블에 members라는 컬럼은 생성되지 않는다. 객체 관계에서 members를 참조할 뿐이라는 것이다.

 


 

3. 양방향 매핑 시 주의점

 

연관 관계 편의 메서드

앞선 글에서도 살펴봤지만, em.flush, em.clear라는 코드가 있기 때문에 JPA는 DB에서 영속성 컨텍스트를 통해 insert된 정보를 다시 불러오게 된다. 그런데, 실무에서는 굳이 em.flush, em.clear를 불러오지 않고 연관 관계 편의 메서드를 활용한다. 이전 글에서 살펴봤듯이, em.flush, em.clear가 없으면 해당 코드 이전에 Team과 Member를 지정한 값이 1차 캐시에 저장되므로 team에 저장된 members의 정보를 제대로 불러 올 수 없게 된다.

 

src/main/java/hellojpa/JpaMain.java

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 try {
      Team teamA = new Team();
      teamA.setName("teamA");
      em.persist(teamA);
 
      Member member1 = new Member();
      member1.setUsername("member1");
      member1.setTeam(teamA);
      em.persist(member1);
 
//      em.flush();
//      em.clear();
 
      //member1의 team을 조회
      Member foundMember = em.find(Member.class, member1.getId());
      Team team = foundMember.getTeam();
 
      List<Member> members = team.getMembers();
        System.out.println("=================");
      for (Member m : members) {
        System.out.println("m = " + m.getUsername());
      }
        System.out.println("=================");
cs

 

실행 결과

team.getMembers()로 불러오는 members의 정보가 없기 때문에 제대로 출력되지 않는다.

 

 

 

연관관계 편의 메서드 적용

이를 위해 양방향 연관관계에서는 연관관계의 주인 객체의 값을 변경할 뿐만 아니라, 그 반대편 객체에도 값을 지정해주고 연관관계 편의 메서드를 적용한다. member.setTeam()으로 값을 지정할 때, 반대편의 값도 지정되도록 하는 것이다. 원래는 team.getMembers().add(member)라는 코드를 JpaMain 파일인 실행 부분에 넣어준다. 그런데 이를 엔티티 객체 코드 자체에 넣으면 보기 좋고, 다른 코드에서도 활용이 가능하다. 또한 Member객체에 Team 객체를 set해줄 때마다 이 코드를 넣어주어야 하는데, 이를 누락할 가능성도 적어지게 된다. 이런 방식을 연관관계 편의 메서드라고 한다.

 

다음과 같이 Member 객체의 setTeam Setter를 변경해준다. team이 갖고 있는 List<Member> members를 불러와서, 거기에 JpaMain에서 실행 시 불러와지는 Member 객체를 추가해주는 것이다.(this = member)

 

src/main/java/hellojpa/Member.java

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Entity
@Getter
@Setter
@DynamicInsert
public class Member {
 
  @Id
  @GeneratedValue
  private Long id;
 
  @Column(name="USERNAME")
  private String username;
 
  @ManyToOne
  @JoinColumn(name = "TEAM_ID")
  private Team team;
 
  public void setTeam(Team team) {
    this.team = team;
 
    team.getMembers().add(this);
  }
}
cs

 

 

실행 결과

em.flush, em.clear 없이도 members가 잘 지정되어 출력된 것을 확인할 수 있다.

 

* 연관관계 편의 메서드 이름 지정

public으로 setter를 열어두어야 하므로, 그냥 setXxx.. 와 같은 이름으로 지정해놓으면 기본 setter와 헷갈릴 수 있다. 따라서 changeXxx.., addXxx...와 같은 이름으로 변경하여 따로 메서드로 빼놓는 것이 좋다.

 

주의사항 +

위에서 Team 엔티티의 members를 지정할 때,

private List<Member> members = new ArrayList<>(); 라고 지정하여 members의 기본값을 빈 리스트로 주었다. 이렇게 해야만 아래 changeTeam 편의 메서드에서 team.getMembers()로 조회 시 nullPointerException이 발생하지 않는다. 기본적으로 List 형태인 필드값에는 new ArrayList<>()를 지정해주도록 하자.

 

 

Member 코드

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Entity
@Getter
@Setter
@DynamicInsert
public class Member {
 
  @Id
  @GeneratedValue
  private Long id;
 
  @Column(name="USERNAME")
  private String username;
 
  @ManyToOne
  @JoinColumn(name = "TEAM_ID")
  private Team team;
 
//  public void setTeam(Team team) {
//    this.team = team;
//
//    team.getMembers().add(this);
//  }
 
  public void changeTeam(Team team) {
    this.team = team;
 
    team.getMembers().add(this);
  }
}
cs

 

JpaMain코드 일부

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
try {
      Team teamA = new Team();
      teamA.setName("teamA");
      em.persist(teamA);
 
      Member member1 = new Member();
      member1.setUsername("member1");
//      member1.setTeam(teamA);
      member1.changeTeam(teamA);
      em.persist(member1);
 
//      em.flush();
//      em.clear();
 
      //member1의 team을 조회
      Member foundMember = em.find(Member.class, member1.getId());
      Team team = foundMember.getTeam();
 
      List<Member> members = team.getMembers();
        System.out.println("=================");
      for (Member m : members) {
        System.out.println("m = " + m.getUsername());
      }
        System.out.println("=================");
 
 
      tx.commit();
    } catch (Exception e) {
cs

 


연관관계 편의 메서드 반대로 적용

어짜피 양방향으로 관계를 지어주기로 했으므로, 위와 같이 member.changeTeam()의 형태로 작성해도 되지만, team.addMember()의 형태로 작성해도 된다.

 

JpaMain 파일의 일부

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  try {
      Team teamA = new Team();
      teamA.setName("teamA");
      em.persist(teamA);
 
      Member member1 = new Member();
      member1.setUsername("member1");
//      member1.changeTeam(teamA);
      em.persist(member1);
 
      teamA.addMember(member1);
 
//      em.flush();
//      em.clear();
 
cs

 

Team 객체

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Entity
@Getter
@Setter
@DynamicInsert
public class Team {
 
  @Id
  @GeneratedValue
  @Column(name = "TEAM_ID")
  private Long id;
 
  private String name;
 
  @OneToMany(mappedBy="team")
  private List<Member> members = new ArrayList<>();
 
  public void addMember(Member member) {
    member.setTeam(this);
    this.getMembers().add(member);
  }
}
cs

 


toString(), @Data, JSON 생성 라이브러리 주의하기

양방향 연관관계시에 toString()을 적용하면 안된다. 엔티티의 탐색이 자유로워야하므로, Team에서 member를 호출하고, Member에서 team을 호출하는 구조에서 서로를 toString으로 만들면서 stackOverflowError가 발생하게 된다. @Data 어노테이션, JSON 생성 라이브러리도 @toString 어노테이션을 참조하고 있으므로, 양방향 연관 관계일때 이런 기능 등을 사용하는 것에 주의하여야 한다.

 

 


 

4. 양방향 연관관계 정리

 

양방향 연관관계는 자제하고, 최대한 단방향 연관관계로 매핑한다.

단방향 매핑만으로도 이미 연관관계 매핑은 완료된 것이다. 기획 내용상 꼭 필요한 것이 아니라면 단방향 매핑을 하는 것으로 설계를 끊어내는 것이 좋다. 역방향으로 탐색하는 양방향 관계를 많이 만들수록, 설계가 불필요하게 복잡해질 수 있으므로 주의하여야 한다.

 

연관관계의 주인은 외래 키의 위치를 기준으로 정한다.

비즈니스 로직을 기준으로 연관관계 매핑의 주인을 정하면 안된다. 외래 키가 있는 부분을 연관관계의 주인으로 기준으로 삼도록 한다.

 


 

참조

 

 

1. 인프런_자바 ORM 표준 JPA 프로그래밍 - 기본편_김영한 님 강의

https://www.inflearn.com/course/ORM-JPA-Basic/lecture/21735?tab=curriculum

728x90
반응형