본문 바로가기
Java, Kotlin, Spring/JPA

JPA - 단방향, 양방향 연관관계 매핑

by Wordbe 2021. 9. 20.
728x90

JPA 004 - 단방향, 양방향 연관관계 매핑

TL;DR

  1. 두 테이블이 있고, 한 테이블에 외래키가 있는 상황에서 테이블은 항상 양방향 관계를 가진다.
  2. A 객체 안에 B 객체가 필드로 있다면, A에서 B로 가는 단방향 관계를 가진다.
  3. 단방향 관계만으로 충분히 연관관계 매핑을 할 수 있다.
    • 아래 예제에서 Post 에 있는 @OneToMany 는 사실 없어도 잘 동작한다. 외래키는 @ManyToOne 이 붙은 Comment 에 자연스럽게 생긴다.
  4. 반대방향으로도 조회하고 싶으면 양방향 관계를 설정하자. (반대 방향의 객체 그래프 탐색이 가능해진다.)
    • 대신에, 연관관계 편의 메소드 등을 만들어서 양방향 매핑을 모두 잘해주고, 논리적으로 오류가 없도록 관리를 잘 해주어야 한다.


단방향, 양방향?

테이블 vs 객체

테이블 관점에서 먼저 생각해보자.

예를 들어 게시글 안에 여러 댓글이 달릴 수 있다. 게시글과 댓글은 1:N (일대다) 관계라는 것을 알 수 있다.

게시글 테이블과 댓글 테이블이 있고, 게시글과 댓글이 생성되면서 데이터가 쌓인다.

우리는 댓글이 어느 게시글에 달렸는지 그 관계도 같이 저장하고 싶다. 그래서 보통 댓글 테이블에 게시글 ID 를 넣어놓는다.


개발자 입장에서보면 테이블은 JOIN 이라는 키워드를 통해 양방향으로 데이터를 가져올 수 있다.

SELECT *
FROM   POST P
JOIN   COMMENT C ON P.ID = C.POST_ID

SELECT *
FROM   COMMENT C
JOIN   POST P ON C.POST_ID = P.ID

즉, 댓글에 외래키를 만들면, 게시글과 댓글 테이블은 양방향 관계가 된다.


이번에는 객체 관점에서 생각해보자.

객체 입장에서는 Post 객체가 Comment 를 참조할 수 있다. 이 경우 단방향이 된다.

Comment 객체에 Post 객체를 참조하도록 넣으면 양방향 관계가 되는데 이는 사실 단방향 관계 2개가 만들어진 것이다.

public class Post {
  private Long id;
  private List<Comment> comments;
}

public class Comment {
  private Long id;
  private Post post;
}

객체는 참조를 통해 연관관계를 탐색할 수 있고 이를 객체 그래프 탐색이라고 한다.



JPA 로 객체 관계 매핑

1 단방향 매핑

이번엔 순수 자바 객체를 JPA 에서 사용할 수 있도록 @Entity 어노테이션을 붙였다.

애플리케이션을 실행하면 테이블도 자동으로 생성된다.

@Entity
public class Post {
    @Id @GeneratedValue
    private Long id;
    private String title;
    private String content;
}
@Entity
public class Comment {
    @Id @GeneratedValue
    private Long id;
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    private Post post;
}

Comment → Post 방향으로 참조가 가능한 상태에서 단방향 매핑을 하려면 @ManyToOne 을 붙이면 된다.

이 때 외래키는 일대다에서 '다' 쪽인 comment 에 생기는 게 자연스럽다. 애플리케이션 실행시 하이버네이트가 자동으로 post_id 라는 이름으로 comment 테이블에 외래키를 생성해준다.

외래키 생성 규칙은 @ManyToOne 에 해당하는 필드이름 + _ + 필드의 id 이름 이다.

즉, post_id 가 된다.

@ManyToOne 을 한 컬럼에 @JoinColumn 을 설정해주면 외래키의 이름을 설정할 수 있다.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "my_post_id")
private Post post;
create table comment (
  id bigint not null,
  content varchar(255),
  my_post_id bigint,
  primary key (id)
)

2 양방향 매핑

