일대다 단방향 매핑은 쓰지 말자?
영한님의 JPA 기본편 강의를 보면 일대다 단방향 매핑을 지양해야 한다는 내용이 나온다. 자바 ORM 표준 JPA 프로그래밍 책에도 다음의 내용이 적혀 있다.
일대다 단방향 매핑을 사용하면 엔티티를 매핑한 테이블이 아닌 다른 테이블의 외래 키를 관리해야 한다. 이것은 성능 문제도 있지만 관리도 부담스럽다. … 다대일 양방향 매핑은 관리해야 하는 외래 키가 본인 테이블에 있다. … 상황에 따라 다르겠지만 일대다 단방향 매핑보다는 다대일 양방향 매핑을 권장한다.
양방향 참조는 객체지향적으로 손해
하지만 흔히 알고 있듯이 순환 참조, 양방향 참조는 객체지향 세계에서는 지양하는 편이다. 의존 관계나 연관 관계를 단방향으로 흐르게 하여 A가 변할 때 B는 영향을 받더라도 B가 변할 때조차 A가 영향을 받게 하는 건 유지보수적인 관점으로 손해라는 것이다.
서로를 사용한다면 또 모를까 오로지 일대다 단방향의 단점을 해소하기 위해 양방향을 맺는 경우도 있다.
아래의 Menu 객체와 MenuProduct 객체 예시를 보자.
@Entity
public class MenuProduct {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long seq;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "menu_id", nullable = false)
private Menu menu;
}
@Entity
public class Menu {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "menu", {CascadeType.PERSIST, CascadeType.REMOVE})
private List<MenuProduct> menuProducts = new ArrayList<>();
}
두 엔티티는 양방향으로 참조 관계를 가지고 있다. 실제 두 엔티티가 가지고 있는 메서드는 생략하였다.
비즈니스 규칙 상 Menu가 MenuProduct를 사용해 검증 등의 로직을 가지고 있다. 그리고 Menu 없는 MenuProduct는 있을 수 없고, MenuProduct는 Menu와 같이 생성되어 저장된다는 특성을 가지고 있어서 CascadeType.PERSIST와 CascadeType.REMOVE를 걸어준 상태이다.
하지만 MenuProduct는 Menu로 뭔가를 하는 일이 없다. 양방향 참조를 맺어주기 위한 메서드만을 가지고 있을 뿐이다.
순환 참조라는 문제도 있지만 MenuProduct만을 테스트하고 싶을 때 Menu도 항상 필요하다는 문제도 생긴다.
그리고 Menu를 저장하고 MenuProduct를 저장할 때 한 번에 insert 쿼리를 보내는 것이 아니라 update 쿼리를 추가로 보내는 문제도 있다. 여기서 오는 패러다임 불일치 때문에 복잡한 실무에서는 문제가 될 수 있다고도 한다. 왜 Menu 엔티티를 건드렸는데 menu_product 테이블에서 update 쿼리가 나가지?
다대일 양방향에서의 저장 쿼리
일단 update 쿼리가 나가는 것에 대해 얘기해보고자 한다.
도메인에 대해 간단히 설명하면 MenuGroup 안에 여러 Menu들이 존재한다. Menu에는 어떤 Product가 몇 개씩 필요한지에 대한 정보가 필요하므로 Product가 몇 개인지 나타내는 MenuProduct가 n개 필요하다.
위에 봤던 코드처럼 양방향일 경우 아래 사진처럼 쿼리가 나간다.
menu를 insert한 뒤 cascade = CascadeType.PERSIST에 의해 menu_product 2개가 바로 각각 insert 된다. (fk도 바로 넣어준다.)
일대다 단방향일 때
그럼 문제의 일대다 단방향 매핑을 해놓고 쿼리를 살펴보자.
@Entity
public class MenuProduct {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long seq;
}
@Entity
public class Menu {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
@JoinColumn(name = "menu_id")
private List<MenuProduct> menuProducts = new ArrayList<>();
흔히 알고 있듯이 처음에는 menu_product를 insert 하는데 fk(menu_id)는 넣어주지 않는다. 그리고 후에 fk를 update 쿼리를 통해 넣어주고 있다.
여기서 만약 menu_product 테이블의 menu_id가 not null이면 어떻게 될까? 실제로도 메뉴 없이 메뉴 상품이 존재할 순 없을 테니 menu_product는 not null일 것이다.
not null일 경우에는 아래와 같이 fk를 null로 menu_product를 저장하려다 DataIntegrityViolationException 예외를 맞고 만다. 제약 조건을 위반했으니 당연한 결과다. fk가 not null인 경우에는 일대다 단방향을 쓸 수조차 없다는 것일까?
org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint [null]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement
nullable = false
@JoinColumn의 속성으로 nullable=false로 주면 어떻게 될까? 원래 코드에서는 JPA가 menu_id가 not null이라는 것을 모르는 상태였다. 테이블의 ddl에 맞춰 코드를 추가해 보았다.
@OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
@JoinColumn(name = "menu_id", nullable = false)
private List<MenuProduct> menuProducts = new ArrayList<>();
원래 쿼리와 똑같은 거 아니냐고 생각할 수 있지만 잘 살펴보면 다르다. menu_product의 menu_id가 not null임에도 예외가 발생하지 않았다. 자세히 보면 처음에 insert 할 때 menu_id도 같이 넣어주고 있는 것을 확인할 수 있다.
그리고 후에 update 쿼리도 보내는데 이미 fk가 저장된 상태라서 쓸데없는 쿼리가 되어버린 듯하다.
nullable=false + updatable=false
@JoinColumn의 속성으로는 updatable=false라는 속성도 있다. 해당 컬럼의 update를 막는 속성인데 추가해 보면 어떻게 될지 궁금했다. 굳이 안 보내도 되는 update 쿼리를 막을 수 있을 것 같았다.
@OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
@JoinColumn(name = "menu_id", nullable = false, updatable = false)
private List<MenuProduct> menuProducts = new ArrayList<>();
결과는 성공이었다. 깔끔하게 insert 쿼리만 나간 것을 확인할 수 있었다.
부모 자식 관계를 바꿀 수 없는 문제
그런데 nullable=false로 하는 순간 자바 컬렉션에서의 제거가 DB 쿼리로 이어지지 않는다. 즉 자바 객체와 DB 테이블 관계 간의 불일치가 발생하게 된다.
MenuProduct 메뉴_후라이드 = 후라이드와_양념.getMenuProducts().get(0);
후라이드와_양념.getMenuProducts().remove(메뉴_후라이드);
nullalbe=false와 updatable=false 중 하나라도 있어도 쿼리는 나가지 않았다. 컬렉션에서의 제거는 곧 menu_id(fk)에 null로 update 쿼리를 보내겠다는 말이다. update가 막혀 있고 설령 update를 실행시키더라도 null을 넣는 쿼리이기 때문에 nullable=false에 의해 막히는 듯하다.
둘 다 제거하면 아래처럼 객체 참조 제거가 update 쿼리로 이어진다. 단 not null에 의해 예외가 발생하지만 말이다.
정리
@OneToMany 단방향의 단점 중 하나는 부모, 자식을 한 번에 insert 할 때, fk=null인 채로 자식을 insert 하고 후에 update로 fk를 별도로 변경하는 데에 있었다.
일대다 단방향으로 하면서 nullable=false와 updatable=false 속성을 주면 update 없이 한 번에 insert 쿼리가 나가게 되어 위 단점은 해결할 수 있다.
하지만 부모에게서 자식을 떼어 놓을 수 없고 자식을 다른 부모에게 옮기는 것도 동작하지 않게 된다.
비즈니스 로직 상 부모 자식 간의 관계가 끊어지지 않고 라이프 사이클이 완전히 동일한 경우에 nullable=false, + updatable=false + 일대다 단방향을 활용해서 양방향 참조를 끊을 수도 있을 듯하다.
위의 상황에서 update 쿼리가 추가로 나가는 단점은 해결할 수 있을 듯 하나 강의에서 영한님은 다음의 문제도 있다고 말씀해 주신다.
엔티티가 관리하는 외래 키가 다른 테이블에 있다는 것 자체가 어마어마한 단점입니다 제가 생각할 때…
복잡한 실무 상황에서 위 말씀처럼 얼마나 혼란을 줄 수 있을지는 아직 겪어보지 않아서 모르겠다. 그래도 사이드 프로젝트를 할 때 실제 Menu와 MenuProduct와 같은 관계를 만나게 된다면 지금까지처럼 일대다 단방향을 무조건 피하자는 생각보단 한 번 더 생각해 볼 듯하다.
추가) 영한님과의 소통
참고
자바 ORM 표준 JPA 프로그래밍 기본편
자바 ORM 표준 JPA 프로그래밍