@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String name;
protectedChild(){ }
publicChild(String name){ this.name = name; } }
위와 같이 작성하면 조인테이블인 parent_children라는 테이블이 새로 생긴다. 뭐 테이블 하나 생기면 어때.. 큰일 나겠어? 라고 생각할 수도 있지만, children이 많지 않을 때만 큰 일이 안 나고, 많으면 제법 큰 일이 난다.
시나리오
위와 같이 매핑된 상태에서 다음과 같은 간단한 시나리오를 생각해보자.
parent가 10개의 Child를 포함하는 children을 가진다.
parent.children에서 Child의 id가 1, 2인 것 2개만 삭제한다.
1번은 뭐 처음 생성이니 parent 1개에 대해 parent 테이블에 insert 1회, children 10개에 대해 child 테이블에 insert 10회 실행된다. 그리고 조인테이블 방식으로 동작하므로 parent_children 테이블에도 insert 10회 실행된다.
2번에서 children 중에서 2개를 지우므로 parent_children 테이블에서 delete 2회 실행되고, orphanRemoval = true로 설정되어 있으므로 child 테이블에서 delete 2회 실행될 것이다.
insertintoparent (name) values (?) binding parameter [1] as [VARCHAR] - [parent1] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child1] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child2] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child3] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child4] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child5] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child6] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child7] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child8] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child9] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child10] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [1] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [2] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [3] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [4] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [5] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [6] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [7] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [8] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [9] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [10] ***************************** deletefrom parent_children where parent_id=? <== 헉!! 형이 왜 여기서 나와!! binding parameter [1] as [BIGINT] - [1] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [3] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [4] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [5] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [6] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [7] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [8] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [9] insertinto parent_children (parent_id, children_id) values (?, ?) binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [10] deletefromchildwhereid=? binding parameter [1] as [BIGINT] - [1] deletefromchildwhereid=? binding parameter [1] as [BIGINT] - [2]
앞에서 children의 갯수가 많지 않을 때만 큰 일이 안 생긴다고 한 이유가 여기에 있다. 위의 사례에서는 children이 10개 밖에 되지 않으므로 insert를 10개 쯤 한다고 해도 사실 거의 티가 나지 않는다. 하지만 10개가 아니라 1000개, 10000개 그 이상이라면? 고작 레코드 2개 삭제하려는 것 뿐인데 1000회, 10000회의 insert가 실행된다. ㄷㄷㄷ
그런데 왜 이렇게 동작하는 걸까?
나름의 사연
실행한 후 parent_children 테이블을 보면 다음과 같다.
parent_id
children_id
1
3
1
4
1
5
1
6
1
7
1
8
1
9
1
10
나: 뭐야, 1 | 1인 행이랑 1 | 2인 행 2개만 지울 수 있었을 것 같은데, 왜 parent_id가 1인 걸 몽땅 지워?
Hibernate: 허허.. 그게 말이야.. 허허.. 테이블로 보기엔 저런데.. 허허.. 일대다 단방향이잖아.. 허허.. 그래서.. 허허.. parent_id가 1이라는 것을 개별 행에 대한 조건으로 줄 수가 없어.. 허허.. 그래서 parent_id가 1인 걸 몽땅 지우고 다시 채웠어.. 허허..
나: 뭐래냐..
이것도 말보다 코드가 더 쉽고 명확한 케이스다. id가 1, 2인 child를 삭제하는 코드는 다음과 같다.
위에 여기로 표시한 부분에서 parent_id에 대한 조건을 줄 수가 없다. 왜냐고? 위에 Hibernate가 얘기해 준대로 일대일 단방향이라서 child는 parent를 모른다. 따라서 parent_id를 children의 개별 행에 대한 삭제 조건으로 지정할 수가 없다.
대신에 dbParent.getChildren()의 dbParent에는 parent_id가 1이라는 정보가 있다. 그래서 children를 개별 행 단위로 삭제할 수는 없지만 parent_children 테이블에서는 parent_id가 1인 행을 모두 삭제할 수는 있다. 그래서 parent_id가 1인 레코드를 모두 delete 한 후에 다시 insert를 반복하는 노가다를 한 것이다.
결국 Hibernate는 주어진 환경에서 최선을 다한 셈이고 아무 죄가 없다. 모두 delete 후 다시 모두 insert 반복으로만 해결할 수 있게 코드를 짠 사람이 잘못이다.
해결
이제 문제를 바로잡아보자. 조인테이블 방식의 일대다 단방향 매핑때문에 children 쪽에서 행 단위로 parent_id를 알 수 없다는 게 원인이었으므로, 어떻게든 children 쪽에서 행 단위로 parent_id를 알 수 있게 해주면 된다. 즉 테이블 상에서 children 쪽에 parent_id 컬럼이 추가되도록 매핑하면 된다.
방법은 두 가지가 있다. 조인테이블이 아닌 조인컬럼 방식의 일대다 단방향 매핑과 일대다 양방향 매핑이다.
먼저 조인컬럼 방식의 일대다 단방향 매핑부터 알아보자.
조인컬럼 방식의 일대다 단방향 매핑
이 방식은 Parent에 단 한 줄의 코드만 추가하면 된다. 물론 예제 코드에서만..
1 2 3
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "parent_id") // <-- 여기!! private List<Child> children = new ArrayList<>();
위와 같이 Parent 엔티티에 @JoinColumn(name = "parent_id")만 추가해주면 된다.
이제 조인테이블 방식이 아니므로 parent_children 테이블은 필요 없고, child 테이블에 parent_id 컬럼이 추가되고, child 테이블의 행 단위로 parent_id를 알 수 있으므로 몽창 delete 후 몽창 insert 하는 노가다는 발생하지 않고 id가 1, 2인 child만 삭제할 수 있을 것이다.
insertintoparent (name) values (?) binding parameter [1] as [VARCHAR] - [parent1] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child1] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child2] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child3] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child4] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child5] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child6] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child7] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child8] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child9] insertintochild (name) values (?) binding parameter [1] as [VARCHAR] - [child10] updatechildset parent_id=? whereid=? binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [1] updatechildset parent_id=? whereid=? binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [2] updatechildset parent_id=? whereid=? binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [3] updatechildset parent_id=? whereid=? binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [4] updatechildset parent_id=? whereid=? binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [5] updatechildset parent_id=? whereid=? binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [6] updatechildset parent_id=? whereid=? binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [7] updatechildset parent_id=? whereid=? binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [8] updatechildset parent_id=? whereid=? binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [9] updatechildset parent_id=? whereid=? binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [10] ***************************** updatechildset parent_id=nullwhere parent_id=? andid=? binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [1] updatechildset parent_id=nullwhere parent_id=? andid=? binding parameter [1] as [BIGINT] - [1] binding parameter [2] as [BIGINT] - [2] deletefromchildwhereid=? binding parameter [1] as [BIGINT] - [1] deletefromchildwhereid=? binding parameter [1] as [BIGINT] - [2]
오 역시나 ***** 아래에 10번의 불필요한 insert 가 모두 사라지고 맨 아래 delete 2회만 실행된 것을 확인할 수 있다.
그런데 ***** 바로 위에 10번의 update는 또 왜 실행된거지?
이유는 이번에도 단방향이기 때문이다. 조인컬럼 방식으로 변환하면서 child 테이블에 parent_id 컬럼이 추가되기는 했지만, 단방향이라서 child는 parent의 존재를 모르므로 parent_id의 값을 알 수는 없다. 뭐랄까 냉장고는 사놨는데 뭘로 채워야할지 모르는..
그래서 개별 행 단위로는 parent_id 컬럼에 값이 없는 채로 insert 되고, insert 된 10개의 행의 parent_id 컬럼에는 dbParent.getChildren()에서 알아낼 수 있는 parent_id 값을 update 를 통해 설정한다. 하지만 이건 최초에 데이터가 세팅될 때 1회만 그런거고, 이렇게 parent_id 값이 저장된 후에는 삭제를 원하는 레코드만 삭제할 수 있게 되므로 조인테이블 방식의 문제는 해결했다고 볼 수 있다.
이제 ***** 아래에 실행된 쿼리를 살펴보자. update가 2회, delete가 2회 실행됐다. delete 2회만 실행되기를 예상했지만 update 2회가 먼저 실행됐다. 이 부분은 자세히 살펴볼 필요가 있다.
신동민 님의 도움으로 정확히 알게 되었는데, 일대다 조인컬럼 방식에서 children.remove(child)를 실행해서 children 쪽의 레코드 삭제를 시도하면 실제 쿼리는 delete가 아니라 해당 레코드의 parent_id에 null을 저장하는 update가 실행된다. 의도와 다르게 동작한 것 같아서 이상해보이지만, 일대다 단방향 매핑에서 children.remove(child)는 사실 child 자체를 삭제하라는 게 아니라 child가 parent의 children의 하나로 존재하는 관계를 remove 하라는 것이다. 따라서 child 자체를 delete 하는 게 아니라 parent_id에 null 값을 넣는 update를 실행하는 게 정확히 맞다. 이 부분의 코드도 신동민 님이 알려주셨는데 여기에서 확인할 수 있다.
결국 이번에도 Hibernate는 정확히 동작한다. 관계의 remove를 레코드의 delete로 넘겨짚은 사람이 문제지..
그럼 마지막에 실행된 2회의 delete는 뭘까? 이건 orphanRemoval = true로 설정되어 있기 때문에 2개의 child 자체를 delete 한 것이다.
그런데 사실 Hibernate가 어찌 동작하든 간에, 데이터 처리 관점에서 보자면 원했던 것은 2개의 레코드를 delete 하는 것이었는데, 2회의 update와 2회의 delete가 실행되는 것은 여전히 불필요한 작업이 추가된 것 같다. 하지만 이를 불필요한 오버헤드라고 부르는 것은 적합하지 않아 보인다.
RDB 관점에서 보면 테이블 사이의 관계는 언제나 양방향이지만 JPA의 엔티티 사이의 관계는 단방향과 양방향이 분명히 다르다. 따라서 RDB 관점에서야 이걸 오버헤드라고 부를 수도 있겠지만, 단방향으로 매핑된 JPA에서는 레코드의 delete가 아니라 관계의 remove로 동작하는 것이 정확하고 따라서 delete가 아니라 update로 실행되는 것이 맞으므로 불필요한 오버헤드라고 부르는 것은 적합하지 않다.
그래도 여전히 2회의 delete만으로 끝날 일을 2회의 update와 orphanRemoval을 동원해서 2회의 delete로 실행하는 것이 마음에 안 든다면, RDB 처럼 양방향으로 만들어 주면 JPA도 RDB 처럼 2회의 delete만으로 끝낸다. 그럼 이제 일대다 양방향 매핑을 살펴보자.
그 전에, 앞에서 조인커럼 방식으로의 전환을 단 한 줄로 적용가능 한 것은 예제 코드라서 가능하다고 했는데, 구체적으로 말하면 ***** 위에서 update로 값을 자동 세팅해주는 것도 예제 코드라서, spring.jpa.properties.hibernate.hbm2ddl.auto 옵션을 create 등 마음대로 줄 수 있기 때문에 가능한 것이고, 실 운영 환경에서는 저렇게 수행할 수 없다.
운영 환경에서는 child 테이블에 parent_id 컬럼도 직접 추가해줘야 하고 다음과 같이 update 쿼리를 만들어서 기존에 parent_children 테이블에 있던 값을 기준으로 child 테이블의 parent_id 컬럼에 수동으로 입력해줘야 한다.
1 2 3 4 5 6
updatechild a set a.parent_id = ( select b.parent_id from parent_children b where a.id = b.children_id )
일대다 양방향 매핑
앞에서 살펴본 것처럼 RDB와 똑같이 동작하기를 원한다면 JPA에서도 양방향으로 매핑을 해줘야 한다. 조인컬럼 방식으로 전환할 때보다는 조금 손이 더 가지만 작업량은 그리 많지 않다.
@Override publicvoidrun(String... args)throws Exception { Parent parent1 = new Parent("parent 1"); for (int i = 1 ; i <= 10 ; i++) { parent1.getChildren().add( new Child("child " + i, parent1) // 생성 시 parent1 추가 ); } Parent dbParent = this.parentRepository.saveAndFlush(parent1);
insertintoparent (name) values (?) binding parameter [1] as [VARCHAR] - [parent1] insertintochild (name, parent_id) values (?, ?) binding parameter [1] as [VARCHAR] - [child1] binding parameter [2] as [BIGINT] - [1] insertintochild (name, parent_id) values (?, ?) binding parameter [1] as [VARCHAR] - [child2] binding parameter [2] as [BIGINT] - [1] insertintochild (name, parent_id) values (?, ?) binding parameter [1] as [VARCHAR] - [child3] binding parameter [2] as [BIGINT] - [1] insertintochild (name, parent_id) values (?, ?) binding parameter [1] as [VARCHAR] - [child4] binding parameter [2] as [BIGINT] - [1] insertintochild (name, parent_id) values (?, ?) binding parameter [1] as [VARCHAR] - [child5] binding parameter [2] as [BIGINT] - [1] insertintochild (name, parent_id) values (?, ?) binding parameter [1] as [VARCHAR] - [child6] binding parameter [2] as [BIGINT] - [1] insertintochild (name, parent_id) values (?, ?) binding parameter [1] as [VARCHAR] - [child7] binding parameter [2] as [BIGINT] - [1] insertintochild (name, parent_id) values (?, ?) binding parameter [1] as [VARCHAR] - [child8] binding parameter [2] as [BIGINT] - [1] insertintochild (name, parent_id) values (?, ?) binding parameter [1] as [VARCHAR] - [child9] binding parameter [2] as [BIGINT] - [1] insertintochild (name, parent_id) values (?, ?) binding parameter [1] as [VARCHAR] - [child10] binding parameter [2] as [BIGINT] - [1] ***************************** deletefromchildwhereid=? binding parameter [1] as [BIGINT] - [1] deletefromchildwhereid=? binding parameter [1] as [BIGINT] - [2]
오! 처음에 원했던 그대로 delete 만 2회 실행될 뿐 아무런 오버헤드도 발생하지 않는다!
일대다 양방향 매핑과 일대다 단방향 조인컬럼 매핑의 결과로 나타나는 테이블 구조는 두 방식에서 모두 동일하다. 두 방식 모두 child에 parent_id FK 컬럼을 두게 된다.
일대다 양방향 매핑과 일대다 단방향 조인컬럼 매핑의 차이점은 다음과 같다.
조인컬럼 일대다 단방향 매핑은 child가 parent를 모르기 때문에, 앞에서 설명한 것처럼 1회성이긴 하지만 parent_id 값을 저장하기 위해 update 오버헤드가 발생한다.
일대다 양방향 매핑은 child가 parent를 알기 때문에 불필요한 오버헤드가 발생하지 않는다.
다만 일대다 양방향 매핑은 도메인 로직 상에서 parent를 몰라도 되는 child에게 굳이 parent를 강제로 알게 만드는 것이 단점인데, 이 단점은 parent에 대한 public getter 메서드를 만들지 않거나 또는 극단적으로 아예 parent에 대한 getter 메서드를 만들지 않는 방식으로 보완할 수 있다.
정리
일대다 단방향 매핑은 직관적으로는 단순해서 좋지만, 조인테이블 방식은 insert가, 조인컬럼 방식은 1회성이긴 하지만 update가 오버헤드로 작용한다.
따라서 1:N에서 N이 큰 상황에서는,
오버헤드가 없는 일대다 양방향 매핑을 사용하는 것이 가장 좋고,
그 다음은 일대다 단방향 조인컬럼 방식,
그리고 parent 쪽에 @OneToMany만 달랑 붙이는 일대다 단방향 조인테이블 방식은 사용하지 않는 것이 좋다.
더 축약하자면, 1:N에서 N이 클 때는 웬만하면 일대다 양방향 매핑을 사용하자.
부록 - 응용편
다음과 같이 하나의 Parent에서 2개의 Child에 대해 1:1, 1:N 연관관계 매핑이 필요하면 어떻게 할까?