DEV/Spring

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

Bi3a 2024. 1. 16. 03:45

728x90

이전 포스팅에서 이어집니다.

 

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

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

doinitright.tistory.com

Spring boot, JPA 기초 깨부시기


 

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

본 부록은 연관관계로 매핑된 두 엔티티에서 서로의 연관관계를 활용해
본인과 연관된 엔티티의 값을 수정하고 이 결과가 정상적으로 트랜잭션 커밋되는지 실험해본 내용이다.

* 앞서 서술했듯이 영속성 컨텍스트에 의해 관리되는 객체에 변경을 가하는 것은 변경 감지로 인해 종속 엔티티와 주인  엔티티 모두에서 변경이 가능하다.
* 따라서, 영속성 컨텍스트에 의해 관리되지 않는 새 연관 엔티티를 생성하고,
  연관 엔티티를 본인의 엔티티 속성으로 집어넣어 해당 내용이 반영되는지 확인해보았다.

 

문답무용, 코드로 설명하겠다.

우선 초기 데이터 설정이다.

 

[Company.java]

@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<>();
}

 

[Employee.java]

@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<>();
}

 

엔티티 클래스는 이전 포스팅과 변동 없이 작성한다.

 

[DataInitService.java]

@Service
@RequiredArgsConstructor
public class DataInitService {
    private final CompanyRepository companyRepository;
    private final EmployeeRepository employeeRepository;

    @PostConstruct
    public void init() {
       // 회사 생성
       Company company = new Company();
       company.setName("ABC Company");
       companyRepository.save(company);

       // 직원 생성
       Employee employee1 = new Employee();
       employee1.setName("John Doe");
       employee1.setCompany(company);
       employeeRepository.save(employee1);

       Employee employee2 = new Employee();
       employee2.setName("Jane Doe");
       employee2.setCompany(company);
       employeeRepository.save(employee2);
       }
    }

 

각자 한개의 회사 엔티티와 해당 회사에 소속된 두개의 사원 엔티티를 생성해 DB에 커밋한다. 

명실상부한 대기업 ABC Company에는 존 도와 제인 도가 소속되어 있다.

 

 

[실행 결과 : json, DB]

{
  "companyId": 1,
  "companyName": "ABC Company",
  "employees": [
    {
      "employeeId": 1,
      "employeeName": "John Doe",
      "companyId": 1
    },
    {
      "employeeId": 2,
      "employeeName": "Jane Doe",
      "companyId": 1
    }
  ]
}

엔티티의 구조에는 변동이 없으며, 각자의 엔티티 서비스 로직에 아래와 같은 메소드를 추가했다.

 

[EmployeeService.java]

@Service
@RequiredArgsConstructor
public class EmployeeService {
    private final EmployeeRepository employeeRepository;
    private final CompanyRepository companyRepository;

    // 조회는 생략 ...

    // employee(연관관계의 주인)을 통해 company(연관관계 종속자)의 이름을 수정하는 메소드
    public void updateCompanyByEmployee() {
       Optional<Employee> opEmployee = employeeRepository.findById(1L);
       Employee employee = opEmployee.get();

       // 영속성 컨텍스트에 의해 관리되는 상태의 엔티티가 아닌 새로운 엔티티 생성
       Company company = new Company();
       company.setName("New Company");
       companyRepository.save(company);

       employee.setCompany(company);
       employeeRepository.save(employee);
       // employee의 companyId(FK)는 null로 연관관계 매핑 X, 이후 컨트롤러에서 NPE 발생
    }
}

 

그러나 ABC Company의 존속을 위협하는 New Company가 등장한다.

EmployeeService에서는영속성 컨텍스트에 감지되지 않는 이 새로운 New Company를 생성하고,

특정 Employee 의 소속 Company를 이 신규 New Company로 변경해보았다.

 

 

[실행 결과 : json, DB]

[
  {
    "employeeId": 1,
    "employeeName": "John Doe",
    "companyId": 2
  },
  {
    "employeeId": 2,
    "employeeName": "Jane Doe",
    "companyId": 1
  }
]

파렴치한 존 도가 이직에 성공했다.

 

이와 같이, mappedBy 로 인해 연관관계에서 주인으로 설정된 Employee에서 Employee 내부 필드의 Company에 대해 수정을 가하는 것은 정상적으로 그 트랜잭션 내용이 반영됨을 확인할 수 있다.

그렇다면 Company에서도 동일한 개념이 적용될지 확인해보자.

 

 

[CompanyService.java]

@Service
@RequiredArgsConstructor
public class CompanyService {
    private final CompanyRepository companyRepository;
    private final EmployeeRepository employeeRepository;

    // 조회는 생략 ...

    // ABC company 의 맨 첫번째 ID의 사원 이름을 수정하는 메소드
    public void updateEmployeeByCompany() {
       Optional<Company> opCompany = companyRepository.findById(1L);
       Company company = opCompany.get();

       // 영속성 컨텍스트에 의해 관리되는 상태의 엔티티가 아닌 새로운 엔티티 생성
       Employee employee = new Employee();
       employee.setName("Foo");
       employeeRepository.save(employee);

       company.getEmployees().set(company.getEmployees().size()-1, employee);
       companyRepository.save(company);
    }
}

 

본 도메인 로직은 1번 company인 ABC Company의 첫번째 사원의 이름을 수정하는 로직이다.

CompanyService에서도 영속성 컨텍스트에 감지되지 않는 새로운 FOO라는 이름의 Employee를 생성하고,

특정 company를 통해 employee 리스트 속 첫번째 사원의 이름을 FOO로 변경해보았다.

 

 

[실행 결과 : json, DB]

null값이 터졌다.
Foo 라는 직원은 생성되었으나 연관관계 매핑에 실패했다.

 

종속 엔티티인 Company에서 주인 엔티티인 Employee에 대한 값 변경은 위의 결과와 같이 연관관계가 매핑되지 않는다는 것을 확인할 수 있었다.

 

 

결론

  • mappedBy 설정으로 인해 다대일 양방향 관계에서 N은 연관관계의 주인이 된다.
  • 연관관계의 주인인 N은 연관된 엔티티를 생성, 수정, 삭제 하는 트랜잭션 권한을 가지고 있다.
  • 연관관계의 종속자인 1은 트랜잭션 권한은 없고, 조회 권한만 가지고 있다.
  • 그러나, 영속성 컨텍스트에 의해 기존에 있는 값을 수정하는 것은 어느 엔티티에서든지 가능하다.
    • 따라서, 특정 엔티티의 서비스 로직에서 다른 엔티티를 변경하는 일은 없게끔 명확한 책임 분리가 요구된다.
    • 예시처럼 하면 절대 안된다.
  • 안전한 구현 방향은 아무래도 양방향 연관관계로 연결된 두 엔티티의 특정 필드가 변경될 시 두 엔티티를 동일하게 변경하는 것이다.
    • 구현 방법에는 편의 메소드나,영속성 컨텍스트의 Persist, Merge를 활용하는 방법이 있을 것이다.

 

# 구현 코드

클릭 시 깃허브로 이동합니다.