DEV/Spring

[Spring Data JPA] 1 : N, N : 1 관계의 양방향 매핑과 단방향 매핑

Bi3a 2024. 1. 16. 03:29

728x90

Spring Data JPA
Spring boot 기초 깨부시기


양방향 매핑과 단방향 매핑

스프링 JPA를 거쳐가며 게시판을 만들어 본 사람이라면
엔티티 연관관계 매핑을 할 때 무조건 만나봤을 그 어노테이션들이다.
특히 글과 댓글과 같은 다대일 관계에서 자신도 모르게 양방향 매핑 또는 단방향 매핑을 사용하게 된다.

오늘은 양방향 매핑과 단방향 매핑의 개념과 사용 용도, 그리고 차이점에 대해 알아보겠다.

 

엔티티 정의

아래와 같이 회사와, 사원 엔티티가 있다고 가정하자. 

회사는 여러 사원을 가질 수 있으나, 사원은 한 개의 회사에만 소속될 수 있다.
그렇다면 회사, 사원은 1 : N 관계 (일대다 관계)가 성립한다.
반대의 경우 사원 , 회사는 N: 1 관계 (다대일 관계)가 성립한다.
본 예제를 활용하며 1 : N , N : 1 관계에서의 단방향 매핑과 양방향 매핑에서의 코드 변화와 개념에 대해 설명한다.

[주의할 점]
여기서 정의하는 단방향 매핑과 양방향 매핑은 DB의 데이터를 객체화하는 ORM (객체 관계 매핑)에서
사용하는 방식이며, DB에서는 통용되는 개념이 아니다.
* 이유 : 
 - DB : 테이블 연관관계가 성립되면 FK(외래 키)로 인해 조인(연결) 되므로 단방향 / 양방향 개념이 의미가 없다.
 - 객체 : 참조를 사용해서 연관된 객체를 찾기 때문에 참조에 의한 객체를 한쪽이 들고 있는가, 양쪽이 있는가에 따라서 코드 구성과 사용 방식이 달라진다.
@Entity
@Table (name = "company")
public class Company {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long companyId;

    private String name;   
}
@Entity
@Table (name = "employee")
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long employeeId;

    private String name;
}

 

다대일 관계(N : 1) 에서의 단방향 매핑

일반적인 일대다, 다대일 매핑에서는 항상 다 쪽에 있는 테이블이 1의 PK를 외래키로써 가지게(참조하게) 된다.

 

단방향 매핑
1 : N, N: 1 관계에서 Company, Employee의 단방향 테이블 매핑

 

 

이를 ORM, JPA 관점에서 코드를 작성해 보면 아래와 같이 작성될 수 있다.

@Entity
@Table (name = "company")
@NoArgsConstructor
@Setter
public class Company {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long companyId;

    private String name;
}
@Entity
@Table (name = "employee")
@NoArgsConstructer
@Setter
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long employeeId;

    private String name;
    
    // 사원 엔티티에 회사와의 연관관계가 있음을 명시한다.
    @ManyToOne 
    @JoinColumn(name = "company_id") // 명시적으로 companyId FK 칼럼의 이름을 지정해줌
    private Company company;
}

 

 

다대일 단방향 연관관계의 개념

  • 본 코드는 @ManyToOne을 통해 사원 엔티티는 회사 엔티티를 참조함으로써 회사의 정보를 가진다.
  • 회사 엔티티는 사원 엔티티를 참조하지 않는다.
    • DB 테이블상 EMPLOYEE가 COMPANY의 PK를 참조해 company_id를 FK로 가지는 형태와 유사하다.  
  • 두 가지

성질에 의해 해당 연관관계는 다대일 단방향 연관관계로 정의된다.

 

다대일 단방향 매핑에서 도메인 로직 (엔티티 생성과 조회 등) 은 아래와 같은 방식으로 이루어질 것이다.

 

 

사원 엔티티 생성

@Service
public class EmployeeService {
	private final EmployeeRepository employeeRepository;

    // Employee 생성
    @TransActional
    public void addEmployee(Company company) { 
        Employee employee = new Employee();
        employee.setName("Foo");
        employee.setCompany(company); // 매핑할 회사를 불러옴

        employeeRepository.save(employee);
    }
}

 

 

