che01 님의 블로그
JPA N:M 관계 완전 정리 본문
N:M 연관관계란?
학생과 수업 예시로 이해하기
현실 상황
- 학생 한 명이 여러 수업을 들을 수 있다
- 하나의 수업도 여러 학생이 들을 수 있다
구체적 예시
학생들:
- 철수: 수학, 영어 수업 수강
- 영희: 수학, 과학 수업 수강
- 민수: 영어, 과학 수업 수강
수업들:
- 수학: 철수, 영희가 수강
- 영어: 철수, 민수가 수강
- 과학: 영희, 민수가 수강
이처럼 서로 다:다로 연결되는 관계를 N:M (Many-to-Many) 관계라고 합니다.
방법 1: 단순한 @ManyToMany 사용
테이블 구조
Student 테이블 student_class 테이블 Class 테이블
┌─────────────────┐ ┌─────────────────────┐ ┌─────────────────┐
│ id (PK) │ 1 │ │ student_id (FK) │ 1 │ │ id (PK) │ 1 │
│ name │철수 │ │ class_id (FK) │ 1 │ │ subject │수학│
└─────────────────┘ │ student_id (FK) │ 1 │ │ │ │
│ class_id (FK) │ 2 │ │ id (PK) │ 2 │
│ student_id (FK) │ 2 │ │ subject │영어│
│ class_id (FK) │ 1 │ └─────────────────┘
└─────────────────────┘
코드 구현
Student 엔티티
@Entity
public class Student {
@Id
@GeneratedValue
private Long id;
private String name;
// 연관관계의 주인
@ManyToMany
@JoinTable(
name = "student_class", // 중간 테이블 이름
joinColumns = @JoinColumn(name = "student_id"), // 현재 엔티티 참조
inverseJoinColumns = @JoinColumn(name = "class_id") // 상대 엔티티 참조
)
private List<Class> classes = new ArrayList<>();
// 생성자, getter, setter...
}
Class 엔티티
@Entity
public class Class {
@Id
@GeneratedValue
private Long id;
private String subject;
// 읽기 전용 (mappedBy 사용)
@ManyToMany(mappedBy = "classes")
private List<Student> students = new ArrayList<>();
// 생성자, getter, setter...
}
사용 방법
// 학생 생성
Student 철수 = new Student("철수");
Student 영희 = new Student("영희");
// 수업 생성
Class 수학 = new Class("수학");
Class 영어 = new Class("영어");
// 관계 설정 (연관관계 주인 쪽에서)
철수.getClasses().add(수학);
철수.getClasses().add(영어);
영희.getClasses().add(수학);
// 저장
studentRepository.save(철수);
studentRepository.save(영희);
방법 1의 문제점
문제 상황
"철수가 수학 수업을 듣는데, 출석률이 90%이고 성적이 A라는 정보를 저장하고 싶다면?"
원하는 정보:
student_id | class_id | 출석률 | 성적
1 | 1 | 90% | A
1 | 2 | 85% | B+
2 | 1 | 95% | A+
단순 @ManyToMany의 한계
- 중간 테이블에는 단순히 연결 정보(FK)만 저장 가능
- 부가 정보(출석률, 성적)를 저장할 수 없음
- 복잡한 비즈니스 로직 처리 어려움
방법 2: 중간 엔티티 사용 (권장)
테이블 구조
Student 테이블 StudentClass 테이블 Class 테이블
┌─────────────────┐ ┌─────────────────────────┐ ┌─────────────────┐
│ id (PK) │ 1 │ │ id (PK) │ 1 │ │ id (PK) │ 1 │
│ name │철수 │ │ student_id (FK) │ 1 │ │ subject │수학│
└─────────────────┘ │ class_id (FK) │ 1 │ └─────────────────┘
│ attendance_rate │ 90 │
│ grade │ A │
└─────────────────────────┘
코드 구현
중간 엔티티 - StudentClass
@Entity
public class StudentClass {
@Id
@GeneratedValue
private Long id;
// 학생 참조
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "student_id")
private Student student;
// 수업 참조
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "class_id")
private Class schoolClass;
// 부가 정보들
private int attendanceRate; // 출석률
private String grade; // 성적
private LocalDateTime enrollmentDate; // 수강 신청일
// 생성자, getter, setter...
// 연관관계 편의 메서드
public void setStudent(Student student) {
this.student = student;
student.getStudentClasses().add(this);
}
public void setSchoolClass(Class schoolClass) {
this.schoolClass = schoolClass;
schoolClass.getStudentClasses().add(this);
}
}
Student 엔티티
@Entity
public class Student {
@Id
@GeneratedValue
private Long id;
private String name;
// 중간 엔티티와 1:N 관계
@OneToMany(mappedBy = "student", cascade = CascadeType.ALL)
private List<StudentClass> studentClasses = new ArrayList<>();
// 편의 메서드: 수업 목록 조회
public List<Class> getClasses() {
return studentClasses.stream()
.map(StudentClass::getSchoolClass)
.collect(Collectors.toList());
}
// 생성자, getter, setter...
}
Class 엔티티
@Entity
public class Class {
@Id
@GeneratedValue
private Long id;
private String subject;
// 중간 엔티티와 1:N 관계
@OneToMany(mappedBy = "schoolClass", cascade = CascadeType.ALL)
private List<StudentClass> studentClasses = new ArrayList<>();
// 편의 메서드: 학생 목록 조회
public List<Student> getStudents() {
return studentClasses.stream()
.map(StudentClass::getStudent)
.collect(Collectors.toList());
}
// 생성자, getter, setter...
}
사용 방법
// 학생과 수업 생성
Student 철수 = new Student("철수");
Class 수학 = new Class("수학");
// 중간 엔티티 생성 및 관계 설정
StudentClass 수강정보 = new StudentClass();
수강정보.setStudent(철수);
수강정보.setSchoolClass(수학);
수강정보.setAttendanceRate(90);
수강정보.setGrade("A");
// 저장
studentClassRepository.save(수강정보);
// 조회
List<StudentClass> 철수수강목록 = studentClassRepository
.findByStudent(철수);
for (StudentClass sc : 철수수강목록) {
System.out.println("과목: " + sc.getSchoolClass().getSubject());
System.out.println("출석률: " + sc.getAttendanceRate() + "%");
System.out.println("성적: " + sc.getGrade());
}
두 방법 비교
비교 항목 단순 @ManyToMany 중간 엔티티 방식
| 구현 복잡도 | 간단 | 복잡 |
| 부가 정보 저장 | 불가능 | 가능 |
| SQL 쿼리 제어 | 어려움 | 명확함 |
| 비즈니스 로직 | 제한적 | 유연함 |
| 유지보수 | 어려움 | 쉬움 |
| 성능 최적화 | 제한적 | 가능 |
언제 어떤 방법을 선택할까?
단순 @ManyToMany 사용하는 경우
// 태그와 게시물 관계 (부가 정보 불필요)
@Entity
public class Post {
@ManyToMany
private List<Tag> tags = new ArrayList<>();
}
@Entity
public class Tag {
@ManyToMany(mappedBy = "tags")
private List<Post> posts = new ArrayList<>();
}
적합한 상황
- 단순히 연결만 필요한 경우
- 부가 정보가 전혀 없는 경우
- 프로토타입이나 간단한 프로젝트
중간 엔티티 사용하는 경우 (권장)
적합한 상황
- 연결에 대한 부가 정보가 필요한 경우
- 복잡한 비즈니스 로직이 있는 경우
- 실무 프로젝트 (대부분의 경우)
실무 권장사항
1. 시작은 중간 엔티티로
// 처음부터 중간 엔티티로 설계
@Entity
public class UserRole {
@Id @GeneratedValue
private Long id;
@ManyToOne
private User user;
@ManyToOne
private Role role;
private LocalDateTime assignedDate; // 나중에 필요할 수 있는 정보
private String assignedBy;
}
2. 연관관계 편의 메서드 작성
// StudentClass에서
public void setStudent(Student student) {
// 기존 관계 제거
if (this.student != null) {
this.student.getStudentClasses().remove(this);
}
// 새 관계 설정
this.student = student;
if (student != null) {
student.getStudentClasses().add(this);
}
}
3. 쿼리 최적화
// N+1 문제 방지를 위한 페치 조인
@Query("SELECT sc FROM StudentClass sc " +
"JOIN FETCH sc.student " +
"JOIN FETCH sc.schoolClass " +
"WHERE sc.student.id = :studentId")
List<StudentClass> findByStudentWithFetch(@Param("studentId") Long studentId);
결론
실무에서는 99% 중간 엔티티 방식을 사용합니다.
이유
- 나중에 부가 정보가 필요해질 가능성이 높음
- 복잡한 비즈니스 로직 처리 가능
- 쿼리 최적화와 성능 튜닝이 용이함
- 유지보수성이 좋음
핵심 기억사항
- N:M 관계는 중간 테이블이 반드시 필요
- 단순 연결만 필요해도 중간 엔티티 방식 권장
- 연관관계 편의 메서드로 양방향 관계 관리
- 페치 조인으로 N+1 문제 방지
'Spring' 카테고리의 다른 글
| JPA N+1 문제 완벽 해결하기 - EntityGraph를 활용한 성능 최적화 (0) | 2025.06.10 |
|---|---|
| JPA 상속관계 매핑 전략 완전 정리 (2) | 2025.06.09 |
| JPA 1:1 관계 완전 정리 (0) | 2025.06.09 |
| JPA 1:N 관계 완전 정리 (0) | 2025.06.09 |
| Spring 데이터 변환 메커니즘 완벽 정리 (0) | 2025.06.06 |