당니의 개발자 스토리

일대다 [1:N] 본문

스프링/자바 ORM 표준 JPA 프로그래밍 - 기본편

일대다 [1:N]

clainy 2024. 6. 23. 21:25

일대다 [1:N]

오늘은 일대다.

여기서는 '일'이 연관관계 주인입니다. 자 그러면 1 방향에서 외래키를 관리하겠다는 거거든요?

이렇게 일대다 라는 단방향 방향이라는 맵핑이 있습니다. 이게 가능합니다. 우선 밑밥을 먼저 깔면 저는 이 모델은 권장하지 않습니다. 그런데 어쨌든 이게 표준 스펙에서 지원하는 거기 때문에 제가 설명을 드리는 거고요. 저는 실무에서 이 모델은 거의 가져가지 않습니다. 그런 이유들도 뒤에 쭉 설명을 드릴게요.

일단 일대다 단방향이라는 게 있고요. 보시면 Member랑 Team을 뒤집었어요. Team이 1이고 Member가 N이잖아요. 근데 Team을 중심으로 이제 뭘 해보겠다는 거예요. Team에서 외래키도 관리하고.

아무튼 Team과 Member가 있는데 Team이 List members를 가집니다 Member 입장에서는 '나는 Team을 알고 싶지 않은데' 하는데, 반대로 Team은 '나는 Member를 알고 싶어!' 하는 거예요. 이렇게 설계가 나올 수 있죠. 객체 입장에서는 이런 설계가 나올 확률이 높아요.

그런데 DB 입장을 생각해 보자고요. DB 입장에서는 TEAM이랑 MEMBER잖아요. 무조건 다 쪽에 외래키가 들어가야 됩니다. TEAM 쪽에는 외래키가 들어갈 수가 없어요. DB 설계상 생각해보시면 만약 TEAM에 외래키인 TEAM_ID가 있으면 Member가 들어올 때마다 Team을 계속 Insert해야 되거든요. 말이 안 되죠. TEAM이 중복이 되어버리잖아요. 1이 아니고 N이 되어버리는 거죠.

그래서 이 케이스를 보시면, 지금 TEAM에는 외래키가 없고 당연히 MEMBER에 외래키가 들어가요. MEMBER가 N이니까. 그러면 이제 이렇게 해야되죠? Team의 List에 members 값을 바꿨을 때 TEAM_ID 외래키가 있는 다른 테이블(MEMBER)에서 외래키를 Update 쳐줘야 돼요. 왜냐하면 Team의 members연관관계의 주인이 되는 거고, 그러면 MEMBER에 있는 외래키를 members가 관리해야죠.

그래서 Team의 members에 추가하거나 변경하거나 하면 그게 MEMBER에 있는 TEAM_ID를 변경시켜 줘야됩니다. 이게 이제 일대다 단방향 맵핑입니다.

이거를 정리하기 전에 일단 한번 코드로 보여드릴게요.

Team의 이거를 지우고,

Member에서는 이걸 지울게요.

그리고 이것도 지우고요.

그래서 Member는 깔끔하게 id랑 username만 딱 있죠. 지금 테이블은 계속 똑같아요. 테이블은 손댄 게 없습니다.

이제 h2를 실행시켜서 띄우고요.

확인해보면 아직 값이 아무것도 없죠.

자 그럼 Member는 텅텅 비어있는 걸 확인하셨고,

반대로 Team은 여기가 연관관계로 맵핑되어 있기 때문에 반대쪽에 있는 외래키를 관리해 줘야돼요.

그래서 @JoinColumn 넣어주시고 name은 "TEAM_ID" 하시면 됩니다. 왜? Team이 지금 연관관계의 주인인 케이스를 하고 있으니까요. TEAM_ID가 두 개라서 조금 어색하긴 하지만 이렇게 하면 동작합니다.

이제 돌려보기 전에

필요없는 코드를 지우고요.

Member를 생성하고 setUsername 해주는데 보통 실제 실무에서는 Setter를 잘 안써요. 거의 뭐 생성자에서 완성인데, 만들면서 빌드업 패턴을 쓰거나 하는데 지금은 쉬운 예제니까 이해해주세요.

