JPA @OneToMany, @ManyToOne으로 연관관계 관리하기

고라니·2023년 3월 26일
14
post-thumbnail

안녕하세요, 오늘은 스프링을 이용하면서 자주 쓰는 JPA에 대해서 이야기해보려고 합니다.

JPA는 스프링 개발을 하면서 이제 거의 필수가 된 ORM 기술입니다.

@OneToMany, @ManyToOne 어노테이션은 1:N, N:1 관계를 설정하는 어노테이션인데요, 안 쓰는 프로젝트를 찾을 수가 없을 정도로 기본적이며, 필수적이며, 중요합니다.

그래서 오늘은 @OneToMany, @ManyToOne에 대해서 자세히 알아보려고 합니다.

어노테이션에 설정할 수 있는 옵션들을 살펴보면서 설명드리겠습니다.

Hibernate ORM core version 6.1.7 Final 기준입니다.

다양한 연관관계

엔티티들은 서로 다양한 연관관계를 맺을 수 있습니다.

연관관계JPA Annotation
1:1@OneToOne
1:N@OneToMany
N:1@ManyToOne
N:M@ManyToMany

N:M 연관관계는 RDB에서 일반적인 방법으로 표현할 수 없어서 중간테이블이 생기게 됩니다.

따로 중간 엔티티(테이블)를 만들어서 1:N, N:1 관계로 분해하지 않으면 관리가 힘들어지기 때문에 사용을 권장하지 않습니다.

1:1 연관관계도 사용 시 객체지향적으로 개발할 수 있다는 점 등 장점이 있지만, 단점도 존재하기에 주의해서 사용해야 됩니다.

오늘은 1:1, N:M 연관관계를 제외하고 많이 사용되는 1:N, N:1 관계에 대해서 살펴보겠습니다.

@OneToMany, @ManyToOne 사용법

본격적으로 들어가기에 앞서서, 보다 쉬운 이해를 위해 예제로 설명하려고 합니다.

앞으로 사용할 엔티티들과 시나리오는 다음과 같습니다.

이번 시나리오에서는 사용자(User)가 글(Post)을 작성하고 댓글(Comment)을 달 수 있습니다.

간결한 코드를 위해 Lombok 어노테이션을 사용했으니 참고해주세요.
참고로, 저는 실제 프로젝트에서 @Setter는 자주 사용하지 않는 편입니다.

@Entity
@Getter @Setter
public class User {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany
    private List<Post> posts;

    @OneToMany
    private List<Comment> comments;
}

@Entity
@Getter @Setter
public class Post {
    @Id @GeneratedValue
    private Long id;

    private String title;

    @ManyToOne
    private User user;

    @OneToMany
    private List<Comment> comments;
}

@Entity
@Getter @Setter
public class Comment {
    @Id @GeneratedValue
    private Long id;

    private String text;

    @ManyToOne
    private Post post;

    @ManyToOne
    private User user;
}

이렇게 엔티티들을 정의하면, 연관관계가 총 3개 만들어집니다.

그럼, @OneToMany, @ManyToOne 어노테이션에 설정할 수 있는 옵션들을 하나씩 살펴보겠습니다.

targetEntity

@OneToMany가 설정된 필드를 보시면 모두 List<T> 타입을 이용했는데요, List 말고도 Set, Map 타입도 이용 가능합니다.

List<T>가 아닌 그냥 List로도 타입 선언이 가능한데요, 이럴 때에는
targetEntity의 명시가 필요합니다.

@OneToMany(targetEntity = Post.class)
private List posts;

하지만 이런 경우는 권장되지도 않고 많이 사용되지도 않기 때문에 잘 사용하지 않는 옵션입니다.

@ManyToOne도 설정할 수 있지만 더더욱 필요 없는 옵션입니다.

양방향과 단방향 연관관계

많은 분들이 가장 헷갈려하시는 부분이 이 부분이 아닐까 싶습니다.

@OneToMany 기준으로 설명드리자면,

단방향(unidirectional)은 상대 엔티티에 @ManyToOne이 없는 경우,
양방향(bidirectional)은 상대 엔티티에 @ManyToOne이 있는 경우입니다.

