당니의 개발자 스토리
다대다 [N:M] 본문
다대다 [N:M]
다대다 연관관계에 대해서 알아보겠습니다.
이전까지는 중요했는데 결론부터 말씀드리면 실무에서 전 이거를 쓰면 안 된다고 봐요. 맵핑이 있어서 그렇긴 한데, 암튼 왜 쓰면 안 되는지 설명드리는 거기 때문에 뭐 알고 안 써야지 모르면 이런거를 쓰고 싶단 말이에요.

우선 관계형 데이터베이스는 정규화된 테이블 두 개로 다대다 관계를 표현할 수가 없어요. 그래서 어떻게 야매로 하는 방법이 있긴 한데 아무튼 결론적으로는 안 돼요. 그래서 어떻게 하냐면, 연결 테이블이라는 걸 추가해서 연결 테이블, 조인 테이블 등등 이름이 여러 가지가 있는데 이거를 일대다-다대일 관계로 풀어내야 돼요.
뭐냐면 하나의 회원이 여러 개의 상품을 선택할 수 있거나 주문할 수 있거나 그래요.

그러면 아래 그림처럼 이렇게 만든단 말이에요. Member가 있고 상품이 있으면 DB는 이 테이블 두 개만으로는 다대다 라는 관계를 못 만들어내요. 하나의 회원이 여러개의 상품을 주문할 수 있고 선택할 수 있고, 반대로 상품 입장에서도 여러 명의 회원에 포함될 수가 있어요. 이런 관계는 이 두 테이블로는 안 돼요.

그래서 이렇게 중간 테이블이 나옵니다. 그래서 이 다대다 관계를 일대다, 다대일 관계로 풀어내야 돼요. MEMBER_PRODUCT 이런 연결 테이블을 만들어서 많이 풀어내죠.

자 객체도 마찬가지입니다. 그런데 객체는 컬렉션 두 개를 사용해서 다대다 관계를 만들 수 있어요. 사실 관계형 데이터베이스와 객체가 여기서 차이가 납니다.
이제 여기서 딜레마가 오는데 뭐냐면 Member는 Product를 List로 가질 수 있어요. 반대로 Product도 레퍼런스 컬렉션 넣어가지고 Member List를 가지면 돼요. 그래서 객체는 컬렉션을 사용해서 객체 두개로 다대다 관계가 된단 말이에요. 어쨌든 ORM 입장에서는 다대다가 객체는 되고 테이블은 안되면 뭔가 지원을 해줘야 되겠죠.

그래서 이제 다대다 맵핑을 하면 객체는 Member도 Product List를 가지고 Product도 Member List를 가질 수 있기 때문에 위의 객체 그림을 아래의 테이블에 일대다-다대일 그림으로 딱 맵핑을 해줍니다. 근데 테이블은 다대다가 안되니까 일대다-다대일이고 중간에 있는 연결 테이블, 조인 테이블을 집어넣어서 하는 맵핑을 해줍니다.

자 이제 @ManyToMany를 쓰면 되구요. @JoinTable로 연결 테이블을 지정할 수가 있습니다. 그리고 다대다도 단방향, 양방향이 됩니다.

그냥 결론부터 말씀드리면은 일단 해볼게요.

다대다 이 그림을 한번 보여드려야 되니까

Product를 만들겠습니다.

id와 name 만든 다음 Getter, Setter도 같이 만들었어요. 이제 Member랑 Product를 다대다 관계로 가져가고 싶단 말이에요. 그러면 이렇게 하면 됩니다.

자 @ManyToMany 로 하고 일단 단방향 관계로 먼저 말씀드릴게요. 그러면 이제 Member 입장에서만 그냥 적는다고 치면,

이렇게 List로 Product를 안에 넣고 new ArrayList<>를 하면 돼요.

그런데 여기서 이제 @JoinTable을 적어줘야 합니다. 그리고 테이블 명을 적어줘야 하는데,

MEMBER_PRODUCT 라고 적어주시면 됩니다. 중간 테이블을 넣어주는 거죠.

그러면 연결 테이블이 이제 PK, FK가 되는 구조로 해서 이거를 풀어냅니다.
자 이제 실행해 볼게요.

보시면, MEMBER_PRODUCT 라는 테이블이 생기고

Member_MEMBER_ID랑 products_id 라는 게 생기죠.

그 다음에 이걸 보시면, 외래키 제약 조건도 이렇게 두가지가 생깁니다.

그리고 딱 이름을 더 지정해 줄 수 있어요. 디테일하게 더 해가지고 이제 뭐 쭉쭉 잡아주면 돼요. 아무튼 이건 잘 안쓰니까

이런 그림을 만들 수가 있고, 그래서 MEMBER_ID랑 PRODUCT_ID를 다 맵핑 할 수 있어요. 아무튼 이렇게 해서 하시면 되고 지금 단방향인데 만약에 양방향을 만들고 싶으면,

