JPA
연관관계 매핑 기초
No-ah98
2022. 3. 29. 02:49
연관관계가 필요한 이유
-객체지향 설계의 목표는 자율적인 객체들의 협력 공동체를 만드는 것이다. - 조영호(객체지향의 사실과 오해)
예제 시나리오
- 회원과 팀이 있다.
- 회원은 하나의 팀에만 소속될 수 있다.
- 회원과 팀은 다대일 관계다.
객체를 테이블에 맞추어 모델링..(연관관계가 없는 객체)
연관 관계가 없는 객체, 참조 대신에 외래 키를 그대로 사용
/**
* @Entity를 무조건 넣어야됨
* -> 로딩할 때, JPA를 사용하는 애라고 인식하여 관리하기 시작
*/
@Entity @Getter @Setter
public class Member {
/**
* @Id -> PK
*/
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "TEAM_ID")
private Long teamId;
}
@Entity
public class Team {
@Id
@GeneratedValue
private Long id;
private String name;
...
}
- 연관 관계가 없는 객체, 외래 키 식별자를 직접 다룸
- 팀을 저장하고 회원을 저장한다. 이때, 팀의 ID(기본키)를 member의 teamId에 set한다.
- member.setTeamId(team.getId());
public Class Main {
public static void main(String[] args) {
...
//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team); // 영속 상태가 됨. PK값이 세팅 된 상태.
//회원 저장
Member member = new Memeber();
member.setName("memberA");
member.setTeamId(team.getId()); // 외래 키 식별자를 직접 다룸
em.persist(member);
}
}
- 식별자로 다시 조회, 객체지향적인 방법은 아니다.
- 연관 관계가 없기 때문에 조회를 두 번 따로 해야된다.
- 객체지향적인 방법은 아니다
//조회
Member findMember = em.find(Member.class, member.getId());
Long findTeamId = findMember.getTeamId();
//연관관계가 없음
Team findTeam = em.find(Team.class, findTeamId.getId());
즉, 데이터베이스 테이블 스타일에 맞추어 객체의 관계를 끼워 맞추는 방식을 사용하면
"객체를 데이터베이스 테이블에 맞추어 데이터 중심으로 모델링하면 협력 관계를 얻을 수 없다."
단방향 연관관계
- 객체 지향 모델링(객체 연관관계 사용)
- 객체 지향 모델링(객체의 참조와 테이블의 외래 키를 매핑)
- 주석과 같이 외래 키 대신에 Team 객체를 선언
- JoinColumn으로 조인 컬럼명을 명시, default로도 가능하지만 되도록이면 선언하는게 좋다.
- 연관 관계 @ManyToOne을 설정
- Member 입장에서 Team과의 관계는 N : 1
- 이제, team 필드가 DB에 TEAM_ID(FK)와 매핑이 됨.
- 관계를 선언(@ManyToOne)하고 조인할 컬럼명을 매핑함
- 이를, 연관관계 매핑(ORM)이라고 한다.
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
private int age;
// @Column(name = "TEAM_ID")
// private Long teamId;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
...
}
- 객체의 참조와 테이블의 외래키를 매핑한 모델링(ORM 매핑)
연관관계 저장하는 방법 및 참조로 연관관계 조회(객체 그래프 탐색)
- member.setTeam(team)
//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
//회원 저장
Member member = new Memeber();
member.setName("member1");
member.setTeam(team); //단방향 연관관계 설정, 참조 저장
em.persist(member);
Member findMember = em.find(Member.class, member.getId());
- 위와 같이 코드를 작성하면 findMember 변수에서 getTeam()을 호출해서 바로 팀 조회가 가능(연관관계의 장점)
- 참고로, em.find 할 때 select 쿼리는 날아가지 않는다.
- why? member는 현재 영속상태(persist)가 됐기 때문에 1차 캐시에서 바로 리턴
- 만약 find 하기전에 flush, clear를 한다면 JPA가 조인해서 한방쿼리를 사용한다.
- 하지만 처음부터 Join 하지 않고 member만 순수하게 가져오고 싶으면 패치 옵션(Lazy, Eager)을 사용하면 된다.(최적화)
객체지향 모델링(연관관계 수정)- setTeam()으로 새로운 팀을 설정하면 Update 쿼리를 통해서 외래키(FK)가 들어간 Value가 바뀐다.
//새로운 팀B
Team teamB = new Team();
team.setName("TeamB");
em.persist(teamB);
//회원1에 새로운 팀B 설정
member.setTeam(teamB);
양방향 연관관계(mappedBy)와 연관관계의 주인
- 양방형 매핑
- 상황) Team 객체에서도 특정 Team에 속한 member를 조회하고 싶음
- DB는 방향이 없기 때문에, 외래키 하나로 Join을 통해서 양방향 조회가 가능
- 하지만, 객체 연관관계를 양방향으로 조회하기 위해서는 둘 다 셋팅(Team team, List members)을 해줘야됨
- 위 두 가지가 객체와 테이블 간에 차이
- 양방향 매핑(코드)
- Member 엔티티는 단방향과 동일하다.
- 양방향 매핑(코드)
- Team 엔티티는 컬렉션 추가
- Team의 입장에서 Member와의 고나계는 일대다(@OneToMany)
- mappedBy를 통해 Member 객체의 team 변수와 매핑되어 있음을 의미
- Team 엔티티는 컬렉션 추가
- 양방향 매핑(반대 방향으로 객체 그래프 탐색)
//팀 조회
Team findTeam = em.find(Team.class, team.getId());
// 역방향으로 멤버들 조회
int memberSize = findTeam.getMembers().size();
- 연관관계 주인과 mappedBy
-
- mappedBy = JPA의 멘탈붕괴 난이도
- mappedBy는 처음에는 이해하기 어려움
- 객체와 테이블간에 연관관계를 맺는 차이를 이해해야 한다.
- 객체와 테이블이 관계를 맺는 차이
- 객체 연관관계 = 2개
- 회원 -> 팀 연관관계 1개(단방향)
- 팀 -> 회원 연관관계 1개(단방향)
- 객체의 양방향 관계는 사실, 서로 다른 단방향이 2개이다.
- 즉, 객체간에 양방향으로 참조하기 위해서는 단방향 연관관계를 2개 만들어야 된다.
- 테이블 연관관계 = 1개
- 회원 <-> 팀의 연관관계 1개(양방향)
- 테이블은 외래키 하나로 두 테이블을 Join하여 양방향으로 조회가 가능
-
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
-
- 객체 연관관계 = 2개
- 둘 중 하나로 외래 키를 관리해야 한다.
- 객체에서는 멤버에 팀을 넣고, 팀에 멤버를 넣는다. 두군데 모두 넣어 준다.
- 그러면 여기서 의문점이 생긴다. 둘 중에 뭘로 연관관계를 매핑해야 되나?
- Member의 team값을 업데이트 할때 외래키(FK) 업데이트?
- Team의 members를 업데이트 했을때 외래키(FK) 업데이트?
- 하지만 DB 입장에서는 둘 중 어느 것을 선택해도 상관이 없다. FK값만 업데이트 되면 된다.
- 단방향 연관관계 매핑에서는 참조와 외래키만 업데이트 하면 됐지만, 양방향 관계 매핑에서는 다르다.
- 이게 가장 큰 패러다임의 문제이다.
❗️ )Team을 연관관계의 주인으로 할 시, Team에 있는 member 값을 바꾸면 Member 테이블에 UPDATE 쿼리가 발생한다.
-> 직관적이지 않고 햇갈리기 쉽다 그리고 성능 이슈에 대한 문제도 발생한다.
연관관계의 주인(Owner)
- 위에서 언급한 패러다임의 문제 해결법
- 양방향 매핑 규칙
- 규칙의 두 관계중 하나를 연관관계의 주인으로 지정
- 연관관계의 주인만이 외래 키를 관리(등록, 수정)
- 주인이 아닌쪽은 읽기만 가능
- 주인은 mappedBy 속성 사용 X, @JoinColumn
- 주인이 아니면 mappedBy 속성으로 주인 지정
- "읽기"(조회)만 가능
- 누구를 주인으로?
- 외래 키가 있는 곳을 주인으로 정해라 ( JoinColumn이 있는 쪽 )
- (1(mappedBy) : N(주인) )
- 여기서는 Member.team이 연관관계의 주인
- 멤버와 팀이 다대일 관계, DB에서 보면 다쪽이 FK를 가지고 있다. 다쪽이 주인이 된다.
- 연관관계의 주인은 비즈니스 적으로 중요하지 않다. 단지 FK를 가진 쪽이 주인이 되면 된다.
- 예를 들면, 자동차와 자동차 바퀴가 있는데 비즈니스적으로는 자동차가 중요하지만, 자동차와 바퀴는 일대다 관계다. 다인 바퀴가 연관관계의 주인이 된다.
- 이렇게 설계 해야,
- 뒤에서 나올 성능이슈도 없고,
- 엔티티랑 테이블이 매핑이 되어있는 테이블에서 FK가 관리가 된다.
- 모 민족에서 프로젝트 중에, 모두 단방향으로 설계하고 개발했다. 양방향으로 뚫어서 조회가 필요한 경우에 그 때 양방향 매핑 하면 된다. 자바 코드만 수정이 일어나기 때문에 DB에 영향을 주지 않는다.
- 이미, 단방향 매핑만으로 ORM 매핑이 다 끝났다. 양방향은 단순히 조회를 편하게 하기 위해 부가 기능이 조금 더 들어가는 거라고 보면 된다. 어차피 조회할 수 있는 기능만 있다.
- 외래 키가 있는 곳을 주인으로 정해라 ( JoinColumn이 있는 쪽 )
- 양방향 매핑시 가장 많이 하는 실수(연관관계의 주인에 값을 입력하지 않음)
- 연관관계의 주인(Member)에 값을 입력하지 않음
- Team의 members는 현재 mappedBy로 매핑되어 있기 때문에 읽기만 가능
- 그래서 실제 DB에 값을 조회 해보면 TEAM_ID에 null 값이 들어감, 아무런 영향을 못미침
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
em.persist(member);
// 10번째 라인 작업을 누락하고
//member.setTeam(team);
//역방향(주인이 아닌 방향)만 연관관계 설정
team.getMembers().add(member); // team의 members는 읽기만 가능
tx.commit();
- 양방향 매핑시 연관관계의 주인에 값을 입력해야 한다.
- 순수한 객체 관계를 고려하면 항상 양쪽 다 값을 입력해야 한다.
- JPA 입장에서만 보면, 연관관계의 주인인 멤버에다가 팀을 세팅해주면 끝난다. DB에 FK값 세팅 되고 문제가 없다.
- 굳이 team의 members 컬렉션에 멤버를 새로 넣어주지 않아도, 지연 로딩을 통해서 해당 멤버를 조회해올 수 있기 때문에 (아래의 코드에서는)아무 문제가 없다.
- 아래의 코드에서는 em.flush(), clear() 하는 순간에 DB에 FK 세팅 된다. 그래서 지연 로딩을 해도 FK로 조인해서 가져올 수 있다.
- 하지만, em.flush(), clear()가 일어나지 않으면 DB에 쿼리가 안날라가고, FK도 없이 MEMBER가 1차 캐시에만 영속화 되어있는 상태이다. members 조회해봤자 size 0이다.
- 즉, flush(), clear()를 명시해놨으면 상관없지만 그게 아니면, 아래 2가지 상황에서 문제가 발생한다.
- em.find
- Test Case 작성
- 즉, flush(), clear()를 명시해놨으면 상관없지만 그게 아니면, 아래 2가지 상황에서 문제가 발생한다.
- 결론은 진짜 객체 지향적으로 고려하면 항상 양쪽다 값을 넣어 주는 것이 맞다.
- 추가적으로 JPA 없이 순수 자바 Object로 테스트 케이스가 동작하게끔 테스트 코드를 짤때도 NPE 발생한다. 양쪽다 넣어주자.
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
em.persist(member);
// 연관관계의 주인에 값 설정
member.setTeam(team);
// 역방향 연관관계를 설정하지 않아도, 지연 로딩을 통해서 아래에서 Member에 접근할 수 있다.
//team.getMembers().add(member);
// 이 동작이 수행되지 않으면 FK가 설정되어 있지 않은 1차캐시에만 영속화 된 상태이다.
// SELECT 쿼리로 조회해봤자 list 사이즈 0이다.
em.flush();
em.clear();
Team findTeam = em.find(Team.class, team.getId());
List<Member> findMembers = findTeam.getMembers();
for (Member m : findMembers) {
// flush, clear가 일어난 후에는 팀의 Members에 넣어주지 않았지만,
// 조회를 할 수 있음. 이것이 지연로딩
System.out.println(m.getUsername());
}
tx.commit();
- 양방향 연관관계의 주의 - 실습
- 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하자
- 양방향 연관관계시 양쪽으로 값을 셋팅 해주자.
- 양방향 매핑시에 무한 루프를 조심하자
- 예) Lombok에서 제공하는 toString() 사용시 서로를 호출하기 때문에 무한루프 및 스택오버플로 발생
- 예 ) JSON 생성 라이브러리
- 컨트롤러에서는 Entity 반환 X, DTO로 반환하자
- why?) 무한루프 발생, Entity 변경하는 순간 API 스펙 변경
- 컨트롤러에서는 Entity 반환 X, DTO로 반환하자
- 연관관계 작성 시, 휴먼에러가 발생(실수)할 가능성이 높다. 연관관계 편의 메소드를 생성하자
- 편의 메소드는 실수를 줄이기 위해, 주인쪽에서 연관관계의 값을 설정할때, 역방향 값도 함께 설정해준다.
- 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정하자
class Member {
...
public void changeTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
}
양방향 매핑 정리
- 단방향 매핑만으로도 이미 연관관계 매핑은 완료
- JPA 모델링 할 때, 단방향 매핑으로 설계를 끝내야 됨,
- 일대다 에서 다 쪽에 단뱡항 매핑으로 설계하면 이미 테이블 외래키(FK) 설정은 끝
- 양방향 매핑은 반대편에서 조회할 일이 있을때만 매핑
- 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐
- JPQL에서 역방향으로 탐색할 일이 많음
- 단방향 매핑을 잘 하고 양방향은 필요할 때 추가해도 됨(테이블에 영향을 주지 않음)
- 단방향 매핑에서 양방향 매핑으로 넘거ㅏ면서 추가된 것은 Team 객체에 members 컬렉션이 들어간것 뿐이다.
- 연관관계의 주인을 정하는 기준
- 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안됨
- 해야된다면 연관관계 편의 메소드를 사용
- 연관관계의 주인은 외래 키의 위치를 기준으로 정해야함.
- 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안됨
Reference
자바 ORM 표준 JPA 프로그래밍 - 기본편