[Spring] 관계 매핑 어노테이션

1. 소개

관계 매핑 어노테이션이란?

 

DB에서 원래 사용하던 개념을 Java 객체에서도 쉽게 사용할 수 있도록 해주는 도구

 

사용하는 이유

 

  • SQL 직접 작성 → 자동 변환
    • 원래라면 JOIN 같은 SQL을 직접 써야 하는데, JPA에서는 어노테이션만 붙이면 자동으로 처리됨.
    • 예를 들어, @OneToMany를 사용하면 내부적으로 JOIN을 사용한 SQL을 실행해 줌.
  • 객체 지향적인 개발을 가능하게 함
    • DB는 테이블과 컬럼 기반인데, Java는 객체와 필드 기반
    • 이를 매끄럽게 연결해주기 위해 관계 매핑 어노테이션이 필요함.
  • 생산성 향상
    • SQL을 일일이 작성하는 대신, JPA가 알아서 적절한 SQL을 실행해 주니까 개발 속도가 빨라짐.
  • 유지보수 용이
    • 비즈니스 로직이 변경되더라도, 관계 매핑을 어노테이션으로 관리하면 SQL을 직접 수정할 필요가 줄어듦.

 

 

 

2. 어노테이션 종류

1. @OneToMany

개념

하나가 여러 개를 가지는 관계

한 부모 엔티티가 여러 개의 자식 엔티티를 가질 때 사용

예제

1) 단방향 

@Entity
public class Department {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;

    @OneToMany
    @JoinColumn(name = "department_id") // 자식 테이블에 외래 키 컬럼 추가
    private List<Employee> employees = new ArrayList<>();
}
@Entity
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
}

 

 

 

  • @OneToMany는 기본적으로 외래 키를 자식 테이블(Employee)에 두지만, 단방향 매핑을 하려면 @JoinColumn을 사용해야 함.
  • 그렇지 않으면 조인 테이블이 자동 생성됨.

 

 

2) 양방향

@Entity
public class Department {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;

    @OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Employee> employees = new ArrayList<>();
}
@Entity
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;

    @ManyToOne
    @JoinColumn(name = "department_id")
    private Department department;
}

 

 

  • mappedBy = "department": 연관 관계의 주인이 아님을 의미. Employee의 department 필드가 연관 관계의 주인.
  • CascadeType.ALL: Department 삭제 시 관련된 Employee도 자동 삭제.
  • orphanRemoval = true: Department에서 employees 리스트에서 제거되면 DB에서도 삭제됨.

 

 

2. @ManyToMany

개념

여러 개의 엔티티가 여러 개의 엔티티와 관계를 가진다.

ex) 학생과 강의, 한 학생은 여러 강의를 들을 수 있고, 강의를 듣는 학생도 여러명이다.

 

예제

1) 기본 사용법

@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;

    @ManyToMany
    @JoinTable(
        name = "student_course",
        joinColumns = @JoinColumn(name = "student_id"),
        inverseJoinColumns = @JoinColumn(name = "course_id")
    )
    private List<Course> courses = new ArrayList<>();
}
@Entity
public class Course {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String title;

    @ManyToMany(mappedBy = "courses")
    private List<Student> students = new ArrayList<>();
}

 

 

  • @JoinTable을 사용하여 연결 테이블(student_course)을 명시적으로 지정.
  • joinColumns: 현재 엔티티(Student)의 외래 키.
  • inverseJoinColumns: 반대 엔티티(Course)의 외래 키.
  • mappedBy = "courses": Course에서는 연결 테이블을 직접 관리하지 않음.

 

 

2) 중간 엔티티로 풀어서 매핑

 

@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "student")
    private List<StudentCourse> studentCourses = new ArrayList<>();
}
@Entity
public class Course {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @OneToMany(mappedBy = "course")
    private List<StudentCourse> studentCourses = new ArrayList<>();
}
@Entity
public class StudentCourse {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

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

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

 

 

  • StudentCourse를 통해 다대다 관계를 일대다 & 다대일 관계로 변환.
  • 추가적으로 등록 날짜, 성적 같은 컬럼을 넣을 수도 있음.

 

 

3. @OneToMany

개념

하나의 엔티티와 하나의 엔티티가 관계를 가진다.

ex) 사용자와 프로필, 주차공간과 차량 같은 경우

 

예제

1) 단방향

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToOne(cascade = CascadeType.ALL)
    @JoinColumn(name = "profile_id") // 외래 키 지정
    private Profile profile;
}
@Entity
public class Profile {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String bio;
}

 

 

  • @OneToOne은 기본적으로 즉시 로딩(EAGER) 을 사용하므로, 성능을 고려하여 fetch = FetchType.LAZY로 변경하는 것이 좋음.
  • @JoinColumn(name = "profile_id"): User 테이블에 profile_id 외래 키를 둠.

 

