[김영한 - JPA 기본] #5 연관관계 매핑 기초
단방향 연관관계
Table 의존적인 연관관계 : 다른 예시
아래는 다대일 관계인 Member와 Team에 대한 코드다
Team team = new Team("H.R.");
em.persist(team);
Member member = new Member("swcho");
member.setTeamId(team.getId());
em.persist(member);
tx.commit();
Member 일부 코드
@Column(name = "TEAM_ID")
private Long teamId;
위와같이 getId로 해서 setId로 하는 번잡한 코드를 참조로 바꾸어 보자
단방향 매핑으로 전환
Member
@ManyToOne // 다대일
@JoinColumn(name = "TEAM_ID") // 실제 테이블의 PK
private Team team;
위와같은 Team을 참조로 하는 필드를 추가하면 된다
그외 팁
종종 persist context에 저장되기 때문에 select 쿼리가 나가는 것을 못 볼 때가 있다
만일 보고자 한다면
아래 코드를 추가하면 된다
em.flush(); // DB 동기화
em.clear(); // 캐시 지움
조회 쿼리
Member findMember = em.find(Member.class, member.getId());
select
member0_.id as id1_0_0_,
member0_.age as age2_0_0_,
member0_.createDate as createDa3_0_0_,
member0_.description as descript4_0_0_,
member0_.lastModifiedDate as lastModi5_0_0_,
member0_.name as name6_0_0_,
member0_.roleType as roleType7_0_0_,
member0_.TEAM_ID as TEAM_ID8_0_0_,
team1_.TEAM_ID as TEAM_ID1_1_1_,
team1_.name as name2_1_1_
from
Member member0_
left outer join
Team team1_
on member0_.TEAM_ID=team1_.TEAM_ID
where
member0_.id=?
위와 같이 join되지 않은 쿼리를 보고자 한다면
아래 Lazy하게 가져오면 된다 (지연로딩 전략)
@ManyToOne(fetch = FetchType.LAZY)
select
member0_.id as id1_0_0_,
member0_.age as age2_0_0_,
member0_.createDate as createDa3_0_0_,
member0_.description as descript4_0_0_,
member0_.lastModifiedDate as lastModi5_0_0_,
member0_.name as name6_0_0_,
member0_.roleType as roleType7_0_0_,
member0_.TEAM_ID as TEAM_ID8_0_0_
from
Member member0_
where
member0_.id=?
select
team0_.TEAM_ID as TEAM_ID1_1_0_,
team0_.name as name2_1_0_
from
Team team0_
where
team0_.TEAM_ID=?
객체 그래프 탐색으로 Team 객체 조회
Member findMember = em.find(Member.class, member.getId());
Team findTeam = findMember.getTeam();
연관관계 바꾸기
Team rndTeam = new Team("RnD");
em.persist(rndTeam);
findMember.setTeam(rndTeam);
양방향 연관관계와 연관관계의 주인 1- 기본
@Entity
public class Member {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
@Entity
public class Team {
...
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
Team에게 있어 Member는 일대다 관계이다
그리고 mappedBy 에는 상대 엔티티의 변수명을 넣는다
굳이 위 사항을 억지로 암기할 필요는 없다 !
예를들어 @ManyToOne
에는 mappedBy
속성이 없는 등 IDE를 통해서 어느쪽이 mapped by 되는 지 알수있다 :)
만일 이름이 일치하지 않으면 아래와 같은 오류가 뜬다
Team team = new Team("H.R.");
em.persist(team);
Long lastMemberId = -1L;
for (int i = 1; i <= 2; i++) {
Member member = new Member("user " + i);
member.setTeam(team);
em.persist(member);
lastMemberId = member.getId();
}
em.flush();
em.clear();
Member findMemberOne = em.find(Member.class, lastMemberId);
// 양방향 탐색
List<Member> findMembers = findMemberOne.getTeam().getMembers();
for (Member findMember : findMembers) {
System.out.println("findMember = " + findMember);
}
tx.commit();
위와 같은 쿼리로 객체 그래프 탐색이 양방향으로 됨을 알 수 있다
객체와 테이블의 차이점
객체의 양방향은 각 단방향 관계의 합이다
- Member가 참조하는 team
Team이 참조하는 mebers
- 객체를 양방향으로 참조하려면 단방향 연관관계를 2개 만들어야 한다
테이블은 외래키(
FK
) 값 하나로 양방향 관계를 맺는다
외래키의 주인은 누구?
MEMBER 테이블의 TEAM_ID (
FK
) 가 삽입/변경되는 것이
Member 객체의 team에 의존할지, Team 객체의 mebers에 의존할지 부터가 의문이다예를들어 회원을 새로운 팀으로 바꾸고자 할 때 이다 !
만일 연관관계 주인을 거꾸로 헀을때…
- 발생 가능한 상황 Team의 members에 업뎃을 시켰는데 실 MEMBER테이블의 FK에 업뎃 쿼리가 난간다 !
- 한번 상황을 재현해보려 했지만 잘 되지 않았다 ㄷ.ㄷ
@Column(s) not allowed on a @ManyToOne property: hellojpa.domain.Member.team
연관관계의 주인 Owner
다대일의 다 다 ! 외래키가 있는 곳이 주인이다 !
- 진짜 매핑 - 연관관계의 주인 (Member.team) -
can write
- 가짜 매핑 - 주인의 바대편 (Team.members) -
read only
- 진짜 매핑 - 연관관계의 주인 (Member.team) -
@JoinColumn
과@ManyToOne
이 있는 곳이 주인이었는줄 알았지만 사실은 그렇지 않았다 !- 사실
@JoinColumn
은 연관관계 주인이 아니다 !- 이 어노테이션은 생략이 가능하다
- 생략시 MEMBER_ID 등 엔티티 네임 기준으로 default가 잡힌다
- 일대일 관계에서 양쪽 모두
@JoinColumn
적용 가능 - 그렇다면 코드상으로 누가 연관관계의 주인인가?
mapped by
속성을 기준으로 정해진다
해당 속성을 가진 필드는 연관관계의 주인이 아니다 -> 반대편이 주인
(말이 어려우니 코드로 이해하고 암기해야 한다)
- 이 어노테이션은 생략이 가능하다
- 사실
연관관계의 주인만이 외래키를 관리한다 (등록, 수정)
- 주인이 아닌 쪽은 조회만 가능하다
주인이 아닌쪽은
mappedBy
속성으로 주인의 참조 객체명을 지정한다 (말이 어렵기 떄문에 코드로 보자 !)
다시 한번 코드로 나타내면 아래와 같다
@Entity
public class Member {
@ManyToOne()
@JoinColumn(name = "TEAM_ID")
private Team team;
@Entity
public class Team {
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
참고로 join 문장은 우리가 아는 DB 벤더사의 그것과 같다
select
m.id, m.name, m.team_id, t.name
from member m
join team t
on m.team_id = t.team_id;
주의할 점
Member member = new Member("user A");
em.persist(member);
Team team = new Team("H.R.");
team.getMembers().add(member);
em.persist(team);
tx.commit();
위와 같은 상황에서는
MEMBER테이블에 TEAM_ID가 할당되지 않는다
왜그러냐면 Team은 연관관계의 주인이 아니기 때문에 쓰기 기능이 없기 때문 !
아래처럼 해주면 정상적으로 FK가 할당된다
Member member = new Member("user A");
em.persist(member);
Team team = new Team("H.R.");
// team.getMembers().add(member);
em.persist(team);
member.setTeam(team);
tx.commit();
살펴보기 : 함정 포인트 : 주의할 것!
Team team = new Team("H.R.");
em.persist(team);
Member member = new Member("user A");
member.setTeam(team);
em.persist(member);
// team.getMembers().add(member);
em.flush();
em.clear();
Team findTeam = em.find(Team.class, team.getId()); // SELECT ~ TEAM
List<Member> members = findTeam.getMembers(); // SELECT ~ MEMBER
for (Member m : members) {
System.out.println("m.getName() = " + m.getName());
}
tx.commit();
위 결과에는 이상이 없다
그러나 객체 지향 관점에서
team.getMembers().add(member);
위 코드가 없다는 것은 이상해 보인다
그리고 실질적으로 문제 2가지 있다
아래 코드를 보자
Team team = new Team("H.R.");
em.persist(team);
Member member = new Member("user A");
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(); // select 쿼리가 나가지 않는다
for (Member m : members) {
System.out.println("m.getName() = " + m.getName());
}
위 구문에는 동기화도 캐시 초기화도 하지 않는다
때문에 캐시가 아직 남아 있기 때문에
DB에서 데이터를 끌고 오지 않고 1차 캐시에 의존하며 members 컬렉션을 가져오지 못한다
- 마치 순수한 자바 객체 상태이다
양방향 편의 메서드를 사용하자
사람이다 보면 어느 한쪽의 setter
를 놓쳐서 양방향 관계를 놓칠 수 있다
이때 누구 하나를 정해놓고 양방향 편의 메서드를 작성하자
이때 흔하디 흔한 setter 메서드보다는
chageTeam 등 메서드명을 직접 적어 놓는 것이 명시적이기 때문에 좋다
// caller
member.changeTeam(team);
// callee
public void changeTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
혹은 다른쪽에 편의 메서드를 삽입해도 된다
team.addMember(member);
public void addMember(Member member) {
members.add(member);
member.setTeam(this);
}
상황에 따라 적절한 곳에 작성하면 된다
그리고 상황에 따라 비즈니스가 복잡할 때는 내부에 null 체크 등을 걸어두어서 작성하면 된다 !
주의할 것 - 그외
- 양방향 매핑시 무한 루프 !!
- IDE - toString()
- 예) 이때 객체들끼리 (team <-> member) 순환참조가 일어나면서 스택오버플로 발생
- lombok - @ToString
- JSON
- 예) 아는 지인분 프로젝트할때 본적이 있다 ㅠㅠ
- Member Json에서 Team을 반환할때 다시 Team Json에서 Member를 반환하고.. 등등
- 답이 거의 정해져 있다. 컨트롤러에서 직접적으로
엔티티
를 반환하지 말것!DTO
를 반환해라 !
- IDE - toString()
설계적인 관점에서 양방향 매핑 정리
단방향 매핑만으로도 이미 연관관계 매핑은 완료된 것이다!
- 양방향 매핑은 반대방향으로 조회 기능이 추가된 것 뿐 ! (객체 그래프 탐색)
JPQL에서 / 실제 업무에서 역방향으로 탐색할 일이 많음 !
단방향 매핑을 잘 하고 양방향은 필요할 때 추가해도 된다
- 왜냐? 테이블에 영향을 주지 않아 !
연관관계의 주인을 정하는 기준
비즈니스 로직 기준으로 선택하면 안됨
- 예를들어 바퀴, 자동차 테이블이 있을때 (다대일)
자동차가 더 중요하다고 이걸 기준으로 하면 안된다 외래키인 바퀴 기준으로 주인으로 정해야 한다
- 예를들어 바퀴, 자동차 테이블이 있을때 (다대일)
연관관계의 주인은 외래키의 위치를 기준으로 정해야함 (일대다의 다)
실전 예제 코드
Member의 orders 컬렉션은 없는 것이 깔끔하다..
- 영한님은 없는 것이 맞다고 보셨다
- 그러나 학습상의 이유로 넣음
양방향은 JPQL 등의 조회를 할 때가 있을 떄 넣어두어도 늦지 않다
일부만 가져왔으며 몇몇 부분에 대해선 생략하였습니다
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(length = 30)
private String name;
private String city;
@Column(name = "ZIP_CODE")
private String zipcode;
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
@Entity(name = "ORDERS")
public class Order {
@Id @GeneratedValue
@Column(name = "ORDER_ID")
private Long id;
public void addOrderItem(OrderItem orderItem) {
orderItems.add(orderItem);
orderItem.setOrder(this);
}
@Entity
public class Item {
@Id @GeneratedValue
@Column(name = "ITEM_ID")
private Long id;
@Column(name = "ITEM_NAME")
private String name;
@OneToMany(mappedBy = "item")
private List<OrderItem> orderItem = new ArrayList<>();
@Entity
public class OrderItem {
@Id
@GeneratedValue
private Long id;
@ManyToOne
@JoinColumn(name = "ORDER_ID")
private Order order;
@ManyToOne
@JoinColumn(name = "ITEM_ID")
private Item item;
@Column(name = "ORDER_PRICE")
private int orderPrice;