@ManyToOne 기준으로 설명드리자면,

단방향(unidirectional)은 상대 엔티티에 @OneToMany가 없는 경우,
양방향(bidirectional)은 상대 엔티티에 @OneToMany가 있는 경우입니다.

@OneToMany 양방향과 @ManyToOne 양방향은 기준이 다를 뿐, 차이가 없습니다.

단뱡향이든 양방향이든 @OneToMany 어노테이션을 달고 있는 엔티티가 부모 엔티티가 됩니다. 즉, FK를 들고 있는 쪽이 자식 엔티티가 됩니다.

연관관계의 주인: mappedBy

연관관계에는 주인(ownership)이라는 개념이 있습니다.

주인은 연관관계의 관리 주체이며, 주인은 이 연관관계를 관리할 책임을 가지고 있습니다.

언뜻 생각하기에 부모 엔티티가 주인 엔티티가 아닌가 생각할 수 있지만 주인 엔티티와 부모 엔티티는 분명히 다릅니다.

  • 부모, 자식 엔티티는 의존도(부모 없이 자식이 존재할 수 없다는 사실을 떠올리면 쉽습니다)를 통해 정해지며,
  • 주인 엔티티는 DB에 저장되는 FK를 어떤 엔티티가 삽입, 수정, 삭제를 담당할 지를 정하는 과정에서 자연스럽게 정해집니다.

단방향의 경우, 주인은 자연스럽게 해당 어노테이션이 존재하는 엔티티가 됩니다.
사실, 이 경우에는 그렇게 주인 개념이 강하지 않습니다. 당연히 해당 어노테이션을 들고 있는 엔티티가 관리해야 된다는 느낌으로 생각하면 좋습니다.

하지만, FK를 들고있는 'Many' 쪽의 자식 엔티티가 주인이 되는 것이 더 자연스럽습니다.

따라서, @OneToMany 단방향을 써서 부모 엔티티가 주인이 되기보다 양방향 연관관계를 이용해 자식 엔티티가 FK를 관리하는 것이 권장됩니다.

@ManyToOne 단방향

가장 많이 쓰이는 연관관계입니다. 엔티티의 관계를 표현하고 FK 관리에 있어서 가장 자연스럽기 때문입니다.

@JoinColumn 어노테이션과 함께 쓰이며, 이때 @JoinColumn은 엔티티 테이블에 FK 칼럼을 정의해줍니다.

/* Post.java */
@ManyToOne
@JoinColumn
private User user

@OneToMany 단방향

상대 엔티티를 참조할 수 있는 매핑이 부모 엔티티 쪽에 존재하지만, FK는 자식 엔티티 테이블에 존재하는 연관관계입니다.

@JoinColumn없이 사용할 경우, Hibernate에서 자체적으로 중간 테이블(link table)을 생성해서 연관관계를 관리하게 됩니다.

이 때 생성되는 DDL을 살펴보면 다음과 같습니다.

create table "user" (
    id bigint generated by default as identity,
    name varchar(255),
    primary key (id)
)
create table "user_posts" (
    "user_id" bigint not null,
    posts_id bigint not null
)
create table post (
    id bigint generated by default as identity,
    title varchar(255),
    primary key (id)
)

post 테이블에 FK 칼럼이 없기 때문에 userpost 테이블을 연결할 수 있도록 중간 테이블이 생성되었습니다.

이렇게 되면 FK칼럼을 만드는 것보다 비효율적이며, 무엇보다 자식 엔티티를 제거할 때 심각한 성능 문제로 이어질 수 있습니다. 밑에서 더 자세히 다루겠습니다.

따라서, 이를 방지하기 위해 @JoinColumn을 같이 사용할 수 있습니다.

/* User.java */
@OneToMany
@JoinColumn(name = "user_id")
private List<Post> posts;

여기서 사용되는 @JoinColumn의 의미는 @ManyToOne에서와 조금 다릅니다.

@OneToMany 단방향에서 사용되는 @JoinColumn은 상대 엔티티에 테이블에 FK 칼럼이 있음을 알려줍니다.