2) 양방향

@Entity
public class Profile {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String bio;

    @OneToOne(mappedBy = "profile")
    private User user;
}
  • mappedBy = "profile"을 추가하면 Profile 테이블에는 별도 외래 키 컬럼이 생기지 않음.

 

 

 

4. @ManyToOne

개념

여러 개의 엔티티와 하나의 엔티티가 관계를 가진다.

ex) 여러 명의 직원이 하나의 회사를 다님

예제

@Entity
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "company_id")
    private Company company;
}
@Entity
public class Company {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
}

 

 

  • @ManyToOne(fetch = FetchType.LAZY): 성능을 위해 지연 로딩 설정.
  • @JoinColumn(name = "company_id"): Employee 테이블에 company_id 컬럼 생성.

 

2) 양방향 @OneToMany와 함께 사용

@Entity
public class Company {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "company")
    private List<Employee> employees = new ArrayList<>();
}

 

  • mappedBy = "company": Employee 엔티티의 company 필드가 연관 관계의 주인임을 명시.

 

3. 추가 개념

1. FetchType (EAGER & LAZY)

기본값

 

  • @OneToOne 및 @ManyToOne → EAGER (즉시 로딩)
  • @OneToMany 및 @ManyToMany → LAZY (지연 로딩)

즉시 로딩(EAGER)의 문제점

Employee emp = employeeRepository.findById(1L);
System.out.println(emp.getCompany().getName()); // 회사 정보까지 즉시 로딩됨

 

 

필요하지 않은 데이터까지 불필요하게 가져오니까 성능 문제 발생

 

지연 로딩(LAZY) 적용 방법

@ManyToOne(fetch = FetchType.LAZY)
private Company company;

 

FetchType.LAZY를 설정하면 emp.getCompany()를 호출하기 전까지 데이터를 가져오지 않음.

 

2. Cascade  (영속성 전이)

 

 

부모 엔티티가 저장/삭제될 때 자식 엔티티도 같이 처리되도록 함.

 

CascadeType 종류:

  • ALL: 모든 변경(저장, 수정, 삭제, 병합) 전파.
  • PERSIST: 저장 시 전파.
  • MERGE: 병합 시 전파.
  • REMOVE: 삭제 시 전파.
  • REFRESH: 엔티티 새로고침 시 전파.
  • DETACH: 엔티티 분리.

사용 예제

@OneToMany(mappedBy = "company", cascade = CascadeType.ALL)
private List<Employee> employees = new ArrayList<>();

 

 

 

3. orphanRemoval

부모 엔티티에서 자식 엔티티를 컬렉션에서 제거하면 DB에서도 삭제됨.

 

사용 예제

@OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Employee> employees = new ArrayList<>();
Company company = companyRepository.findById(1L);
company.getEmployees().remove(0); // 첫 번째 직원 제거

 

orphanRemoval = true를 설정하면, 컬렉션에서 제거된 Employee는 DB에서도 삭제됨.

 

 

4. 고려할 점

  • 단방향 vs 양방향
    • 단방향: 간단하지만 조회 시 조인 비용이 증가.
    • 양방향: 객체 참조가 가능하지만, 무조건 mappedBy를 설정해야 불필요한 중복 테이블 생성 방지.

 

  • 연관 관계의 주인 설정
    • mappedBy가 없는 쪽이 연관 관계의 주인.
    • 외래 키를 관리하는 테이블이 주인이어야 함.
  • 다대다(@ManyToMany) 사용 주의
    • @ManyToMany는 연결 테이블을 직접 관리하지 못함.
    • 중간 엔티티를 만들어 @OneToMany & @ManyToOne으로 변환하는 것이 좋다.
  • 쿼리 최적화 필요
    • fetch = FetchType.LAZY 사용 권장. @OneToMany와 @ManyToMany는 기본적으로 LAZY 로딩.
    • @EntityGraph 또는 JOIN FETCH를 사용하여 N+1 문제 해결.

 

5. 결론

결국 이것도 개발자가 SQL을 직접 작성하는 시간을 줄이고, 유지보수를 쉽게 하기 위해 만들어진 것!

 

 

'Spring' 카테고리의 다른 글

[Spring] Spring Security  (0) 2025.02.06
[Spring] N + 1 문제  (0) 2025.02.06
[Spring] 스프링 부트 구조 정리  (0) 2025.02.04
[Spring] 동적으로 HTTP 상태 코드 설정  (0) 2025.02.04
[Spring] Bean  (0) 2025.02.04