JPA 연관관계 매핑을 학습해보자!

maketheworldwise·2023년 1월 22일


이 글의 목적?

이번 강의는 연관관계 매핑에 대한 내용이다. 나는 JPA를 한번 사용해본 경험자이기에 객체지향형 모델링에 대한 파트는 생략하고 개념적으로 나에게 필요한 부분만 정리해보자!

양방향 연관관계

테이블의 경우 연관관계가 외래키 하나로 양방향이 모두 존재한다. 따라서 외래키만 있으면 양쪽 테이블 어디에서든 모든 정보를 얻을 수 있기에 테이블에서는 방향이라는 개념 자체가 사실 모호하다. 반면 객체는 참조라는 개념이 있기 때문에 방향이 존재한다.

객체와 테이블이 관계를 맺는 차이를 강의에서 사용한 예시로 살펴보자.


객체의 연관관계는 2개다.

  • 회원에서 팀으로 향하는 연관관계 1개 (단방향)
  • 팀에서 회원으로 향하는 연관관계 1개 (단방향)

테이블의 연관관계는 1개다.

  • 회원과 팀의 연관관계 1개 (양방향)

즉, 객체의 양방향 관계는 테이블처럼 양쪽에서 조인할 수 있는 관계가 아닌 서로 다른 단방향 관계가 2개로 이루어져있는 것을 의미한다. 결국 객체가 양방향으로 참조하려면 단방향 연관관계 2개를 만들어야 한다는 것이다.

코드로 표현해보자.

@Entity
@Table(name = "MEMBER")
public class Member {

  @Id
  @GeneratedValue(strategy = GenerationType.AUTO)
  @Column(name = "MEMBER_ID")
  private Long id;

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

  @ManyToOne
  @JoinColumn(name = "TEAM_ID")
  private Team team;

  // Getter, Setter 생략
@Entity
@Table(name = "TEAM")
public class Team {

  @Id
  @GeneratedValue(strategy = GenerationType.AUTO)
  @Column(name = "TEAM_ID")
  private Long id;

  private String name;

  @OneToMany(mappedBy = "team")
  private List<Member> members = new ArrayList<>();
  
  // Getter, Setter 생략

💡 왜 ArrayList로 초기화를 해주는가?

JPA에서 컬렉션을 사용할 때는 선언과 동시에 초기화해주는 것이 NullPointerException을 방지하는데 도움이 되기에 ArrayList로 초기화해주는 것이 거의 관례라고 한다.

그럼 객체의 입장에서 2개의 단방향 연관관계 중 어떤 것을 외래키로 관리해야할까? 이 질문에 관련된 내용이 바로 연관관계 주인이다.

연관관계 주인

연관관계 주인은 내가 회사에서 서비스를 개발할 때도 많이 헷갈렸던 내용이다. 🥲

양방향 매핑할 때 연관관계 주인을 지정하기 위한 규칙은 다음과 같다.

  • 객체의 두 관계중 하나를 연관관계의 주인으로 지정
  • 연관관계의 주인만이 외래키를 관리 (등록, 수정)
  • 주인이 아닌 곳은 읽기만 가능
  • 주인은 mappedBy 속성을 사용하지 않음
  • 주인이 아니면 mappedBy 속성으로 주인을 지정
  • 외래키가 있는 곳을 주인으로 지정 (주인은 N, 주인이 아닌 곳은 1)

외래키가 있는 곳을 주인으로 지정하는 이유는 헷갈리지 않기 때문이다. 생각해보자. Team의 members를 변경했는데 TEAM이 아닌 MEMBER 테이블에 UPDATE 쿼리가 전달되면 이상하지 않은가?! 그리고 아직 강의에서는 나오진 않았지만 성능 이슈도 있다고도 한다!

양방향에서는 주인이 아닌 곳에도 값 설정이 필요하다?

본래라면 주인인 곳에만 값을 넣어주는 것이 맞지만, 객체지향적으로 봤을 때는 양방향 연관관계에서 양쪽에 값을 넣어주는것이 맞다.

먼저 코드에서 데이터를 DB에 플러시하고 영속성 컨텍스트를 완전히 초기화한 후에 연관관계의 주인이 아닌 팀을 조회하고 팀에서 회원을 조회해보자. 글로 이해하기 힘드니 순서도 함께 적어보자.