다양한 상황에서의 @JoinColumn의 의미

  • FK를 이용한 One-to-One, Many-to-One에서는 테이블 내에 FK 칼럼이 존재
  • 단방향 One-to-Many에서는 상대 테이블 내에 FK 칼럼이 존재
  • 조인 테이블을 이용한 Many-to-Many, One-to-One, One-to-Many/Many-to-One 양방향에서는 조인 테이블 내에 FK 칼럼이 존재
  • Element Collection인 경우, Collection 테이블내에 FK 칼럼이 존재

@ManyToOne 양방향 (@OneToMany 양방향)

단방향 @ManyToOne과 더불어 가장 많이 쓰이는 연관관계입니다.

DB 관점에서는 단방향 @ManyToOne과 차이점이 없으며, 어플리케이션에서 상대 엔티티쪽에서 참조할 수 있는 변수가 생기는 장점이 있습니다.

/* User.java */
@OneToMany(mappedBy = "user")
private List<Post> posts;

/* Post.java */
@ManyToOne
@JoinColumn
private User user

@OneToMany쪽에 mappedBy를 설정해서 상대 엔티티에서 어떻게 매핑이 되어 있는 지를 설명하고 @ManyToOne쪽에서 @JoinColumn을 통해 같은 테이블 내에서 FK 칼럼을 정의합니다.

여기서 @OneToManymappedBy를 설정하지 않으면 @OneToMany가 단방향처럼 취급되어 중간 테이블이 생성되기 때문에 주의가 필요합니다.

@OneToMany, @ManyToOne 편의 옵션

@OneToMany 어노테이션을 설정한 엔티티는 자식 엔티티에 영향을 줄 수 있습니다.

프록시 옵션: fetch

해당 객체를 DB에서 조회할 때, 연관관계에 있는 엔티티의 정보를 언제 같이 끌어올 지에 대한 옵션입니다.

1. Lazy Fetch

연관관계에 있는 엔티티에 접근할 때, DB에 쿼리를 날려 엔티티를 조회하게 됩니다.

접근하지 않는 경우, 쿼리가 발생하지 않습니다.

2. Eager Fetch

상대 엔티티의 조회 여부와 상관없이, 쿼리가 발생하게 됩니다.

@OneToMany의 기본값은 Lazy Fetch이며, @ManyToOne의 기본값은 Eager Fetch입니다.

Eager Fetch, Lazy Fetch 상관 없이 단건 조회가 아닌 컬렉션 조회에서 N+1 문제가 발생할 수 있습니다.

Eager Fetch는 조회 여부와 상관없이 쿼리가 발생하기 때문에, 더 잘 보이는 차이가 있을 뿐입니다.

영속성 전파: cascade

CascadeType으로 6가지를 줄 수 있습니다.

PERSIST, MERGE, REMOVE, REFRESH, DETACH와 모든 옵션을 줄 수 있는 ALL입니다.

영속성 전파를 설정하게 되면, 객체에 해당 작업이 이루어질 때, 자식엔티티에도 작업이 전파됩니다.

예를 들어, 유저의 posts에 cascadeType으로 PERSIST가 걸려있으면, 유저 객체만 저장해도 글 객체도 저장됩니다.

고아 객체 관리: orphanRemoval

orphanRemoval 옵션은 @OneToMany에만 존재하는 옵션입니다.

orphanRemoval 옵션은 연관관계가 끊긴 엔티티에 대해서 REMOVE 작업을 진행하고 전파할 지에 대한 옵션입니다.

예를 들면,

user.getPosts().remove(post);
post.setUser(null);

위 코드를 실행하면 유저의posts 리스트에 해당 객체가 존재하지 않아서 연관관계가 끊기게 됩니다. 이 때, DB로 삭제하는 쿼리가 발생합니다.

주의해야 될 점은 PERSIST의 cascade가 적용되어야, orphanRemoval이 제대로 작동합니다. JPA가 컬렉션에 있는 엔티티를 추적해야 되기 때문입니다.

착각하시면 안되는 것이, cascade의 REMOVE와는 조금 다릅니다.

