연관관계가 필요한 이유
‘객체지향 설계의 목표는 자율적인 객체들의 협력 공동체를 만드는 것이다.’
조영호(객체지향의 사실과 오해)
예제 시나리오
- 회원과 팀이 있다.
- 회원은 하나의 팀에만 소속될 수 있다.
- 회원과 팀은 다대일 관계다.
객체를 테이블에 맞추어 모델링 (연관관계가 없는 객체)
객체를 테이블에 맞추어 모델링 (참조 대신에 외래 키를 그대로 사용)
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "USERNAME", nullable = false)
private String name;
@Column(name = "TEAM_ID")
private Long teamId;
public Member() {
}
}
@Entity
public class Team {
@Id
@GeneratedValue
@Column(name = "TEAM_ID")
private Long teamId;
private String name;
}
객체를 테이블에 맞춰서 모델링하면 뭐가 문제일까?
저장
//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
//회원 저장
Member member = new Member();
member.setName("member1");
member.setTeamId(team.getId());
em.persist(member);
객체를 테이블에 맞춰서 모델링하는 상황에서 member에 team을 저장할려면 외래키 식별자를 직접 저장한다.
이런 방식은 객체 지향적인 방법은 아니다.
조회
Member findMember = em.find(Member.class, member.getId());
Long findTeamId = findMember.getTeamId();
Team findTeam = em.find(Team.class, findTeamId);
member가 속한 team을 조회 할려면 우선 member를 찾은 후 member에 있는 teamId를 가지고 team을 조회해야 한다. 식별자로 다시 조회하는 방식은 객체 지향적인 방법은 아니다.
객체를 테이블에 맞추어 데이터 중심으로 모델링하면, 협력 관계를 만들 수 없다.
테이블은 외래 키로 조인을 사용해서 연관된 테이블을 찾는다.
객체는 참조를 사용해서 연관된 객체를 찾는다.
테이블과 객체 사이에는 이런 큰 간격이 있다.
외래키랑 참조는 완전히 다른 방식이다.
단방향 연관관계
객체 지향 모델링 (객체 연관관계 사용)
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME", nullable = false)
private String name;
// @Column(name = "TEAM_ID")
// private Long teamId;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
@ManyToOne
- Member 입장에서는 Team과 다:1 관계이다.
- 그래서 team 필드 위에 @ManyToOne 을 사용한다.
@JoinColumn(name = "TEAM_ID")
- 사진에서 객체 연관관계를 보면 Member에는 Team 타입의 필드가 있다. 즉 Member는 Team을 참조하고 있다.
- 테이블 연관관계를 보면 MEMBER 테이블에는 외래키로 TEAM의 id값이 존재한다.
- 따라서 객체의 참조와 테이블의 외래키를 매핑해야 한다.
- JoinColumn 을 사용하여 매핑할 수 있다. 이때 name에는 외래키 컬럼 이름을 작성하면 된다.
위 어노테이션을 사용하면 최종적으로 아래 이미지와 같이 연관관계 매핑을 할 수 있다.(ORM 매핑)
연관관계 저장
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);
member에 새로운 team으로 수정하고 싶으면 setTeam() 해서 수정하면 된다.
그러면 DB의 왜래키 값이 업데이트 된다.
양방향 연관관계와 연관관계의 주인
양방향 매핑
양쪽으로 참조해서 갈 수 있게 만들면 양방향 연관관계라고 한다.
아래 이미지를 보면 앙방향으로 객체간에 참조하게 만들어도 테이블에는 변화가 없다.
테이블에서는 외래키를 가지고 조인을 해서 member에서 team으로 team에서 member로 연관을 맺을 수 있다.
즉 테이블의 연관관계는 외래키 하나로 양방향이 있다.
사실 테이블의 연관관계에는 방향이라는 개념 자체가 없다.
문제는 객체다.
member에서 팀을 갈 수 있지만 team에서 member로 갈 수 없었다.
그래서 team에 members라는 List를 넣어줘야 team에서 Member로 갈 수 있다.
객체에서는 양쪽에 값이 있어야 양방향으로 참조할 수 있지만 테이블은 외래키 하나로 양방향이 가능하다.
이게 객체의 참조와 테이블의 외래키의 가장 큰 차이이다.
예시 코드
@Entity
public class Team {
@Id
@GeneratedValue
@Column(name = "TEAM_ID")
private Long teamId;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
Team 엔티티에 Member 타입을 요소로 가지는 컬렉션을 추가한다.
관례상 new ArrayList<>()로 초기화한다.(null point 예외를 막을 수 있다.)
@OneToMany
- team 입장에서는 member와 1:N 관계이다.
mappedBy = "team"
- Member에 있는 team으로 매핑 되어 있다는 의미다.
반대 방향으로 객체 그래프 탐색
//조회
Team findTeam = em.find(Team.class, team.getId());
intmemberSize=findTeam.getMembers().size();//역방향 조회
연관관계의 주인과 mappedBy
객체와 테이블간에 연관관계를 맺는 차이를 이해해야 한다.
객체와 테이블이 관계를 맺는 차이
객체간에 관계를 맺는 것과 테이블간에 관계를 맺는 것의 차이는 무엇일까?
객체는 연관관계가 되는 키포인트가 두 가지가 있다.
단반향 연관관계가 두 개가 있다고 보면 된다.
멤버에서 팀으로 가는 연관관계 하나 팀에서 멤버로 가는 연관관계 하나
반면 테이블은 외래키 하나로 양쪽으로 연관관계를 맺을 수 있다.
- 객체 연관관계 = 2개
- 회원 -> 팀 연관관계 1개(단방향)
- 팀 -> 회원 연관관계 1개(단방향)
- 테이블 연관관계 = 1개
- 회원 <-> 팀의 연관관계 1개(양방향)
객체의 양방향 관계
객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단뱡향 관계 2개다.
객체를 양방향으로 참조하려면 단방향 연관관계를 2개 만들어야 한다.
테이블의 양방향 연관관계
테이블은 외래키 하나로 두 테이블의 연관관계를 관리
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으로 가는 team 참조값
- team에서 member로 가는 members 참조값
그렇다면 테이블의 외래키 연관관계를 둘 중에 어떤거랑 매핑해야 할까?
- member의 team 값을 바꿨을때 테이블의 외래키 값을 업데이트를 해야하는가
- 아니면 team의 members의 값을 바꿨을때 테이블의 외래키 값을 업데이트 해야하는가
member에 있는 team으로 외래키를 관리할지 아니면 team에 있는 members로 외래키를 관리할지 둘 중에 하나로 외래키를 관리해야 한다.
연관관계의 주인(Owner)
양방향 매핑 규칙
- 객체의 두 관계중 하나를 연관관계의 주인으로 지정
- 연관관계의 주인만이 외래 키를 관리(등록, 수정) 가능하다.
- 주인이 아닌쪽은 읽기만 가능
- 주인은 mappedBy 속성 사용X
- 주인이 아니면 mappedBy 속성으로 주인 지정
누구를 주인으로?
- 외래키가 있는 있는 곳을 주인으로 정해라
- 여기서는 Member.team이 연관관계의 주인
- DB 입장에서 보면 외래키가 있는 곳이 무조건 'N' 이고 외래키가 없는 곳이 1이다.
- DB의 N쪽이 무조건 연관관계의 주인이 되는거다.
위에처럼 'N'쪽을 연관관계 주인으로 설계해야 성능 이슈도 없고 설계도 깔끔하게 할 수 있다.
그리고 테이블에서 관리 되는 외래키랑 객체의 'N'쪽을 연관관계 주인으로 해서 매핑하면 직관적으로 인식이 된다.
Member.team을 연관관계의 주인으로 하면 Member.team으로만 테이블의 외래키 값을 등록하고 수정할 수 있다.
Team.members는 오직 외래키 값을 읽기만 가능하다.
양방향 매핑시 가장 많이 하는 실수
연관관계의 주인에 값을 입력하지 않음
양방향 매핑시에 연관관계의 주인이 아닌 역방향에만 값을 넣어가지고 외래키 값이 null이 되는 부분을 조심하자.
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
//역방향(주인이 아닌 방향)만 연관관계 설정
team.getMembers().add(member);
em.persist(member);
위와 같이 역방향에만 값을 넣을 경우 아래처럼 TEAM_ID 에는 null이 들어간다.
양방향 매핑시 연관관계의 주인에 값을 입력해야 한다.
순수한 객체 관계를 고려하면 항상 양쪽 다 값을 입력해야 한다.
예시 코드
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.getTeamId());
List<Member> members = findTeam.getMembers();
for (Member m : members) {
System.out.println("m = " + m.getName());
}
실행결과
Hibernate:
select
t1_0.TEAM_ID,
t1_0.name
from
Team t1_0
where
t1_0.TEAM_ID=?
Hibernate:
select
m1_0.TEAM_ID,
m1_0.MEMBER_ID,
m1_0.USERNAME
from
Member m1_0
where
m1_0.TEAM_ID=?
m = member1
실행결과를 보자
team.getMembers().add(member); 부분을 주석 처리하여 team의 members에 값을 안 넣었는데 왜 m = member1이 출력 됐을까?
정답은 지연 로딩이다.
첫 번재 select는 Team findTeam = em.find(Team.class, team.getTeamId()); 에서 em.find()을 할때 발생한 조회 쿼리다.
그 다음 select는 반복문안에 m.getName() 호출로 인해 발생한 쿼리이다. Team에 있는 members의 데이터를 실제 사용하는 시점에 쿼리를 보낸다. 그래서 team.getMembers().add(member); 부분을 주석처리해도 Team에 있는 members의 값을 가져올 수 있다. 하지만 team의 members에 값을 넣어주지 않으면 객체지향스럽지 않다.
또한 넣어주지 않으면 두군데에서 문제가 발생한다.
첫번째 문제
flush(), 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.getTeamId());
List<Member> members = findTeam.getMembers();
System.out.println("===============");
for (Member m : members) {
System.out.println("m = " + m.getName());
}
System.out.println("===============");
tx.commit();
실행결과
Hibernate:
/* insert for
jpa_basic.ex1_hello_jpa.hellojpa.Team */insert
into
Team (name, TEAM_ID)
values
(?, ?)
Hibernate:
/* insert for
jpa_basic.ex1_hello_jpa.hellojpa.Member */insert
into
Member (USERNAME, TEAM_ID, MEMBER_ID)
values
(?, ?, default)
===============
===============
flush()를 하지 않으면 영속성 컨텍스트의 쓰기 저장소에 있는 쿼리들이 DB에 반영되지 않는다.
clear()를 하지 안으면 영속성 컨텍스트는 그대로 존재한다.
그래서 em.find()를 할 경우 DB에서 값을 조회하는게 아닌 영속성 컨텍스트에 있는 값을 조회한다.
위 코드에서는 members에 값을 넣어주지 않은 상태로 영속성 컨텍스트에 저장이 되었다.
그러므로 영속성 컨텍스트에서 조회한 team에는 members에 값이 존재하지 않는다.
두번째 문제
테스트 케이스 작성 시 jpa 없이도 순수하게 자바 코드 상태로 테스트 케이스를 작성해야 한다.
이런 경우 member.getTeam()은 되는데 반대로 하면 값이 빈 값으로 나오는 문제가 발생한다.
결론은 양방향 연관관계를 세팅할때는 양쪽 객체에다가 값을 넣어주는게 맞다.
양방향 연관관계 주의 - 실습
- 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하자
- 연관관계 편의 메소드를 생성하자
- 양방향 매핑시에 무한 루프를 조심하자
- 예: toString(), lombok, JSON 생성 라이브러리
- controller에서 엔티티를 클라로 보낼때 JSON 생성 라이브러리를 쓰는데 여기서 무한 루프가 발생할 수 있다.
- lombok에서 toString() 만드는거는 웬만하면 쓰지 말자
- 컨트롤러에서 엔티티를 절대 반환하지마라
- 위에서 말한 무한 루프가 발생할 수 있고 엔티티 변경 시 api 스펙이 바뀌어 버린다.
연관관계 편의 메서드 예시
member에 있는 연관관계 편의 메서드
public void changeTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
member에 있는 setTeam() 메서드를 위와 같이 만들면 team.getMembers().add(member); 처럼 추가할 필요가 없어진다. 추가로 메소드명을 setter를 쓰는게 아닌 changeTeam()처럼 메소드명을 달리 하는게 좋다.
team에 있는 연관관계 편의 메서드
public void addMember(Member member) {
member.setTeam(this);
members.add(member);
}
연관관계 편의 메서드는 1(team)에 넣어도 되고 N(member)에 넣어도 되는데 둘 중에 하나만 넣어야 한다.
양방향 매핑 정리
- 단방향 매핑만으로도 이미 연관관계 매핑은 완료
- JPA 모델링 할때 단방향 매핑으로 설계를 끝내야 한다.
- 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐
- JPQL에서 역방향으로 탐색할 일이 많음
- 단방향 매핑을 잘 하고 양방향은 필요할 때 추가해도 됨
(테이블에 영향을 주지 않음)
- 테이블을 바꾸는거 없이 객체쪽에 코드를 추가하면 된다.
연관관계의 주인을 정하는 기준
- 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안됨
- 연관관계의 주인은 테이블의 외래키의 위치를 기준으로 정해야함
실전 예제 - 2. 연관관계 매핑 시작
테이블 구조
객체 구조
정리
최대한 단항뱡으로 설계하자.
하지만 실무에서 하다보면 조회를 편하게 하고 싶고 JPQL을 작성할때 편하게 작성할려다 보니 양쪽 방향으로 조회해야 될 일이 많이 생긴다. 이럴 때는 양방향으로 설계하면 좋다.
참고
김영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본 편 강의를 보고 정리한 내용입니다.
'JPA > JPA 기본' 카테고리의 다른 글
7. 상속관계 매핑 (0) | 2024.08.10 |
---|---|
6. 다양한 연관관계 매핑 (0) | 2024.08.10 |
4. 엔티티 매핑 (0) | 2024.08.09 |
3. 영속성 관리 - 내부 동작 방식 (0) | 2024.08.08 |
2. JPA 시작하기 (1) | 2024.08.08 |