이제 반대로 Product 들어가서 members를 만들어줍니다.

그리고 또 이렇게 mappedBy를 해줘야겠죠. @OneToOne 이랑 똑같은데 중간에 테이블이 있다는 것에서 차이점이 있습니다.

자 그런데 이 다대다 맵핑에는 한계가 아주 아주 큽니다. 굉장히 편해 보여요.

딱 보시면은 '아 두 개로 이게 되네' 하고 편하다라고 생각하고 쓰시면 이제 큰일납니다. 왜 큰일나냐?

이게 편리해 보이지만 실무에서 사용할 수 있는 게 아니에요. 연결 테이블이 단순 연결만 하고 끝나는 일은 없습니다. hello world 하는 게 아니잖아요. JPA 정도를 배우신다는 거는 실무에서 거의 쓰시겠다는 건데 실무에서는 단순하게 연결만 하고 끝난다? 진짜 팔자 좋은 소리죠.

진짜 이런 중간 테이블만 해도 시간 언제 했어, 수량 몇 개야, 이 데이터 언제 변경됐어 같은 추가 데이터들이 별의 별 게 다 들어가요. 그런데 다대다 맵핑은 맵핑 정보만 딱 들어가고 이 중간 테이블에 추가 정보를 더 넣는 게 불가능합니다. 그래서 편리해 보이지만 실무에서 사용할 수가 없어요. 그리고 또 애매한 게 쿼리가 이상하게 나가요. 뭐냐면 Member랑 Product를 조회하려면 쿼리에 중간 테이블이 들어가고 조인이 돼서 나와야 되겠죠? 그런데 중간 테이블이 숨겨져 있기 때문에 쿼리가 내가 생각하지도 못한 쿼리로 나옵니다. 그래서 이게 좀 되게 어려워요. 그래서 저는 안 쓰는 게 맞다고 봐요.
자 그래서 이제 다대다는 어떻게 그 한계를 극복하냐?

@ManyToMany를 @OneToMany, @ManyToOne로 바꾸고 중간 테이블을 엔티티로 승격하는 거예요. 중간에 엔티티를 하나 만드는 거예요. 예를 들어서 MemberProduct 라는 거를 만드시는 거예요.
그래서 이 케이스를 보면,

이렇게 MemberProduct 클래스를 만들고 얘를 엔티티로 만드는 거예요.

이렇게 id까지 만들고

Member 입장에서는 이제 이걸 가지는 게 아니라,

MemberProduct 라는 테이블을 가지고 그냥 이렇게 잡는 거예요. 이제 Member 입장에서는 @OneToMany가 될 거고, mappedBy 해서 넣어줘야 되니까, 다시 MemberProduct로 돌아와서

Member와 Product를 만들어준 다음에, @ManyToOne 해주고

@JoinColumn 해서 이렇게 잡아줍니다. 그럼 다시 Member로 돌아와서,

이제 @OneToMany의 mappedBy를 member로 잡아주죠. 그럼 이런 식으로 Product도 바꿔줘야 겠죠.

아까 해준 그대로 이렇게 해주면 됩니다.

그러면 이거랑 맵핑이 되겠죠.

그리고 그냥 엔티티니까 웬만하면 여러분이 @Id는 @GeneratedValue로 해서 PK는 뭘로 잡는 게 좋다고 설명 드렸던 것처럼 이걸 그냥 따는 걸 저는 권장드립니다. 이제 이런 식으로 다대다를 푸는 거죠.

그렇게 풀면 결과적으로 이 그림이 되는 거에요. 그러니까 Member랑 Product가 중간 테이블을 엔티티로 승격 하는 거에요. 그리고 rdb 처럼 이것도 다대다를 다대일, 일대다로 풀어내는 겁니다. 이렇게 하면 이제 내가 원하는 걸 마음대로 이 MemberProduct에 넣을 수 있죠.

등등 이런 식으로 해서 그냥 내가 원하는 대로 쭉 넣어서 써야 돼요.

근데 생각해 보면 지금처럼 단순하게 MemberProduct 이렇게 할 수도 있지만

사실은 Ordrs라던가 이제 의미 있는 이름으로 나오겠죠.

여기서 이제 결론적으로 말씀드리면 실제 비즈니스는 복잡하단 말이예요. 이렇게 단순하게 @ManyToMany 어노테이션으로 풀어쓰는 건 거의 없어요. 그래서 '연결 테이블을 entity로 승격해라' 라는 게 이제 제가 드리는 가이드구요. 그런데 여기서 이제 그 질문을 하실 수가 있어요.

