
이번에는 N:M 관계에 대한 내용을 정리해보자. 저번 글과 동일하게 나에게 필요한 내용들만 정리해보자.
연관관계는 다대일, 일대다, 일대일, 다대다 개념이 있다. 그럼 어디가 "다"고 어디가 "일"일까? 영상에서는 앞 글자가 바로 연관관계의 주인이라 생각하면 된다고 했다.
일대다는 양방향에서 다대일과 매칭되는 개념이다. 그동안 나는 일대다를 다대일과 함께 양방향 연관관계 매핑할 때만 사용하는 줄 알았지만 일대다 관계만을 이용하여 단방향, 양방향으로 사용하는 경우가 있다고 한다. 물론 김영한님께서는 해당 모델을 권장하지 않는다고 했다.
예시로 이해해보자.
일대다 단방향 관계는 팀은 회원을 알고 싶지만 회원은 팀의 정보를 알고 싶지 않을 경우다. 여기서 가장 헷갈리는 부분은 - 테이블에서는 "다"에 해당하는 쪽에 외래키를 관리한다는 점이다. 따라서 객체에서 팀의 회원을 변경한다는 것은 회원 테이블의 팀 정보를 변경하는 것을 의미한다. 😵💫

일대다를 적용하는 코드는 간단하다.
@Entity
@Table(name = "MEMBER")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String name;
// Getter, Setter 생략
단, JPA에서 기본적으로 회원과 팀사이에 중간 테이블을 새로 생성하기 때문에 일대다 관계를 적용할 때는 @JoinColumn을 반드시 명시해주어야 한다.
@Entity
@Table(name = "TEAM")
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany
@JoinColumn(name = "TEAM_ID")
private List<Member> members = new ArrayList<>();
// Getter, Setter 생략
// JpaMain.java
try {
Member member = new Member();
member.setName("Member1");
entityManager.persist(member);
Team team = new Team();
team.setName("Team");
// MEMBER 테이블에 대한 UPDATE 쿼리 발생
team.getMembers().add(member);
entityManager.persist(team);
entityTransaction.commit();
} catch (Exception e) {
entityTransaction.rollback();
} finally {
entityManager.close();
}
entityManagerFactory.close();

실행 결과를 보면 기존 쿼리에서 UPDATE 쿼리가 추가되는 것을 볼 수 있다. 이 UPDATE 쿼리는 성능에 엄청난 영향을 미치지는 않지만 추가적인 쿼리가 전달되었다는 부분에서 손해를 보았고, 제 3자의 입장에서 코드는 팀에 대한 로직만이 존재하는데 쿼리를 추적했을 때 회원에 관련된 쿼리가 발생한다는 부분이 의아할 수 있다. 무엇보다 수 많은 테이블이 존재하는 실무 환경에서는 운영하기 까다로운 요소가 될 수 있다.
즉, 객체와 테이블의 차이 때문에 반대편 테이블의 외래키를 관리하는 특이한 구조가 발생하면서 골치아픈 문제들이 발생한다는 것이다. 따라서 영상에서는 일대다 단방향 매핑보다는 다대일 양방향 매핑을 사용하는 것을 권장했다.
일대다 양방향은 공식적으로 존재하지 않는 관계다. 심지어 김영한님께서도 야매라고 하셨다. 🤭 (키득)

일대다 양방향 관계를 맺을 때는 위의 코드에서 주인인 곳에서는 건드릴 내용은 없고 주인이 아닌 곳에서 @JoinColumn의 속성을 이용하여 읽기 전용 필드로 생성만 해주면된다.
@Entity
@Table(name = "MEMBER")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String name;
@ManyToOne
@JoinColumn(name = "TEAM_ID", insertable = false, updatable = false)
private Team team;
// Getter, Setter 생략
일대일은 객체가 아닌 테이블 입장에서 외래키의 관리가 자유로운 관계다. 주 테이블에서 외래키를 관리할지 혹은 대상 테이블에 외래키를 관리할지 상관이 없기 때문이다. 단, 외래키에 UNIQUE 제약 조건이 있어야 한다.
주 테이블, 대상 테이블에 대한 내용이 헷갈릴 경우에는 그냥 내가 내것만 관리할 수 있다는 것을 생각하면 된다. (외래키를 관리하는 테이블의 엔티티가 주인으로 보면 된다!)
회원이 사물함을 딱 하나씩 가지는 예시로 생각해보자. 외래키의 관리가 자유롭다는 것은 회원 테이블이나 사물함 테이블 둘 중 어디든 한 군데에서 외래키를 관리할 수 있다는 의미다.
가장 단순하고 쉬운 일대일 관계다.

