본문 바로가기
개념 공부/Spring

[JPA 개념] 연관 관계 매핑

by clean01 2024. 6. 6.

단방향 연관 관계 매핑

MEMBER라는 테이블과 TEAM이라는 테이블이 있다고 하겠습니다.

한 멤버는 한 팀에 속할 수 있고, 한 팀에는 여러명의 멤버가 속해있습니다.

따라서 MEMBER와 TEAM은 N:1 관계입니다.

Member.java

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
public class Member {
  @Id
  @GeneratedValue
  @Column(name = "MEMBER_ID")
  private Long id;

  @Column(name = "USERNAME")
  private String username;

    // 여기를 주목!
  @Column(name = "TEAM_ID")
  private Long teamId;

}

Team.java

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
public class Team {
  @Id
  @GeneratedValue
  @Column(name = "TEAM_ID")
  private Long id;

  private String name;

}

위와 같이 엔티티를 만들면 문제가 있습니다.

바로, Team과 Member의 관계가 참조 관계가 아닌 Id 값으로 연결되어 있어, Member를 통해 Team에 접근하기가 어렵습니다.

Member에서 Team에 접근하려면 Member 객체 안에 있는 teamId로 DB에서 Team 객체를 한번 찾아와야 합니다. (객체지향스럽지 않음)

위와 같은 코드는 아래처럼 개선할 수 있습니다.

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
public class Member {
  @Id
  @GeneratedValue
  @Column(name = "MEMBER_ID")
  private Long id;

  @Column(name = "USERNAME")
  private String username;

//  @Column(name = "TEAM_ID")
//  private Long teamId;

  @ManyToOne
  @JoinColumn(name = "TEAM_ID") // 조인해야하는 컬럼
  private Team team;
}

Member의 입장에서 Team은 N:1 관계입니다.

한 팀에 여러 명의 멤버가 속할 수 있기 때문입니다.

따라서 team 속성 위에 @ManyToOne 어노테이션을 붙여줍니다.

또한, 조인을 할 때 어떤 컬럼을 기준으로 할 것인지를 알려주어야 하기 때문에 @JoinColumn(name = “TEAM_ID”) 어노테이션도 붙여줍니다.

이제 멤버 객체에서 setTeam()으로 Team 객체와의 참조관계를 넣어주면 DB에 저장될 때 알아서 team의 id 값을 FK로 사용하게 됩니다.

       Member member = new Member();
       member.setUsername("member1");
       member.setTeam(team);

양방향 연관 관계

위의 단방향 연관관계에서는 Member에서 Team을 꺼내오는 것은 가능했지만, Team에서 Member를 바로 꺼내올 수 있는 방법은 없었습니다.

관계가 있는 두 객체가 서로를 참조하여 한쪽에서 다른 쪽을 접근할 수 있게 하는 것을 양방향 연관 관계라고 합니다.

객체에서는 양방향 연관 관계이지만, 테이블 스키마에서는 변함이 없어야합니다. (Member 테이블에서 TEAM_ID를 fk로 가지고 있는 구조)

객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단방향 관계 2개입니다.

객체를 양방향으로 참조하려면 단방향 연관 관계 2개를 만들어야 합니다.

위에서 단방향 연관 관계를 가졌던 Member와 Team을 양방향 연관 관계로 바꾸어 보겠습니다.

Team의 코드를 다음과 같이 바꾸어주면, Team에서도 멤버를 참조하고 있으므로 양방향 연관 관계가 됩니다.

(mappedBy 라는 속성이 어떤 것인지는 아래에서 더 알아보겠습니다.)

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
public class Team {
  @Id
  @GeneratedValue
  @Column(name = "TEAM_ID")
  private Long id;

  private String name;

  // 멤버 리스트 추가!
  // NullPointerException이 발생하지 않도록 바로 초기화 하는 것이 관례입니다.
  @OneToMany(mappedBy = "team")
  private List<Member> members = new ArrayList<>();

}