이 그림이 되게 괜찮거든요. 뭐냐면 MEMBER_ID를 PK면서 FK로 잡고, PRODUCT_ID도 PK면서 FK로 잡고 이 두 개를 딱 묶는 거죠. 이런 식으로 설계가 보통 많이 들어가요. 이 두 개를 묶어서 PK로 잡는데 전통적인 방식에서는 이 두 개를 묶어서 PK로 잡으면서 각각을 Foreign Key로 딱딱 잡는 거예요. Foreign Key 제약 조건을 얘기하는 게 아니라, 그냥 조인할 수 있도록 딱 잡는 거죠.
이렇게 해서 묶는 방식도 있는데 저는 이것보다는 약간 좀 추상적일 수 있는데, 많이 공부하고 실전에서 이것저것 부딪히면서 느낀 것은 뭐냐면

웬만하면 PK는 그냥 의미 없는 값을 써야 됩니다. 그리고 이런 경우에라도 그냥 @GenerateValue를 가지고 그냥 쭉 쓰는 것을 저는 권장드려요. 이러면 나중에 유연성이 좀 생겨요. 이렇게 하는 게 좀 더 다른 의미로 다가오더라도

이렇게 되면 이 MEMBER_ID 와 PRODUCT_ID 에 Member, Product 딱딱 제약이 물리거든요.

근데 완전히 @GenerateValue를 해서 써버리면 이것도 더 유연하게 쓸 수 있고 뭔가 필요하면 DB에 제약 조건을 추가하면 되죠.

나중에 배우실 텐데, JPA에서 뭔가 2개의 PK를 묶어서 만들면 Composit Id를 하나 만들어줘야 되는데 그게 조금 귀찮긴 해요. 근데 뭐 그런 걸 떠나서라도

DB 설계 관점에서도 이렇게 하는 게 장단이 있어요. 보시면 ORDER_ID처럼 뭔가 새로운 Id를 PK로 딱 잡고 다른 건 FK로 그냥 따로 내리는 거죠. 이 방식을 쓸지,

아니면 이 2개를 묶어서 PK, FK로 쓸지 이 2개의 트레이드 오프가 있는데 이거는 이제 고민해 보시면 되고,

저는 이 방식을 씁니다. 모든 테이블에 일관성 있게 그냥 @GenerateValue로 다 깝니다. 저도 예전에 DB 공부할 때는

이런 식으로 설계하는 게 되게 좋다고 배웠었거든요. 근데 저도 실무을 계속 하면서 느낀 게 이런 게 이제 뭔가 그 순간에는 제약 조건을 딱 걸 때는 장점이 많은데 운영을 하면서 느낀 게 애플리케이션은 한 번 만들고 끝나는 게 아니고 계속 발전을 하잖아요. 그러면 Id라는 게 뭔가 어디에 종속되는 식으로 딱딱 걸리면 이게 되게 좀 시스템을 뭔가 유연스럽게 갈아 치기가 쉽지가 않아지더라고요.

이게 어쨌든 ORDER_ID는 비즈니스적으로 정말 의미가 없고,

물론 여기도 비즈니스적으로 의미 없는 값 두 개를 묶은 거지만, 어떻게 보면 그걸 어쨌든 이용해서 뭔가 의미가 탄생을 하죠. Member랑 Product가 묶여서 하나만 들어갈 수 있다는 조건이 생기는 거죠. 근데 뭔가 비즈니스적으로 조건이 더 추가가 되면, 이제 이거는 테이블을 완전 크게 업데이트 해야 되는 거죠. PK가 바뀌어 버리니까. PK를 지금 두 개를 묶은 건데 하나 더 추가된다거나 아니면 완전 다른 방식으로 뭔가 돈다거나 해버리면 크게 업데이트 해야됩니다.

근데 이렇게 진짜 그냥 의미 없는 값 하나만 이렇게 뭔가 userId를 쓰거나 그냥 @GeneratedValue를 쓰거나 해서 그냥 PK 하나만 딱 잡고 가면 JPA 맵핑도 되게 심플하고 개발할 때에도 정 필요하면 MEMBER_ID 와 PRODUCT_ID, 이 두 개를 묶어서 제약 조건을 깔면 되죠. 저는 이게 훨씬 더 애플리케이션을 개발하는 데 더 유연하고 쉬웠어요.
그래서 다대다에 대한 결론은 '다대다를 쓰지 마시고 일대다-다대일로 정리를 하세요' 라고 가이드를 드렸습니다.

실전 예제 3은 다음 시간에 한번 알아보겠습니다.
'스프링 > 자바 ORM 표준 JPA 프로그래밍 - 기본편' 카테고리의 다른 글
| Mapped Superclass - 매핑 정보 상속 (0) | 2024.08.22 |
|---|---|
| 실전 예제 3 - 다양한 연관관계 매핑 (0) | 2024.07.17 |
| 일대일 [1:1] (0) | 2024.07.07 |
| 일대다 [1:N] (0) | 2024.06.23 |
| 다대일 [N:1] (0) | 2024.06.21 |