- JPA 에서의 연관관계2025년 03월 07일 02시 39분 30초에 업로드 된 글입니다.작성자: do_hyuk
이전에 연관관계에 대해서 헷갈리던 걸 정리하기 위해 작성했던 글이 있다.
지금 다시 확인해보니 빈약한 부분이 많다고 생각하여 좀 더 자세히 알아보도록 하자.
JPA 에서 가장 중요한 개념이라고 하면 연관관계 매핑과 영속성 컨텍스트가 있다.
객체지향 프로그램에서의 객체와 RDB 에서의 테이블이 서로 연관관계를 맺는 방법이 다르다.
그렇기 때문에 이 둘의 차이를 채우기 위한 매핑과정이 필요하고 이를 ORM 인 JPA 가 수행하게 된다.
연관관계에서는 아래와 같은 용어들이 등장한다.
- 방향(Direction) : 단방향 연관관계, 양방향 연관관계
- 다중성(Multiplicity) : 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M)
- 연관관계의 주인(Owner)
데이터 중심의 모델링
JPA 에서 해주는 연관관계 매핑에 대해서 알아보기에 앞서 잘못된 연관관계 방법에 대해서 살펴보려고 한다.
class Post { private long id; private long wirterId; private String writerName; } class Member { private long id; private String name; }
위 예시는 객체를 RDB 의 테이블에 맞춰 데이터 중심적으로 모델링 한 것이다.
테이블은 외래키를 통해 또 다른 테이블과의 연관관계를 가지는데 memberId 가 이 외래키에 해당하는 것이다.
하지만 이렇게 테이블에 맞춰 연관관계를 가지면 객체지향 프로그래밍에서의 객체를 제대로 활용할 수 없다.
즉, 객체 사이의 협력관계를 만들 수 없고 굉장히 부자연스러워진다.
또한 해당 객체를 테이블에 저장하고 읽어오는 과정에서 여러가지 단점들이 있는데 예시코드로 살펴보자.
@RequiredArgsConstructor public class Jpa { private final EntityManagerFactory emf; public void save() { EntityManager em = emf.createEntityManager(); EntityTransaction tx = em.getTransaction(); tx.begin(); try { Member member = new Member(); member.setName("memberA"); em.persist(member); Post post = new Post(); post.setWriterId(member.getId()); post.setWriterName(member.getName()); em.persist(post); tx.commit(); } catch(Exception e) { tx.rollback(); } finally { em.close(); } } public void find(Long postId) { EntityManager em = emf.createEntityManager(); EntityTransaction tx = em.getTransaction(); tx.begin(); try { // 불필요한 과정이 굉장히 많이 포함된다. Post findPost = em.find(Post.class, postId); Long findMemberId = findPost.getWriterId(); Member findMember = em.find(Member.class, findMemberId); tx.commit(); } catch(Exception e) { tx.rollback(); } finally { em.close(); } } }
특정 post 가 속한 member 정보를 조회하기 위해서는 Post 를 조회한 뒤 외래키로 가지고 있는 memberId 를 통해서 Member 를 조회하는 과정을 계속해서 반복해야 한다.
즉, Member, Post 를 조회하는 2개의 쿼리를 따로 작성해야 한다.
물론 이 과정을 하면되지 않나? 라고 생각할 수도 있다. 하지만 연관관계가 많아진다면 어떻게 될지 생각해보자.
단방향 매핑
단방향 연관관계에서 객체와 데이터베이스의 테이블이 각각 어떻게 연관관계를 가지는지 살펴보자
- 객체 : 참조를 통해 연관된 객체를 찾는다. A가 B를 참조할 때 B -> A 는 불가능하다.
- 테이블 : 외래키로 Join 해 연관된 테이블을 조회한다. 양방향으로 A -> B, B -> A 모두 가능하다.
이러한 차이가 있고 이러한 차이를 극복하기 위해 매핑이 필요한 것이다. 위에서 설명한 데이터 중심의 모델링은 이러한 차이를 극복하기 위해 객체를 데이터에 맞춘 것이다.
위 그림과 같이 연관관계를 맺는 것을 객체 중심의 모델링이라고 한다. 코드를 살펴보자.@Entity public class Post { @Id @GeneratedValue private Long id; @ManyToOne @JoniColumn(name = "member_id") private Member member; }
굉장히 자연스러운 연관관계를 가지는 것을 확인할 수 있다.
그럼 단순히 이렇게만 쓰면 되는걸까?
아니다. 이러한 객체는 테이블과 연관관계를 맺는 구조가 다르다.
그렇기 때문에 데이터를 테이블에 삽입하고 읽어올 때 다음과 같은 불필요한 과정들을 거쳐야한다.
public Post findPost(Long postId) { // SELECT * FROM posts WHERE postId = :postId; Post post = new Post(); // 쿼리 결과로 객체 생성 // SELECT * FROM members WHERE memberId = :memberId; // 위에서 쿼리한 post 결과에는 외래키인 memberId 가 존재한다. Member member = new Member(); post.setMember(member); return Post; }
이러한 코드를 개발자가 매번 작성해줘야 하는 것이다.
또한 post.getMember() 를 믿고 사용하려면 이렇게 제대로 매핑이 되어있는지 확인하는 단계를 거쳐야 한다.
서비스 계층과 데이터 액세스 계층이 명확하게 분리가 안되는 것이다.
이러한 매핑을 ORM 이 해주겠다는 것이다. EntityManager 를 이용한 코드를 살펴보자.
@RequiredArgsConstructor public class Jpa { private final EntityManagerFactory emf; public void save() { EntityManager em = emf.createEntityManager(); EntityTransaction tx = em.getTransaction(); tx.begin(); try { Member member = new Member(); member.setName("memberA"); em.persist(member); Post post = new Post(); post.setMember(member); em.persist(post); tx.commit(); } catch(Exception e) { tx.rollback(); } finally { em.close(); } } public void find(Long postId) { EntityManager em = emf.createEntityManager(); EntityTransaction tx = em.getTransaction(); tx.begin(); try { // findPost 를 가져올 때 연관관계를 가지는 Member 까지 가져와서 객체 참조형태로 매핑까지 해준다. Post findPost = em.find(Post.class, postId); Member findMember = findPost.getMember(); tx.commit(); } catch(Exception e) { tx.rollback(); } finally { em.close(); } } }
여기서 잠시 N+1 문제에 대해서 살펴보고 넘어가자.
단방향 연관관계를 사용한다면 아주 흔하게 발생하는 문제이고 인식하지 않고 있다면 발생하는지 알지 못하고 넘어가버리게 된다.
개인이 공부 목적으로 프로젝트를 진행할 때 N+1 문제가 성능상의 이슈로 식별되기 쉽지 않기 때문이다.
N + 1 문제
N + 1 문제는 JPA 를 사용한다면 굉장히 흔하게 접할 수 있는 문제이기 때문에 더욱 중요하다.
하지만 트래픽이 많지 않은 프로젝트에서는 N + 1 문제를 체감하기 어렵기 때문에 크게 의식하지 않을 수 있다는 문제가 있다.
그럼 N + 1 문제란 무엇일까?
@Entity public class Post { @Id @GeneratedValue private Long id; } @Entity public class Member { @Id @GeneratedValue private Long id; private String name; @OneToMany @JoniColumn(name = "post_id") private List<Post> posts; }
위와 같은 단방향 연관관계를 가지는 두 엔티티가 있다. 현재 MemberA 라는 팀에는 총 12개의 Post 가 포함되어 있다고 했을 때 MemberA 를 find() 하면 무슨일이 벌어질까?
SELECT * FROM Member WHERE memberId = 1; // MemberA 의 memberId 는 1 이라고 가정한다. SELECT * FROM Post WHERE postId = "post1"; SELECT * FROM Post WHERE postId = "post2"; SELECT * FROM Post WHERE postId = "post3"; SELECT * FROM Post WHERE postId = "post4"; SELECT * FROM Post WHERE postId = "post5"; SELECT * FROM Post WHERE postId = "post6"; SELECT * FROM Post WHERE postId = "post7"; SELECT * FROM Post WHERE postId = "post8"; SELECT * FROM Post WHERE postId = "post9"; SELECT * FROM Post WHERE postId = "post10"; SELECT * FROM Post WHERE postId = "post11"; SELECT * FROM Post WHERE postId = "post12";
위와 같은 쿼리가 발생하게 된다.
조회 대상이었던 Member 를 조회하기 위한 한 번의 쿼리와 해당 팀과 연관관계를 맺고 있는 Post 들을 조회하기 위한 N 번의 쿼리(총 N + 1 번의 쿼리)가 발생한다.
만약 하나의 Member 에 속한 Post 가 10만개라면 무슨일이 벌어질까?
한 번의 요청에 10만 1번의 SQL 쿼리가 발생할 것이다.
그러면 이런 요청이 10만번 발생하면 무슨일이 벌어질까?
셀 수 없을만큼 많은 수의 SQL 쿼리가 발생할 것이다.
정리하자면 N + 1 문제가 발생할 때 트래픽이 증가함에 따라 SQL 쿼리의 수는 기하 급수적으로 늘어나게 된다.
이는 서비스에 심각한 성능저하를 유발할 수 있고 따라서 반드시 해결해야하는 문제다.
이러한 N + 1 문제를 해결하기 위한 방법으로는 Fetch Join, @EntityGraph 가 있는데 이는 별도의 포스팅을 통해서 살펴볼 예정이다.
양방향 연관관계
사실 객체에서는 양방향 연관관계라는건 없다.
그냥 두 객체가 서로 참조하는 단방향 연관관계가 2개인 것을 양방향 연관관계라고 하는 것이다.
데이터베이스의 테이블은 애초에 단방향 연관관계가 없었고 양방향 연관관계만 존재했다.
외래키를 통해 Join 하고 그 결과를 통해 A->B, B->A 모두 가능하기 때문이다.
객체에서 양방향 연관관계가 없고 단방향 연관관계 2개를 양방향 연관관계라고 말하기 때문에 데이터베이스 테이블과의 차이점이 생기게되고 이러한 차이점을 채우는 것이 양방향 연관관계 매핑이다.
Member 에서도 Post에 가고 싶은 경우를 말한다.
눈여겨 봐야하는 것은 RDB 쪽에는 단방향 매핑과 달라지는 것이 없다는 것이다.
위와 같이 객체가 서로를 참조하기 위해 단방향 연관관계를 가지는 것을 양방향 연관관계라 말한다.
객체에는 두 개의 단방향 연관관계가 있고 테이블에는 하나의 양방향 연관관계가 있다.
그럼 Post 객체의 member 를 수정해도 외래키인 MEMBER_ID 가 수정되어야 하고, Member 의 posts 를 수정해도 외래키인 MEMBER_ID 가 수정되어야 한다.
다시 말해 POST 테이블의 외래키 MEMBER_ID 는 두 개의 연관관계 매핑에 엮여있다.
이러한 문제를 해결하기 위해 양방향 연관관계 매핑에서는 해당 연관관계의 주인을 결정해야 한다.
즉, 객체의 두 개의 단방향 연관관계 중에서 외래키 MEMBER_ID 에 영향을 줄 하나의 단방향 연관관계를 지정해줘야 한다는 것이다.
@Entity public class Post { @Id @GeneratedValue private Long id; @ManyToOne @JoinColumn("member_id") private Member member; } @Entity public class Member { @Id @GeneratedValue private Long id; private String name; @OneToMany(mappedBy = "member") List<Post> posts = new ArrayList<>(); }
여기서 중요하게 봐야할 것은 @OneToMany 어노테이션에 mappedBy 값이다.
이 값이 하는 역할이 두 개의 단바향 연관관계 중에서 연관관계 매핑의 주인을 정하는 것이다.
위에서 @OneToMany(mappedBy = "member") 의 의미는 member 라는 이름을 가진 변수에 매핑한다는 것이다.
따라서 연관관계 매핑의 주인은 member 를 가지고 있는 Post 클래스가 된다.
이렇게 연관관계 매핑의 주인이 정해지면 다음과 같은 규칙이 생긴다.
- 연관관계 매핑의 주인만이 외래키를 관리한다. (등록 & 수정)
- 주인이 아닌 쪽에서는 오직 읽기만 가능하다.
- 주인은 mappedBy 속성을 사용하지 않는다.
- 주인이 아닌 쪽에서는 mappedBy 속성을 통해 주인을 지정해줘야 한다.
이런 규칙이 필요한 이유는 "난 posts 를 수정했는데 왜 테이블에 반영이 안되지" 와 같은 문제가 발생할 수 있기 때문이다.
테이블에 반영하려면 반드시 연관관계 매핑의 주인에 값을 변경해줘야 한다.
그럼 여기서 이러한 의문이 들 수 있다.
그래서 두 객체중 어떤걸로 연관관계 매핑의 주인을 지정해야되는데?
그 답은, 외래키를 가지고 있는 테이블을 연관관계 매핑의 주인으로 설정하면 된다. 물론 정해진 것은 아니다.
하지만 이렇게 하지 않으면 Member 객체의 posts 를 수정했을 때 POST 테이블이 수정되는 불일치 문제가 발생할 수 있다.
이러한 혼동을 방지하기 위해 외래키를 가지고 있는 테이블에 해당하는 객체를 연관관계 매핑의 주인으로 설정하는 것을 권장한다.
양방향 연관관계에서 주의점
양방향 연관관계에서 주의해야 할 점은
연관관계 매핑의 주인에 값을 입력하지 않으면 데이터베이스 테이블에 데이터가 반영되지 않는다는 점이다.
Post post = new Post(); em.persist(post); Member member = new Member(); member.setName("member1"); member.getPosts().add(post); em.persist(member);
이렇게 했을 때 외래키에는 null 값이 저장되는 문제가 생긴다.
연관관계 매핑의 주인이 Post 인데 post.setMember() 메소드를 통해 Member 를 지정해주지 않았기 때문이다.
이러한 문제를 해결하기 위해 연관관계 매핑의 주인만 수정해도 되지만,
순수한 객체 참조관계를 고려한다면 그냥 양쪽 다 입력해주는게 바람직하다.
더 나아가서 Member 를 셋팅하는 메소드를 만들어서 처리하면 더 깔끔하다.
public void changeMember(Member member) { this.member = member; member.getPosts().add(this); }
또 한가지 주의해야할 점은 순환참조문제가 발생할 수 있다는 것이다.
우리가 굉장히 흔하게 사용하는 toString(), lombok, JSON 생성 라이브러리를 쓰게되면 순환참조문제가 발생할 수 있다.
/* 회원(Member) 엔티티*/ @Entity public class Member { @Override public String toString() { return "Member{" + "id=" + id + ", name='" + name + '\'' + ", posts=" + posts + '}'; } } /* 팀(Post) 엔티티 */ @Entity public class Post{ @Override public String toString() { return "Post{" + "id=" + id + ", member=" + member + '}'; } }
그럼 이러한 양방향 연관관계가 필요한 이유는 무엇일까?
사실 데이터베이스 테이블에서의 연관관계는 객체에서의 단방향 연관관계나, 양방향 연관관계나 변화가 없기 때문에 단방향 연관관계로도 충분하다.
그러면 양방향 연관관계가 필요한 이유에는 어떤 것들이 있을까?
가장 쉽게 생각할 수 있는 이유는 "반대 방향으로의 참조가 가능하기 때문(반대 방향으로의 객체 그래프 탐색 기능 추가)" 이다.
반대 방향으로의 참조가 필요한 경우는 단순히 비즈니스 로직에서 필요한 경우도 있을 것이다.
하지만 JPQL 에서 역방향 참조가 필요한 경우가 많다는 점이 중요하다.
기본적으로 단방향 연관관계를 사용하고 필요에 따라서 양방향 연관관계를 사용하는 것은 옳다.
하지만, 단방향 연관관계가 양방향 연관관계보다 좋다고 말하는 것은 잘못된 말이다.
일대다(1:N) 단방향 연관관게 매핑에서 영속성 전이(@OneToMany(cascade = CascadeType.ALL)) 를 통한 Insert 시에 외래키 지정을 위한 추가적인 Update 쿼리가 발생할 수 있다.
이 경우에는 오히려 양방향 연관관계로 변경함으로써 이러한 불필요한 Update 쿼리를 없앨 수 있다.
@Transactional public void createMemberWithPosts() { Member member = new Member("memberA"); Post post1 = new Post(); Post post2 = new Post(); member.getPosts().add(post1); member.getPosts().add(post2); Member savedMember = memberRepository.save(member); }
이렇게 하게 되면 하나의 트랜잭션으로 묶여있기 때문에 쓰기지연으로 인해서 최종적으로 총 3번의 Insert 쿼리가 발생할 것이라고 예상하게 된다.
하지만, 실제로 발생하는 쿼리는 다음과 같다.
Hibernate: insert into member (name, id) values (?, ?) Hibernate: insert into post (id) values (?) Hibernate: insert into post (id) values (?) Hibernate: update post set member_id=? where id=? Hibernate: update post set member_id=? where id=?
즉 왜래키 지정을 위해 추가적인 Update 쿼리가 발생하게 된다.
만약 Insert 가 10만번 발생한다면 10만번의 Update 쿼리가 추가적으로 발생하게 된다.
따라서 이러한 경우에 대해서는 양방향 연관관계가 필요한 경우가 있다.
(일반적인 상황에서는 단방향 연관관계를 사용하는 것이 좋다)
'Spring' 카테고리의 다른 글
@AllArgsConstructor 과 record 클래스의 차이 (1) 2024.11.15 ThreadLocal이란? (1) 2024.04.28 연관관계 매핑 (1) 2024.01.22 Spring Data JPA란? (2) 2024.01.09 Spring JPA란? (1) 2024.01.09 댓글