강의를 들으며 생각 정리 + "자바 ORM 표준 JPA 프로그래밍" 책 참고
엔티티들은 대부분 다른 엔티티와 연관관계가 있다. 그런데 객체는 참조(주소)를 사용해서 관계를 맺고 테이블은 외래 키를 사용해서 관계를 맺는다. 객체 관계 매핑에서 가장 어려운 부분이 바로 객체 연관관계와 테이블 연관관계를 매핑하는 일이다. 즉, 객체의 참조와 테이블의 외래 키를 매핑하는 것이 이 장의 목표이다.
단방향 연관관계
멤버와 팀 객체가 다대일(N:1) 단방향 관계를 맺는다고 하자.
- 객체 연관관계는 멤버에서만 팀에 접근할 수 있는 단방향 관계이다.
- 테이블 연관관계는 외래 키를 통해 멤버 팀을 조인할 수 있고 반대로 팀과 멤버 조인할 수 있는 양방향 관계이다.
- 다대일(N:1) 관계에서 N쪽에 외래 키가 있어야 한다.
즉, 객체 연관관계(참조)는 언제나 단방향이고, 양방향으로 만들고 싶으면 반대쪽에서 필드를 추가해서 참조를 보관해야 한다. 결국 양방향 관계가 아니라 서로 다른 단방향 관계 2개로 만들어야 한다. 반면에 테이블은 외래 키 하나로 양방향으로 조인할 수 있다.
<Member.java>
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String username;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
// Getter, Setter
}
- @ManytoOne : 이름 그대로 다대일(N:1) 관계라는 매핑 정보다.
- @JoinColumn : 외래 키(TEAM_ID)를 매핑할 때 사용한다.
연관관계 저장
//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
//회원 저장
Member member = new Member();
member.setName("member1");
member.setTeam(team); //단방향 연관관계 설정, 참조 저장
em.persist(member);
연관관계 조회
//조회
Member findMember = em.find(Member.class, member.getId());
//참조를 사용해서 연관관계 조회
Team findTeam = findMember.getTeam();
연관관계 수정
// 새로운 팀B
Team teamB = new Team();
teamB.setName("TeamB");
em.persist(teamB);
// 회원1에 새로운 팀B 설정
member.setTeam(teamB);
양방향 연관관계와 연관관계의 주인
양방향 연관관계
기존에 멤버에서만 팀에 접근할 수 있는 단방향 연관관계였다면(N:1), 이번엔 팀에서도 멤버에 접근해보자.(1:N)
앞서 말했듯이 테이블은 외래 키 하나로 양방향으로 조회할 수 있지만, 객체는 단방향 연관관계만 가능하기 때문에 팀 엔티티에 멤버를 참조할 필드를 따로 추가해야 한다.
<Team.java>
@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<Member>();
public void addMember(Member member) {
member.setTeam(this);
getMembers().add(member);
}
// Getter, Setter
}
- @OnetoMany : 팀과 회원은 일대다 관계이기 때문에 컬렉션을 사용해서 멤버 객체를 참조한다. mappedBy 속성은 양방향 매핑일 때 사용되는데 반대쪽 매핑의 필드 이름을 값으로 주면 된다.
연관관계의 주인
그렇다면 왜 mappedBy를 사용할까? 그 전에 다시 한 번 객체와 테이블의 연관관계의 차이에 대해 생각해보자.
- 객체는 양방향 연관관계라는 것이 없고 서로 다른 단방향 연관관계 2개를 사용한다.
- 테이블은 외래 키 하나로 양방향 연관관계를 사용할 수 있다.
즉, 엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데 외래 키는 하나다. 따라서 둘 사이에 차이가 생긴다. 예를 들어, 멤버 객체에서 팀을 변경할 때, 혹은 팀 객체에서 멤버를 변경할 때 중에 어는 때에 외래 키를 수정해야 하는지 의문이 생긴다. 이런 차이로 인해 JPA에서는 두 객체 연관관계 중 하나를 정해서 테이블의 외래 키를 관리해야 하는데 이것을 연관관계의 주인이라 한다.
연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래 키를 관리할 수 있다. 반면에 주인이 아닌 쪽은 읽기(조회)만 할 수 있다. 이 때, mappedBy 속성을 사용한다.
- 주인은 mappedBy 속성을 사용하지 않는다.
- 주인이 아니면 mappedBy 속성을 사용해서 속성의 값으로 연관관계의 주인을 지정한다.
그렇다면 멤버와 팀 중 어떤 것을 연관관계의 주인으로 정해야 할까?
어느 쪽을 선택해도 상관은 없지만 일반적으로 외래 키를 갖고 있는 객체를 연관관계의 주인으로 정하는 것이 좋다. 그렇지 않으면 팀 객체에서 멤버를 수정했는데, 멤버 테이블(물리적으로 다른 테이블)의 외래 키를 관리하기 위해 update 쿼리를 주는 등 복잡한 설계가 요구되기 때문이다.
위와 같이 외래 키를 갖고 있는 멤버 객체가 연관관계의 주인이 되고 반대편 팀 객체는 읽기만 가능하고 외래 키를 변경하지는 못하는 가짜 매핑이 된다.
+) 데이터베이스 테이블의 다대일 연관관계에서 항상 다 쪽이 외래 키를 가지기 때문에 다 쪽이 연관관계의 주인이 된다.
참고로 연관관계의 주인을 비즈니스 로직상 더 중요하다고 판단하면 안 된다. 단순히 외래 키 관리자 정도로 생각하는 것이 좋다. 즉, 연관관계의 주인은 외래 키의 위치와 관련해서 정해야지 비즈니스 중요도로 접근하면 안 된다.
양방향 연관관계 저장
//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
//회원 저장
Member member = new Member();
member.setName("member1");
member.setTeam(team); //단방향 연관관계 설정, 참조 저장
em.persist(member);
사실 이 코드는 단방향 연관관계에서 살펴본 코드와 완전히 같다.
team.getMembers().add(member)와 같은 코드가 추가로 있어야 할 것 같지만 연관관계의 주인인 멤버에서만 팀을 참조하고 플러시를 하면 JPA에서 알아서 양방향 연관관계를 설정해준다. 오히려 연관관계의 주인이 아닌 곳에 입력된 값은 외래 키에 영향을 주지 않기 때문에 해당 코드는 데이터베이스에 저장될 때 무시된다.
+) 앞서 말했듯이 데이터베이스로 플러시가 되야 연관관계가 제대로 매핑된다. 플러시 하기 전 영속상태에서 양 객체를 조회하면 연관관계 매핑이 되어 있지 않다.
양방향 연관관계의 주의점
양방향 연관관계에서 가장 흔히 하는 실수는 연관관계의 주인에 값을 입력하지 않는것이다. 데이터베이스에 외래 키 값이 정상적으로 저장되지 않으면 이것부터 의심해보자.
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
//역방향(주인이 아닌 방향)만 연관관계 설정
team.getMembers().add(member);
em.persist(member);
이전 코드와는 반대로 팀 객체에서만 멤버를 참조했기 때문에 데이터베이스에서 양방향 연관관계가 매핑되지 않는다.
그렇다면 정말 이전 코드처럼 연관관계의 주인에만 값을 저장하고 주인이 아닌 곳에는 값을 저장하지 않아도 될까?
사실은 객체 관점에서 양쪽 방향에 모두 값을 입력해주는 것이 가장 안전하다. JPA를 사용하지 않는 단위 테스트의 경우 양 객체 모두 서로를 참조해야 테스트를 진행할 수 있다. 앞서 말했듯이 양쪽 모두 참조해주는 것이 안전하고 객체 지향스럽다.
연관관계 편의 메서드
그러나 setter 등을 사용해서 양 객체를 서로 참조하는 것은 헷갈릴 수 있으니 보통 연관관계 편의 메서들 사용한다. 양방향 관계에서 두 참조는 하나인 것처럼 사용하는 것이 안전하다.
<Team.java>
@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<Member>();
public void addMember(Member member) {
member.setTeam(this);
getMembers().add(member);
}
// Getter, Setter
}
팀 객체의 addMember처럼 두 참조를 하나로 리팩토링 하면 실수도 줄어들고 좀 더 그럴듯하게 양방향 연관관계를 설정할 수 있다. 연관관계 편의 메서드는 어느 객체에서 정의해도 상관없으며 개발하기 편한 객체에 정의하면 된다.
주의점
만약 setTeam이 연관관계 편의 메서드일 때, 다음 코드를 보자.
member1.setTeam(teamA);
member1.setTeam(teamB);
Member findMember = teamA.getMember(); // member1이 여전히 조회된다.
처음에 member1과 teamA가 양방향 연관관계로 매핑되어 있다가 member1이 teamB와 양방향 연관관계로 매핑되면 member1은 teamB를 참조하지만 teamA는 여전히 member1은 참조하고 있는 상태가 된다. 따라서 다음 코드처럼 기존 관계를 제거하도록 setTeam을 수정해야 한다.
public void setTeam(Team team) {
// 기존 팀과 관계를 제거
if (this.team != null) {
this.team.getMembers().remove(this);
}
this.team=temam;
team.getMembers().add(this);
}
물론 teamA->member1 관계가 제거되지 않아도 데이터베이스 외래 키를 변경하는 데는 문제가 없다. teamA는 연관관계의 주인이 아니기 때문이다. 연관관계의 주인인 member1의 참조를 변경했기 때문에 데이터베이스에 외래 키는 teamB를 참조하도록 정상 반영된다. 그러나 플러시가 되지 않고 영속성 컨텍스트가 아직 살아있는 상태에서 teamA의 멤버를 호출하면 member1이 반환되므로 앞서 설명한 것처럼 관계를 제거하는 것이 안전하다.
+) 추가로 toString()과 같은 메서드도 양방향 연관관계에서 사용하면 무한루프에 빠질 수 있으므로 가급적 사용을 자제하자.
정리
- 단방향 매핑만으로 테이블과 객체의 연관관계 매핑은 이미 완료되었다.
- 단방향을 양방향으로 만들면 반대방향으로 객체 그래프 탐색 기능이 추가된다.
- 양방향 연관관계를 매핑하려면 객체에서 양쪽 방향을 모두 관리해야 한다.
실전 예제 2 - 연관관계 매핑 시작
공부한 예제를 깃허브에 등록한다.
코드 작성 시 헷갈렸던 점
- persist를 한 상태에서 setter 등을 사용해 필드 값을 수정해도 영속성 컨텍스트에는 반영된다. -> 추후 조회 시에 수정된것으로 반영된다.
- 양방향 연관관계에서, 연관관계의 주인에만 참조를 하고 플러시&clear -> 이 후, 주인이 아닌 객체를 find할 때 연관관계의 주인이 아닌 객체 역시 상대 객체를 참조하고 있다.(JPA의 기능)
- 가능하면 객체들을 모두 영속 상태로 변경하고 그 안에서 연관관계를 맺는 편이 좋다.
Team team = new Team();
team.setName("java");
Member member = new Member();
member.setUsername("kang");
member.setTeam(team);
em.persist(member); //member를 persist 하는 시점에 member[id=1], team[id=null, name="java"]
em.persist(team); //team을 persist 하는 시점에 member[id=1], team[id=2, name="java"]
//결과적으로 member가 사용해야 하는 team의 id가 null -> 2로 변경됨, 따라서 update 추가 발생
em.flush();
물론 flush를 하면 매핑을 하는 데 지장 없긴 하다. 그러나 위와 같이 추가 update 쿼리가 발생한다.
'java > jpa' 카테고리의 다른 글
[JPA] 고급 매핑 (0) | 2021.01.27 |
---|---|
[JPA] 다양한 연관관계 매핑 (0) | 2021.01.27 |
[JPA] 엔티티 매핑 (0) | 2021.01.22 |
[JPA] 영속성 관리 - 내부 동작 방식 (0) | 2021.01.21 |
[JPA] JPA 시작하기 (0) | 2021.01.21 |