본문 바로가기

Spring

연관관계 매핑

테이블을 하나만 사용하여 애플리케이션의 모든 기능을 구현하기란 불가능에 가깝다. 대체로 설계가 복잡해지면 각 도메인에 맞는 테이블을 설계하고 연관관계를 설정하여 조인(Join) 등의 기능을 활용한다. JPA를 사용하는 애플리케이션에서도 테이블의 연관관계를 엔티티 간의 연관관계로 표현할 수 있다. 다만, 객체와 테이블의 성질이 달라 정확한 연관관계를 표현할 수는 없다.

이러한 제약을 보완하면서 연관관계를 매핑하고, 사용법을 알아보자.

 

1 연관관계 매핑 종류와 방향

 

연관관계를 맺는 두 엔티티 간에 생성할 수 있는 연관관계의 종류는 다음과 같다.

  • One To One: 일대일 (1:1)
  • One To Many: 일대다 (1:N)
  • Many To One: 다대일(N:1)
  • Many To Many: 다대다(N:M)

재고관리시스템을 통해 예를 들어보자. 재고로 등록된 상품 엔티티에는 가게로 상품을 공급하는 공급업체의 정보 엔티티가 매핑돼 있다.

 

공급업체 입장: 한 가게에 납품하는 상품이 여러 개 있을 수 있으므로 상품 엔티티와는 일대다 관계가 된다.

상품 입장: 하나의 공급업체에 속하게 되므로 다대일 관계가 된다.

 

즉, 어떤 엔티티를 중심으로 연관 엔티티를 보느냐에 따라 연관관계의 상태가 달라질 수 있다.

상품 테이블과 공급업체 테이블의 관게

 

데이터베이스: 두 테이블의 연관관계를 설정하면 외래키를 통해 서로 조인해서 참조하는 구조로 생성

JPA: 엔티티 간 참조 방향 설정

  • 단방향: 두 엔티티의 관계에서 한쪽의 엔티티만 참조하는 형식
  • 양방향: 두 엔티티의 관계에서 각 엔티티가 서로의 엔티티를 참조하는 형식

데이터베이스와 관계를 일치시키기 위해 양방향으로 설정해도 무관하지만, 비즈니스 로직의 관점에서 봤을 때는 단방향 관계만 설정해도 해결되는 경우가 많다.

 

연관관계가 설정되면 한 테이블에서 다른 테이블의 기본값을 외래키로 갖게 된다. 이런 관계에서는 주인(Owner)이라는 개념이 사용된다. 일반적으로 외래키를 가진 테이블이 그 관계의 주인이 되며, 주인은 외래키를 사용할 수 있으나 상대 엔티티는 읽는 작업만 수행할 수 있다.

 

1.1 일대일 관계

 

일대일 관계는 한 엔티티가 오직 하나의 다른 엔티티와 관련되면 사용한다. 예를 들어, User 엔티티와 Profile 엔티티가 있다고 가정하자. 각 User는 하나의 Profile만을 가지며, 각 Profile은 하나의 User에 속한다.

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

    @OneToOne(cascade = CascadeType.ALL)
    @JoinColumn(name = "profile_id", referencedColumnName = "id")
    private Profile profile;
}

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

    @OneToOne(mappedBy = "profile")
    private User user;
}

 

@JoinColumn 어노테이션을 사용하여 외래키를 설정할 수 있다. 기본값이 설정돼 있어 자동으로 이름을 매핑하지만, 의도한 이름이 들어가지 않기 때문에 name 속성을 사용하여 원하는 칼럼명을 지정하는 것이 좋다.

@JoinColumn을 선언하지 않으면 엔티티를 매핑하는 중간 테이블이 생기면서 관리 포인트가 늘어나 좋지 않다.

 

@OneToOne 어노테이션은 기본 fetch 전략이 EAGER(즉시로딩)로 채택돼 있다. 그리고 optional() 메서드가 존재하는데, 기본값으로 true가 설정돼 있다. true는 매핑되는 값이 nullable이라는 의미이다.

 

양방향 관계일 때 주의 사항

양방향 연관관계가 설정되면 ToString을 사용할 때 순환참조가 발생하기 때문에 @ToString.Exclude를 사용해야 한다.

(ToString 메서드 내부에서 참조하기 때문)

 

1.2 mappedBy

 