그래서 이름을 정하고 em.persist 하면 됩니다.

이제 Team도 똑같이 해주는데, em.persist를 하기 전에 team.getMembersadd 해가지고 이 member를 집어넣어줄 거예요.

그런데 지금 getMembers가 없으니까

만들어주고,

이제 team의 members에다가 우리가 만든 member를 집어넣어요.

이제 em.persist해서 team을 넣으면,

여기 MEMBER에 있는 이 외래키가 Update 될 겁니다.

여기까지 돌리면 Member가 당연히 저장될 거고요.

이거를 돌릴 때 이제 재미난 일들이 벌어집니다. 당연히 이 코드를 보시면 Team은 저장이 되는 게 맞아요. 그런데 애매한 게 있죠.

지금 이 포인트가 이제 좀 애매해져요.

이거는 그냥 생각해 보시면 이 내용은 TEAM 테이블에 Insert가 되면 돼요.

근데 이 내용은 지금 Team 테이블에 Insert 될 수 있는 내용이 아니죠. 왜냐면 members 자체가 컬럼에 존재하지 않고(실제 테이블에 있는 TEAM_ID는 Long id에 대응), MEMBER 테이블에 있는 TEAM_ID랑 연관관계로 맵핑되어 있기 때문이에요.

그래서 지금 이렇게 team의 members를 변경하면 외래키가 바뀌어야 되잖아요. 그러면 이 외래키가 지금 어디에 있습니까? TEAM 테이블에 있는 게 아니라, MEMBER 테이블에 있단 말이에요. 그러면 MEMBER에 있는 TEAM_ID를 Update 쳐줘야 돼요.

자 실행해 볼게요.

Insert는 Member랑 Team을 하고요.

그 다음에 하이버네트 주석이 나오네요.

그리고 MEMBER에 실제 Update 쿼리가 나가는게 보이시죠.

자 값이 잘 들어왔나 db를 한번 보겠습니다.

보시면 db의 값은 다 정상적으로 들어왔어요. 그러나 지금 쿼리가 좀 많이 나갔죠.

자 이 Update 쿼리가 왜 추가로 나가야 되냐면,

얘 입장에서 볼때는 team을 변경할 때

이 부분은

그냥 TEAM 테이블에 넣으면 돼요.

근데 문제는 이 부분이죠. team 엔티티를 저장하는데

Team에서는 MEMBER의 TEAM_ID 부분을 어떻게 할 방법이 없잖아요. 내가 가지고 있는 게 아니니깐. 지금 members가 바꼈으니 외래키도 Update 쳐야하는데 TEAM 테이블은 자기가 외래키를 갖고있는 게 아니니까 외래키를 가진 옆 테이블, 즉 MEMBER 테이블에 있는 TEAM_ID에다가 update 칠 수밖에 없어요.

그래서 이 관계는 어쩔 수 없이 Update 쿼리가 나가야 됩니다. 암튼 이게 약간 성능상 조금 단점이 있고요. 뭐 사실 Update 쿼리가 나간다고 해서 성능상 크게 문제는 없는데, 그래도 손해긴 손해죠.

이것보다 이 관계의 진짜 심각한 점이 있어요. 저는 실무에서 이걸 잘 안 쓰는 이유가 JPA를 잘 아는 저도 하다보면 query가 지금, 그러니까 예를 들어서,

보세요. 이거는 로직에서 저장을 했으니까

saveMember 이렇게 해서

member를 저장했어요. 근데 코드를 내가 직접 볼 때 비즈니스 로직을 막 이렇게 짜다 보면,

이 코드밖에 안 보여요. 그러면 이게 내 입장에서는 분명히 뭔가 Team만 손을 댄 것 같은데 쿼리를 추적을 해보면

'Team에 Insert는 됐는데, 근데 이 Update는 뭐지?'

'나는 Team 엔티티를 손댔는데 왜 MEMBER 테이블에서 Update가 되지?' 이런 내용을 깊이 있게 모르는 분들은 대부분 다 고민이 되고, 그리고 이제 JPA를 좀 잘해도 권장하지 않는 이유가 실무에서 테이블이 한 두개가 아니잖아요. 테이블 수십개가 엮여서 돌아가는 상황에서 이렇게 되면 운영이 되게 힘들어져요. 그래서 저는 이걸 거의 안쓰고요. 어떻게 하냐면 그 이전과 똑같이 다대일 단방향 관계가 필요하면 양쪽에서 양방향을 추가한다. 이 전략으로 갑니다. 그렇게 가면,

