스프링 부트/Java

[JPA] 양방향 매핑을 지양해야 하는 이유

주인 완 2024. 11. 19. 20:06

관습적으로 개발한 결과물

무심코 스프링 부트와 JPA를 통해 개발을 하다 보면, 편리함에 녹아들어 테이블 간 연관관계를 막무가내로 설정하고는 한다.

당장 본인도 그랬다..

양방향 매핑이 되어 있는 두 엔티티의 모습

주로 양방향 매핑을 통해 개발하는 이유는 다음과 같다.

  • 개발의 편의성을 증진시키기 위해
  • 비즈니스 로직을 작성할 때, 양쪽 엔티티에서 원하는 객체를 쉽게 가져올 수 있어서

물론 양방향 매핑을 통해, 개발하는 데 있어서 정말 많은 이점을 가져갈 수 있다고 생각했다. 특정 객체와 연관된 객체들을 손쉽게 가져온다거나, 단순 조회를 위해 여러 객체들을 가져올 때 덕분에 시간을 정말 아낄 수 있었다.

 

그러면 왜 그러한 이점을 버리면서까지 양방향 매핑을 지향한 걸까?

 

양방향 매핑이 가져다주는 문제점

순환 참조

만약 두 엔티티가 서로를 참조하게 될 경우, JSON 직렬화 과정에서 무한 순환 참조가 발생할 수 있다. 스프링 부트는 JSON 직렬화 과정에서 Jackson 라이브러리를 사용한다. 해당 라이브러리는 엔티티의 `get()` 메서드를 통해 연관된 엔티티를 불러와 JSON으로 직렬화한다.

위의 코드를 예시로 들자면, 불러와진(Menu) 엔티티에 불러온(Pub) 엔티티가 Menu 엔티티를 다시 참조하기 때문에, Pub -> Menu -> Pub -> Menu... 이렇게 순환 참조가 발생하게 되고 결국 StackOverFlowError가 발생한다.

무한한 참조가 발생하는 모습

이러한 문제를 @JsonIgnore 등의 어노테이션으로 해결할 수 있지만, 이는 완전한 해결책이 아닐뿐더러.. 내가 지향하는 해결 방안과는 달랐다. 왜냐면 본인은 양방향 매핑 자체를 지양하고 싶었기 때문이다.

 

복잡한 엔티티 상태 관리

양방향 매핑을 사용하면, 연관관계를 양쪽에서 관리해줘야 한다는 불편함이 존재한다. 한 엔티티를 수정하면, 참조하고 있는 다른 엔티티도 수정해주어야 한다는 뜻이다. 해당 과정이 이루어지지 않으면, 데이터 간 불일치 문제가 발생할 수도 있다!

 

예시를 들어보자. 만약 Pub에서 List<Menu>로 관리하고 있는 특정 Menu 엔티티를 수정한 경우, 우리는 Menu 엔티티만 수정하는 것이 아닌, List<Menu> 필드도 수정해주어야 한다.

메뉴 엔티티도 수정하고, Pub 엔티티도 수정해주어야 한다.

 

@OneToMany

불필요한 쿼리 동반

우리가 양방향 매핑을 사용하게 되면, 필연적으로 특정 엔티티에는 1:N 연관관계를 나타내주는 해당 어노테이션을 사용해야 한다. 그리고 해당 어노테이션은 연관관계의 주인을 '1'에 해당되는 엔티티로 설정한 것이다.

 

'1'에 해당되는 엔티티를 수정 또는 저장할 때는 문제가 발생하지 않는다. 문제는 'N'에 해당되는 엔티티를 수정 또는 저장할 때 발생한다.

예시 시나리오

이제 Pub의 List<Menu>를 업데이트하는 과정에서 Menu 엔티티에 추가적인 업데이트 쿼리가 진행되는데, 이유는 연관관계의 주인이 Menu에 있기에, Pub 엔티티가 Menu 엔티티를 수정할 수 없기 때문이다.. 따라서 불필요한 쿼리가 추가적으로 발생하게 되고, 이는 다뤄야 하는 엔티티가 늘어날수록 성능 저하를 동반할 수도 있다.

 

N + 1 문제

이는 실제로 본인이 겪었던 문제이기도 했는데, Pub 엔티티에 매핑되어 있는 Menu 엔티티들을 조회할 때 N+1 쿼리 문제가 발생한다는 것이었다.

 

모든 주점들의 메뉴를 가져오는 로직이 있다고 가정하면,

  • 모든 Pub을 조회할 때 쿼리가 한 번 (1)
  • 각 Pub에 매핑되어 있는 메뉴들을 조회 (N)

총 N + 1번의 쿼리가 발생한다는 문제가 존재한다. 사용자 또는 엔티티가 많지 않으면 성능적으로 문제가 없겠지만, 실사용자 또는 엔티티의 개수가 증가하여 쿼리가 중첩되어서 DB에 부하가 발생하는 상황을 보고 싶지는 않았다.

 

따라서 N + 1, 엔티티의 수동 관리, 그리고 추가적인 쿼리 등을 고려하였을 때, 해당 방식의 연관관계는 코드를 유지보수하는 데 있어서 도움이 되지 않을 것이라고 생각하였다.

 

해결: 단방향 매핑

위의 모든 양방향 매핑을 제거하고, 동시에 모든 연관관계의 주인을 'N' 쪽 엔티티로 변경해 주었다.

Pub 엔티티에서는 Menu 엔티티에 관련된 내용을 전부 삭제해주었다.

 

이로 인해 주점의 메뉴들을 가져올 때, 객체를 참조해서 가져오는 방식에서 쿼리문을 통해 조회하는 방식으로 변경해 주었다.

 

마치며

처음 서비스를 기획하면서 DB를 설계할 때 관습처럼 특정 엔티티에 소속되어 있다고 생각하는 값들을 컬렉션 타입으로 다루려고 생각했다. 이는 로직을 작성하는 데 있어서 간편함을 가져다줄 수 있지만, 동시에 유지보수를 어렵게 만드는 단점을 지니기도 한다는 것을 깨달았다.

 

따라서 앞으로는 꼭 필요한 경우가 아니면 양방향 및 @OneToMany 방식의 연관관계 설정을 지향하는 방향으로 개발을 진행하고자 한다.

 

참고: 자바 ORM 표준 JPA 프로그래밍(김영한)