객체간 연관관계 매핑을 할 떄는 단방향 매핑이면 충분하다.

양방향 매핑을 하는 이유는 Post → Comment 방향으로의 객체 참조를 만들어서 조회를 쉽게하도록 하기 위함이다.

하지만, 양방향 매핑시 주의사항도 많으니 연관관계시 논리적 오류가 없도록 조치를 잘해주어야 한다.


1) 연관관계 주인 설정 (외래키 관리자)

객체의 양방향 매핑은 단방향 매핑 2개라고 했다. 테이블에서 외래키는 하나의 테이블이 관리한다. 객체도 마찬가지로 외래키를 하나의 객체에서 관리하는 것이 맞다. 외래키를 관리하는 객체가 연관관계의 주인이 된다. JPA 에서는 이 객체를 설정해주어야 한다.

연관관계 주인이 아닌 쪽에 mappedBy 속성으로 설정해준다. 아래 예제를 보자.

@Entity
public class Post {
    @Id @GeneratedValue
    private Long id;
    private String title;
    private String content;

    @OneToMany(mappedBy = "post")
    private List<Comment> comments = new ArrayList<>();
}
@Entity
public class Comment {
    @Id @GeneratedValue
    private Long id;
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;
}

외래키는 Comment 가 가지고 있다. 따라서 Comment 가 연관관계의 주인이다.

연관관계의 주인이 아닌쪽에 mappedBy = "post" 를 명시하자.

이렇게 되면, Post 에서는 외래키를 읽기만 할 수 있고, Comment 에서는 외래키를 읽기, 등록, 수정, 삭제까지 할 수 있다.


데이터베이스에서 조회해서 확인해보면 간단하지만, 테스트 자동화를 위해 테스트코드를 작성해보자.

외래키를 직접 불러오기 위해 Comment 객체에 외래키를 조회할 수 있도록 필드를 추가해주었다. 이 때 이 외래키가 함부로 변경되면 영속 객체의 정합성이 깨질 수 있으므로, update, insert 옵션을 false 로 한다.

@Data
@Entity
public class Comment {
    @Id @GeneratedValue
    private Long id;
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

    @Column(name = "post_id", insertable = false, updatable = false)
    private Long postId;
}
  • @Data 는 편의상 설정해두었다.
@DataJpaTest
@Rollback(false)
class PostTest {
    @Autowired
    PostRepository postRepository;

    @Autowired
    CommentRepository commentRepository;

    @Autowired
    EntityManager em;

    @Test
    void 게시글과댓글_외래키확인하기() {
        Post post = new Post();
        post.setTitle("백종원의 김치찌개");
        post.setContent("1:3의 비율로");

        Comment comment1 = new Comment();
        comment1.setContent("맛있어요.");
        comment1.setPost(post);

        Comment comment2 = new Comment();
        comment2.setContent("뜨거워요.");
        comment2.setPost(post);

        Post savedPost = postRepository.save(post);
        Comment savedComment1 = commentRepository.save(comment1);
        Comment savedComment2 = commentRepository.save(comment2);

          // 이 시점에 insert 문이 발생하도록 하기 위함.
        em.flush(); em.clear();

        Comment foundComment1 = commentRepository.findById(savedComment1.getId()).get();
        Comment foundComment2 = commentRepository.findById(savedComment2.getId()).get();

        assertThat(foundComment1.getPostId()).isEqualTo(savedPost.getId());
        assertThat(foundComment2.getPostId()).isEqualTo(savedPost.getId());
    }

}
  • 데이터베이스는 h2 메모리 DB 를 사용했다.

2) 엔티티에 양방향 매핑 설정

위 예제는 연관관계 주인인 Comment 객체에 Post 를 매핑하여 저장하였다. 따라서 외래키가 잘 등록되었다.

하지만 반대의 경우 (연관관계 주인이 아닌 Post 객체에 Comment 만 매핑한경우) 는 null 로 들어가게 된다. 아래 테스트를 보자.

