[JPA] 일대다(OneToMany) 테이블 생성과 데이터 삽입, 조회
안녕하세요. 이번 포스트에서는 양방향 매핑의 연관 관계에 있는 테이블들에 데이터를 어떤 방식으로 삽입하고 조회하는지에 대해 알아보겠습니다.
이번 포스트는 지난 포스트 에 이어서 작성되었습니다.
개발 환경
- IntelliJ
- Spring Boot 2.4.5
- Gradle
- Lombok
의문점
- 양방향 매핑이면 서로의 PK를 가지고 있어야 할텐데 새로 생성되는 데이터들에게는 서로의 PK를 어떻게 입력하지? 순차적으로 입력해야하나? 서로 동등한 위치라면 순서는 어떻게 해야하지?
- 이렇게 순차적으로 서로의 PK를 저장한다고 해도 1:N이라면 1 테이블에서는 N 테이블의 PK가 리스트로 저장되는데 어떻게 표현되지?
- Json 응답에서 무한 루프는 어떻게 해결해야할까?
세팅
public class Post{
//...
@OneToMany(mappedBy = "post")
private List<PostTag> tags;
}
public class Tag {
//...
@OneToMany(mappedBy = "tag")
private List<PostTag> posts;
}
public class PostTag {
//...
@ManyToOne
private Post post;
@ManyToOne
private Tag tag;
}
현재 엔터티에 설정된 연관관계입니다. 엔터티에서 양방향으로 설정하였기때문에 DB에서도 테이블이 양방향으로 설정될 것으로 예상하였지만 생성된 테이블의 구성은 달랐습니다.
테이블 생성
현재 설정은 spring.jpa.hibernate.ddl-auto=create-drop 입니다.
create table post (id bigint not null auto_increment, title varchar(255), primary key (id))
create table post_tag (id bigint not null auto_increment, point integer, post_id bigint, tag_id bigint, primary key (id))
create table tag (id bigint not null auto_increment, name varchar(255), primary key (id))
테이블 생성을 할 때는 연관 관계를 설정하지 않고 GenerationType, PK 등을 설정하는 것을 볼 수 있습니다. 또한 @ManyToOne 으로 조인을 걸었던 속성은 객체가 아닌 bigint 로 저장되었습니다.
alter table post_tag add constraint FKc2auetuvsec0k566l0eyvr9cs foreign key (post_id) references post (id)
alter table post_tag add constraint FKac1wdchd2pnur3fl225obmlg0 foreign key (tag_id) references tag (id)
이후 제약 사항 쿼리를 통해 각각 조인이 걸린 속성에 외래키를 설정합니다.
desc post;
Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
id | bigint | NO | PRI | NULL | auto_increment |
title | varchar(255) | YES | NULL |
desc tag;
Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
id | bigint | NO | PRI | NULL | auto_increment |
name | varchar(255) | YES | NULL |
그 결과 1:N 에서 1에 해당하는 post와 tag 테이블에는 매핑하였던 N 테이블의 정보가 없습니다. 그럼 N에 해당하는 테이블을 확인해보겠습니다.
desc post_tag;
Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
id | bigint | NO | PRI | NULL | auto_increment |
point | int | YES | NULL | ||
post_id | bigint | YES | MUL | NULL | |
tag_id | bigint | YES | MUL | NULL |
해당 테이블에는 ManyToOne 으로 설정한 속성이 foreign key 방식으로 각각의 pk가 담겨있습니다.
DB에서는 Join을 foreign key로 맺기때문에 참조하는 테이블에서만 해당 조인의 정보가 나타납니다. foreign key 만으로도 각각의 테이블에서 조인된 정보를 확인할 수 있기때문에 단방향으로 생성이 되고 연관관계의 주인(foreign key 가 저장된 테이블)이라는 정보가 만들어지게 됩니다.
ex) select * from A where A.id = B.A_id; 와 select * from B where A.id = B.A_id; 처럼 foreign key 하나로 각각 테이블에서 조인 정보 확인이 가능합니다.
위의 결과로부터 여러 정보를 알 수 있었습니다.
-
ORM과 DB의 차이가 나타난다.
👉 DB는 foreign key로 조인 정보를 저장하지만 Java 에서는 객체로 표현됩니다. 그리고 hibernate 와 같은 ORM 을 통해 서로 매핑이 됩니다.
-
@ManyToOne 의 FetchType default 값이 EAGER 인 이유와 @OneToMany 의 FetchType default 값이 LAZY 인 이유를 알 수 있다.
👉 @ManyToOne 이 담긴 테이블은 조인된 정보를 이미 담고있기때문에 EAGER를 통해 가진 정보를 바로 가져옵니다.
👉 @OneToMany 가 담긴 테이블은 조인된 정보를 담고있지 않기때문에 조인된 정보가 필요없다면 굳이 한번에 가져 올 필요가 없어서 LAZY 가 default 입니다.
해결
의문점 1번과 2번이 한번에 해결되었습니다. JPA 에서 양방향으로 매핑을 했더라도 DB 에서는 단방향으로 표현이 됩니다. 따라서 서로의 PK를 가지고 있는 것도 아니고 1:N 에서 1 에 해당하는 테이블은 연관 관계의 리스트 정보를 가지고 있지도 않습니다.
데이터 삽입
테이블 생성의 플로우와 유사하게 1:N 에서 1에 해당하는 테이블의 객체를 먼저 만듭니다.
그리고 저장된 객체를 N에 해당하는 테이블의 객체의 속성에 set 한 뒤 저장합니다.
@Service
@RequiredArgsConstructor
public class TestService {
private final PostRepository postRepository;
private final TagRepository tagRepository;
private final PostTagRepository postTagRepository;
Post post = postRepository.save(new Post());
Tag tag = tagRepository.save(new Tag());
PostTag postTag = postTagRepository.save(PostTag.builder().post(post).tag(tag).build());
//...
}
객체를 생성할 때 테스트를 위해 @Builder 를 사용하였습니다.
발생한 쿼리를 살펴보겠습니다.
insert into post (title) values (?)
insert into tag (name) values (?)
insert into post_tag (point, post_id, tag_id) values (?, ?, ?)
에상했던대로 post 와 tag 테이블에 데이터를 삽입한 뒤 이 데이터들의 id 를 저장하는 쿼리가 발생하였습니다.
** 여기서 레포지토리에 저장이 되지 않은 post 또는 tag 객체를 post_tag 의 객체에 넣고 저장한다면 foreign key 에 해당하는 post 또는 tag 의 id 가 담기지 않습니다. 이 데이터의 pk 는 auto_increment 이기때문에 저장되지 않았을 때에는 id 가 생성되지 않았기 때문입니다.
DB 에 저장된 데이터를 확인하겠습니다.
select * from tag;
id | name |
---|---|
1 | name |
select * from post;
id | title |
---|---|
2 | title |
select * from post_tag;
id | point | post_id | tag_id |
---|---|---|---|
3 | NULL | 2 | 1 |
이상없이 데이터가 저장되었습니다. 이제 저장된 데이터를 조회하는 방법과 주의점을 알아보겠습니다.
데이터 조회
조인된 데이터를 조회할 때는 FetchType 으로 LAZY 와 EAGER 를 API 특성에 맞게 설정을 하여 효율적인 쿼리를 목표로 합니다. LAZY 와 EAGER 에 대한 더욱 자세한 내용은 인터넷에 풍부하기때문에 이번 포스트에서는 간단하게 쿼리만 살펴보겠습니다.
조회하여 응답을 내보낼 때에는 매핑으로 인해 무한 루프가 발생합니다. 따라서 dto 또는 @JsonIgnore 어노테이션을 이용하여 응답을 내보냅니다. 다만 API 의 통일된 형태와 다양한 응답으로 인해 해당 속성을 참조해야 하는 응답이 있을 수 있습니다. 그래서 @JsonIgnore 보다는 dto 를 선호한다고 합니다.
@Data
public class PostResponseDto {
String title;
List<String> tags;
public PostResponseDto(Post post) {
this.title = post.getTitle();
this.tags = post.getTags().stream() // --- 2-1
.map(postTag -> postTag.getTag().getName())
.collect(Collectors.toList());
}
}
stream 을 이용하여 tag name 리스트를 반환하게 만들었습니다.
@RestController
@RequiredArgsConstructor
public class TestController {
private final PostRepo postRepo;
@GetMapping("/test")
public PostResponseDto test() {
Post post = postRepo.findById(1L).orElse(null); // --- 1
if (post != null) {
return new PostResponseDto(post); // --- 2
}
return null;
}
}
발생한 쿼리를 살펴보겠습니다.
select
post0_.id as id1_1_0_,
post0_.title as title2_1_0_
from
post post0_
where
post0_.id=?
해당 쿼리는 1번 findById 메소드에 의해 발생하였습니다. Fetch Type이 lazy 이기때문에 tag list 를 조회하는 쿼리가 발생하지 않았습니다.
select
tags0_.post_id as post_id3_2_0_,
tags0_.id as id1_2_0_,
tags0_.id as id1_2_1_,
tags0_.point as point2_2_1_,
tags0_.post_id as post_id3_2_1_,
tags0_.tag_id as tag_id4_2_1_,
tag1_.id as id1_3_2_,
tag1_.name as name2_3_2_
from
post_tag tags0_
left outer join
tag tag1_ on tags0_.tag_id=tag1_.id
where
tags0_.post_id=?
해당 쿼리는 2번 새로운 Dto 를 생성할 때 2-1번 getTags 메소드로 인해 발생하였습니다. 조금 더 자세히 살펴보면 해당 엔터티의 Tag 는 EAGER 이기때문에 곧바로 조인 쿼리가 발생하였습니다.
{"title":"title","tags":["name"]}
결과로 발생한 Json 데이터입니다. 예상한대로 dto 에 맞게 데이터를 받았습니다.
Leave a comment