사원 엔티티 조회

public interface EmployeeRepository extends JpaRepository<Employee, Long> {
    
    // 특정 회사에 속한 직원들을 조회하는 메서드
    List<Employee> findByCompany(Company company);
}
@Service
public class EmployeeService {
	private final employeeRepository;

    // Employee 조회
    @TransActional(readonly = "true")
    public List<Employee> findByCompany(Company company) { 
        return employeeRepository.findByCompany(company);
    }
}

 

 

다대일 단방향 연관관계의 장점

  • 가장 기본적인 연관관계 정의로 유지보수 용이
    • DB 테이블과 가장 유사한 연관관계로 이후의 연관관계를 양방향으로 확장하는 것을 고려할 수 있다.
  • 도메인 책임 분리
    • 회사(1)와 관련된 사원(N) 엔티티를 조회하거나 수정하는 도메인 로직이 반드시 FK를 관리하는 사원(N)에 의해 이뤄질 수밖에 없기 때문에 명확한 책임분리가 가능해진다.
  • 엔티티 코드의 간결성

 

 

다대일 단방향 연관관계의 단점

  • 편의성 감소
    • 참조되는 엔티티에서 연관된 엔티티를 조회할 수 있는 방법이 없음
      • 회사(1) 에서 사원(N) 엔티티를 조회할 수 있는 방법이 없다. 따라서 분리된 도메인 로직에 따라 회사가 본인 회사의 사원을 조회하고자 할 시 사원의 도메인 로직을 사용해야 한다.
      • 회사 호출 로직과 회사와 연관된 사원 호출 로직이 분리됨에 따라 코드가 길어질 우려가 있다.

 

다대일 단방향 관계는 다 쪽이 1의 정보를 가지고 있으나, 1이 다의 정보를 가지지 않기 때문에 단방향이다.

즉, 1에도 다의 정보를 추가하게 되는 순간 연관관계는 단방향에서 양방향으로 다시금 정의된다. 

 

 

다대일 양방향 연관관계의 개념

양방향이 된 시점에 다대일, 일대다 양방향 연관관계는 동일한 개념이다.
본 포스팅에서는 개념의 혼란을 줄이기 위해, 다대일 양방향 연관관계로 용어를 통칭한다.
DB 개념에서는 양방향과 단방향 둘 다 다 쪽이 외래 키를 가지고 있기 때문에 구조적 차이가 없다.

하지만 ORM과 객체 관점에서 단방향과 양방향의 차이는 아래와 같다.
단방향 : N 방향의 엔티티 기반 테이블만이 FK를 가지고 있듯이 1 방향의 엔티티를 N 방향 엔티티만 참조한다.
양방향 : FK를 가지고 있지 않은 1 방향의 엔티티도 N 방향 엔티티를 참조한다.

즉, 양방향은 1과 N이 서로 단방향 ↔ 단방향으로 참조하기에 양방향 관계로 정의된다.

 

 

양방향 테이블 매핑
1 : N, N: 1 관계에서 Company, Employee의 양방향 테이블 매핑

 

 

이를 ORM, JPA 관점에서 코드를 작성해 보면 아래와 같이 작성될 수 있다.

 

@Entity
@Table (name = "company")
@NoArgsConstructor
@AllArgsConstructor // Builder 사용을 위해 추가
@Builder // Builder 사용을 위해 추가
@Setter
public class Company {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long companyId;
    
    private String name;

    // @OneToMany로 회사는 회사에 소속된 사원을 참조하고 이를 리스트(목록)로 가진다.
    @OneToMany
    @Builder.Default // 최초 엔티티 인스턴스 생성 시 초기화 값 지정, NPE 방지 위함
    private List<Employee> employees = new ArrayList<>();
}
@Entity
@Table (name = "employee")
@NoArgsConstructer
@Setter
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long employeeId;

    private String name;
    
    // @ManyToOne으로 사원은 회사를 참조해 사원별 소속 회사 정보를 가진다.
    @ManyToOne 
    @JoinColumn(name = "company_id") // 명시적으로 companyId FK 칼럼의 이름을 지정해줌
    private Company company;
}

 

 

