Home JPA 프록시 컬렉션
Post
Cancel

JPA 프록시 컬렉션

JPA 프록시에 대해 학습하며 작성한 글입니다. 학습 과정에서 작성되었기 때문에 잘못된 내용이 있을 수 있으며, 이에 대한 지적이나 피드백은 언제든 환영입니다.

image





1. 글을 작성하게 된 계기


회사에서 컬렉션을 포함한 엔티티를 업데이트할 일이 있었는데, 아래와 같은 오류가 발생했습니다. 영속성프록시에 대한 이해가 부족했기 때문에 발생한 문제였는데요, 왜 오류가 발생했고, 어떤 문제가 있었는지를 정리해야겠다는 생각이 들어 이 글을 작성하게 되었습니다.

image









예제에서 사용할 테이블 구조는 다음과 같습니다. 사원이 부서(Department), 담당 업무(Duty) 테이블과 일대 일, 일대 다의 관계를 맺고 있습니다.

image









2. 문제 상황


문제가 발생한 상황을 똑같이 재현하기 위해 연관 관계가 어떻게 맺어져 있는지, 서비스 레이어의 로직은 어떻게 돼 있는지, 테스트는 어떻게 했는지를 차례대로 살펴보겠습니다.

  1. 연관 관계
  2. 서비스 레이어
  3. 테스트 코드





2-1. 연관 관계

연관 관계는 이전 상황과 같습니다. 직원과 부서, 직원과 담당 업무는 일대일, 일대 다의 관계를 맺고 있습니다.

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
@Getter
@Entity(name = "employee")
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name")
    private String name;

    // 일대 다
    @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<EmployeeDuty> employeeDuties = new HashSet<>();

    // 일대 일
    @OneToOne(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true)
    private Department department;

    protected Employee() {
    }

    public Employee(String name) {
        this.name = name;
    }

    public void register(
        Set<EmployeeDuty> employeeDuties,
        Department department
    ) {
        this.employeeDuties = employeeDuties;
        this.department = department;
    }

    public void update(
        Set<EmployeeDuty> employeeDuties,
        Department department
    ) {
        changeDuties(employeeDuties);
        changeDepartment(department);
    }

    private void changeDuties(Set<EmployeeDuty> employeeDuties) {
        this.employeeDuties = employeeDuties;
    }

    private void changeDepartment(Department department) {
        if (department == null || isSame(department)) {
            return;
        }
        this.department = department;
    }

    @Override
    public boolean equals(Object object) {
        if (this == object) return true;
        if (object == null || getClass() != object.getClass()) return false;
        Employee employee = (Employee) object;
        return id.equals(employee.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }

    @Override
    public String toString() {
        return String.valueOf(id);
    }
}
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Getter
@IdClass(DutyId.class)
@Entity(name = "employee_duty")
public class EmployeeDuty {

    @Id
    @Column(name = "employee_id")
    private Long employeeId;

    @Id
    @Enumerated(EnumType.STRING)
    private DutyCode dutyCode;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "employee_id", referencedColumnName = "id", insertable = false, updatable = false)
    private Employee employee;  // insertable, updatable 설정

    @Column(name = "created_at")
    private LocalDateTime createdAt;

    protected EmployeeDuty() {
    }

    public EmployeeDuty(
        Employee employee,
        DutyCode dutyCode,
        LocalDateTime createdAt
    ) {
        this.employeeId = employee.getId();
        this.employee = employee;
        this.dutyCode = dutyCode;
        this.createdAt = createdAt;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        EmployeeDuty that = (EmployeeDuty) o;
        return employeeId.equals(that.employeeId) && dutyCode == that.dutyCode;
    }

    @Override
    public int hashCode() {
        return Objects.hash(employeeId, dutyCode);
    }

    @Override
    public String toString() {
        return String.format("memberId: %s, dutyCode: %s", employeeId, dutyCode);
    }
}
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
32
33
34
35
36
37
38
39
40
41
42
43
44
@Getter
@Entity(name = "department")
public class Department {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name")
    private String name;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "employee_id", referencedColumnName = "id", insertable = false, updatable = false)
    private Employee employee; // insertable, updatable 설정

    protected Department() {
    }

    public Department(
        String name,
        Employee employee
    ) {
        this.name = name;
        this.employee = employee;
    }

    @Override
    public boolean equals(Object object) {
        if (this == object) return true;
        if (object == null || getClass() != object.getClass()) return false;
        Department employee = (Department) object;
        return id.equals(employee.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }

    @Override
    public String toString() {
        return String.valueOf(id);
    }
}









