백엔드/JPA

[JPA] [5] 연관관계 매핑 기초

RE-Heat 2023. 8. 9. 23:59

https://www.inflearn.com/course/ORM-JPA-Basic

 

자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런 | 강의

초급자를 위해 준비한 [웹 개발, 백엔드] 강의입니다. JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자

www.inflearn.com

인프런 김영한 님의 강의를 듣고 작성한 글입니다.

 

목표

  • 객체와 테이블 연관관계의 차이를 이해
  • 객체의 참조와 테이블의 외래 키를 매핑
  • 용어 이해
    • 방향(Direction): 단방향, 양방향
    • 다중성(Multiplicity): 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M) 이해
    • 연관관계의 주인(Owner): 객체 양방향 연관관계는 관리 주인 이 필요

 

[1] 단방향 연관관계

■ 연관관계가 필요한 이유

"객체 지향 설계의 목표는 자율적인 객체들의 협력 공동체를 만드는 것이다"
                                                                    조영호(객체지향의 사실과 오해)

 

객체를 테이블에 맞춰 모델링하면 객체지향과는 거리가 먼 설계가 된다. 그래서 객체 지향 모델링을 위해 생긴 개념이 객체 연관관계다.

 

■ 예제 시나리오[공통]

회원과 팀이 있다.

회원은 하나의 팀에만 소속될 수 있다.

회원과 팀은 다대일 관계다.

 

① 객체를 테이블에 맞춰 모델링(연관관계X)

 

Member

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

참조 대신 테이블의 외래키 값을 가지고 있다. 

 

Team

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

 

JpaMain

▶ 저장

// 팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);	
// 회원 저장
Member member = new Memeber();
member.setName("member1");
member.setTeamId(team.getId());	// 외래키 식별자를 직접 추가  
em.persist(member);

member.setTeamId(team.getId())는 객체지향적인 방법이 아니다.(객체지향적인 방법은 member.setTeam(team))

 

조회

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

연관관계가 없으므로 조회를 두 번 해야 한다. 이는 객체지향적인 방법이 아니다.

 

따라서 객체를 테이블에 맞춰 모델링하면 협력 관계를 만들 수 없다.

테이블 : 외래 키로 조인해서 연관 테이블을 찾는다.

객체 : 참조를 사용해 연관 객체를 찾는다.

=> 테이블 중심 설계론 객체 그래프 탐색이 불가능

 

② 단방향 연관관계 매핑 이론

객체 지향 모델링(객체 연관관계 사용)

Member에 teamId가 아닌 Team의 참조값을 가져온다.

객체 연관관계를 사용해 객체의 참조(Team team)와 테이블의 외래키(TEAM_ID)를 매핑한다.

 

@Entity
public class Member {
  @Id
  @GeneratedValue
  @Column(name = "MEMBER_ID")
  private Long id;
  
  @Column(name = "USERNAME")
  private String username;
  
  private int age;
  
//  @Column(name = "TEAM_ID")
//  private Long teamId;
  
  @ManyToOne
  @JoinColumn(name = "TEAM_ID") // 매핑 
  private Team team;
  ...
}

목표 : 외래키 대신 Team 객체를 넣고 TEAM_ID를 매핑한다..

1. 연관관계를 맺는다(Member N : 1 Team)

@ManyToOne으로 연관관계를 설정한다.

 

2. 조인할 컬럼을 명시한다.

@JoinColumn(name ="TEAM_ID")

참조값 team이 Team 테이블의 "TEAM_ID"란 외래 키와 매핑된다.

 

객체 지향 모델링(ORM 매핑)

 

JpaMain

▶ 저장

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

//회원 저장
Member member = new Member();
member.setName("member1");
member.setTeam(team); //단방향 연관관계 설정, 참조 저장
em.persist(member);

1. member.setTeamId(team.getId()); => member.setTeam(team);으로 변화

 

 조회

// 조회
Member findMember = em.find(Member.class, member.getId());
// 참조를 사용해서 연관관계 조회
Team findTeam = findMember.getTeam();

SELECT 쿼리문을 확인하면 JPA가 조인을 해서 Member와 Team을 함께 가져오는 것을 알 수 있다.

 

연관관계 수정

// 새로운 팀B
Team teamB = new Team();
teamB.setName("TeamB");
em.persist(teamB);

