객체가 지향하는 패러다임과 관계형 DB가 지향하는 패러다임의 차이에서 오는 문제들을 해결하고 둘 사이를 매핑하기 위한 방법을 정리한 내용이다.
객체와 테이블 연관관계의 차이를 이해하고, 객체의 참조와 테이블의 외래 키를 매핑하는 방법에 대해서 알아보자.
용어 이해
- 방향(Direction): 단방향, 양방향
- 다중성(Multiplicity): 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M)
- 연관관계의 주인(Owner): 객체 양방향 연관관계는 관리 주인이 필요
두 패러다임의 차이 - 팀원(N):팀(1) 관계
팀원과 팀과의 관계에서 객체와 관계형 DB의 차이점을 알아보자.
팀원-팀은 N:1의 다대일 관계이다. 여러 팀원은 한 팀에 속할 수 있다.
다음 객체들은 테이블의 구조에 맞추어 데이터 중심으로 설계한 클래스들이다. Member 객체에는 TEAM 객체를 가지고 있는 것이 아니라 관계형 DB처럼 id(FK)를 가지고 있다.
팀원 | 팀 |
@Entity public class Member { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = "MEMBER_ID") private Long id; @Column(name = "USERNAME") //테이블 컬럼 변경 private String username; @Column(name = "TEAM_ID") private Long teamId; } |
@Entity public class Team { @Id @GeneratedValue @Column(name = "TEAM_ID") private Long id; private String name; } |
팀원 클래스가 Long teamId를 필드로 가지는 것은 객체지향스럽지 않다.
단방향 연관관계
객체지향 패러다임에 맞추어 개선해보자.
팀원 | 팀 |
@Entity public class Member { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = "MEMBER_ID") private Long id; @Column(name = "USERNAME") //테이블 컬럼 변경 private String username; @ManyToOne @JoinColumn(name = "TEAM_ID") private Team team; } |
@Entity public class Team { @Id @GeneratedValue @Column(name = "TEAM_ID") private Long id; private String name; } |
Member 클래스에는 Team이 연관관계로 들어가야하므로 다대일 관계임을 `@ManyToOne`어노테이션을 통해 표시하고 `@JoinColumn` 어노테이션을 통해 Team 객체가 테이블의 TEAM_ID와 연관관계임을 매핑해주어야 한다.
이는 팀원이 -> 팀을 가리키는 단방향 연관관계이다.
EntityManager를 통한 호출시 변경점
try {
//저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setUsername("member1");
// member.setTeamId(team.getId()); //-> 변경
member.setTeam(team);
em.persist(member);
// 조회
Member findMember = em.find(Member.class, member.getId());
// Long findTeamId = findMember.getTeamId();
Team findTeam = findMember.getTeam(); //-> 직접 객체를 호출하면 된다.
System.out.println("findTeam.getName() = " + findTeam.getName());
// 트랜잭션 커밋.
tx.commit();
}
양방향 연관관계와 연관관계의 주인
객체와 관계형 DB는 연관된 정보를 찾을 때 다르다.
객체는 참조를 통해 찾고, 테이블은 join을 통해 연관관계를 찾는다. 이 둘간의 차이를 이해해야지만 연관관계의 주인에 대한 개념을 이해할 수 있다.
앞의 Member-Team 단방향 관계를 양방향으로 변경해보면서 연관관계의 주인이 누구인지 정리해보자.
단방향 관계라 함은 오로지 Member 객체에서만 Team에 접근할 수 있다. 만약, Team에서 N개의 Member를 받아보고 싶다면 양방향 관계로 만들어야 할 것이다.
객체는 양방향 관계를 맺기 위해 각 객체에 상대 객체의 정보가 필요하지만 테이블은 FK 하나만 둠으로써 서로 참조가 가능하다는 차이점이 있다.
팀원 | 팀 |
@Entity public class Member { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = "MEMBER_ID") private Long id; @Column(name = "USERNAME") //테이블 컬럼 변경 private String username; @ManyToOne @JoinColumn(name = "TEAM_ID") private Team team; } |
@Entity public class Team { @Id @GeneratedValue @Column(name = "TEAM_ID") private Long id; private String name; @OneToMany(mappedBy = "team") //Member 객체의 team과 묶여있음을 명시 private List<Member> members = new ArrayList<>(); } |
서로 접근 가능한 Member-Team의 관계 - 코드로 보기
try {
//저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setUsername("member1");
// member.setTeamId(team.getId());
member.setTeam(team);
em.persist(member);
em.flush(); //영속성 컨텍스트 더티체킹 SQL 내보냄
em.clear(); //영속성 컨텍스트 클리어 -> 모든 객체가 삭제되어 다음부터는 새롭게 select해옴
// 조회
Member findMember = em.find(Member.class, member.getId());
List<Member> members = findMember.getTeam().getMembers();
for (Member m : members) {
System.out.println("m.getUsername() = " + m.getUsername());
}
// 트랜잭션 커밋.
tx.commit();
}
결과 화면

MappedBy 란?
양방향 관계에서 MappedBy란 무엇을 의미할까? 단방향만 명시하여도 Member 객체를 통해 그리고 `@OneToMany` 어노테이션을 통해 상대 객체에 대한 추측이 가능할텐에 왜 이런 옵션을 사용하는 것일까?
이것 또한 객체와 테이블의 차이인데 테이블은 연관관계를 FK 하나만을 통해 두 테이블이 참조함을 의미한다. 하지만 객체는 그렇지 않다.
객체는 단방향 관계를 2개 가짐으로써 양방향 관계를 표현한다고 이해하여 다음과 같은 2개의 관계가 필요한 것이다.
Member ---> Team : @ManyToOne @JoinColumn
Member <--- Team : @OneToMany(mappedBy = "")
연관관계 주인의 탄생 : 객체 - 테이블 패러다임 차이에 따른 문제점
이 때 고민해봐야 할 점이 발생한다.
테이블의 경우에는 FK 관계 만으로 양방향 관계를 정의하므로 데이터의 변경이 필요할 때 FK의 값만 변경하면 자동으로 Join 과정에서 수정된 데이터를 얻을 수 있다.
하지만, 객체의 경우에는 단방향 2개의 관계이므로 어느 쪽의 객체를 수정해야하는지 고민이 된다.
만약 Member A가 1 팀에서 2 팀으로 바꾸고 싶다면 어느 객체를 수정해야 하는 것인가?(Member.team과 Team.members 모두 수정해야하는가?)
이런 문제로 인해 연관관계에 있어 주인이 누구인지 정하게 되었다.
객체에서 양방향 관계를 맺을 때는 다음 규칙을 준수한다.
- 객체의 두 관계중 하나를 연관관계의 주인으로 지정
- 연관관계의 주인만이 외래 키를 관리(등록, 수정)
- 주인이 아닌쪽은 읽기만 가능
- 주인은 mappedBy 속성 사용X
- 주인이 아니면 mappedBy 속성으로 주인 지정
-> 외래 키가 있는 곳을 연관관계의 주인으로 정한다.(주인은 비즈니스적으로 중요함을 뜻하는 것은 아니다.)
양방향 연관관계 매핑시 가장 많이 하는 실수
1. 주인이 아닌 곳에서 주입하는 경우
try {
//저장
Member member = new Member();
member.setUsername("member1");
em.persist(member);
Team team = new Team();
team.setName("TeamA");
team.getMembers().add(member); //주인이 아닌 부분에서 주입.
em.persist(team);
em.flush();
em.clear();
tx.commit();
}
mappedBy가 붙어있는 쪽에서는 읽기만 가능하므로 member persist시 team이 null로 표기된다.
주인에서 주입해야한다! 코드 확인하기
try {
//저장
Team team = new Team();
team.setName("TeamA");
// team.getMembers().add(member);
em.persist(team);
Member member = new Member();
member.setUsername("member1");
member.setTeam(team);
em.persist(member);
team.getMembers().add(member); // 양 쪽에 값을 모두 넣음
em.flush(); //영속성 컨텍스트 더티체킹 SQL 내보냄
em.clear(); //영속성 컨텍스트 클리어 -> 모든 객체가 삭제되어 다음부터는 새롭게 select해옴
// 트랜잭션 커밋.
tx.commit();
}
JPA만을 보았을 때는 주인에서 데이터를 주입하면 되지만 객체지향적으로 보았을 때는 Member와 Team.Members 모두에 값을 변경해주는 것이 맞다. 그 이유는 위 코드에서는 flush와 clear를 진행하여 영속성 컨텍스트 즉 1차 캐시를 비워 새롭게 DB에서 데이터를 받아오지만 만약 flush와 clear를 하지 않는다면 DB에서는 변화가 없고 데이터를 받아올 때 캐시에 저장된 데이터를 받아오기 때문에 변경되지 않은 정보를 받아보는 문제가 발생한다.
또한, 테스트케이스를 작성할 때 문제가 발생할 수 있으므로 양방향 매핑관계에서 데이터 세팅이 필요할 때는 양쪽 모두 넣어주는 것이 현명하고 객체지향적인 방법이다.
해결 방법 - 연관관계 편의 메서드를 작성하자
Member와 Team 양방향 관계에서 데이터를 설정하다가 놓칠 수 있기 때문에 양방향 연관관계 어느 쪽이든지 편의 메서드를 만들어 놓음으로써 놓치기 쉬운 부분들을 방지할 수 있게 만들면 좋다.
public class Member {
...
public void changeTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
}
이렇게 미리 작성해놓으면 연관관계의 주인에서 변경을 하게 되면 원자적으로 변경된다. 메서드 명은 단순히 setter를 사용하는 것이 아니라 이렇게 비즈니스 로직이 변경되면 그에 따라 변경해주는 것이 좋다.
참고로, 실제로는 memberList에는 기존의 Member가 있을 것이므로 삭제하고 넣어주어야 하는데 복잡한 비즈니스 로직일수록 이 부분을 신경써서 관리해주어야 할 것이다.
2. 양방향 매핑으로 인한 무한루프 주의
toString, Lombok, JSON 생성 라이브러리 등을 사용하다보면 자연스레 무한루프가 걸릴 수 있다.
예시를 확인하자.
Member | Team |
@Override public String toString() { return "Member{" + "id=" + id + ", username='" + username + '\'' + ", team=" + team + '}'; } |
@Override public String toString() { return "Team{" + "id=" + id + ", name='" + name + '\'' + ", members=" + members + '}'; } |
만약 둘 중의 하나에서 toString 호출시 서로는 team과 member를 가지고 있으므로 무한 루프에 빠질 수 있다.
Entity를 JSON 변환시 주의해야 하는 이유
1. JSON으로 변환하며 생기는 무한루프
2. Entity는 변경될 수 있으므로 API 스펙의 변환이 생김
-> Controller에서 DTO로 변환하여 사용한다.
양방향 매핑 정리
기본적으로는 단방향 매핑으로 테이블 구조를 세팅하고, 나중에 역 참조가 필요할 때 Entity 객체에 양방향 세팅을 한다.(테이블 변화 없이 mappedBy 같은 코드 몇 줄만 들어가면 되므로..)
'노트 정리 > 자바 ORM 표준 JPA 프로그래밍' 카테고리의 다른 글
[JPA이론] 7. 고급매핑 (0) | 2022.10.14 |
---|---|
[JPA이론] 6. 다양한 연관관계 매핑 (0) | 2022.10.13 |
4. 엔티티 매핑 (0) | 2022.08.24 |
3. 영속성 관리 - 내부 동작 방식 (0) | 2022.08.21 |
2. JPA 시작하기 (0) | 2022.08.20 |