JPA에서도 실제 데이터베이스의 연관관계를 반영해서 한쪽의 테이블에서만 외래키를 바꿀 수 있도록 정하는 것이 좋다. 이 경우 엔티티는 양방향으로 매핑하되, 한쪽에게만 외래키를 줘야 하는데, 이때 사용되는 속성값이 mappedBy이다. mappedBy를 이용하여 어떤 객체가 주인인지 표시한다.

 

JPA에게 관계를 소유한 엔티티를 알려, 데이터베이스의 외래키 칼럼이 중복되는 것을 방지할 수 있다.

 

mappedBy로 설정된 필드는 칼럼에 적용되지 않는다. 즉, 양쪽에서 연관관계를 설정하고 있을 때 RDBMS의 형식처럼 사용하기 위해 mappedBy를 통해 한쪽으로 외래키 관리를 위임한다.

@Entitypublic class Author {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    // 한 Author가 많은 책을 보유
    @OneToMany(mappedBy = "author")
    private List<Book> books;
}

@Entitypublic class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    // 각 Book은 하나의 Author와 연관 (관계의 소유 측면)
    @ManyToOne
    @JoinColumn(name = "author_id")  // This side will contain the foreign key
    private Author author;
}

 

이 예에서 Author 클래스는 관계의 반대 측이고 Book 클래스는 소유 측이다.
Author 클래스의 mappedBy = "author"는 JPA에게 books 목록이 Book 클래스의 author 필드에 매핑된다는 것을 알려준다.

 

1.3 일대다 관계

 

일대다 관계는 한 엔티티가 여러 다른 엔티티와 관련되면 사용한다. Department가 여러 Employees를 가지는 예제를 보자.

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

    @OneToMany(mappedBy = "department", cascade = CascadeType.ALL)
    private List<Employee> employees;
}

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

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

 

여기서 Department 엔티티는 여러 EmployeesList를 가지며, 일대다 관계를 설정한다. Employee 클래스의 @ManyToOne 애노테이션은 역관계를 유지하는 데 도움을 준다.

 

일대다 양방향 연관관계에서는 연관관계 설정을 위한 UPDATE 쿼리가 발생할 수 있다.

@Entitypublic
class Parent {
    @Id
    @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "parent")
    private List<Child> children = new ArrayList<>();
}

@Entitypublic
class Child {
    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "parent_id")
    private Parent parent;
}

 

Parent 클래스에서 Child를 추가하게 되면 JPA는 다음을 수행한다.

1. Child의 parent 속성을 설정

2. UPDATE를 생성하여 Child 테이블의 parent_id 외래키가 올바른 Parent를 가리키는지 확인

 

Child를 추가할 때마다 위 같은 동작이 반복되면 비효율적이다. 이 같은 문제를 해결하기 위해서는 일대다 양방향 연관관계를 사용하기보다는, 다대일 연관관계를 사용하는 것이 좋다.

 

양방향 연관관계인 경우, JPA는 양쪽 간에 동기화를 유지해야 하므로 UPDATE 쿼리가 발생할 수 있는 것이다. 단방향 연관관계에서는 하위 항목만 상위 항목에 대한 참조를 가지며, 상위 항목에는 관리하거나 동기화해야 하는 컬렉션이 없다. 이는 추적 및 수정되는 엔티티 수가 적어서 업데이트 횟수도 줄어든다는 의미이다.

 

1.4 다대다 관계

 

다대다 관계는 두 엔티티가 서로 다수의 관계를 가질 때 사용된다. 각 엔티티에서는 서로를 리스트로 가지는 구조가 된다. 이런 경우에는 교차 엔티티라고 부르는 중간 테이블을 생성하여 다대다 관계를 일대다 또는 다대일 관계로 해소한다. 그러나 이 중간 테이블 때문에 예기치 못한 쿼리가 생길 수 있다. 즉, 관리하기 힘든 포인트가 발생한다. 이 때문에 중간 테이블을 생성하는 대신, 일대다/다대일로 연관관계를 맺을 수 있는 중간 엔티티로 승격시켜, JPA에서 관리할 수 있게 생성하는 것이 좋다.

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

    private String name;

    @OneToMany(mappedBy = "student")
    private List<StudentCourse> studentCourses;
}

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

    private String title;

    @OneToMany(mappedBy = "course")
    private List<StudentCourse> studentCourses;
}

@Entity
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;

    private String grade;
}

 