cascade의 REMOVE은 해당 엔티티가 삭제되었을 때, 그 삭제 작업을 연관관계에 있는 엔티티까지 전파할 것인지 조정하는 옵션입니다.

여기서 user는 삭제되지 않았기 때문에 관련이 없는 것이지요.

Null 관리: optional

optional 옵션은 @ManyToOne에만 존재하는 옵션입니다.

해당 옵션은 FK 칼럼에 Null 여부를 설정합니다.

기본값은 true이며, false인 경우, FK에 Null을 허용하지 않습니다.

연관관계 매핑 시 주의해야될 점

이제 연관관계 설정하고 사용 시에 주의해야될 점들에 대해 살펴보겠습니다.

양방향 연관관계가 설정된 경우라도 DB에서는 오직 한 쪽 테이블만 FK를 들고 있다.

당연한 얘기입니다.

양방향 연관관계가 설정된 경우, 연관관계를 일관성있게 유지해야 한다.

어플리케이션 레벨에서도 데이터 정합성을 유지하는 것이 좋습니다.

이를 위해서는 아래와 같은 편의 메소드를 정의하는 것이 좋습니다.

/* User.java */
public void addPost(Post post) {
	posts.add(post);
    post.setUser(this);
}

public void removePost(Post post) {
	posts.remove(post);
    post.setUser(null);
}

아래와 같은 코드를 실행한다고 했을 때,

/**
 * User의 posts Cascade: PERSIST
 * Post의 user Cascade: PERSIST
 */
User user = new User();
user.setName("user");

Post post = new Post();
post.setTitle("post");

user.getPosts().add(post);

userRepository.save(user);

아래와 같은 쿼리가 발생합니다.

insert into user (name) values ('user');
insert into post (title, user_id) values ('post', null);
        
-- 이후 테이블 조회 시, 다음과 같은 결과가 나옵니다.
select * from user; select * from post;
+----+------+
| id | name |
+----+------+
|  1 | user |
+----+------+
1 row in set (0.00 sec)

+----+-------+---------+
| id | title | user_id |
+----+-------+---------+
|  1 | post  |    NULL |
+----+-------+---------+
1 row in set (0.00 sec)

userpostspost를 추가했지만, 실제 postuser에는 값이 설정이 안돼서 실제 테이블에도 user_id의 값이 null이 됩니다.

잊지 않고 주인 엔티티에서 연관관계를 꼭 설정해주어야 DB에도 제대로 반영이 됩니다.

@OneToMany 단방향에서 중간 테이블이 생기면 안되는 이유

UserPost사이에 @OneToMany 단방향이 설정되어 있는 상태에서 아래 코드를 실행시켜보겠습니다.


/** User.java에 다음과 같이 설정
 * @OneToMany(cascade = CascadeType.ALL)
 * private List<Post> posts = new ArrayList<>();
 */
User user = new User();
user.setName("user");

userRepository.save(user);

Post post1 = new Post();
post1.setTitle("post1");
user.getPosts().add(post1);

Post post2 = new Post();
post2.setTitle("post2");
user.getPosts().add(post2);

userRepository.flush();

/** Insert 쿼리 발생
 * insert into user (name) values ('user')
 * insert into post (title) values ('post1')
 * insert into post (title) values ('post2')
 * insert into user_posts (`user_id`, posts_id) values (1, 1)
 * insert into user_posts (`user_id`, posts_id) values (1, 2)
 */

user.getPosts().remove(post1);
/** Delete 쿼리 발생
 * delete from user_posts where user_id=1
 * insert into user_posts (user_id, posts_id) values (1, 2)
 */

진짜 문제는 user.getPosts().remove(post1) 실행 후 생기는 쿼리에서 발생합니다.

Hibernate는 먼저 user와 관련된 모든 레코드를 중간 테이블에서 삭제한 후에, 남아 있는 연관관계에 대해서 다시 삽입합니다.

레코드가 100개였을 때 N개를 삭제했다면, 총 1+(100-N)개의 쿼리가 발생하게 됩니다.

불필요한 쿼리가 대량으로 발생하니 조심해야 합니다.

