@ManyToMany 대신 중간 엔티티 쓰기 (JPA 6편)

1편부터 5편까지 JPA 기본기와 N+1, OSIV를 다뤘다. 이번엔 @ManyToMany다.

처음 배울 때는 편해 보이는데 실무 코드에서는 거의 안 보인다. @ManyToMany 대신 중간 엔티티를 쓰는 게 왜 정석인지 정리해둔다.

@ManyToMany, 왜 쓰면 안 될까

일단 코드부터.

@Entity
public class Student {

    @Id @GeneratedValue
    private Long id;

    @ManyToMany
    @JoinTable(name = "student_course")
    private List<Course> courses = new ArrayList<>();
}

어노테이션 하나로 중간 테이블까지 만들어준다. 편해 보이지만 여기서 함정이 시작된다.

@ManyToMany 대신 중간 엔티티를 쓸 때의 조인 테이블 구조 비교 다이어그램

실제로 조회하면 이런 쿼리가 나간다.

select s1_0.id, s1_0.name
from student s1_0 where s1_0.id=?

select c1_0.student_id, c1_1.id, c1_1.title
from student_course c1_0
join course c1_1 on c1_1.id=c1_0.courses_id
where c1_0.student_id=?

두 번째 쿼리의 컬럼명이 courses_id 다. 내가 정한 적 없는 이름인데, 필드명이 courses 라서 JPA 가 붙인 것이다. 중간 테이블이 내 손을 벗어나 있다는 게 여기서부터 드러난다.

1. 중간 테이블에 컬럼을 못 붙인다. 수강 신청 날짜나 평점, 상태값을 저장하고 싶어도 student_course 는 FK 두 개짜리 숨겨진 테이블이라 손댈 방법이 없다.

“언제 신청했는지 정도는 알아야지” 라는 요구사항은 거의 반드시 나온다. 그 순간 발목을 잡힌다.

2. 삭제·수정 쿼리가 원하는 대로 안 나간다. 컬렉션에서 하나만 빼도 중간 테이블 데이터를 통째로 지웠다가 다시 넣는 식으로 동작할 때가 있다. 의도치 않게 전체 delete 가 나간다.

데이터가 많아지면 성능 문제이기도 하고, 운영 중에 이 쿼리를 보면 식겁합니다.

3. 양방향으로 걸면 무한루프가 생긴다. 양쪽에 @ManyToMany 를 걸고 toString() 이나 JSON 직렬화를 기본값으로 쓰면 서로를 참조하다 StackOverflowError 가 난다.

@JsonIgnore 나 DTO 변환으로 끊어야 하는데, 애초에 양방향 @ManyToMany 자체가 이런 관리 비용을 만든다.

이 부분은 아래 글에 정리가 잘 돼 있다.

ManyToMany를 사용하면 안 되는 이유

@ManyToMany 대신 중간 엔티티가 정석이다

다대다를 다대다로 두지 말고 중간 엔티티를 직접 만들어 일대다 + 다대일로 쪼갠다.

중간 테이블도 “학생이 어떤 강의를 언제 신청했는지” 를 담는 의미 있는 개념이다. 그걸 숨겨진 테이블 취급할 이유가 없다.

@Entity
public class StudentCourse {

    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "student_id")
    private Student student;

    @ManyToOne
    @JoinColumn(name = "course_id")
    private Course course;

    private LocalDateTime enrolledAt;
    private String status;
}

중간 엔티티를 따로 뽑으면 이런 게 달라집니다.

  • 원하는 컬럼(신청일, 상태 등) 자유롭게 추가 가능
  • 일반 엔티티라서 save(), findBy...() 등 우리가 익숙한 Repository 패턴 그대로 사용 가능
  • 실행되는 쿼리가 명확하게 예측 가능해짐 (숨겨진 동작이 없음)
  • 양방향 순환참조 부담 없이 필요한 쪽에서만 조회하면 됨

Student, Course 쪽에는 각각 @OneToMany로 StudentCourse를 참조하게만 해주면 관계 설정 끝이에요.

참고로 @OneToMany도 양방향으로 걸 땐 연관관계의 주인(FK를 가진 쪽, 여기선 StudentCourse)을 명확히 하고, 반대쪽엔 mappedBy를 붙여서 읽기 전용으로만 쓰는 게 실무 관례예요.

그래도 @ManyToMany를 쓸 수 있는 경우가 있을까

솔직히 실무에서 완전히 못 쓸 정도는 아니에요.

딱 두 조건을 동시에 만족할 때만 예외적으로 고려해요: 중간 테이블에 추가 컬럼이 앞으로도 절대 필요 없고, 조회/삭제 패턴이 아주 단순한 경우(태그-게시글처럼 진짜 연결 여부만 중요한 관계).

그마저도 저는 처음부터 중간 엔티티로 시작하는 걸 추천해요.

나중에 컬럼 하나 추가하려고 @ManyToMany 를 중간 엔티티로 리팩터링하는 게, 처음부터 중간 엔티티로 시작하는 것보다 훨씬 번거롭다.

정리

  • @ManyToMany는 편해 보이지만 실무에서는 거의 못 쓰는 이유가 명확해요. 컬럼 추가 불가, 예측 안 되는 쿼리, 양방향 순환참조 위험
  • 정석은 중간 엔티티(StudentCourse)를 직접 만들어서 @OneToMany + @ManyToOne으로 풀어내는 것
  • 정말 단순한 연결 관계가 아니면, 처음부터 중간 엔티티로 시작하는 게 나중에 훨씬 이득

다음 편에서는 실무에서 자주 마주치는 또 다른 JPA 함정을 이어서 다뤄볼게요.

JPA 시리즈 전체 보기

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