Notice
Recent Posts
Recent Comments
Link
«   2026/01   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

che01 님의 블로그

JPA N:M 관계 완전 정리 본문

Spring

JPA N:M 관계 완전 정리

che01 2025. 6. 9. 11:56

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 문제 방지