JPA 게시글은 대부분 인프런의 김영한님의 강의인 '자바 ORM 표준 JPA 프로그래밍' 기반으로 내용을 정리했습니다.
양방향 연관관계와 연관관계의 주인 - 기본
이전 글에서 배운 단방향 연관관계와 양방향 연관관계의 차이점은 아래와 같다.
// Member 객체로 Team을 가져올 수 있다.
Member findMember = em.find(Member.class, member.getId());
Team findTeam = findMember.getTeam();
// Team 객체로 Member을 가져올 수 없다.
Team team = em.find(Team.class, team.getId());
Member findMember1 = team.getMember();
Team에서도 Member의 객체를 가져올 수 있으면 양방향 관계라고 한다.
양방향 매핑
테이블 연관관계를 보면 단방향 연관관계에서의 테이블 모델링과 양방향 연관관계에서의 테이블 모델링이 똑같은 걸 볼 수 있다. 테이블에서는 외래키를 이용해 서로의 정보를 JOIN 해서 가져올 수 있다. 사실 테이블에는 방향성이 없다. 그래서 JOIN을 하면 해당하는 값을 모두 가져올 뿐이다. 즉 테이블은 외래키로 모든 정보를 가져올 수 있어 따로 설정을 더 해줄 필요는 없지만 객체에서 양방향 연관관계를 표현하려면 상대 객체를 가져오는 방법이 있어야 한다. 그래서 양방향 객체 연관 관계에 Team 객체에 List members란 컬렉션을 넣어준다. 일대다이므로 여러 개의 값이 들어올 수 있어서 List를 사용한 것이다.
반대 방향으로 객체 그래프 탐색하기 - Team 객체 클래스
@Entity
public class Team {
@Id
@GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "team") // 반대편 객체 클래스의 연관관계가 적용된 필드의 이름을 적어준다.
private List<Member> members = new ArrayList<>();
// new ArrayList<>()로 생성해두면 조회할 때 null 값이 반환되지 않는다.
...getter/setter 생략
}
mappedBy에서 살짝 헷갈릴 수 있는데 Team의 반대격 객체 클래스인 Member 객체 클래스에 있는 필드 중 Team 객체와 연관 관계 매핑이 되어 있는 필드의 이름을 적어주면 된다.
// Member.class 일부
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
반대 방향으로 객체 그래프 탐색하기 - 실행 클래스
// 조회
Member findMember = em.find(Member.class, member.getId());
List<Member> members = findMember.getTeam().getMembers();
for (Member m : members) {
System.out.println("m = " + m.getUsername());
}
// 실행 로그 중 일부
Hibernate:
select
members0_.TEAM_ID as TEAM_ID3_0_0_,
members0_.MEMBER_ID as MEMBER_I1_0_0_,
members0_.MEMBER_ID as MEMBER_I1_0_1_,
members0_.TEAM_ID as TEAM_ID3_0_1_,
members0_.USERNAME as USERNAME2_0_1_
from
Member members0_
where
members0_.TEAM_ID=?
m = member1
이제 실행 클래스에서 Member와 Team을 간단하게 불러올 수 있다.
객체와 테이블의 연관관계 차이
- 객체 연관관계는 2개
- 회원 -> 팀 연관관계 1개 (단방향)
- 팀 -> 회원 연관관계 1개 (단방향)
- 테이블 연관관계는 1개
- 회원 <-> 팀의 연관관계 1개 (양방향)
초반에서도 말했지만 DB 테이블의 경우엔 외래키 하나로 두 테이블을 양방향 관계로 만들 수 있다. 외래키 하나로 Member나 Team 테이블의 데이터를 자유롭게 양쪽에서 JOIN 해서 가져올 수 있기 때문이다. 그에 반해 객체 연관관계는 그냥 단방향 두 개를 서로 잘 묶어서 양방향처럼 보이게 한다. 즉 양방향 관계를 만들려면 각 객체 테이블의 상대 객체 테이블을 참조할 수 있는 코드를 작성해야 한다는 것이다.
// Member 객체 클래스
// Member에서 Team을 참조하고 있다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
// Team 객체 클래스
// Team에서 Member를 참조하고 있다.
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
이렇게 서로의 객체를 참조하는 코드를 만들면 이제 의문이 생기기 시작한다. 테이블에 있는 외래키를 그럼 어떤 객체의 어떤 필드로 선택해야 하는 거지?라는 의문 말이다.
즉 Member에 있는 team으로 외래키를 관리할지, 아니면 Team에 있는 members로 외래키를 관리할지를 정해야 한다는 것이다. 이 둘 중에 선택된 필드는 이 양방향 연관 관계의 주인(Owner)이 된다.
연관관계의 주인(Owner)
연관관계의 주인은 양방향 관계에서만 존재하는 개념이다. 양방향 매핑 규칙은 다음과 같다.
- 객체의 두 관계 중 하나를 연관관계의 주인으로 지정한다.
- 연관관계의 주인만이 외래키를 관리(등록 및 수정)할 수 있다.
- 주인이 아닌쪽은 읽기(조회)만 가능하다.
- 주인은 mappedBy 속성을 사용하지 않는다.
- 주인이 아니면 mappedBy 속성으로 주인을 지정한다.
위의 내용을 종합해서보면 작성했던 코드 중에 누가 주인이고, 누가 주인이 아닌지 알 수 있다.
//Member.class
//주인
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
//Team.class
//조회만 가능하다
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
그럼 규칙까진 알겠는데 왜 Member객체가 주인이 된 걸까? 주인을 고르는 규칙 또한 존재한다.
- 외래키가 있는 곳을 주인으로 정하면 된다.
Team을 주인으로 정하면 Team의 객체를 등록 및 수정 등의 SQL 쿼리 문이 발생하면 MEMBER Table에 SQL문이 적용되는 것이다. 이러면 JPA를 사용하면서 혼선이 잦아질 것이다. 그러므로 되도록 외래키가 있는 테이블을 객체화한 객체를 주인으로 정하면 된다.
참고로 데이터베이스에도 일대다, 다대일의 경우에 다쪽으로 외래키를 둔다.
양방향 연관관계 주의점
// 실행 클래스
...생략
// 회원 저장
Member member = new Member();
member.setUsername("member1");
em.persist(member);
// 팀 저장
Team team = new Team();
team.setName("Team A");
team.getMembers().add(member);
em.persist(team);
em.flush();
em.clear();
tx.commit();
...생략
위와 같은 코드를 작성하고 실행하면 우리가 의도했던 원래 기능대로 잘 동작할까? 실행하고 DB를 확인해보자.
생각했던 거와 달리 TEAM_ID에 null 값이 들어가버렸다. 이 이유는 연관관계의 주인인 MEMBER 객체가 아닌 TEAM 객체의 members에만 값을 저장했기 때문이다. TEAM 객체의 members는 연관관계의 주인이 아니므로 외래키 관련해 조회가 아닌 모든 SQL문은 발생하지 않는다. 그러니 결국엔 null 값으로 저장된 것이다.
순수한 객체 관계를 고려
객체 관점에서보면 양쪽 객체에 모두 값을 입력해주는 것이 맞다.
// 저장
Team team = new Team();
team.setName("Team A");
em.persist(team);
Member member = new Member();
member.setUsername("member1");
member.setTeam(team); // member에 team 저장
em.persist(member);
team.getMembers().add(member); // team에 member 저장
em.flush();
em.clear();
tx.commit();
이번에 TEAM_ID에 null 값이 아니라 정상적인 값이 들어갔다. 사실 team에 member를 저장하는 저 코드가 없어도 정상적으로 저장이 되긴 한다. 근데 굳이 team에도 member를 저장하는 데에는 이유가 있다.
- 객체지향적인 관점에서 team에도 member를 저장해주는게 맞다.
- 만약 team에 member를 저장하지 않고 DB에 SQL 쿼리를 날리지 않을 경우 1차 캐시에 저장되어 있는 값을 그냥 가져올 뿐이라 결국 team에 있는 members 리스트는 아무런 값도 존재하지 않는다.
// 저장
Team team = new Team();
team.setName("Team A");
em.persist(team);
Member member = new Member();
member.setUsername("member1");
member.setTeam(team);
em.persist(member);
// team.getMembers().add(member);
// em.flush();
// em.clear();
Team findTeam = em.find(Team.class, team.getId()); // 1차 캐시
List<Member> members = findTeam.getMembers();
for (Member m : members) {
System.out.println("m = " + m.getUsername()); // 아무것도 출력 X
}
tx.commit();
연관관계 편의 메서드
양쪽 객체에 항상 값을 집어넣어야 한다는게 어쩔 땐 번거로울 수도 있다. 그래서 귀찮음과 실수를 방지하기 위해 연관관계 편의 메서드를 작성하는 것이 어쩌면 더 좋을 수도 있다.
//Member.class 중 일부
public void setTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
이러면 실행 클래스에서 team의 members에 따로 값을 저장하는 코드를 작성하지 않아도 된다.
// 실행 클래스
// team.getMembers().add(member);
양방향 매핑 시 무한 루프
양방향 매핑 시에 무한 루프가 발생할 수 있는 환경이 있는데 다음과 같다.
- toString()
- lombok
- JSON 생성 라이브러리
간단하게 toString()을 사용했을 경우의 무한루프에 대해 알아보자.
// Member.class
@Override
public String toString() {
return "Member{" +
"id=" + id +
", username='" + username + '\'' +
", team=" + team +
'}';
}
// Team.class
@Override
public String toString() {
return "Team{" +
"id=" + id +
", name='" + name + '\'' +
", members=" + members +
'}';
}
// 실행 클래스
Team findTeam = em.find(Team.class, team.getId()); // 1차 캐시
List<Member> members = findTeam.getMembers();
System.out.println("members = " + findTeam);
위의 코드를 실행하면 멋진 검빨의 조화를 확인할 수 있다.
toString()이 왜 문제가 되냐면 Member 클래스의 toString()에서는 team을 출력한다. 즉, team.toString()과 같은 코드이다. 또 Team 클래스에서는 members를 출력한다. 컬렉션은 또 컬렉션 안에 있는 요소마다 toString()을 호출한다. 그래서 양쪽에서 toString() 무한 호출하면서 나타나는 장애이다.
정리
- 단방향 매핑만으로도 이미 연관관계 매핑은 완료된다.
- 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것뿐이다.
- JPQL에서 역방향으로 탐색할 일이 많다.
- 단방향 매핑을 잘하고 양방향은 필요할 때 추가해도 된다.
- 연관관계 주인은 외래키의 위치를 기준으로 정해야 한다.