양방향 연관 관계에서의 딜레마

이렇게 양방향 연관 관계를 쓰면, “어떤 멤버의 팀이 바뀌었다면, 도대체 어떤 객체의 어떤 값을 바꿔줘야하지..?”라는 딜레마가 생길 수 있습니다.

실제 DB 테이블에서는 MEMBER 테이블의 TEAM_ID만 바꿔주면 되지만, Team 객체와 Member 객체가 서로 양방향 참조를 하고 있는 자바 코드 안에서는 둘다 바꿔주는 것이 맞는 것처럼 보입니다.

→ 따라서 “둘 중 하나로 외래키를 관리해야 한다”는 룰이 생겼습니다.

이 룰에 대한 내용은 아래 “연관 관계의 주인” 파트에서 더 자세히 알아보겠습니다.


연관 관계의 주인

양방향 매칭 규칙

  • “연관 관계의 주인”이라는 개념은 양방향 연관 관계에서 생겨났습니다.
  • 객체의 두 관계 중 하나를 연관 관계의 주인으로 지정해야합니다.
  • ⭐️ 연관 관계의 주인만이 외래키를 관리(등록, 수정)할 수 있습니다.
    • 연관 관계의 주인이 아닌 쪽은 읽기만 가능합니다.
  • 연관 관계의 주인이 아닌 쪽mappedBy 속성으로 주인을 지정합니다.

그럼 누굴 주인으로..??

→ ⭐️ DB 테이블에서 외래키가 있는 곳이 주인입니다.

여기서 든 예시인 Member와 Team의 관계에서는 테이블에서 TEAM_ID라는 fk를 가지고 있는 Member가 연관 관계의 주인이 됩니다.

양방향 매핑할 때 많이 하는 실수

1. 연관 관계의 주인에 값을 입력하지 않는 것

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

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

       // 연관 관계의 주인인 Member에는 Team을 넣지 않음
       //member.setTeam(team);

       // 연관 관계의 주인이 아닌 Team에는 멤버를 넣음
       team.getMembers().add(member);
       em.persist(member);

       em.flush();
       em.clear();

      tx.commit();

위 코드는 주석에 써있다 싶이, 연관 관계의 주인인 Member에는 Team의 참조를 걸어주지 않고, 주인이 아닌 Team에는 Member에 대한 참조를 걸어준 코드입니다.

위 코드를 실행하면 DB에는 아래와 같은 데이터가 들어가게 됩니다.

MEMBER 테이블 데이터에 TEAM_ID fk가 걸려있지 않은 모습입니다.

JPA에서 연관 관계를 매핑할 때에는 반드시 주인 객체 쪽에 주인이 아닌 쪽의 참조관계를 넣어주어야 합니다.

코드를 아래와 같이 변경해주고 다시 실행해보겠습니다.

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

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

       // 연관 관계의 주인인 Member에 객체에 Team 참조 걸어주기
       member.setTeam(team);

       em.persist(member);

       em.flush();
       em.clear();

      tx.commit();

의도대로 참조관계가 잘 걸린 것을 확인할 수 있습니다.

아래와 같이 엔티티 매니저의 1차 캐시를 비운 후, find() 메소드로 Team을 다시 찾아와서(DB 조회) members를 조회해보면 Team 객체의 members에도 멤버가 잘 들어가 있는 것을 확인할 수 있습니다.

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

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

       // 연관 관계의 주인인 Member에 객체에 Team 참조 걸어주기
       member.setTeam(team);
       em.persist(member);

       em.flush();
       em.clear();

      // 엔티티 매니저 1차 캐시에 member, team이 없는 상태에서 team의 Member를 조회
      Team findTeam  = em.find(Team.class, team.getId());
      List<Member> members = findTeam.getMembers();
      for (Member member1 : members) {
        System.out.println("members = " + member1.getUsername());
      }

그런데 이렇게 연관 관계의 주인에만 참조를 추가해줘도 문제가 생길 수 있는 경우가 있습니다.