// 회원1에 새로운 팀B 설정
member.setTeam(teamB);

setTeam()으로 기존 팀을 새로운 팀으로 바꾸면 연관관계도 변경된다.

Update Query에서 FK가 새로 업데이트된다.

 

 

[2] 양방향 연관관계와 연관관계의 주인 1 - 기본

목표 : 객체는 참조, 테이블은 외래 키를 이용한 JOIN을 쓴다. 이 차이를 이해해야 한다.

 

■ 양방향 매핑

1. 테이블의 연관관계는 바뀐 게 없다. JOIN을 통해 양쪽에서 접근할 수 있으므로 사실상 양방향이다.

2. 객체는 참조로 연관관계를 설정하기 때문에 단방향, 양방향 구분이 있다.

    따라서 Team → Member로 가려면 member에 참조를 넣어줘야 한다.

    여기선 List Members를 가지고 있도록 설계했다.

 

Member - 단방향과 동일

@Entity
public class Member {
  @Id
  @GeneratedValue
  @Column(name = "MEMBER_ID")
  private Long id;
  
  @Column(name = "USERNAME")
  private String username;
  
  private int age;

  @ManyToOne // N:1
  @JoinColumn(name = "TEAM_ID")
  private Team team;
  ...
}

 

Team - 양방향 적용

@Entity
public class Team {
  @Id
  @GeneratedValue
  @Column(name = "TEAM_ID")
  private Long id;
  
  private String name;
  
  @OneToMany(mappedBy = "team") // 1:N
  private List<Member> members = new ArrayList<Member>(); // 초기화 (관례) - NullPointerException 방지 
  ...
}

1. 연관관계를 맺는다.

@OneToMany 일대다 관계 설정

2. mappedBy로 team과 연관돼 있다는 것을 명시한다.

 

이제 반대방향으로도 객체 그래프를 탐색할 수 있다.

//조회
Team findTeam = em.find(Team.class, team.getId());
int memberSize = findTeam.getMembers().size(); //역방향 조회

 

■ 연관관계 주인과 mappedBy

객체와 테이블 간에 연관관계를 맺는 차이를 이해해야 한다.

 

관계를 맺을 때 객체와 테이블의 차이

① 객체 연관관계 : 단방향 2개

  • 객체 연관관계 = 2개
    • 회원 -> 팀 연관관계 1개(단방향)
    • 팀 -> 회원 연관관계 1개(단방향)

1. 객체의 연관관계는 사실상 양방향이 아니라 서로 다른 단방향 관계 2개다.

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

•  A → B (a.getB())

class A {
    B b;
}

• B -> A (b.getA())

class B {
    A a;
}

 

② 테이블의 연관관계 : 1개

  • 테이블 연관관계 = 1개
    • 회원 ↔ 팀 연관관계 1개 (양방향, 사실 방향이 없는 것)
    • 외래키 값 하나로 테이블을 조인하면 양쪽의 연관관계가 끝이 난다.

1. 테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다.

2. MEMBER.TEAM_ID 외래 키(FK) 하나로 양방향 연관관계를 가진다. (양쪽에서 서로 JOIN 가능)

SELECT *
FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
SELECT *
FROM TEAM T
JOIN MEMBER M ON T.TEAM_ID = M.TEAM_ID

 

■ 둘 중 하나로 외래 키를 관리해야 한다.

1. Member도 Team을 가지고 있고 Team도 Member 참조값을 가지고 있다.

2. 이럴 땐 둘 중 어떤 것과 테이블 외래키를 매핑해야 할까?

3. 그래서 양방향 연관관계에선 외래 키 관리 주체를 명확하게 하기 위해 연관관계의 주인이라는 개념이 생겼다.

 

■ 연관관계의 주인(Owner)

양방향 매핑 규칙

  • 객체의 두 관계 중 하나를 연관관계의 주인으로 지정한다.
  • 연관관계의 주인만 외래 키를 관리한다.
    • 주인만 DB에 접근하여 값을 등록, 수정, 변경할 수 있다.
    • 주인이 아닌 객체가 값을 변경하더라도 DB에는 영향이 없다.
  • 주인이 아닌 쪽은 읽기(SELECT)만 가능하다.
    • List Members는 가짜 매핑