여기서 살펴볼 부분은 update 메서드 인데요, EmployeeDuty의 Set이 들어오면 이를 컬렉션에 추가하거나 제거하는 것이 아닌, 새로 할당해 버립니다.

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
32
33
34
35
@Getter
@Entity(name = "employee")
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name")
    private String name;

    @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<EmployeeDuty> employeeDuties = new HashSet<>();

    @OneToOne(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true)
    private Department department;

    ......

    public void update(
        Set<EmployeeDuty> employeeDuties,
        Department department
    ) {
        changeDuties(employeeDuties);
        changeDepartment(department);
    }

    // 새로 할당
    private void changeDuties(Set<EmployeeDuty> employeeDuties) {
        this.employeeDuties = employeeDuties;
    }

    ......

}









2-2. 서비스 레이어

서비스 레이어에서는 직원을 findById로 조회한 후 담당 업무와 부서를 업데이트해 줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
@RequiredArgsConstructor
class EmployeeUpdateService implements EmployeeUpdateUseCase {

    private final EmployeeRepository employeeRepository;
    private final EmployeeDutyRepository employeeDutyRepository;

    @Override
    @Transactional
    public void update(
        Long employeeId,
        Set<EmployeeDuty> employeeDuties,
        Department department
    ) {
        Employee findEmployee = employeeRepository.findById(employeeId)
            .orElseThrow(EmployeeNotFoundException::new);
        findEmployee.update(employeeDuties, department);
    }

    ......

}









2-3. 테스트

직원을 저장한 후, 직원의 정보를 업데이트해서 어떤 에러가 발생하는지 살펴보겠습니다.

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
32
@DisplayName("[IntegrationTest] 직원 저장 통합 테스트")
class EmployeeRelationUpdateIntegrationTest extends IntegrationTestBase {

    @Autowired
    private EmployeeSaveUseCase employeeSaveUseCase;

    @Autowired
    private EmployeeUpdateUseCase employeeUpdateUseCase;

    @Autowired
    private EmployeeSearchUseCase employeeSearchUseCase;

    @Test
    @DisplayName("EmployeeDuty를 생성할 때, 연관관계를 함께 저장할 수 있다.")
    void employeeCreateWithRelationTest() {
        Employee employee = employeeSaveUseCase.save("Jun", "Developer");

        Set<EmployeeDuty> duties = new HashSet<>();
        EmployeeDuty newEmployeeDuty = new EmployeeDuty(employee, DutyCode.WEB_MARKETING, LocalDateTime.now());
        duties.add(newEmployeeDuty);
        Department newDepartment = new Department("Design", employee);

        employeeUpdateUseCase.update(employee.getId(), duties, newDepartment);

        Employee findEmployee = employeeSearchUseCase.findById(employee.getId());

        assertAll(
            () -> assertTrue(findEmployee.contains(newEmployeeDuty)),
            () -> assertTrue(findEmployee.hasDepartment(newDepartment))
        );
    }
}









이는 다음과 같이 JpaSystemException이 발생하게 됩니다. 회사에서 만난 에러와 똑같은 에러로, 문제 상황이 정확하게 재현되었습니다. 이제 다음으로 어떻게 이를 해결하는지에 대해 살펴보겠습니다.

image









3. 해결


해결 방법은 간단합니다. 값을 재할당하는 대신, 기존에 존재하는 컬렉션에 데이터를 추가/제거해 주는 것입니다.

Usually you want to only “new” the set once in a constructor. Any time you want to add or delete something to the list you have to modify the contents of the list instead of assigning a new list.





3-1. 해결 방법

update 메서드를 재 할당 하는 것이 아닌, 기존 컬렉션에 데이터를 추가/삭제 하도록 변경합니다.

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
32
33
34
35
36
37
38
39
40
41
42
@Getter
@Entity(name = "employee")
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name")
    private String name;

    @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<EmployeeDuty> employeeDuties = new HashSet<>();

    @OneToOne(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true)
    private Department department;

    ......

    public void update(
        Set<EmployeeDuty> employeeDuties,
        Department department
    ) {
        changeDuties(employeeDuties);
        changeDepartment(department);
    }

    // 기존 컬렉션을 활용하도록 변경
    private void changeDuties(Set<EmployeeDuty> employeeDuties) {
        Set<EmployeeDuty> dutiesShouldBeRemoved = new HashSet<>();
        for (EmployeeDuty employeeDuty : this.employeeDuties) {
            if (!employeeDuties.contains(employeeDuty)) {
                dutiesShouldBeRemoved.add(employeeDuty);
            }
        }
        this.employeeDuties.removeAll(dutiesShouldBeRemoved);
        this.employeeDuties.addAll(employeeDuties);
    }

    ......

}

image









3-2. 원인 분석