위의 코드를 통해 아래와 같이 서로 단방향으로 연관관계를 매핑했고, 이로 인해 양방향 연관관계가 성립한다.

  • 다수의 사원은 한개의 회사만에 소속될 수 있음 → 사원 엔티티는 @ManyToOne으로 회사 엔티티를 참조
  • 한 개의 회사는 다수의 사원을 가질 수 있음 → 회사 엔티티는 @OneToMany로 회사 엔티티를 참조

 

그러나, 양방향 연관관계에서는 연관관계의 주인을 지정해서 ORM 체계에 알려야 하는데, 주인을 지정해야 이유와 Spring JPA에서의 연관관계의 주인 지정 방법, 그 예시에 대해 알아보자.

 

 

mappedBy, 연관관계의 주인 지정

Spring JPA 에서는 mappedBy 속성을 통해  연관관계를 지정한 두 엔티티 간에 실질적인 제어권을 가지는 연관관계의 주인과 종속 엔티티를 지정할 수 있다.

 

 

양방향 연관관계에서 연관관계의 주인을 지정해야 하는 이유는 DB와 객체의 패러다임의 차이에서 기인한다.

 

[양방향 연관관계에서 연관관계의 주인을 지정해야 하는 이유]

    1. DB와 객체의 관점에서 연관관계는 다르기 때문
      • 특정 회사에 소속된 사원 정보를 조회하는 로직을 구현한다고 가정하자.
      • DB는 그저 FK로 두 테이블을 연결할 뿐이고, 이 FK의 기반인 PK는 한쪽 테이블에만 들어있다.
        • 따라서 DB의 관점에서는 조회 로직에는 아래와 같은 한 가지 방식만이 존재한다.
SELECT name 
FROM EMPLOYEE 
LEFT JOIN COMPANY 
ON EMPLOYEE.companyId = COMPANY.id 
where COMPANY.name = "Foo";

 

    • 그러나 객체의 관점에서는 위의 코드와 같이 작성한 경우 특정 회사에 소속된 사원 정보를 조회하는 접근하는 방식은 두 가지 방식이 존재할 것이다.
      • 회사 엔티티를 통해 조회 : Company.getEmployeeList로 불러오는 방법
      • 사원 엔티티를 통해 조회 : Employee.findByCompany(company)로 List로 불러오는 방법 
      • JPA와 같은 ORM은 이러한 객체의 두가지 접근 방식을 DB와 같이 한 가지 방식으로 일치시킴으로써 DB와 객체의 간극을 줄여 쿼리를 적절히 최적화하고자 시도한다. 

 

2. DB 테이블 생성, 삭제, 수정 등 트랜잭션 권한 주체 선정의 필요성

  • 그렇다면 특정 회사에 소속되어 있는 사원의 이름과 같은 특정 필드를 수정하는 로직은 어떨까?
  • 1번에서의 연장선으로, DB의 관점에서는 조회 로직과 동일하게 한가지 방식으로 Update Table을 하면 된다.
  • 1번에서의 연장선으로, 객체의 관점에서는 조회 로직과 동일하게 두가지 방법으로 수정이 가능할 것이다.
    • 회사 엔티티를 통해 수정 : company.getEmployeeList.get(index).setName(name)
    • 사원 엔티티를 통해 수정 : Employee.findByCompany(company). get(index). setName(name)
  • 그러나 여기서 한 가지 문제점을 생각할 수 있다.
    • 과연 사원의 특정 필드를 수정하는 로직인데 회사 엔티티를 통해 사원 정보를 수정하는 방식이 옳을까?
    • JPA는 이러한 객체 간 양방향 관계의 접근 방식에서 연관관계의 주인을 설정함으로써 한쪽의 객체에서만 올바르게 생성, 수정, 삭제할 수 있는 권한을 부여한다. (나머지 쪽은 조회만 가능하다)

 

 

[양방향 연관관계에서 연관관계의 주인을 지정하는 방법]

N : 1의 관계에서는 외래키를 사용하는(참조하는) N을 주인으로 지정하면 된다.
예제의 경우에는 Employee (사원)을 연관관계의 주인으로 정의한다.

 

 

앞선 예제에서 살펴보았듯이, 사원의 특정 필드를 수정하는 로직이 회사 엔티티(N 방향의 엔티티)를 통해 이뤄져서는 안 된다. 따라서, 연관관계의 주인은 N 쪽에 설정을 한다.

