연관관계 기본
- Registry(게시글)와 Comment(댓글)가 있다.
객체를 테이블에 맞춰 모델링
객체를 테이블에 맞춰 모델링할 경우 참조 대신에 외래 키를 그대로 사용하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// Comment.java
@Entity
public class Comment {
@Id @GeneratedValue
@Column(name = "COMMENT_ID")
private Long id;
private String comment;
@Column(name = "REGISTRY_ID")
private Long registryId;
}
1
2
3
4
5
6
7
// Main.java
Comment findComment = em.find(Comment.class, comment.getId());
// Comment가 어느 게시글에서 작성된 것인지 알고 싶어서 조회함
Long findRegistryId = findComment.getRegistryId();;
Team findRegistry = em.find(Registry.class, findRegistryId);
이 경우 식별자로 다시 조회
→ 객체지향 X
단방향 연관관계
registry의 id가 아니라 참조 값을 그대로 가져왔다.
위와 같이 적으면 에러가 뜬다.
이제 JPA에 이 둘의 관계가 무슨 관계인지(ex. 1:N
, N:1
) 알려줘야 한다.
Registry와 Comment는 1:N에서 누가 1이고 누가 N인지 매우 중요하다.
→ DB 관점으로 매우 중요하다.
@Column
과 같은 어노테이션들은 db와 매핑하는 어노테이션이다.
@ManyToOne
여기서 Comment가 N이고 Registry가 1이다. (하나의 게시글에 여러 개의 댓글)
그래서 Comment 입장에서는 @ManyToOne
이라는 어노테이션으로 매핑을 해야한다.
@JoinColumn
Registry²의 reference와 Comment 테이블에 있는 REGISTRY_ID(FK)¹와 매핑을 해야한다.
@JoinColumn(name = "REGISTRY_ID")
이렇게 하면 매핑이 끝난다.
1
2
3
4
5
// Comment.java
@ManyToOne
@JoinColumn(name = "REGISTRY_ID")
private Registry registry;
code
*getter, setter 생략
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Comment.java
@Entity
public class Comment {
@Id @GeneratedValue
@Column(name = "COMMENT_ID")
private Long id;
private String comment;
@ManyToOne
@JoinColumn(name = "REGISTRY_ID")
private Registry registry;
}
1
2
3
4
5
6
7
8
9
10
// Registry.java
@Entity
public class Registry {
@Id @GeneratedValue
@Column(name = "REGISTRY_ID")
private Long id;
private String title;
}
조회
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Main.java
try {
Registry registry = new Registry();
registry.setTitle("title");
em.persist(registry);
Comment comment = new Comment();
comment.setComment("comment");
comment.setRegistry(registry);
em.persist(comment);
Comment findComment = em.find(Comment.class, comment.getId());
Registry findRegistry = findComment.getRegistry();
System.out.println("findRegistry.getTitle() = " + findRegistry.getTitle());
tx.commit();
}
insert 쿼리가 2번 나간 것을 볼 수 있다.
참고로 영속성 컨텍스트 말고 db에서 가져오는 쿼리를 보고 싶다면 아래 코드를 추가하면 된다.
1
2
3
// Main.java
em.flush(); // 강제 호출 (영속성 컨텍스트에 있는 것들을 db에 쿼리를 날려버려서 싱크를 맞춤)
em.clear(); // 영속성 컨텍스트 초기화
point
관계가 무엇인지 @ManyToOne
이 관계를 할 때 join 하는 column은 무엇인지 @JoinColumn
양방향 연관관계
Comment에서 Registry로 갈 수 있는데 반대로 Registry에서 getComment는 안된다.
reference만 넣어두면 Comment ↔ Registry로 왔다갔다 할 수 있다.
→ 양방향 연관관계라고 한다. (양쪽으로 참조해서 갈수있게 함)
테이블 연관관계는 차이가 하나도 없다.
why? REGISTRY_ID(FK) 와 REGISTRY_ID(PK)랑 JOIN하면 되기 때문에
테이블의 연관관계는 외래키 하나로 양방향이 다 있는 것이다.
사실상 테이블의 연관관계는 방향이라는 개념 자체가 없다.
FK만 집어넣으면 양쪽으로 다 알 수 있다.
그러나 문제는 객체다.
이 전에 Comment에서 Registry로 갈 수 있는데 Registry에서 Comment로 갈 수 있는 방법은 없다.
그래서 Registry에 comments라는 List를 넣어줘야 양쪽으로 갈 수 있는 것이다.
code
*Comment 엔티티는 단방향과 동일하다.
Registry 엔티티는 컬렉션을 추가한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// Registry.java
@Entity
public class Registry {
@Id @GeneratedValue
@Column(name = "REGISTRY_ID")
private Long id;
private String title;
@OneToMany(mappedBy = "registry")
private List<Comment> comments = new ArrayList<>();
}
private List<Comment> comments = new ArrayList<>();
관례로 ArrayList<>()
로 초기화 해준다. (그래야 add할때 nullpoint가 안뜨므로)
Registry에서 Comment로 가는 것은 일대 다 이므로 @OneToMany
를 작성하고
mappedBy를 적어준다. (mappedBy = "registry")
mappedBy는 일대 다 매핑에서 어떤 거랑 연결되어있는지 적는 곳인데
Registry의 변수명 registry랑 매핑이 되어있다라는 얘기다.
객체와 테이블이 관계를 맺는 차이
객체 연관관계 = 2개
회원 → 팀 연관관계 1개(단방향)
팀 → 회원 연관관계 1개(단방향)
회원에서 팀으로 가려면 참조 값 하나 넣어줘야 하고
팀에서 회원으로 가려면 참조 값을 하나 넣어놔야 한다.
→ 단방향 연관관계가 2개가 있는 것이다.
테이블 연관관계 = 1개
- 회원 ↔ 팀의 연관관계 1개(양방향)
양방향이라고 적었지만 사실은 방향이 없는 것이다.
하나만 있으면 양쪽으로 왔다갔다 할 수 있다.
연관관계의 주인(Owner)
Comment를 바꾸고 싶거나 새로운 Registry에 작성하려고 한다고 가정
그럴때 Comment의 registry 값을 변경해야할지 Registry의 comments를 바꿔야 할지 모름(둘다 맞기 때문에)
하지만 db 입장에서는 COMMENT에 있는 REGISTRY_ID(FK) 외래키 값만 업데이트 하면 된다.
그래서 rule이 생긴다. → 둘 중 하나로 외래 키를 관리해야한다.
→ 주인을 정해야한다.
“연관관계의 주인”이라는 개념은 양방향 매핑에서 나오는 것이다.
• 연관관계의 주인만이 외래 키를 관리(등록, 수정)
• 주인이 아닌쪽은 읽기만 가능
• 주인은 mappedBy 속성 사용X
• 주인이 아니면 mappedBy 속성으로 주인 지정
mappedBy
라는 뜻 자체가 저것에 의해 내가 매핑이 되었어 라는 뜻
누구를 주인으로?
DB 입장에서 보면 외래키(FK)가 있는 곳이 무조건 N(다)이다.
외래키가 없는 곳이 무조건 1이다.
그 말인 즉슨, DB의 N 쪽이 연관관계의 주인이 된다. → @ManyToOne
이미 코드를 보면 답이 다 나와있다.
@OneToMany(mappedBy = "registry")
→ mappedBy로 나는 registry에 의해서 관리가 되고 있다.
이 registry는 아래 코드를 말한다.
1
2
3
4
5
6
7
8
// Comment.java
@Entity
public class Comment {
@ManyToOne
@JoinColumn(name = "REGISTRY_ID")
private Registry registry;
}
mappedBy가 적힌 곳은 읽기만 된다.
1
2
3
4
5
6
7
// Registry.java
@Entity
public class Registry {
@OneToMany(mappedBy = "registry")
private List<Comment> comments = new ArrayList<>();
}
comments에 값을 넣어봤자 아무 일도 일어나지 않는다.(대신 조회는 가능)
연관관계의 주인은 Comment에 있는 Registry가 연관관계의 주인이다.
그래서 연관관계 주인에 값을 넣어야 한다.
주의할 점
양방향 매핑시 주의할 점으로는 3가지가 있다.
1. 연관관계의 주인에 값을 입력하지 않음
1
2
3
4
5
6
7
8
9
10
11
12
13
// Main.java
Registry registry = new Registry();
registry.setTitle("게시글 제목");
em.persist(registry);
Comment comment = new Comment();
comment.setComment("댓글");
// 역방향(주인이 아닌 방향)만 연관관계 설정
registry.getComments().add(comment);
em.persist(comment);
실행하면 COMMENT 테이블에 REGISTRY_ID가 null이다.
왜 그럴까? 연관관계의 주인은 Comment에 있는 registry가 연관관계의 주인이다.
Registry에 있는 comments는 mappedBy 읽기 전용이다.(가짜 매핑)
그래서 연관관계 주인에 값을 넣어야 한다.
1
2
3
4
5
6
7
8
9
10
11
// Main.java
Registry registry = new Registry();
registry.setTitle("게시글 제목");
//registry.getComments().add(comment); ←
em.persist(registry);
Comment comment = new Comment();
comment.setComment("댓글");
comment.setRegistry(registry); // ⭐⭐⭐⭐⭐
em.persist(comment);
2. 연관관계 편의 메소드 생성하기
JPA 입장에서는 아래 코드가 맞는 코드이다.
1
2
3
4
5
6
7
8
9
10
// Main.java
Registry registry = new Registry();
registry.setTitle("게시글 제목");
em.persist(registry);
Comment comment = new Comment();
comment.setComment("댓글");
comment.setRegistry(registry); // ⭐⭐⭐⭐⭐
em.persist(comment);
그런데 객체지향적으로 생각하면 양쪽에 다 값을 걸어야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Main.java
Registry registry = new Registry();
registry.setTitle("게시글 제목");
em.persist(registry);
Comment comment = new Comment();
comment.setComment("댓글");
comment.setRegistry(registry);
em.persist(comment);
em.flush(); // 강제 호출 (영속성 컨텍스트에 있는 것들을 db에 쿼리를 날려버려서 싱크를 맞춤)
em.clear(); // 영속성 컨텍스트 초기화
Registry findRegistry = em.find(Registry.class, registry.getId()); // 1)
List<Comment> comments = findRegistry.getComments(); // 2)
for (Comment c: comments) { // 2)
System.out.println("c.getComment() = " + c.getComment()); // 2)
}
tx.commit();
위 코드를 실행하면 문제 없이 동작한다.
실행하면 두개의 select 쿼리가 나오는데
1) Registry를 조회했을 때 나오는 쿼리
2) 실제 Comment 데이터를 로딩했을 때 (Registry의 comments를 사용하는 시점에 쿼리를 날린다.)
flush()
, clear()
를 해버리기 때문에
registry.getComments().add(comment);
라고 코드를 안넣어줘도 동작하지만
flush()
, clear()
를 주석처리하고 실행시키면
1)
은 1차 캐시에 있지만 2)
에서 컬렉션에는 값이 없다.
1
2
3
4
5
6
7
8
9
Registry findRegistry = em.find(Registry.class, registry.getId()); // 1)
List<Comment> comments = findRegistry.getComments(); // 2)
System.out.println("= = = = = = = = =");
for (Comment c: comments) { // 2)
System.out.println("c.getComment() = " + c.getComment()); // 2)
}
System.out.println("= = = = = = = = =");
tx.commit();
insert 쿼리만 나가는 것을 볼 수 있다.
Registry는 순수한 객체 상태이기 때문에 컬렉션에는 값이 없다.
저장한 상태 그대로 영속성 컨텍스트에 들어가 있기 때문에 db에서 select 쿼리가 날라가지 않는다.
따라서 순수 객체 상태를 고려해서 항상 양쪽에 값을 설정한다.
*참고로 테스트 케이스에서도 jpa 없이 작성해 줄 수 있기 때문에 둘 다 값을 세팅해줘야한다.
여기서 주의할 점은
registry.getComments().add(comment);
와 comment.setRegistry(registry)
를 같이 넣어야 한다는 것이다.
1
2
3
4
5
6
7
8
9
10
Registry registry = new Registry();
registry.setTitle("게시글 제목");
em.persist(registry);
Comment comment = new Comment();
comment.setComment("댓글");
comment.setRegistry(registry); // 👈🏻
em.persist(comment);
registry.getComments().add(comment); // 👈🏻
이 부분을 까먹을 수도 있기 때문에 연관관계 편의 메소드를 생성하는 것을 추천한다.
연관관계 편의 메소드
1
2
3
4
5
6
// Comment.java
public void setRegistry(Registry registry) {
this.registry = registry;
registry.getComments().add(this);
}
참고로 연관관계 편의 메소드나 jpa 상태를 변경하는 것은 set을 잘 안쓴다.
getter, setter 관례 때문에 로직이 추가로 들어가면 이름을 바꿔준다.
🔽
1
2
3
4
5
6
// Comment.java
public void changeRegistry(Registry registry) {
this.registry = registry;
registry.getComments().add(this);
}
1
2
3
4
5
6
7
8
9
10
// Main.java
Registry registry = new Registry();
registry.setTitle("게시글 제목");
em.persist(registry);
Comment comment = new Comment();
comment.setComment("댓글");
comment.changeRegistry(registry);
em.persist(comment);
만약 Registry를 기준으로 comment를 넣는다면 아래와 같이 작성하면 된다.
1
2
3
4
5
6
// Registry.java
public void addComment(Comment comment){
comment.setRegistry(this);
comments.add(comment);
}
1
2
3
4
5
6
7
8
9
10
11
// Main.java
Registry registry = new Registry();
registry.setTitle("게시글 제목");
em.persist(registry);
Comment comment = new Comment();
comment.setComment("댓글");
em.persist(comment);
registry.addComment(comment);
여기서 조심해야할 것은 연관관계 편의 메소드가 양쪽에 다 있으면 문제를 일으킬 수 있다.
그래서 한쪽은 지워준다. (둘 중에 하나만 정해주면 된다.)
정리하자면 연관관계의 주인은 Comment에 있는 Registry가 주인이고
값을 세팅하는 것은 본인 마음이다. (단, 연관관계 편의 메소드는 둘 중에 하나만 세팅한다.)
3. 양방향 매핑시에 무한 루프를 조심한다.
Comment에서 toString()
을 생성해본다.
1
2
3
4
5
6
7
8
9
10
// Comment.java
@Override
public String toString() {
return "Comment{" +
"id=" + id +
", comment='" + comment + '\'' +
", registry=" + registry +
'}';
}
여기서 registry는 registry.toString()
을 또 호출한다는 얘기가 된다.
1
2
3
4
5
6
7
8
9
10
// Comment.java
@Override
public String toString() {
return "Comment{" +
"id=" + id +
", comment='" + comment + '\'' +
", registry=" + registry.toString() +
'}';
}
또 Registry에서 toString()
을 생성하면 아래와 같다.
1
2
3
4
5
6
7
8
9
10
// Registry.java
@Override
public String toString() {
return "Registry{" +
"id=" + id +
", title='" + title + '\'' +
", comments=" + comments +
'}';
}
comments는 컬렉션 하나하나 안에 있는 toString()
을 다 호출한다.
그래서 양쪽으로 toString()
을 무한 호출하게 된다.
1
2
3
// Main.java
System.out.println(findRegistry);
→ 실행하면 StackOverflowError가 뜸(양쪽으로 계속 호출하기 때문이다.)
정리
• 테이블은 외래 키로 조인을 사용해서 연관된 테이블을 찾는다.
• 객체는 참조를 사용해서 연관된 객체를 찾는다.
• 테이블과 객체 사이에는 이런 큰 차이 있다.