문제는 간단히 해결됐지만, 왜 이런 문제가 발생했는지를 한 번 살펴보겠습니다. JPA는 엔티티를 찾아올 때, 프록시로 감싸서 가져옵니다. 이는 컬렉션도 마찬가지 인데, 아래와 같이 Session을 PersistentSet으로 감싸서 객체를 생성하는 것을 볼 수 있습니다.

image









위에서 Employee 엔티티가 맺었던 연관관계를 다시 한번 살펴보면, Employee에서 EmployeeDuty를 Set으로 가지고 있습니다. 이는 겉보기에는 자바의 Set 구조이지만, 사실 PersistentSet으로 감싸진 상태입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Getter
@Entity(name = "employee")
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name")
    private String name;

    // 겉보기엔 Set이지만 PersistenceSet으로 감싸진 상태
    @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<EmployeeDuty> employeeDuties = new HashSet<>();

    ......

}









이를 확인해보겠습니다. Employee 엔티티의 수정한 update 메서드를 보면, 로직 마지막에 addAll 메서드가 있습니다.

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
32
33
34
35
36
37
38
39
40
41
42
43
@Getter
@Entity(name = "employee")
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name")
    private String name;

    @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<EmployeeDuty> employeeDuties = new HashSet<>();

    @OneToOne(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true)
    private Department department;

    ......

    public void update(
        Set<EmployeeDuty> employeeDuties,
        Department department
    ) {
        changeDuties(employeeDuties);
        changeDepartment(department);
    }

    private void changeDuties(Set<EmployeeDuty> employeeDuties) {
        Set<EmployeeDuty> dutiesShouldBeRemoved = new HashSet<>();
        for (EmployeeDuty employeeDuty : this.employeeDuties) {
            if (!employeeDuties.contains(employeeDuty)) {
                dutiesShouldBeRemoved.add(employeeDuty);
            }
        }
        this.employeeDuties.removeAll(dutiesShouldBeRemoved);

        // addAll
        this.employeeDuties.addAll(employeeDuties);
    }

    ......

}









여기에 브레이크 포인트를 걸고 디버깅을 해보면 다음과 같이 PersistentSet의 addAll 메서드를 호출하는 것을 볼 수 있습니다. 즉, 해당 메서드를 호출한 순간 프록시 객체를 호출하는 것입니다.

image









프록시를 호출할 때, 실제 타겟의 컬렉션에 이 값을 넣어주는데, 프록시로 감싸진 컬렉션을 마음대로 자바 컬렉션으로 재 할당해 버렸기 때문에 문제가 발생했던 것입니다.

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
@Incubating
public class PersistentSet<E> extends AbstractPersistentCollection<E> implements Set<E> {

	protected Set<E> set;

	......

	@Override
	public boolean addAll(Collection<? extends E> coll) {
		if ( coll.size() > 0 ) {
			initialize( true );

            // Target 호출
			if ( set.addAll( coll ) ) {
				dirty();
				return true;
			}
			else {
				return false;
			}
		}
		else {
			return false;
		}
	}

    ......

}









참고로 PersistentSet는 다음과 같은 상속 계층도를 가지고 있는데, 이는 AbstractPersistentCollection의 구현체로, 내부에 실제 Set 자료 구조(target)를 가지고 있습니다. Set을 상속했기 때문에 addAll과 같은 컬렉션의 메서드를 그대로 사용할 수 있습니다.

image









3-3. 결론

정리하면, JPA가 프록시화 시킨 컬렉션을 반환하는데, 이를 자바 컬렉션으로 바꿨기 때문에 발생한 문제 였습니다.

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
32
33
34
35
36
37
@Getter
@Entity(name = "employee")
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name")
    private String name;

    // 일대 다
    @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<EmployeeDuty> employeeDuties = new HashSet<>();

    // 일대 일
    @OneToOne(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true)
    private Department department;

    ......

    public void update(
        Set<EmployeeDuty> employeeDuties,
        Department department
    ) {
        changeDuties(employeeDuties);
        changeDepartment(department);
    }

    // 마음대로 바꿔치기 해버림
    private void changeDuties(Set<EmployeeDuty> employeeDuties) {
        this.employeeDuties = employeeDuties;
    }

    ......

}









4. 정리


영속화된 엔티티의 컬렉션에 데이터를 추가/삭제할 때는, 기존 컬렉션에 데이터를 추가/삭제해 줘야 합니다. 이는 엔티티를 반환할 때, 내부에 컬렉션이 있다면, 이를 프록시로 감싸서 반환하기 때문입니다. JPA가 프록시로 감싼 컬렉션을 개발자 마음대로 자바 컬렉션으로 바꾼다면, 이는 예기치 못한 동작을 할 수 있습니다.


This post is licensed under CC BY 4.0 by the author.

Rest-Assured ObjectMapper

JPA에서 insertable=false, updatable=false를 하면 반드시 값이 저장되지 않을까?