@Entity
@Table(name = "MEMBER")
public class Member {
// (생략...)
@OneToOne
@JoinColumn(name = "LOCKER_ID")
private Locker locker;
// Getter, Setter 생략
반대로 위와 동일한 객체 연관관계에서 주 테이블이 아닌 대상 테이블에 외래키 매핑하는 것은 가능할까?
당연히 불가능하다. 심지어 JPA에서도 지원해주지 않는다!

다대일 양방향 매핑처럼 외래키가 있는 곳이 연관관계의 주인이다.

코드는 주 테이블에 외래키 단방향 매핑 코드에서 주인이 아닌 곳에서 mappedBy 속성만 추가해주면 된다.
@Entity
@Table(name = "LOCKER")
public class Locker {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String name;
@OneToOne(mappedBy = "locker")
private Member member;
// Getter, Setter 생략
대상 테이블에 외래키 양방향 매핑의 경우 일대일 주 테이블에 외래키 양방향 매핑 방법은 동일하다. 그냥 뒤집힌 구조라고 생각하면 된다. ㅎㅎ

DBA는 발생할 수 있는 다양한 상황들을 고려하여 DB를 설계해야한다. 미래에 변경될 가능성이 있는 부분들에 대해서 빠르게 대처하기 위해서다. 간단한 수정만으로도 해결할 수 있는 상황도 있겠지만, DB 구조를 갈아엎어야 하는 최악의 시나리오까지 발생할 수 있기 때문에 최대한 초반에 설계를 단단하게 하는게 좋다.
나도 회사를 다니면서 기획이 바뀌거나 새로 추가될 때마다 테이블 구조를 크게 수정해야하는 상황을 여러 번 경험했었기 때문에 영상에서 장기적인 시선(?)으로 DB를 설계해야한다는 말에 많이 공감했다. 🥹
회원과 사물함의 예시를 생각해보자.
하나의 사물함을 여러 명이 같이 사용할 수 있다고 한다면 회원 테이블에 사물함 외래키를 가지고 있는 것이 맞다. 하지만 회원이 여러 개의 사물함을 가질 가능성이 있을 경우에는 사물함 테이블에서 회원 외래키를 가지고 있는 것이 맞다.
하나의 사물함을 여러 명이 소유할 수 있을 경우
| MEMBER | 제약 조건 | LOCKER |
|---|---|---|
| MEMBER_ID | PK | LOCKER_ID |
| USER_NAME | NAME | |
| LOCKER_ID | FK, UNI |
회원이 여러 개의 사물함을 소유할 수 있을 경우
| MEMBER | 제약 조건 | LOCKER |
|---|---|---|
| MEMBER_ID | PK | LOCKER_ID |
| USER_NAME | NAME | |
| FK, UNI | MEMBER_ID |
반면 ORM을 이용하여 개발하는 사람의 입장에서는 회원 테이블 내에 사물함 외래키가 있는 구조가 성능상 더 이점을 가질 수 있다. 회원을 조회하는 것이 서비스내에서도 가장 많다는 점을 생각해보면, 해당 회원이 사물함을 가지고 있는지 없는지를 판단할 때 어떠한 조인없이 회원 테이블에서만 조회하면 되기 때문이다.
그럼 어떻게 해야할까? 🧐
이 부분에 대해서는 DBA와 개발자가 모여서 협의를 해야하는 부분이라 정답은 없다. 하지만 김영한님은 대체로 일대일 관계에서는 회원 테이블 내에 사물함 외래키가 있는 주 테이블에 외래키 단방향 구조를 더 선호하신다 하셨다.
만약 회원과 사물함 관계를 많은 DBA분들이 선호하는 방법대로 진행한다면, 대상 테이블에 외래키 단방향 매핑이 불가능하기에 양방향 매핑으로 만들어줘야 한다. (추가적으로 대상 테이블에 외래키가 있을 경우에는 프록시 기능의 한계로 인해 지연 로딩이 되지 않고 즉시 로딩이 되는 단점이 있다.)
다대다 관계는 DB에서는 표현할 수 없기 때문에 중간 테이블을 일대다, 다대일 관계로 풀어낸다.

반면 객체는 컬렉션을 이용하여 객체 2개로 다대다 관계를 표현할 수 있다.

코드로 양방향 연관관계 매핑을 표현하면 다음과 같다.
@Entity
@Table(name = "MEMBER")
public class Member {
// (생략...)
@ManyToMany
@JoinTable(name = "MEMBER_PRODUCT")
private List<Product> products = new ArrayList();
// Getter, Setter 생략
@Entity
@Table(name = "PRODUCT")
public class Product {
// (생략...)
@ManyToMany(mappedBy = "products")
private List<Member> members = new ArrayList();
// Getter, Setter 생략

결과를 보면 회원과 제품 사이의 중간 테이블이 새로 생성되는 것을 볼 수 있다.
언뜻보면 굉장히 편리해 보일 수 있겠지만, 실무에서는 사용하면 안된다. 생성된 중간 테이블에는 단순히 연결만 하기 위한 정보만 설정되기 때문에 그 외에 추가적인 데이터를 넣을 수 없기 때문이다. 또한 다대다 관계로 생성된 중간 테이블은 숨겨져있어 생각치도 못한 쿼리가 만들어지는 문제가 있다.

따라서 JPA가 다대다로 생성해주는 중간 테이블을 사용하는 것이 아닌, 직접 연결 테이블을 엔티티로 승격시켜서 한계를 극복하는 방법을 많이 사용한다.
중간 테이블의 PK를 어떻게 설정하면 될지에 대한 명확한 답은 없다. 위의 예시를 본다면, MEMBER_ID와 PRODUCT_ID를 묶어서 하나의 PK로 지정하는 방법이 있고 혹은 MEMBER_PRODUCT_ID라는 비즈니스적으로 관계없는 별도의 PK값을 만드는 방법이 있을 것이다.
개인적으로 전자의 경우에는 복합키에 대한 내용을 알아야하기에 학습 측면에서는 후자의 방법을 사용하는 것이 가장 쉬운 길이라 생각한다. 김영한님도 전자의 방법이 DB 관점에서는 좋겠지만, 서비스가 계속 발전하면서 PK가 어딘가에 종속적인 구조가 될 경우 애플리케이션이 유연하지 못하다는 단점이 있어 후자의 방법을 사용하신다고 하셨다.