이런식으로 안되고 그냥 단순하게 연관관계 주인을 아예 Member 쪽으로 그냥 들고 가는 거죠. Member는 객체적으로 조금 손해를 볼 수는 있죠. Member 입장에서는 '나는 Team으로 갈 일이 없어' 라고 할지라도 약간 트레이드 오프인데 객체지향적으로 조금 손해를 보더라도 이렇게 하는 거죠. 손해를 본다는 건 Member에서 Team으로 갈 일이 없는데 그냥 강제로 하나 만드는 거예요. Member에서 Team으로 가는 거랑 MEMBER의 TEAM_ID랑 맵핑을 걸어놓는 거죠. 마치 저희가 이전에 설명한 다대일 단방향을 양방향으로 바꾸면 거의 비슷하게 쓸 수가 있어요.

Member 입장으로 '나는 Team으로 가는 레퍼런스를 만들기 싫어' 라고 해도 이제 약간 ORM 보다는 좀 더 DB에 설계 방향을 조금 더 맞춰서 유지보수하기 쉽게 선택을 하는 거죠. 어쨌든 이게 트레이드 오프가 있는데 저는 그런 선택을 합니다.

이제 정리를 해야죠.

일대다 단방향은 1 쪽이 연관관계 주인이구요. 문제는 테이블의 일대다 관계는 항상 다 쪽에 외래키가 있단 말이에요. 그래서 이게 딱 관계가 틀어져요. 그래서 객체와 테이블의 차이 때문에 반대편 테이블의 외래키를 관리해야 되는 아주 특이한 구조가 나오게 됩니다. 둘의 패러다임이 다른 거죠. 이걸 ORM이 억지로 뭔가 맞춰서 해결해 주려고 노력을 하는 거죠.

그리고 이제 일대다 단방향은 이 @JoinColumn을 꼭 써야 됩니다. 처음에 JPA 하시는 분들은 실수를 많이 하시는데, 이걸 안 쓰면 조인 테이블 방식을 사용한다 라고 되어있죠. 이게 뭔지 보여드릴게요.

자 실행해 보면, 여기 보시면 못 보던 테이블이 하나 생겨요.

refresh 해보면 TEAM_MEMBER 라고 하는 중간 테이블 하나가 생겨버리죠. 이거를 보시면 TEAM_ID랑 MEMBER_ID를 들고 있는 중간 테이블이 생기는데 이거를 갖다가

조인 테이블이라고 해가지고 이렇게 막 설정 정보를 넣을 수 있어요. 뭐 테이블 명이나 이런 것들. 근데 이제 그런거죠. 이 @JoinColumn을 안 넣으면 테이블 사이에 중간 테이블을 하나 만드는 식으로 바뀌어요. 이제 이렇게 중간 테이블 넣으면 장점도 있지만 단점은 아무래도 이제 테이블이 한 개 더 들어가고 하니까 성능상 좀 애매하고 운영하기가 좀 쉽지가 않죠.

그래서 이 전략을 쓰시려면 @JoinColumn을 넣으셔야 됩니다. 안 넣으면 Default가 @JoinTable 이라는 전략으로 동작하게 됩니다.

그래서 이제 일대다 단방향을 정리하면

단점은 엔티티가 관리하는 외래키가 다른 테이블에 있다는 것 자체가 그냥 어마어마한 단점입니다. 그 다음에 연관관계 관리를 위해서 추가로 Update SQL이 실행이 됩니다. 당연하겠죠. 내 엔티티가 아닌 다른 곳에 외래키가 있으니까 내 것을 넣을 때는 나만 Insert 치면 되는데, 내 엔티티와 관련 없는 다른 엔티티의 값을 변경하면, 옆 테이블에 있는 값을 Update 쳐줘야 되겠죠.