@Test
void 연관관계주인_반대편에만_값설정하면_외래키는_null이다() {
  Comment comment1 = new Comment("맛있어요.");
  Comment comment2 = new Comment("뜨거워요.");

  Post post = new Post("백종원의 김치찌개", "1:3의 비율로");
  post.getComments().add(comment1);
  post.getComments().add(comment2);

  postRepository.save(post);
  Comment savedComment1 = commentRepository.save(comment1);
  Comment savedComment2 = commentRepository.save(comment2);


  em.flush(); em.clear();

  Comment foundComment1 = commentRepository.findById(savedComment1.getId()).get();
  Comment foundComment2 = commentRepository.findById(savedComment2.getId()).get();

  assertThat(foundComment1.getPostId()).isNull();
  assertThat(foundComment2.getPostId()).isNull();
}

따라서 우리는 양방향 관계에서는 항상 양방향 모두 매핑 해놓는 습관을 가지는 것이 좋다.

그래야 외래키기가 null 로 입력되는 것을 방지할 수 있다.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Post {
    @Id
    @GeneratedValue
    private Long id;
    private String title;
    private String content;

    @OneToMany(mappedBy = "post")
    private List<Comment> comments = new ArrayList<>();

    public Post(String title, String content) {
        this.title = title;
        this.content = content;
    }

      // 연관관계 편의 메소드 정의
    public void changeComment(Comment comment) {
        comment.setPost(this);
          this.comments.add(comment);
    }
}
@Test
void 연관관계편의메소드사용하면_외래키가잘등록된다() {
  Comment comment1 = new Comment("맛있어요.");
  Comment comment2 = new Comment("뜨거워요.");

  Post post = new Post("백종원의 김치찌개", "1:3의 비율로");
  post.changeComment(comment1);
  post.changeComment(comment2);

  Post savedPost = postRepository.save(post);
  Comment savedComment1 = commentRepository.save(comment1);
  Comment savedComment2 = commentRepository.save(comment2);

  em.flush(); em.clear();

  Comment foundComment1 = commentRepository.findById(savedComment1.getId()).get();
  Comment foundComment2 = commentRepository.findById(savedComment2.getId()).get();

  assertThat(foundComment1.getPostId()).isEqualTo(savedPost.getId());
  assertThat(foundComment2.getPostId()).isEqualTo(savedPost.getId());
}

3) 논리적 오류 처리 (연관관계 삭제시)

한 가지 주의해야할 사항이 더 있다.

comment1 <--> post1


comment1 <--- post1
comment1 <--> post2

comment1 과 post1 이 이미 양방향 관계에 있다고 해보자.

comment1 이 post2 에 달리도록 변경이 바뀌면 어떻게 될까? (실제 댓글이 다른 게시글로 옮기는 현상은 없을 것 같지만, 예시로써 이해부탁드립니다.)

changeComment() 를 보며 생각해보면 comment1 은 post1 과 더이상 관계가 아니도록 바뀌지만, post1 은 여전히 comment1 을 가리키게 된다.

그래서 이렇게 변경된 후에 post1 에서 댓글을 조회해보면 여전히 comment1 이 조회될 것이다.

따라서 비즈니스로직에서 이런 것들을 막기 위해 추가적인 로직 등록이 필요하다.

public void changeComment(Comment comment) {
  if (comment.getPost() != null) {
    comment.getPost().getComments().remove(comment);
  }
  comment.setPost(this);
  this.comments.add(comment);
}

위와 같이하면, post1 은 더이상 comment1 을 가지지 않게 될 것이다.



정리

  1. 단방향 관계만으로 충분히 매핑을 할 수 있다.

    위 예제에서 Post 에 있는 @OneToMany 는 사실 없어도 잘 동작한다. 외래키는 @ManyToOne 이 붙은 Comment 에 자연스럽게 생긴다.

  2. 반대방향으로도 조회하고 싶으면 양방향 관계를 설정하자. (반대 방향의 객체 그래프 탐색이 가능해진다.)

    대신에, 양방향 매핑을 모두 잘해주고, 논리적으로 오류가 없도록 관리를 잘 해주어야 한다.



본 글은 자바 ORM 표준 JPA 프로그래밍 (김영한 저)참고하여 재구성했습니다.



728x90

댓글