  1. 팀, 회원 데이터 DB에 저장
  2. 영속성 컨텍스트 초기화
  3. DB에서 팀 조회
  4. 주인이 아닌 팀에서 회원 읽기
try {
  // 저장
  Team team = new Team();
  team.setName("TeamA");
  entityManager.persist(team);

  Member member = new Member();
  member.setName("Member1");
  member.setTeam(team);
  entityManager.persist(member);

  entityManager.flush();
  entityManager.clear();
  
  // 조회
  Team findTeam = entityManager.find(Team.class, team.getId());
  List<Member> members = findTeam.getMembers();
  for(Member m : members) {
    System.out.println("member = " + m.getName());
  }

  entityTransaction.commit();
} catch (Exception e) {
  entityTransaction.rollback();
} finally {
  entityManager.close();
}

entityManagerFactory.close();

결과를 보면 entityManager.find(Team.class, team.getId())를 통해 팀을 조회하는 쿼리와 findTeam.getMembers()를 통해 회원을 조회하는 쿼리 두 개가 날아간 것을 확인할 수 있다. 이렇게 보면 아무런 문제도 없지만, 만약 영속성 컨텍스트에 대한 처리를 수행하지 않을 경우에는 어떻게 될까?

try {
  // 저장
  Team team = new Team();
  team.setName("TeamA");
  entityManager.persist(team);

  Member member = new Member();
  member.setName("Member1");
  member.setTeam(team);
  entityManager.persist(member);

//  entityManager.flush();
//  entityManager.clear();
  
  // 조회
  Team findTeam = entityManager.find(Team.class, team.getId()); // 1차 캐시
  List<Member> members = findTeam.getMembers();
  for(Member m : members) {
    System.out.println("member = " + m.getName());
  }

  entityTransaction.commit();
} catch (Exception e) {
  entityTransaction.rollback();
} finally {
  entityManager.close();
}

entityManagerFactory.close();

결과는 회원의 이름이 찍히지 않고 INSERT 쿼리만 나가는 것을 확인할 수 있다. 즉, 1차 캐시에 올라와있는 데이터를 가져오게 되면서 비어있는 값을 출력하게 되어 문제가 생기는 것이다.

  1. 팀, 회원 데이터 영속화 (1차 캐시)
  2. 팀을 조회할 때 1차 캐시를 이용
  3. 1차 캐시로 받은 데이터에는 회원에 대한 데이터가 비어있음

따라서 하단의 코드처럼 주인이 아닌 곳에서도 값을 설정해주는 것이 맞다.

try {
  // 저장
  Team team = new Team();
  team.setName("TeamA");
  entityManager.persist(team);

  Member member = new Member();
  member.setName("Member1");
  member.setTeam(team);
  entityManager.persist(member);
  
  // 주인이 아닌 곳에도 값 삽입
  team.getMembers().add(member);

//  entityManager.flush();
//  entityManager.clear();
  
  // 조회
  Team findTeam = entityManager.find(Team.class, team.getId()); // 1차 캐시
  List<Member> members = findTeam.getMembers();
  for(Member m : members) {
    System.out.println("member = " + m.getName());
  }

  entityTransaction.commit();
} catch (Exception e) {
  entityTransaction.rollback();
} finally {
  entityManager.close();
}

entityManagerFactory.close();

또한 테스트 코드에서도 JPA 없이 테스트 케이스를 작성하기 때문에 객체가 비어있을 경우에 문제가 발생할 수 있어 주인인 곳과 주인이 아닌 곳 양쪽에 값을 설정해주어야 한다.

연관관계 편의 메서드 작성하기

주인이 아닌 곳에도 값을 삽입하는 코드를 좀 더 이쁘게 개선시킬 수 있는 방법도 있다. 바로 로직을 연관관계 편의 메서드로 빼서 처리하는 방법이다. (추가적으로 Setter 메서드명을 변경하여 단순히 Setter의 기능을 수행하는 것이 아닌 더 중요한 기능을 수행하는 메서드임을 명시하도록 해주면 더 좋다.)

단, 편의 메서드는 주인인 곳이든 주인이 아닌 곳이든 작성해도 상관은 없지만 값이 두 번 삽입될 수 있기 때문에 반드시 한 곳에서만 작성되어야 한다.

@Entity
@Table(name = "MEMBER")
public class Member {

  // (생략...)
  
  @ManyToOne
  @JoinColumn(name = "TEAM_ID")
  private Team team;
  
  // setTeam
  public void changeTeam(Team team) {
    this.team = team;
    team.getMembers().add(this);
  }
}

무한 루프 피하기

양방향 연관관계에서는 무한 루프가 발생할 수도 있다. 대표적으로 toString() 메서드의 경우 자동으로 만들어주는 코드를 사용하면 서로가 서로를 호출하게 되어 무한 루프에 빠져버리게 된다.

public class Member {

  // (생략...)

  @Override
  public String toString() {
    return "Member{" +
    	   "id=" + id + 
           ", name='" + name + '\'' + 
           ", team=" + team + // team.toString() 호출
           '}';
  }
}
public class Team {

  // (생략...)

  @Override
  public String toString() {
    return "Team{" +
    	   "id=" + id + 
           ", name='" + name + '\'' + 
           ", members=" + members + // member.toString() 호출
           '}';
  }
}

그 외에도 Lombok이나 JSON 생성 라이브러리에서 무한 루프를 발생시켜 stackOverflow 장애가 발생할 수 있기에 조심해야한다. (JSON의 경우 컨트롤러에서 직접 엔티티를 반환하지 않고 DTO 클래스로 변환하여 전달하면 거의 문제가 발생하지 않는다고 한다.)


이전에 JPA를 제대로 숙지하지 않고 레퍼런스만을 참고하여 무조건 양방향 연관관계는 좋은가?라는 글을 작성한 적이 있다. 이때도 양방향 매핑을 바로 적용하지 말고 단방향 매핑을 먼저 맺어둔 뒤에 필요할 때 역방향을 추가하라고 했었는데, 강의에서도 단방향 매핑을 잘하고 나중에 추가하는 것을 권장했다. 주인이 아닌 곳에 값을 설정하는 것과 무한 루프에 대한 내용을 학습하면서 왜 나중에 추가하는게 좋은지 조금 감이 올 것 같다. 🥹

이 글의 레퍼런스

profile
세상을 현명하게 이끌어갈 나의 성장 일기 📓

0개의 댓글