Spring Data JPA에서 연관관계 설정은 mappedBy를 이용하고,  아래와 같이 Company 엔티티를 수정한다.

 

@Entity
@Table (name = "company")
@NoArgsConstructor
@Setter
public class Company {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long companyId;
    
    // mappedBy : 정의된 엔티티는 종속 엔티티, 정의되지 않은 엔티티는 주인 엔티티
    // mappedBy = "company" : 연관관계의 주인 엔티티에서 company 필드에 의해 관리됨을 의미
        // 즉, Employee가 가지고 있는 FK가 Company의 PK이므로 company 필드를 매핑시켜줌
    @OneToMany(mappedBy = "company")
    @Builder.Default
    private List<Employee> employees = new ArrayList<>();
}
@Entity
@Table (name = "company")
@NoArgsConstructor
@Setter
public class Company {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long companyId;
    
    // mappedBy : 정의된 엔티티는 종속 엔티티, 정의되지 않은 엔티티는 주인 엔티티
    // mappedBy = "company" : 연관관계의 주인 엔티티에서 company 필드에 의해 관리됨을 의미
        // 즉, Employee가 가지고 있는 FK가 Company의 PK이므로 company 필드를 매핑시켜줌
    @OneToMany(mappedBy = "company")
    @Builder.Default
    private List<Employee> employees = new ArrayList<>();
}

 

 

mappedBy 속성을 가지고 있는 엔티티는 해당 속성을 통해서 JPA에 본인이 연관관계의 주인이 아님을 알린다.

 

 

Employee 엔티티는 변동 없다.

 

@Entity
@Table (name = "employee")
@NoArgsConstructer
@Setter
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long employeeId;

    private String name;
    
    @ManyToOne 
    @JoinColumn(name = "company_id") 
    private Company company;
}

 

 

다대일 양방향 연관관계의 장점

  • 편리성 증가 : 특정 엔티티를 기반으로 연관된 엔티티를 모두 쉽게 검색 가능
  • 도메인 로직에서 코드 감소 : 1 방향의 엔티티에서도 N 관련 테이블 조회를 할 수 있기 때문에 코드가 간결
    • ex) 회사 엔티티 내 사원 리스트를 조회 : List <Employee> employess = company.getEmployees();

 

 

다대일 양방향 연관관계의 단점

  • 복잡성 및 유지보수 어려움
    • 영속성 콘텍스트가 유지되는 전제에서는 종속되는 엔티티에서도 주인 엔티티 관련 필드를 수정 / 삭제할 수 있다.
    • 따라서, 이에 대한 명확한 대처가 필요하며, 도메인 로직을 구상할 때 명확한 책임 분리가 요구된다.
  • 순환참조 문제
    • @ResponseBody로 날 것의 Company를 던진다고 가정할 시 발생할 수 있는 문제
      • Company의 Employees 안의 Employee 엔티티들은 Company 정보를 가지고 있다.
      • Company 가 Employee 호출 -> Employee 가 Company 호출 → Company 가 Employee 호출...
      • 무한한 재귀호출에 빠질 가능성이 있으므로 이에 따라 던질 값에 대한 명확한 DTO가 요구된다. 

 

 

참고 내용

  • @OneToMany, @ManyToOne은 기본적으로 FetchType.Lazy가 걸려있다.
  • Lazy 로딩으로 데이터를 불러오기 때문에 연관관계 매핑이 되어 있는 DB 조회 시 성능 최적화가 된다.
    • Eager 로딩으로 한꺼번에 모든 데이터를 불러오면 불필요한 데이터가 한꺼번에 많이 조회되게 된다.

 

[부록] 연관관계 주인이 트랜잭션 권한을 가지는 예시

 

[Spring Data JPA] mappedBy에 의한 연관관계 주인이 트랜잭션 권한을 가지는 예시

이전 포스팅에서 이어집니다. [Spring Data JPA] 1 : N, N : 1 관계의 양방향 매핑과 단방향 매핑 양방향 매핑과 단방향 매핑 스프링 JPA를 거쳐가며 게시판을 만들어 본 사람이라면 엔티티 연관관계 매

doinitright.tistory.com