그래서 결론적으로 말씀드리면 일대다 단방향 맵핑 보다는 조금 객체적으로 트레이드 오프 해서 설계가 조금 덜 깔끔해지더라도, 참조를 하나 더 넣는 한이 있더라도 다대일 양방향 맵핑을 사용하는 것을 권장을 드립니다. 그래서 이렇게 정리를 해보면 사실 이 일대다 단방향 자체를 그냥 처음부터 안 가져오는 것도 방법이에요. 그러면 이제 심플해지죠. 그러니까 사실 다대일 단방향, 양방향만 알면 일대다 단방향에 대해서 몰라도 되는 거죠.

그럼 일대다 양방향은 없나요? 있습니다. 억지성이 있기는 한데,

지금 Team의 List members에 있는 애가 지금 연관관계 주인이잖아요. 1쪽에 있는 이 members가 MEMBER의 TEAM_ID를 관리하는 이 그림에서, 양방향인데 '나도 그럼 Member에서 Team을 조회하는 걸 하고 싶어' 라고 하면 이제 이게 스펙상 되는 건 아니고 약간 야매로 됩니다.

하다보니까 '일단 이렇게 맵핑 하고 싶어. 그런데 반대쪽에서도 보고 싶어, 그냥 읽기 전용으로 넣고 싶어. 역방향 하나 집어 넣고 싶어!' 라고 하면 이렇게 하면 됩니다.

Team을 넣으신 다음에, @ManyToOne을 넣어주시구요.

이게 중요한데 @JoinColumn을 넣으신 다음, insertable = false, updatable = false를 넣으시면 됩니다.

그 전에 TEAM_ID 라고 이름을 넣어주시고요. insertable = false, updatable = false 잠깐 지우고,

일단 이렇게 하고 그냥 맵핑을 걸면, 여기까지는 이 전이랑 똑같단 말이에요. 그냥 다대일 단방향 관계네 하고 딱 걸어요. 그런데 이제 이렇게 해버리면 team이 연관관계 주인처럼 돼버리잖아요.

문제는 members도 연관관계 주인이고

team도 연관관계 주인이란 말이에요. 돌렸을 때 하이버네이트가 에러도 안 내주고 이러면 완전 망하는 거거든요.

그래서 이러한 옵션을 넣어서 무효화 시키는 겁니다. insertable = false, updatable = false 라는 옵션을 넣으시면, 얘는 그냥 읽기 전용이 돼버려요. 맵핑은 되어있고 값을 다 쓰는데 최종적으로 Insert랑 Update를 안 하는 거예요.

그렇게 해서 결과적으로는 Team에 있는 members가

연관관계의 주인을 계속 행사를 하고 Member에 있는 Team도 연관관계의 주인처럼 만들었는데 얘는 읽기 전용으로 걸어버린 거죠. 이렇게 해버리면 사실상 양방향 맵핑을 한 거랑 똑같이 되는 거죠. 그래서 관리는 Team의 members로 하고 Member의 team은 읽기만 하도록 할 수가 있습니다.

일대다 양방향 맵핑은 JPA 스펙상 공식적으로 존재하는 건 아니고요. 이런 식으로 해서 만들 수 있다는 겁니다. 생각보다 하시다 보면 이런 맵핑을 해놓고 읽기 전용으로 쓰는 전략이 실무에서 복잡하게 하다 보면 한번씩 필요할 때가 있어요.

이제 어쨌든 내리는 결론은 딱 그거에요. 그냥 일대다 단방향, 양방향은 쓰시지 마시고 그냥 다대일 양방향을 사용하세요. 라는 게 제가 명확하게 드릴 수 있는 가이드에요.

막 옆 테이블에 Update 쿼리가 날라가고 있으면 테이블이 막 수십 개가 돌아가고 있는데 한번에 그러면 막 멘붕 되거든요. 그래서 그냥 깔끔하게 다대일 양방향을 사용하는 것을 저는 권장드립니다.

맵핑이랑 설계라는 게 테이블이 한 두개 있는 것도 아니고 수십 개씩 돌아갈 텐데 결국은 단순해야 되거든요. 그래야 누구나 사용할 수 있는 거지 복잡하게 들어가면 다 힘들어지는 것 같아요.

이제 다음 시간에는 일대일을 해보겠습니다.