위 예제는 다대다 관계를 두 개의 일대다/다대일 관계로 유도하여 JPA에서 처리하기 쉽고, 예기치 않은 쿼리를 피할 수 있다.

Student와 Course 간에 직접적인 다대다 관계를 사용하는 대신 중간 실체인 StudentCourse를 사용
StudentCourse에는 grade 같은 추가 속성이 포함되어 있어 관계를 더 유연하고 더 잘 관리할 수 있다.

 

 

2 영속성 전이 (casecade)

 

영속성 전이란 특정 엔티티의 영속성 상태를 변경할 때, 그 엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐, 영속성 상태를 변경하는 것을 의미한다. 예를 들어 @OneToMany 어노테이션의 인터페이스를 살펴보면 아래와 같다.

@OneToMany 어노테이션에 있는 casecade

 

그림을 보면 casecade()라는 요소를 볼 수 있다. 이 어노테이션은 영속성 전이를 설정하는 데 활용된다. casecade 요소와 함께 사용하는 영속성 전이 타입은 아래 표와 같다.

종류 설명
ALL 모든 영속 상태 변경에 대해 영속성 전이를 적용
PERSIST 엔티티가 연속화할 때 연관된 엔티티도 함께 영속화
MERGE 엔티티를 영속성 컨텍스트에 병합할 때 연관된 엔티티도 병합
REMOVE 엔티티를 제거할 때 연관된 엔티티도 제거
REFRESH 엔티티를 새로고침할 때 연관된 엔티티도 새로고침
DETACH 엔티티를 영속성 컨텍스트에서 제외하면 연관된 엔티티도 제외

 

표를 보면 알 수 있둣이 영속성 전이에 사용되는 타입은 엔티티 생명주기와 연관이 있다. 한 엔티티가 casecade 요소의 값으로 주어진 영속 상태의 변경이 일어나면 매핑으로 연관된 엔티티에도 동일한 동작이 일어나도록 전이를 발생시키는 것이다. @OneToMany 어노테이션의 인터페이스 그림을 보면 casecade() 요소의 리턴 타입은 배열 형식인 것을 볼 수 있다. 이 뜻은 개발자가 사용하고자 하는 casecade 타입을 골라 각 상황에 적용할 수 있다는 것이다.

 

특정 상황에 맞춰 영속성 전이 타입을 설정하면 영속 상태의 변화에 따라 연관된 엔티티들의 동작도 함께 수행할 수 있어 개발의 생산성이 높아진다. 다만, 자동으로 동작하는 코드가 정확히 어떤 영향을 미치는지 파악할 필요가 있다. 예를 들어, REMOVE와 REMOVE를 포함하는 ALL 같은 타입을 무분별하게 사용하면 연관된 엔티티가 의도치 않게 모두 삭제될 수 있기 때문에 다른 타입보다 더욱 사이드 이펙트(side effect)를 고려해서 사용해야 한다.

 

 

3 고아 객체

 

JPA에서 고아(orphan)란 부모 엔티티와 연관관계가 끊어진 엔티티를 의미한다. JPA에는 이러한 고아 객체를 자동으로 제거하는 기능이 있다. 물론 자식 엔티티가 다른 엔티티와 연관관계를 맺고 있다면 사용하지 않는 것이 좋다.

@Entitypublic
class Parent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Child> children = new ArrayList<>();
}

@Entitypublic
class Child {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "parent_id")
    private Parent parent;
}

 

Parent 클래스는 Child와 일대다 관계를 가지며, 여기서 orphanRemoval 속성은 true로 설정하였다.
orphanRemoval = true는 부모의 자녀 목록에서 하위가 제거되면 하위 엔티티도 데이터베이스에서 삭제된다는 의미이다.

 

 

4 지연로딩과 즉시로딩

 

엔티티라는 객체의 개념으로 데이터베이스를 구현했기 때문에 연관관계를 가진 각 엔티티 클래스에는 연관관계가 있는 객체가 필드에 존재하게 된다.

연관관계와 상관없이 즉각 해당 엔티티의 값만 조회하고 싶거나, 연관관계를 가진 테이블의 값도 조회하고 싶은 경우 등 여러 조건을 만족하기 위해 등장한 개념이 지연로딩과 즉시로딩이다.

 

지연로딩 (lazy loading)

