단방향 연관 관계
@Entity
public class Member {
private Long id;
@Column(name = "USERNAME")
private String name;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
@Entity
public class Team {
@Id
@GeneratedValue
private Long id;
private String name;
}
- 객체 지향적으로 모델링을 하면 Member에는 TEAM_ID가 아니라 TEAM 참조값을 그대로 가지게 된다.
- 내가 속하는 팀 그 자체를 가지는 것이다.
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();
tx.commit();
- Team을 참조값으로 가지기 때문에 setTeam()으로 팀을 설정해 주고 그대로 persist()로 저장한다.
- em.find() 메소드에서는 쿼리가 나가지 않는다. 왜냐하면 persist(member)로 이미 영속성 컨텍스트에 들어가 1차 캐시에 있기 때문이다.
- 여기서 쿼리가 나가도록 하고 싶다면 persist(member) 후에 em.flush()를 사용하여 싱크를 맞추면 된다.
- FK 대신 객체를 참조하며 연관 관계가 매핑된 것을 확인할 수 있다.
- 연관 관계 수정도 기존과 다를 것 없이 아래처럼 그냥 값만 바꾸고 커밋하면 변경 감지가 발생하여 insert가 아닌 update쿼리가 발생한다.
em.persist(team);
Team newTeam = new Team();
newTeam.setName("new Team");
member.setTeam(newTeam);
양방향 연관 관계
- DB 테이블의 경우 Member 에서 Team이나 Team에서 Member나 FK로 join을 사용하여 쉽게 조회할 수 있다. 하지만 객체의 경우 이런 방식이 불가능하기 때문에 team에 List<Member> members를 넣어줘야 접근이 가능하다.
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
private int age;
@ManyToOne
@Column(name = "TEAM_ID")
private Team team;
}
@Entity
public class Team {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team")
List<Member> members = new ArrayList<>();
}
- mappedBy는 Member에 있는 ‘team’ 변수가 연결되어 있음을 의미한다. 즉 연결된 반대편에 무엇으로 연결되어 있는지 알려주는 역할이다.
- 이때 members를 초기화 했는데 해주는 것이 관례라고 한다. NullPointerException을 방지하기 위함이라고 한다.
Team findTeam = em.find(Team.class, team.getId());
int memberSize = findTeam.getMembers().size();
- 이렇게 하면 team에서도 member를 조회할 수 있다.
연관 관계의 주인과 mappedBy
객체는 연관 관계가 2개이다. 회원 → 팀, 팀 → 회원으로 단방향 연관 관계가 2개이다.
반면 DB 테이블은 회원 ←→ 팀으로 양방향 연관 관계가 하나이다.
즉 테이블 연관 관계는 FK하나로 풀어낼 수 있지만 객체는 참조가 두 곳에 다 있어야 한다. 이 방법은 사실 양방향이 아니라 서로 다른 단방향 관계가 2개인 상태이다. DB 테이블의 양방향을 따라 하려면 단방향 연관 관계 2개를 만들어야 하는 것이다.
- 이런 관계에서 member를 새로운 team에 넣을 때 member에서 team 값을 바꿀지 team에서 members값을 바꿔야 할지 헷갈린다.
- member의 team을 수정했을 때 FK값을 업데이트하는지, team의 members값을 수정했을 때 FK값을 업데이트해야 하는지 헷갈린다.
- DB 입장에서는 MEMBER에 있는 TEAD_ID만 업데이트되면 되고 결국 둘 중 하나는 FK를 관리해야 하는 것이다. 즉 연관 관계의 주인을 정해야 한다(이건 양방향 매핑에서만 나온다)
연관 관계의 주인
객체의 두 관계 중 하나를 연관 관계의 주인으로 지정한다. 연관 관계의 주인은 FK 등록 및 수정 등을 관리하며 mappedBy 속성을 사용하지 않는다. 반대로 주인이 아닌 쪽은 읽기만 가능하고 mappedBy 속성으로 자신의 주인이 누구인지 명시해 주어야 한다.
이런 주인을 결정하는 기준은 아래와 같다.
@Entity
public class Member {
...
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
@Entity
public class Team {
...
@OneToMany(mappedBy = "team")
List<Member> members = new ArrayList<>();
}
- 주인
- 외래키가 있는 곳인 Many를 주인으로 정한다.
- Member.team
- 가짜 매핑
- 주인의 반대편인 One 쪽을 말한다.
- Team.members
Member.team이 주인이 되면 FK가 Member에 있기 때문에 쿼리를 한 방에 보낼 수 있다. Member 객체를 바꿨으니 member 테이블에 update 쿼리가 나가는 것을 직관적으로 이해할 수 있다.
반대로 Team.members가 주인이 되면 members를 바꿨을 때 내가 수정한 테이블인 team이 아니라 member테이블에 쿼리가 나가야 한다. team 객체를 수정했지만 member테이블에 update 쿼리가 나가기 때문에 혼동이 온다.
헷갈리면 무조건 FK가 있는 곳을 주인으로 정하면 된다. 이걸 DB 입장에서 생각해 보면 FK가 있는 곳이 무조건 N이고 FK가 없는 곳이 무조건 1이다. 즉 N 쪽이 무조건 주인이 되며 ManyToOne이 된다.
양방향 매핑 시 가장 많이 하는 실수
- 연관 관계 주인에 값을 입력하지 않는 경우
Team team = new Team(); team.setName("TeamA"); em.persist(team); Member member = new Member(); member.setName("member1"); member.setTeam(team); em.persist(member);
- 위 코드의 경우 가짜 매핑인 One 쪽에 member를 추가했다. 이렇게 한다면 쿼리는 2번 나가지만 member에 team이 저장되지 않는다.
- JPA는 insert, update 시에 읽기 전용 필드(mappedBy가 있는 쪽)를 보지 않기 때문이다. 그러므로 아래처럼 연관 관계의 주인인 member에 team을 추가해 주어야 한다.
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
member.setTeam(team);
em.persist(member);
- 연관 관계의 주인인 member에만 값을 넣도록 수정하면 정상적으로 team까지 저장된다.
- 양쪽 모두에 값을 세팅하지 않는 문제
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();
for (Member m : members) {
System.out.println("m = " + m.getUsername());
}
- 주인 쪽에만 add를 해주어도 반대편에 함께 적용되기 때문에 findTeam.getMembers()로 조회하면 select쿼리가 나갈 것이다. 하지만 양쪽 전부 관계를 설정해 주는 것이 좋은데 위의 em.flush(), em.clear()가 없으면 연관 관계가 반영되지 않은 데이터를 1차 캐시에서 가져올 수 있기 때문이다.
- 위의 코드에서 team은 em.persist(team) 해줬을 때의 상태 그대로 1차 캐시에 들어가 있다. 연관 관계가 적용된 데이터는 메모리에만 올라가 있기 때문에 Team.members를 조회하면 값이 나오지 않는다. 영속성 컨텍스트에는 team.setName()까지만 했던 상태로 남아있기 때문이다.
- 객체 지향적으로도 순수 객체 상태를 고려하여 항상 양쪽에 값을 설정해 주는 것이 맞다.
- JPA 없이 순수하게 동작하도록 테스트 케이스를 작성했더라도 team.getMembers()가 비어있는 값으로 나오는 문제가 발생할 수 있다.
- 이를 해결하기 위해 아래와 같이 연관 관계 편의 메소드를 사용하는 방법이 있다.
- 이렇게 하면 team에도 추가해 주는 것을 잊지 않고 해 줄 수 있다. 반대로 Team클래스에서도 메소드를 추가하여 사용할 수 있다.
- public class Member{ ... public void changeTeam(Team team){ this.team = team; team.getMembers().add(this); } }
무한 루프
- toString(), lombok, JSON 생성 라이브러리 등에서 문제가 발생한다.
public class Member {
@Override
public String toString() {
return "Member{" +
"id=" + id +
", name='" + name + '\\'' +
// 무한 루프
", team='" + team + '\\'' +
'}';
}
}
- toString()
- team을 출력하면 team.toString()이 호출된다.
- team.toString()에서 member를 호출하며 무한 루프에 빠진다.
- JSON 라이브러리
- Entity의 연관 관계가 양방향일 때 컨트롤러에서 Entity를 Response로 직접 보내는 경우 JSON으로 변환하며 무한 루프에 빠진다.
- 해결 방법
- Entity에서 toString을 사용하지 말자
- JSON라이브러리의 경우 DTO로 변환하여 보내자. 이는 Entity를 변경하면 API스펙이 바뀌는 문제도 발생하기 때문에 바꾸는게 좋다.
@Entity
public class Member {
private Long id;
@Column(name = "USERNAME")
private String name;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
@Entity
public class Team {
@Id
@GeneratedValue
private Long id;
private String name;
}
- 객체 지향적으로 모델링을 하면 Member에는 TEAM_ID가 아니라 TEAM 참조값을 그대로 가지게 된다.
- 내가 속하는 팀 그 자체를 가지는 것이다.
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();
tx.commit();
- Team을 참조값으로 가지기 때문에 setTeam()으로 팀을 설정해주고 그대로 persist()로 저장한다.
- em.find()메소드에서는 쿼리가 나가지 않는다. 왜냐하면 persist(member)로 이미 영속성 컨텍스트에 들어가 1차 캐시에 있기 때문이다.
- 여기서 쿼리가 나가도록 하고 싶다면 persist(member) 후에 em.flush()를 사용하여 싱크를 맞추면 된다.
- FK 대신 객체를 참조하며 연관 관계가 매핑된 것을 확인할 수 있다.
- 연관 관계 수정도 기존과 다를 것 없이 아래처럼 그냥 값만 바꾸면 변경 감지가 발생하여 insert가 아닌 update쿼리가 발생한다.
em.persist(team);
Team newTeam = new Team();
newTeam.setName("new Team");
member.setTeam(newTeam);
양방향 연관 관계
- DB 테이블의 경우 Member 에서 Team이나 Team에서 Member나 FK로 join을 사용하여 쉽게 조회할 수 있다. 하지만 객체의 경우 이런 방식이 불가능하기 때문에 team에 List<Member> members를 넣어줘야 접근이 가능하다.
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
private int age;
@ManyToOne
@Column(name = "TEAM_ID")
private Team team;
}
@Entity
public class Team {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team")
List<Member> members = new ArrayList<>();
}
- mappedBy는 Member에 있는 ‘team’ 변수가 연결되어 있음을 의미한다. 즉 연결된 반대편에 무엇으로 연결되어 있는지 알려주는 역할이다.
- 이때 members를 초기화 했는데 해주는 것이 관례라고 한다. NullPinterException을 방지하기 위함이라고한다.
Team findTeam = em.find(Team.class, team.getId());
int memberSize = findTeam.getMembers().size();
- 이렇게 하면 team에서도 member를 조회할 수 있다.
연관 관계의 주인과 mappedBy
객체는 연관 관계가 2개이다. 회원 → 팀, 팀 → 회원 으로 단방향 연관 관계가 2개이다.
반면 DB 테이블은 회원 ←→ 팀 으로 양방향 연관 관계가 하나이다.
즉 테이블 연관 관계는 FK하나로 풀어낼 수 있지만 객체는 참조가 두 곳에 다 있어야 한다. 이 방법은 사실 양방향이 아니라 서로 다른 단방향 관계가 2개인 상태이다. DB 테이블의 양방향을 따라하라면 단방향 연관 관계를 2개를 만들어야 하는 것이다.
- 이런 관계에서 member를 새로운 team에 넣을 때 member에서 team 값을 바꿀지 team에서 members값을 바꿔야할지 헷갈린다.
- member의 team을 수정했을 때 FK값을 업데이트하는지, team의 members값을 수정했을 때 FK값을 업데이트해야 하는지 헷갈린다.
- DB 입장에서는 MEMBER에 있는 TEAD_ID만 업데이트되면 되고 결국 둘 중 하나는 FK를 관리해야 하는 것이다. 즉 연관 관계의 주인을 정해야 한다(이건 양방향 매핑에서만 나온다)
연관 관계의 주인
객체의 두 관계 중 하나를 연관 관계의 주인으로 지정한다. 연관 관계의 주인은 FK 등록 및 수정 등을 관리하며 mappedBy 속성을 사용하지 않는다. 반대로 주인이 아닌 쪽은 읽기만 가능하고 mappedBy 속성으로 자신의 주인이 누구인지 명시해 주어야 한다.
이런 주인을 결정하는 기준은 아래와 같다.
@Entity
public class Member {
...
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
@Entity
public class Team {
...
@OneToMany(mappedBy = "team")
List<Member> members = new ArrayList<>();
}
- 주인
- 외래키가 있는 곳인 Many를 주인으로 정한다.
- Member.team
- 가짜 매핑
- 주인의 반대편인 One 쪽을 말한다.
- Team.members
Member.team이 주인이 되면 FK가 Member에 있기 때문에 쿼리를 한 방에 보낼 수 있다. Member 객체를 바꿨으니 member 테이블에 update 쿼리가 나가는 것을 직관적으로 이해할 수 있다.
반대로 Team.members가 주인이 되면 members를 바꿨을 때 내가 수정한 테이블인 team이 아니라 member테이블에 쿼리가 나가야 한다. team 객체를 수정했지만 member테이블에 update 쿼리가 나가기 때문에 혼동이 온다.
헷갈리면 무조건 FK가 있는 곳을 주인으로 정하면 된다. 이걸 DB 입장에서 생각해 보면 FK가 있는 곳이 무조건 N이고 FK가 없는 곳이 무조건 1이다. 즉 N쪽이 무조건 주인이 되며 ManyToOne이 된다.
양방향 매핑 시 가장 많이 하는 실수
1. 연관 관계 주인에 값을 입력하지 않는 경우
...
Member member = new Member();
member.setName("member1");
em.persist(member);
Team team = new Team();
team.setName("TeamA");
team.getMembers().add(member);
em.persist(team);
- 위 코드의 경우 가짜 매핑인 One쪽에 member를 추가했다. 이렇게 한다면 쿼리는 2번 나가지만 member에 team이 저장되지 않는다.
- JPA는 insert, update 시에 읽기 전용 필드(mappedBy가 있는 쪽)를 보지 않기 때문이다. 그러므로 아래처럼 연관 관계의 주인인 member에 team을 추가해 주어야 한다.
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
member.setTeam(team);
em.persist(member);
- 연관 관계의 주인인 member에만 값을 넣도록 수정하면 정상적으로 team까지 저장된다.
2. 양쪽 모두에 값을 세팅하지 않는 문제
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();
for (Member m : members) {
System.out.println("m = " + m.getUsername());
}
- 주인 쪽에만 add를 해주어도 반대편에 함께 적용되기 때문에 findTeam.getMembers()로 조회하면 select쿼리가 나갈 것이다. 하지만 양쪽 전부 관계를 설정해주는 것이 좋은데 위의 em.flush(), em.clear()가 없으면 연관 관계가 반영되지 않은 데이터를 1차 캐시에서 가져올 수 있기 때문이다.
- 위의 코드에서 team은 em.persist(team) 해줬을 때의 상태 그대로 1차 캐시에 들어가 있다. 연관 관계가 적용된 데이터는 메모리에만 올라가 있기 때문에 Team.members를 조회하면 값이 나오지 않는다. 영속성 컨텍스트에는 team.setName()까지만 했던 상태로 남아있기 때문이다.
- 객체 지향적으로도 순수 객체 상태를 고려하여 항상 양쪽에 값을 설정해 주는 것이 맞다.
- JPA 없이 순수하게 동작하도록 테스트 케이스를 작성했더라도 team.getMembers()가 비어있는 값으로 나오는 문제가 발생할 수 있다.
- 이를 해결하기 위해 아래와 같이 연관 관계 편의 메소드를 사용하는 방법이 있다.
public class Member{
...
public void changeTeam(Team team){
this.team = team;
team.getMembers().add(this);
}
}
- 이렇게 하면 team에도 추가해주는 것을 잊지 않고 해줄 수 있다. 반대로 Team클래스에서도 메소드를 추가하여 사용할 수 있다.
무한 루프
- toString(), lombok, JSON 생성 라이브러리 등에서 문제가 발생한다.
public class Member {
@Override
public String toString() {
return "Member{" +
"id=" + id +
", name='" + name + '\\'' +
// 무한 루프
", team='" + team + '\\'' +
'}';
}
}
- toString()
- team을 출력하면 team.toString()이 호출된다.
- team.toString()에서 member를 호출하며 무한 루프에 빠진다.
- JSON 라이브러리
- Entity의 연관 관계가 양방향일 때 컨트롤러에서 Entity를 Response로 직접 보내는 경우 JSON으로 변환하며 무한 루프에 빠진다.
- 해결 방법
- Entity에서 toString을 사용하지 말자
- JSON라이브러리의 경우 DTO로 변환하여 보내자. 이는 Entity를 변경하면 API스펙이 바뀌는 문제도 발생하기 때문에 바꾸는게 좋다.
'Java > JPA' 카테고리의 다른 글
[JPA] 즉시 로딩과 지연 로딩 (0) | 2023.06.25 |
---|---|
[JPA] 프록시 (0) | 2023.06.25 |
[JPA] 다양한 연관 관계 매핑(다대일, 일대다, 일대일, 다대다) (0) | 2023.06.25 |
[JPA] Entity 매핑 (0) | 2023.06.24 |
[JPA] 개념 정리(영속화, 영속성 컨텍스트) (0) | 2023.06.24 |