바로 아래와 같은 경우입니다.

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

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

       // 연관 관계의 주인인 Member에 객체에 Team 참조 걸어주기
       member.setTeam(team);
       em.persist(member);

      // 엔티티 매니저 1차 캐시에 member, team이 *있는* 상태에서 team의 Member를 조회
      Team findTeam  = em.find(Team.class, team.getId());
      List<Member> members = findTeam.getMembers();
      System.out.println("members size: " + members.size());
      for (Member member1 : members) {
        System.out.println("members = " + member1.getUsername());
      }

       em.flush();
       em.clear();

      tx.commit();

위의 코드는 엔티티 매니저 1차 캐시에 team이 있는 상태에서 find 메소드로 Team 객체를 찾아와서 멤버리스트를 조회하는 코드입니다.

1차 캐시에 해당 객체가 있다면, 그 DB에 쿼리를 날려 찾아오지 않으므로, members에 아무 회원도 없습니다.

이런 문제가 생길 수 있기 때문에, 보통 연관 관계 주인과 주인이 아닌 객체 모두에 값을 추가해주는 것이 좋다고 합니다. (즉 Team 객체의 members에도 member를 add 해주는 것이 좋습니다.)

2. entity 객체의 toString() 메소드를 호출하거나 JSON으로 변환

결론부터 말하자면, @Entity가 붙은 클래스는 롬복 같은 라이브러리로 toString 메소드를 만들지 않기, RestController에서 반환값으로 리턴하지 말기를 권장합니다.

그 이유는, 해당 클래스에 양방향 매핑이 있을 경우, 참조하고 있는 객체끼리 무한으로 메소드를 호출하는 경우가 발생할 수 있기 때문입니다.

이것이 무슨 말이나면, Team 클래스에서도 Member를 참조하고 있고, Member 클래스에서도 Team을 참조하고 있기 때문에, Member 객체를 RestController의 반환 값으로 리턴해버리면 JSON으로 변환하는 과정에서 아래와 같은 일이 생길 수 있습니다.

  1. Member를 JSON으로 변환
  2. Member의 필드 중, Team이 있기에, Team을 JSON으로 변환
  3. Team의 필드 중 List가 있기에, 이를 JSON으로 변환하기 위해 Member를 JSON으로 변환
  4. 1,2,3 무한 반복

이런 상황을 막기 위해, 엔티티 클래스에는 toString()을 오버라이딩하지 않거나 양방향 매핑된 필드를 제외하고 만들어야 합니다.

또한 @Entity가 붙은 클래스를 바로 컨트롤러의 반환값으로 쓰는 게 아니라, 별도의 응답용 클래스를 만들어주는 것이 좋습니다.

정리

단방향 매핑은 필수, 양방향 매핑은 선택

연관 관계 매핑시 일단 단방향 매핑을 모두 해주고, 반대쪽에서도 값을 꺼내올 일이 있을 경우 양방향 매핑을 해주는 것이 좋습니다.

즉, 위 예시로 생각해보면, 연관 관계의 주인인 Member에만 Team을 참조관계로 넣어놓고, @ManyToOne
, @JoinColumn(name = "TEAM_ID") 어노테이션을 달아줍니다.

그리고 Team 객체를 통해 Member를 접근할 일이 있다면, 반대되는 매핑도 만들어주는 것입니다.

두 객체의 연관 관계를 표현하기 위해 주인 쪽에 참조관계 설정을 해놓는 것은 필수이지만, 반대쪽 매핑은 read only이기에 테이블에 영향을 주지 않아 필요할 때만 하는게 좋습니다.

연관 관계의 주인은 외래키를 가지고 있는 쪽이다.

연관 관계의 주인은 DB 테이블에서 fk를 가지고 있는 쪽입니다.

위 예시에서는 Member 테이블에 TEAM_ID fk가 있는 것이 자연스러우므로 Member가 연관 관계의 주인이 됩니다.