정의: 엔티티를 처음 가져올 때 관련 데이터가 로드되지 않는 전략이다. 단, 처음 액세스할 때만 로드된다.

 

동작: 지연로딩이 설정된 엔터티를 검색할 때 관련 엔터티를 즉시 가져오지 않는다. 대신 프록시로 유지되며 관련 컬렉션이나 필드에 명시적으로 액세스하는 경우에만 실제 데이터를 가져온다.


사용 사례: 지연 로딩은 불필요한 데이터베이스 쿼리를 피하여 성능을 향상하고 메모리 사용량을 줄이려는 경우에 유용하다.

@OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
private List<Child> children;

 

위 예제에서는 Parent 엔티티를 검색할 때 children 목록이 로드되지 않는다. children에 액세스하려고 하면 데이터베이스에서 데이터를 가져온다.

 

장점

- 대규모 컬렉션이나 복잡한 관계를 즉시 로드하는 것을 방지하여 메모리를 절약하고 성능을 향상시킨다.
- 모든 관계가 항상 액세스되지 않는 시나리오에서 데이터베이스 쿼리 수를 최소화한다.


단점
- 주의 깊게 사용하지 않으면 지연 로딩으로 인해 N+1 문제가 발생할 수 있습니다. 즉, 각 관련 항목에 대한 추가 쿼리로 인해 로드할 항목이 여러 개 있는 경우 성능이 저하될 수 있다.
- 지속성 컨텍스트 외부에서(예: 세션 또는 트랜잭션을 닫은 후) 느리게 로드된 연결에 액세스하면 'LazyInitializationException'이 발생할 수 있습니다.

즉시로딩 (eager loading)

정의: 즉시로딩은 관련 엔티티가 기본 엔티티와 함께 ​​즉시 로드되는 전략


동작: 즉시로딩이 설정된 엔티티를 가져오면 JPA는 일반적으로 조인 쿼리 또는 여러 개의 개별 쿼리를 사용하여 관련 데이터를 자동으로 가져온다.


사용 사례: 즉시 로드는 관련 엔티티가 항상 필요하고 나중에 여러 데이터베이스 쿼리를 피할 때 유용하다.

@OneToMany(mappedBy = "parent", fetch = FetchType.EAGER)
private List<Child> children;

 

위 예에서는 Parent 엔티티가 검색되면 children 목록도 데이터베이스에서 즉시 가져온다.

 

장점
- 대체로 조인을 사용하여 필요한 모든 데이터를 한 번에 가져와 N+1 문제를 방지한다.
- 관련 데이터가 항상 필요하고 엔티티가 로드된 후 즉시 사용해야 할 때 적합하다.


단점
- 관련 엔티티가 크거나 복잡하고 불필요하게 가져오는 경우 성능 문제가 발생할 수 있다.
- 항상 사용되지 않더라도 관련 컬렉션이 모두 메모리에 즉시 로드되므로 관련 컬렉션이 큰 경우 메모리 오버헤드가 발생할 수 있다.

 

예제

지연로딩

@Entitypublic
class Parent {
    @Id
    @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
    private List<Child> children;
}

@Entitypublic
class Child {
    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "parent_id")
    private Parent parent;
}
Parent parent = entityManager.find(Parent.class, 1L); // Fetch Parent
List<Child> children = parent.getChildren(); // children은 여기서 Fetch

 

첫 번째 줄에서는 Parent를 가져오지만, children 컬렉션은 즉시 로드되지 않는다.
getChildren()이 호출되면 관련 Child 항목을 가져오기 위해 추가 쿼리가 수행된다.

 

즉시로딩

Parent parent = entityManager.find(Parent.class, 1L); // Parent와 children을 즉시 Fetch

 

여기서는 children이 나중에 사용되는지에 관계없이 조인이나 여러 쿼리를 사용하여 Parent와 해당 children을 한 번에 가져온다.

 

 

 

 

 

출처: 이 글의 출처는 책 '스프링 부트 핵심 가이드'를 참고하여 작성하였습니다.

https://www.yes24.com/Product/Goods/110142898

'Spring' 카테고리의 다른 글

액추에이터 활용하기  (0) 2024.10.31
유효성 검사와 예외 처리  (1) 2024.10.24
Spring Data JPA  (7) 2024.10.10
스프링 부트와 ORM  (5) 2024.10.03
API를 작성하는 다양한 방법  (1) 2024.09.27