orphanRemoval은 벌크 쿼리로 발생하지 않는다.

아래와 같은 코드를 살펴봅시다.

User user = new User();
user.setName("user");

userRepository.save(user);

ArrayList<Post> postsToDelete = new ArrayList<>();

for (int i = 1; i <= 10; i++) {
	Post post = new Post();
	post.setTitle("post" + i);
	post.setUser(user);
	user.getPosts().add(post);
	if (i % 2 == 0) {
		postsToDelete.add(post);
	}
}

userRepository.flush();
user.getPosts().removeAll(postsToDelete);

10개의 Post를 저장하고 5개의 Post를 삭제하는 코드입니다.

하지만 이를 실행하게 되면, 10개의 삽입 쿼리와 5개의 삭제 쿼리가 발생합니다.

delete from post where id=1;
delete from post where id=3;
delete from post where id=5;
delete from post where id=7;
delete from post where id=9;

그에 따른 비효율이 발생할 수 있으니, 많은 양의 레코드를 삭제할 때는 orphanRemoval을 활용하지 않고 벌크 쿼리를 작성하는 것이 좋습니다.

REMOVE cascade는 참조하는 곳이 하나일 때만 사용하자.

연관관계의 옵션들이 다음과 같을 때를 생각해봅시다.

/* User.java */
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Post> posts = new ArrayList<>();

@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();

/* Post.java */
@ManyToOne
@JoinColumn
private User user;

@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();

/* Comment.java */
@ManyToOne
@JoinColumn
private Post post;

@ManyToOne(cascade = CascadeType.PERSIST)
@JoinColumn
private User user;

아래와 같은 코드처럼, 다른 유저의 댓글이 달린 게시물을 삭제하는 상황을 가정해보겠습니다.

User user = new User();
user.setName("user");

userRepository.save(user);

Post post = new Post();
post.setTitle("post");
post.setUser(user);
user.getPosts().add(post);

for (int i = 1; i <= 5; i++) {
    User user1 = new User();
    user1.setName("user" + i);

    Comment comment = new Comment();
    comment.setText("comment" + i);
    comment.setUser(user1); // comment의 cascade로 user1도 저장됨
    user1.getComments().add(comment);
    comment.setPost(post);
    post.getComments().add(comment);
}

userRepository.flush();

user.getPosts().remove(post);

코드를 실행하면, FK constraint로 인한 에러가 발생합니다.

java.sql.SQLIntegrityConstraintViolationException: Cannot delete or update a parent row: a foreign key constraint fails

조금 생각해보면 이상합니다.

userpost의 연관관계 끊김 -> post 삭제 -> REMOVE cascade로 인해 comment도 삭제
가 되어야 할 것 같은데, comment 삭제가 이루어지지 않아 그에 따른 FK constraint로 인한 에러가 발생합니다.

해당 에러는 User쪽에서 Comment를 참조하기 때문에 발생합니다.
따라서 User-Comment의 연관관계를 양방향에서 @ManyToOne 단방향으로 바꿔주면 정상적으로 삭제가 이루어집니다.

-- @ManyToOne 단방향으로 바꾼 후 삭제 시 발생하는 쿼리
delete from comment where id=1;
delete from comment where id=2;
delete from comment where id=3;
delete from comment where id=4;
delete from comment where id=5;
delete from post where id=1;

마무리

오늘은 연관관계를 설정하는 @OneToMany, @ManyToOne에 대해 자세히 알아보았습니다.

어노테이션에 사용되는 옵션을 살펴보고, 그에 따른 주의점도 알아보았습니다.

많은 분들이 헷갈려하시는 부분인데, 제 글이 많은 도움이 되었으면 좋겠습니다.

어려운 내용인 만큼 제가 잘못 알고 있을 수도 있으니, 틀린 내용이 있다면 꼭 알려주시면 감사하겠습니다.

1개의 댓글

comment-user-thumbnail
2023년 12월 27일

진짜 너무 자세하게 정리가 잘 되어 있어서 이해하는데 큰 도움이 됐습니다!
감사합니다!

답글 달기