누구를 주인으로 해야 할까?

  • 외래 키가 있는 곳을 주인으로 정해라.
    • DB에서는 N에 해당하는 테이블이 FK를 가지고 있다. (N쪽이 연관관계의 주인)
    • 즉, @JoinColumn이 있는 쪽이 주인이다.
    • 다시 말해 mappedBy가 있는 쪽은 주인이 아니다.

 

외래 키가 있는 쪽을 연관관계 주인으로 정하는 이유는 무엇인가?

1] Team에 있는 members를 바꿨는데, (Team은 읽기 전용이므로) JPA에선  Member의 update의 쿼리가 날아간다. 이러면 개발자 입장에선 상당히 헷갈릴 수밖에 없다.

2] 성능 이슈 : Member는 insert 쿼리 한 방에 해결 가능. 반면 Team은 insert 쿼리, Member update 쿼리 두 번 날아간다.

 

영한님 : 연관관계의 주인은 비즈니스적으론 의미가 없다. 자동차와 바퀴가 있다면 자동차가 중요하지만, 연관관계의 주인은 바퀴다. 

 

 

[3] 양방향 연관관계와 연관관계의 주인 2 - 주의점, 정리

■ 양방향 매핑 시 가장 많이 하는 실수 - 연관관계의 주인에 값을 입력하지 않음

JpaMain

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

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

//역방향(주인이 아닌 방향)만 연관관계 설정
team.getMembers().add(member);

em.persist(member);

 

DB 결과

JPA에서 mappedBy가 있는 쪽(주인 X)은 update, insert 쿼리 날릴 때 고려하지 않는다.

따라서 연관관계의 주인만 외래키 값을 등록할 수 있다.

 

■ 양방향 매핑 시 연관관계의 주인에 값을 입력

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

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

team.getMembers().add(member);
//연관관계의 주인에 값 설정
member.setTeam(team);

em.persist(member);

 

■ 사실 순수한 객체 관계를 고려하면 양쪽 다 값을 입력해야 한다.

이유

① 1차 캐시 문제

  • 위의 코드에서 team도 영속성 컨텍스트에 들어가 있다.
  • 연관관계의 주인에만 값 설정해 주면 영속성 컨텍스트에 있는 team 객체의 members 컬렉션은 여전히 비어있는 상태다. (물론 커밋시점에 연관관계의 주인에 의해서 DB에는 업데이트 쿼리 날아간다.)
  • 따라서 트랜잭션 안에서 영속성 컨텍스트 flush하고 clear 되기 전에 해당 컬렉션 조회한다면 정상적인 결과가 출력되지 않는다.

② 테스트 케이스 작성 문제

  • 또 하나의 문제점은 나중에 테스트 케이스 작성할 때이다.
  • 테스트 케이스 작성 시 JPA 없이도 동작하게 순수 자바 코드로도 작성한다.
  • 그런 케이스에도 대비하기 위해서는 양쪽 다 입력해 주는 것이 맞다.

 

결론 : 양방향 연관관계 매핑에선 순수 객체 상태를 고려해 양쪽에 값을 설정하자.

 

■ Tip

연관관계 편의 메소드를 생성하면 헷갈리지 않게 설정할 수 있다.

양방향 매핑 시 무한 루프에 주의해야 한다.

    예시) : toString(), lombok, JSON 생성 라이브러리

해결책 

1. Lombok의 @ToString은 웬만하면 안 쓰는 게 루프를 막을 수 있다.

2. JSON 생성 라이브러리 : 컨트롤러에서 엔티티를 절대 반환하지 마라. 값만 넘기는 간단한 dto로 반환하는 게 무한 루프를 방지할 수 있다.

 

■ 정리

  • 단방향 매핑만으로도 이미 연관관계 매핑은 완료
  • 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것뿐이다.
  • 생각보다 실무에서는 JPQL에서 역방향으로 탐색할 일이 많음
  • 단방향 매핑을 잘하고 양방향 매핑을 필요할 때 추가해도 된다.
    (관계형 DB 테이블에 영향을 주지 않는다.)

연관관계 주인 정하는 기준

  • 비즈니스 로직을 기준으로 연관관계 주인을 선택하면 안 된다.
  • 연관관계의 주인은 외래키의 위치를 기준으로 정해야 한다.

 

[4] 실전예제 2 - 연관관계 매핑 시작

 

 

실습 내역