목표
- 객체와 테이블 연관관계의 차이를 이해해야 한다.
- 객체의 참조와 테이블의 외래 키를 어떻게 매핑하는지에 대해 이해한다.
용어 | 종류 및 설명 |
방향(Direction) | 단방향, 양방향 |
다중성(Multiplicity) | 일대일(1:1), 다대일(N:1), 일대다(1:N), 다대다(N:M) 이해 |
연관관계의 주인(Owner) | 객체 양방향 연관관계는 관리가 필요 |
연관관계가 필요한 이유
객체지향 설계의 목표는 자율적인 객체들의 협력 공동체를 만드는 것이다
–조영호(객체지향의 사실과 오해)
객체를 테이블에 맞추어 모델링( 연관관계가 없는 객체)
객체를 테이블에 맞추어 모델링 코드 (참조 대신 외래 키를 그대로 사용)
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
/* 회원(Member) 엔티티*/
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
@Column(name = "TEAM_ID")
private Long teamId;
}
/* 팀(Team) 엔티티 */
@Entity
public class Team{
@Id @GeneratedValue
private Long id;
private String name;
}
- 객체를 위와 같이 테이블에 맞춰 모델링을 할 경우 생기는 문제는 무엇일까?
객체를 테이블에 맞춰 모델링 했을 경우 DB 저장&조회하는 로직
public class JpaMain {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try{
/* 팀과 멤버를 저장하는 로직 */
Team team = new Team();
team.setName("teamA");
em.persist(team);
Member member = new Member();
member.setName("mamber1");
member.setTeamId(team.getId()); // 객체지향적이라면 setTeam()이지 않을까?
em.persist(member);
tx.commit();
}catch(Exception e ){
tx.rollback();
}finally {
em.close();
}
}
}
외래키 식별자를 직접 다루고 있는데, 이럴 경우 문제는? → 조회할 때 역시 해당 외래키를 가지고 조인 쿼리를 직접짜야한다.
Q. member1 이 소속된 팀 정보를 조회하려면 어떻게 해야하는가?
Member findMember = em.find(Member.class, member.getId());
// 연관관계가 없음
Long findTeamId = findMember.getTeamId();
Team findTeam = em.find(Team.class, findTeamId);
매번 member를 우선 조회한 뒤 외래키를 뽑아 그것으로 팀의 정보를 조회해야 한다.
→ 협력관계를 만들 수 없다.
결론
외래키를 직접 관리하는 테이블에 맞춘 객체 모델링은 객체간의 협력관계를 만들 수 없고, 객체가 참조를 통해 연관객체를 찾는 다는 사상을 적용할 수 없다. 이 말은 객체지향 프로그래밍의 패러다임을 정면으로 반박하는 것이다.
- 테이블은 외래 키로 조인을 사용해서 연관된 테이블을 찾는다.
- 객체는 참조를 사용해서 연관된 객체를 찾는다.
테이블과 객체 사이에는 이런 큰 간격이 있다.
단방향 연관관계
객체 지향 모델링 (객체 연관관계 사용)
객체 지향적으로 엔티티 설계(객체 연관관계 사용)
/* 회원(Member) 엔티티*/
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
@ManyToOne // Member 엔티티와 Team 엔티티 간의 관계 설정
@JoinColumn(name = "team_id") // Team 레퍼런스와 테이블의 FK와 매핑
private Team team;
... getter, setter
}
- @ManyToOne, @JoinColumn 을 통해 멤버(Member)에서 팀(Team)을 참조하도록 했다.
- 즉, 이러한 ManyToOne 관계에서 Join하는 컬럼은 무엇인지를 명시
try{
// 팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
// 회원 저장
Member member = new Member();
member.setName("member1");
member.setTeam(team); //단방향 연관관계 설정, 참조 저장
em.persist(member);
em.flush();
em.clear();
// 조회
Member findMember = em.find(Member.class, member.getId());
// 참조를 사용해서 연관관계 조회
Team findTeam = findMember.getTeam();
System.out.println("findTeam = " + findTeam.getName());
tx.commit();
}catch (Exception e){
tx.rollback();
}finally {
em.close();
}
※ 참고
Test 할 때 영속성 컨텍스트의 1차 캐시를 사용하지 않고 직접 DB 쿼리로 조회하고 싶다면 em.flush(), em.clear()를 이용하여 테스트가 가능하다.
양방향 연관관계와 연관관계의 주인 1 - 기본
단방향에서 양방향이 된다는 것의 의미는 객체의 입장에선 양측에서 서로를 참조할 수 있다는 것이다. 기존 단방향에서는 Member에서는 getTeam()을 통해 Team 엔티티를 참조할 수 있지만 Team에서는 Member를 참조할 수 없었다.
하지만 테이블 연관관계에서는 외래키 "단 하나"를 가지고 양측에서 서로를 Join하여 데이터를 얻어 올 수 있다.
사실상 외래키(FK) 하나만 추가하면 양쪽으로 데이터 조회가 가능하기 때문에 "방향" 이라는 개념 자체가 없는 것이다.
양방향 객체 연관관계(반대 방향으로 객체 그래프 탐색) 추가하기
- Team객체에 members라는 List를 추가해서 양방향 연관관계를 만들어준다.
- 이때 관례적으로 Liad에 add() 할때 NPE 를 방지하기 위해 new ArrayList<>() 처럼 컬랙션 초기화를 미리 해준다.
@Entity
public class Team{
...
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
...
}
추가한 members를 확인하는 코드를 작성해보자.
Member findMember = em.find(Member.class, member.getId());
List<Member> members = findMember.getTeam().getMembers();
for (Member m : members) {
System.out.println("m = " + member1.getName()); // m = member1
}
- 이제 반대방향으로도 객체 그래프 탐색이 가능해졌다
연관관계의 주인과 mappedBy
객체와 테이블이 관계를 맺는 차이란?
객체는 서로간 참조를 통해 관계를 맺을 수 있으므로 연관관계를 맺는 부분이 2가지가 존재한다.
즉, 단방향 연관관계가 2개 존재하는 것이다. 그저 억지로 양방향 연관관계라고 하는 것이다.
- 객체 연관관계 = 2개
- 회원 -> 팀 연관관계 1개(단방향)
- 팀 -> 회원 연관관계 1개(단방향)
반면 테이블은 단순히 FK 하나로 양쪽의 연관관계 설정이 끝나며 Join을 통해 데이터를 가져올 수 있다.
- 테이블 연관관계 = 1개
- 회원 <-> 팀의 연관관계 1개(양방향)
※ 참고
객체의 연관관계와의 비교를 위해 테이블 연관관계에서도 "양방향" 이라는 용어를 사용했지만 사실상 FK 만 설정해두면 양쪽으로 관계가 설정되기 때문에 "방향"이라는 개념이 존재하지 않는다.
객체의 양방향 관계
- 객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단방향 관계 2개다.
- 객체를 양방향으로 참조하려면 단방향 연관관계를 2개 만들어야 한다.
- A → B(a.getB())
- B → A(b.getA())
테이블의 양방향 연관관계
- 테이블은 외래 키 하나로 두 테이블의 연관관계를 관리
MEMBER.TEAM_ID 외래 키 하나로 양방향 연관관계 가질수 있다. (양쪽으로 조인할 수 있다.)
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
그렇다면 두 객체가 서로가 서로를 참조하는 값을 만들어 놓은 상황에서
- Member에 있는 team을 업데이트했을 때 테이블의 FK 값을 업데이트 한다.
- Team에 있는 members를 업데이트했을 때 테이블의 FK 값을 업데이트 한다.
라는 딜레마에 빠질 수도 있다. DB 입장에서는 외래키만 제대로 업데이트되면 되기 때문에 객체의 참초 관계를 알아서 관리하라는 입장이다.
또 다른 극단적인 예시로 Member의 team에는 값을 넣고 Team의 members에는 값을 넣지 않는다 던지 혹은 반대 상황일 시나리오에서는 외래키는 대체 어떻게 업데이트해야할 지에 대한 고민에 빠질 것이다.
그래서
둘 중 하나로 외래 키를 관리해야 한다.
연관관계의 주인(Owner)
양방향 매핑 규칙
- 객체의 두 관계중 하나를 연관관계의 주인으로 지정
- 연관관계의 주인만이 외래 키를 관리(등록, 수정)
- 주인이 아닌쪽은 읽기만 가능
- 주인은 mappedBy 속성을 사용하지 않는다.
- 주인이 아니면 mappedBy속성으로 주인을 지정한다.
누구를 주인으로?
- 외래 키가 있는 곳을 주인으로 정하라.
- DB 테이블로 따지만 외래키가 있는 테이블이 "다"에 해당되고 해당 테이블을 주인으로 정하면 된다.
- 여기서는 Member.team이 연관관계의 주인
※ 참고
Q. Team에서 외래키를 관리하는 것은 불가능한가?
A. 가능은 하다. 하지만 Team에서 members를 수정하면 Team이 아닌 Member에 업데이트 쿼리가 날라가는 불일치 현상 발생
결론: 외래키가 있는곳을 주인(Owner)로 결정하라.
양방향 연관관계와 연관관계의 주인 2 - 주의점
양방향 매핑시 가장 많이 하는 실수
1. 연관관계의 주인에 값을 입력하지 않음
Member member = new Member();
member.setName("mamber1");
em.persist(member);
Team team = new Team();
team.setName("teamA");
//역방향(주인이 아닌 방향)만 연관관계 설정
team.getMembers().add(member);
em.persist(team);
결론 : 양방향 매핑시 연관관계의 주인에 값을 입력해야 한다.
순수한 객체 관계를 고려하면 항상 양쪽다 값을 입력해야 한다.
Team team = new Team();
team.setName("teamA");
em.persist(team);
Member member = new Member();
member.setName("mamber1");
team.getMembers().add(member);
member.setTeam(team); //연관관계의 주인에 값 입력
em.persist(member);
Q.만약 여기서 team.getMembers().add(member); 을 넣지 않는다면 어떤 문제가 있을까?
우선 아래 코드를 실행해보자.
// 팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
// 회원 저장
Member member = new Member();
member.setName("member1");
member.setTeam(team);
em.persist(member);
em.flush();
em.clear();
Team findTeam = em.find(Team.class, team.getId());
List<Member> members = findTeam.getMembers();
for(Member m : members){
System.out.println("m = " + m.getName());
}
tx.commit();
쿼리를 살펴보면 SELECT 쿼리가 2번 실행되는 것을 알 수 있다. 하지만 이때 JPA는 flush(), clear()를 하고 있기 때문에 DB에 실제 데이터를 미리 넣고 FK로 연관관계를 다 설정하고 값을 가져오고 있다.
※ 참고
첫 번째 쿼리는 Team을 조회할 때 실행되는 쿼리이다. 한가지 주의할 점은 JPA는 지연 로딩을 이라는 특징이 있기 때문에 Team의 members를 조회할 때는 해당 members를 getMembers(); 처럼 실제 사용하는 시점에 쿼리를 날려 값을 가져온다. (추후 설명)
이제 flish(), clear()를 하지 않은 코드를 살펴보자.
// 팀 저장
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);
// em.flush();
// em.clear();
Team findTeam = em.find(Team.class, team.getId());
List<Member> members = findTeam.getMembers();
System.out.println("=====================");
for(Member m : members){
System.out.println("m = " + m.getName());
}
System.out.println("=====================");
tx.commit();
결과를 확인해보면 members의 값이 조회되지 않는다.
A. DB에 반영하는데 문제는 생기지 않는다. 하지만, 영속화 컨텍스트의 1차 캐시에 저장된 team에서는 members에 해당 Member가 추가되지 않은 상태이다. 이런 상황에서 team.members를 사용하게 된다면 DB에서 조회하는게 아닌 1차 캐시에서 꺼내 사용하기 때문에 해당 member가 추가되지 않은 결과가 반환 될 것이고, 문제가 생기게 된다.
그렇기 때문에 양쪽에 모두 값을 세팅해주는게 맞다.
TIP: 연관관계 편의 메서드를 생성하자.
public void changeTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
======== 혹은 ================
public void addMember(Member member){
member.setTeam(this);
this.members.add(member);
}
- chargeTeam() : Member를 세팅해주는 시점에 team의 member List에서 자기 자신을 추가시켜준다.
or
addMember() : Team을 세팅해주는 시점에 해당 team에 속할 member를 추가해준다. - 이러한 연관관계 편의 메서드는 "일" 혹은 "다" 쪽 어느 곳이든 한쪽에만 추가해주면 된다.
- 다만 양쪽 모두 편의 메서드는 추가하는 것을 순환 참조와 같은 오류가 발생할 우려가 있기 때문에 반드시 한쪽에만 추가해주자.
- (Optional) 연관관계 편의 메서드같은 Optional메서드는 관례적으로 쓰이는 Getter, Setter가 아닌 사용자 정의 메서드명(임의)으로 정의해주는게 좋다.
2. 양방향 매핑시 무한 루프를 조심하자.(순환 참조)
→ Ex: toString(), lombk , JSON 생성 라이브러리
/* 회원(Member) 엔티티*/
@Entity
public class Member {
...
@Override
public String toString() {
return "Member{" +
"id=" + id +
", name='" + name + '\'' +
", team=" + team +
'}';
}
...
}
/* 팀(Team) 엔티티 */
@Entity
public class Team{
...
@Override
public String toString() {
return "Team{" +
"id=" + id +
", name='" + name + '\'' +
", members=" + members +
'}';
}
...
}
Member의 toString()에 있는 team은 Team 엔티티의 toString()을 호출한다. Team의 toString()에 있는 members는 members에 있는 요소 하나하나의 Member 엔티티의 toString()을 계속 호출한다. 즉, 순환 참조가 지속적으로 발생.
그러므로 웬만하면 Lombok의 @ToString 과 같은 어노테이션 사용은 피하는게 좋다. 굳이 toString()이 필요하다면 참조 관계에 있는 엔티티는 빼고 오버라이딩하는 것이 좋다.
또한 양방향 관계에 있는 엔티티를 JSON 생성 라이브러리를 사용하면 마찬가지로 연관 관계가 걸려있는 엔티티를 모두 참조하기 때문에 장애가 발생한다.
※ 주의사항
엔티티를 Contoller 계층에서 직접 사용하지 말아야할 이유
1. Controller 단에서 엔티티를 직접 response로 보내버리면 양방향이 걸려있는 엔티티들이 모두 순환 참조되기 때문에 장애가 발생한다.
ex) JSON 생성 라이브러리를 사용하는 와중에 순환 참조에 의해 큰 장애가 발
2. 엔티티는 변경 가능성이 있기 때문에 엔티티를 변경하는 순간 API 스팩이 변경된다.
그러므로 엔티티는 DTO로 변환해서 반환하는 것이 좋다!!
양방향 매핑 정리
- 단방향 매핑만으로도 이미 객체와 테이블의 매핑은 완료. 그러므로 설계 단계에서 단방향 매핑으로 설계를 끝내는 것이 좋다.
- 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐
- JPQL에서 역방향으로 탐색할 일이 많음
- 단방향 매핑을 잘 하고 양방향은 필요할 때 추가해도 됨
(테이블에 영향을 주지 않는다.) - 연관관계의 주인은 외래 키의 위치를 기준으로 정해야 함.
- 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안된다.
실전 예제 2- 연관관계 매핑 시작
1. 테이블 구조는 이전과 같다.
2. 객체 구조
- 서로 간에 참조가 가능하도록 변경되었다.
- 연관관계의 주인은 테이블구조의 외래키가 있는 위치를 기준으로 한다.
※ 참고
실무에선 JPQL 을 이용하여 복잡한 쿼리를 작성할 일이 많다. 이때 양방향으로 조회할 경우가 생길 때가 있는데 그때 양방향 연관관계를 고려해도 된다.
보통은 단방향 연관관계로 모든 로직이 가능하다.
Member
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String name;
private String city;
private String street;
private String zipdode;
@OneToMany(mappedBy = "member") // Order에 있는 member가 연관관계 주인
private List<Order> orders = new ArrayList<>();
... getter / setter
}
Order
@Entity
@Table(name = "ORDERS")
public class Order {
@Id
@GeneratedValue
@Column(name = "ORDER_ID")
private Long id;
@ManyToOne
@JoinColumn(name = "MEMBER_ID")
private Member member;
@OneToMany(mappedBy = "order") // OrderItem에 있는 order가 연관관계 주인
private List<OrderItem> orderItems = new ArrayList<>();
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus orderStatus;
public void addOrderItem(OrderItem orderItem){
this.orderItems.add(orderItem);
orderItem.setOrder(this);
}
... getter / setter
}
OrderItem
@Entity
public class OrderItem {
@Id
@GeneratedValue
@Column(name = "ORDER_ITEM_ID")
private Long id;
@ManyToOne
@JoinColumn(name = "ORDER_ID")
private Order order;
@ManyToOne
@JoinColumn(name = "ITEM_ID")
private Item item;
private int orderPrice;
private int count;
... getter / setter
}
Item
@Entity
public class Item {
@Id
@GeneratedValue
@Column(name = "ITEM_ID")
private Long Id;
private String name;
private int price;
private int stockQuantity;
... getter / setter
}