문제 상황
동시성 학습 과정에서 강좌의 최대 수강 인원과 현재 수강한 인원에 대해 엔티티 필드로 넣지 않고 @Embedded를 통해 엔티티에 값 객체를 사용하여 책임을 나눠 구현했습니다.
비즈니스 로직은 수강 신청을 할 때, 현재 수강 인원이 최대 수강 인원보다 작다면 수강 신청을 성공하고 강좌에 대해 현재 수강 인원을 증가합니다.
해당 기능을 구현하고 테스트하니 로직이 정상적으로 실행되지 않는 문제가 발생했는데 이에 대해 알아봅시다.
@Embeddable
@Getter
@NoArgsConstructor(access = PROTECTED)
public class EnrollmentCapacity {
@Column(nullable = false)
private int maxCapacity;
@Column(nullable = false)
private int currentEnrollment;
private EnrollmentCapacity(int maxCapacity, int currentEnrollment) {
this.maxCapacity = maxCapacity;
this.currentEnrollment = currentEnrollment;
}
public static EnrollmentCapacity of(int maxCapacity, int currentEnrollment) {
return new EnrollmentCapacity(maxCapacity, currentEnrollment);
}
public void addCurrentCapacity() {
if (currentEnrollment >= maxCapacity) {
throw new IllegalArgumentException("인원이 꽉 찼습니다.");
}
currentEnrollment++;
}
}
최대 수강 인원과 현재 수강 인원을 다루는 EnrollmentCapacity 클래스입니다. addCurrentCapacity 메서드를 통해 현재 수강 인원의 증가가 이뤄지며 해당 객체에서 진행됩니다. 값 타입으로 Course 엔티티의 필드로 사용됩니다.
@Entity
@Getter
@NoArgsConstructor(access = PROTECTED)
public class Course {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Embedded
private EnrollmentCapacity enrollmentCapacity;
public static Course of(String name, EnrollmentCapacity enrollmentCapacity) {
return new Course(name, enrollmentCapacity);
}
private Course(String name, EnrollmentCapacity enrollmentCapacity) {
this.name = name;
this.enrollmentCapacity = enrollmentCapacity;
}
}
강좌 클래스입니다. 위에서 언급한 EnrollmentCapacity 를 필드로 가지고 있습니다.
@Service @Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CourseService {
private final CourseRepository courseRepository;
@Transactional
public CourseResponse applyCourse(Long courseId, String userId) {
// 강좌 조회하기
Course course = courseRepository.findByIdWithLock(courseId).orElseThrow(() -> new EntityNotFoundException("Course not found"));
// 강좌의 EnrollmentCapacity 조회하기
EnrollmentCapacity enrollmentCapacity = course.getEnrollmentCapacity();
//현재 인원 늘리기
enrollmentCapacity.addCurrentCapacity();
return CourseResponse.builder()
.id(course.getId())
.build();
}
}
강좌 신청 로직이 있는 서비스 클래스입니다. 서비스 메서드 로직 순서는 다음과 같습니다.
- 비관적 락을 통해 강좌 조회하기
- 해당 강좌에서 수강 인원 처리에 대한 역할을 가진 EnrollmentCapacity 조회하기
- EnrollmentCapacity를 통해 현재 수강 인원 증가하기
최대 수강 인원과 비교하여 현재 수강 인원을 증가하는 로직입니다.
해당 로직을 수행하면 어떤 문제가 있을지 확인해보겠습니다.
Test
@SpringBootTest
@Transactional
class CourseServiceTest {
@Autowired
CourseService courseService;
@Autowired
CourseRepository courseRepository;
@PersistenceContext // 영속성 컨텍스트를 초기화하여 독립적인 환경을 만들기 위해 필요
EntityManager entityManager;
@DisplayName("해당 강좌에 대해 수강 신청합니다.")
@Test
void test() {
// given
int maxCapacity = 10;
EnrollmentCapacity enrollmentCapacity = EnrollmentCapacity.of(maxCapacity, 0);
Course course = Course.of("강좌1", enrollmentCapacity);
Course savedCourse = courseRepository.save(course);
entityManager.flush(); // 영속성 컨텍스트 초기화 및 DB 반영
entityManager.clear();
// when
CourseResponse courseResponse = courseService.applyCourse(savedCourse.getId());
// then
EnrollmentCapacity result = savedCourse.getEnrollmentCapacity();
assertThat(result.getCurrentEnrollment()).isEqualTo(1);
}
}
부분적으로 코드를 살펴보겠습니다.
@Autowired
CourseService courseService;
@Autowired
CourseRepository courseRepository;
@PersistenceContext
EntityManager entityManager;
courseService : applyCourse 메서드를 실행하기 위함
courseRepository : DB 안에 Course 정보가 없기에 초기 설정을 위함
entityManager : flush/clear를 통해 영속성 컨텍스트를 초기화하여 독립된 환경을 보장하기 위함
// given
int maxCapacity = 10;
EnrollmentCapacity enrollmentCapacity = EnrollmentCapacity.of(maxCapacity, 0);
Course course = Course.of("강좌1", enrollmentCapacity);
Course savedCourse = courseRepository.save(course);
entityManager.flush(); // 영속성 컨텍스트 초기화 및 DB 반영
entityManager.clear();
EnrollmentCapacity 정적 팩토리 메서드로 최대 수강 인원은 10명, 현재 수강 인원은 0명으로 설정하고 Course 객체를 생성하여 JPA를 통해 저장했습니다.
영속성 컨텍스트 저장되어도 다른 외부 요소가 없기에 문제 없지만 독립된 환경을 보장하기 위해 DB에 반영하고 영속성 컨텍스트를 초기화 했습니다.
// when
CourseResponse courseResponse = courseService.applyCourse(savedCourse.getId());
// then
EnrollmentCapacity result = savedCourse.getEnrollmentCapacity();
assertThat(result.getCurrentEnrollment()).isEqualTo(1);
예상대로라면 수강 신청 메서드를 1회 호출했기에 현재 수강 인원은 0에서 1로 변경되어야 합니다. 수행 결과는 어떻게 될까요?
예상과 다르게 테스트가 실패합니다. 왜 실패할까요?
디버깅 시, 값이 1로 변경된 걸 확인했지만 그럼에도 검증 결과는 0으로 나옵니다. 이는 간혹 JPA에서 @Embedded 값 타입의 변경을 JPA 자동으로 감지하지 못하는 경우가 있어 발생한다고 합니다.
JPA의 변경 감지(Dirty Checking) 는 @Embedded 타입에서도 작동해야 하지만, 필드의 변경이 감지되지 않는 경우가 발생할 수 있습니다. 이는 JPA 구현체와 구성에 따라 다를 수 있으며 JPA가 엔티티의 참조(Reference)를 비교하는 방식 때문입니다.
@Embedded 객체 내에서 값이 변경되어도 새로운 객체로 할당되지 않으면, JPA는 해당 필드가 변경되었다고 인식하지 못할 수 있습니다. 따라서 EnrollmentCapacity 내부 필드(currentEnrollment)만 직접 수정하면 변경이 감지되지 않을 가능성이 큽니다.
해결 방법은 다음과 같습니다.
- courseRepository.save(course)
- 새로운 값 객체로 교체하기
저는 1번 방법을 사용해도 해결되지 않아 2번째 방법으로 해결했습니다.
새로운 값으로 교체는 어떻게 이루어질까요?
public class CourseService {
private final CourseRepository courseRepository;
@Transactional
public CourseResponse applyCourse(Long courseId, String userId) {
...
EnrollmentCapacity enrollmentCapacity = course.getEnrollmentCapacity();
enrollmentCapacity.addCurrentCapacity();
return CourseResponse.builder()
.id(course.getId())
.build();
}
}
public class EnrollmentCapacity {
@Column(nullable = false)
private int maxCapacity;
@Column(nullable = false)
private int currentEnrollment;
....
public void addCurrentCapacity() {
...
currentEnrollment++;
}
}
기존에는 현재 수강 인원 증가를 Course 의 필드인 EnrollmentCapacity 값 객체를 통해 수행했습니다. 같은 객체에서 기본 타입(int) 자료형으로 다뤘습니다. 하지만 다음과 같은 방법으로 하면 새로운 값 객체를 대입할 수 있습니다.
public class Course {
@Embedded
private EnrollmentCapacity enrollmentCapacity;
...
public void addCurrentEnrollmentCapacity() {
enrollmentCapacity.addCurrentCapacity();
this.enrollmentCapacity = EnrollmentCapacity.of(enrollmentCapacity.getMaxCapacity()
, enrollmentCapacity.getCurrentEnrollment());;
}
}
이처럼 변경한 이후에 다시 검증하겠습니다.
정상적으로 반영하는 방법은 값 객체에 대한 변화가 있을때는 새로운 객체로 바꿔주는 것입니다. 저는 강좌의 수강인원을 공유하지 않기에 해당 방법을 사용하지 않았지만 값 객체는 같은 자원을 공유할 수 있는 위험이 있기에 사용할 때마다 새로운 값 객체로 바꿔줘야 합니다. 예시로 다음 코드를 보겠습니다.
@Entity @Getter
@NoArgsConstructor
@Table(name = "users")
public class User {
@Id @GeneratedValue(strategy = IDENTITY)
private Long id;
private String name;
@Embedded
private Address address;
public User(String name, Address address) {
this.name = name;
this.address = address;
}
}
@Embeddable @Getter
@NoArgsConstructor
public class Address {
private String street;
private String city;
public Address(String city, String street) {
this.city = city;
this.street = street;
}
public void updateAddress(String street) {
this.street = street;
}
}
위 코드는 사용자 엔티티와 주소를 나타내는 값 타입 코드 입니다.
user1 과 user2 는 ‘서울’, ‘마포’라는 같은 주소를 가지고 있습니다.
위 테스트에서는 user1 의 주소 객체의 street을 마포에서 발산으로 변경했습니다.
user1의 주소만 변경했기에 user1의 주소 변경을 예상했지만 검증 결과로는 user2의 주소도 같이 변경되었습니다. 왜 이럴까요?
출력을 확인해보면 user1 과 user2 모두 같은 인스턴스를 참조하고 있습니다. 그렇기에 생성자를 통해서 다른 인스턴스를 참조하게 해야 합니다.
위 검증 에시와 같이 새롭게 인스턴스를 할당해주니 user1의 주소를 바꾸더라도 user의 주소는 기존 그대로 나오는 것을 알 수 있습니다.
출력창에서 Address 인스턴스도 user 마다 다르게 나오는 것을 볼 수 있습니다.
그렇기에 JPA에서 @Embedded 롤 통한 값 객체를 사용할 때는 매번 생성자를 통해 새로운 인스턴스를 할당해줘야 합니다.
해결책
- 공유할 위험성이 없더라도 JPA 변경 감지를 고려하여 값 객체를 사용할 때는 객체 생성으로 할당하자.
- JPA 변경감지가 일어나지 않는다면 JPA에서 제공하는 save 메서드로 변경 감지를 수동으로 적용하자.