더티 체킹 테스트와 save 메서드 사용시 영속화 문제

임현규·2023년 4월 9일
0

Meca project 개발 일지

목록 보기
14/27
post-thumbnail

더티 체킹

https://jojoldu.tistory.com/415
더티 체킹에 대해 간략하게 잘 설명되어 있는 블로그다.

더티 체킹이란 트랜잭션 내에서 영속화 상태의 엔티티의 데이터가 변경되면 별다른 요청 없이 트랜잭션 종료시 변경값으로 update 된다.

더티 체킹은 JPA의 핵심 기술이라 볼 수 있다. 기본적으로 영속화를 하면 엔티티는 영속 컨텍스트에 저장되며 entityManager가 이를 관리한다. managed(영속)상태의 데이터는 커밋시에 반영되며 수정 변경된다고 해서 바로 쿼리를 호출하지 않는다. 이렇게 쿼리 생성 및 동작을 지연시키는 방법을 통해 성능을 올릴 수 있다.(db에 데이터를 넣고 조회할 때 커넥션과 projection 작업은 보통 application보다 무거운 작업이다)

더티 체킹은 detach나 비영속 상태에서는 동작하지 않는다.

더티 체킹 테스트에서 발생한 문제

	@Transactional
	public CategoryResponseDto updateCategory(UpdateCategoryRequestDto updateCategoryRequestDto, Id categoryId,
		Id memberId) {
		Category category = categoryChecker.checkAuthority(categoryId, memberId);
		if (updateCategoryRequestDto.getTitle() != null) {
			category.changeTitle(updateCategoryRequestDto.getTitle());
		}
		if (updateCategoryRequestDto.getIsShared() != null) {
			category.changeShare(updateCategoryRequestDto.getIsShared());
		}
		return CategoryMapper.entityToCategoryResponseDto(category);
	}

카테고리 엔티티를 수정하는 코드이다. 그리고 이 코드를 테스트하기 위해 다음과 같은 테스트 코드를 짯다

		@Test
		@DisplayName("수정 요청에 title만 입력된 경우 title만 수정되는지 테스트")
		void updateCategoryWithOnlyTitleTest() {
			// given
			Category category = CategoryTestHelper.generateUnSharedCategory("title", Id.generateNextId(),
				Id.generateNextId());
			categoryRepository.save(category);
			Mockito.doReturn(category).when(categoryChecker).checkAuthority(any(), any());
			UpdateCategoryRequestDto updateCategoryRequestDto = UpdateCategoryRequestDto.builder()
				.title(Title.of("update title"))
				.build();

			// when
			CategoryResponseDto result = categoryService.updateCategory(updateCategoryRequestDto,
				category.getCategoryId(),
				category.getMemberId());

			// then
			Category updatedCategory = categoryRepository.findAll().get(0);
			assertThat(updatedCategory.getTitle()).isEqualTo(Title.of("update title"));
			assertThat(updatedCategory.isShared()).isFalse();
			assertThat(result)
				.extracting("title", "thumbnail", "isShared")
				.containsExactly(Title.of("update title"), null, false);
		}

테스트 코드를 간략히 설명하면 카테고리 엔티티를 생성하고 이를 저장한다. 그리고 저장된 category를 mockito를 활용해서 리턴하고 update된 결과를 확인하는 것이다.

그러나 결과는

전혀 변경되지 않았다. 그리고 심지어 update 쿼리는 호출되지도 않았다!!!

문제 분석하기

값이 변경되지 않는 이유는 크게 2가지가 있을 수 있다.

1. 결과 검사시 잘못된 변수를 가져왔다.
2. 의도한 대로 영속화를 수행하지 않았다.

사실 1번은 확인해본 결과 문제가 없었다. 그렇다면 2번이 문제인데 어디 부분에서 문제가 생긴걸까?

가장 의심스러운 부분은 categoryRepository.save() 부분이였다. 그래서 해당 메서드를 분석해보기로 했다.

CrudRepository의 save() 메서드 분석하기

categoryRepository.save()는 CrudRepository의 메서드이고 이의 구현부는 SimpleJpaRepository에 구현되어 있다. 여기서 save() 메서드를 살펴보니 다음과 같았다.

	@Transactional
	@Override
	public <S extends T> S save(S entity) {

		Assert.notNull(entity, "Entity must not be null.");

		if (entityInformation.isNew(entity)) {
			em.persist(entity);
			return entity;
		} else {
			return em.merge(entity);
		}
	}

코드를 해석하면

  1. Reposiotory 에서 선언한 매개변수 타입 T의 하위 타입 S(entity)를 입력으로 받는다.
  2. 입력한 엔티티의 null을 체크한다.
  3. 엔티티가 새로운 엔티티인 경우 persist를 수행하고 entity를 리턴한다.
  4. 그렇지 않다면 merge를 수행한다.

여기서 간략하게 persist와 merge에 대해서 설명하자면 다음과 같다.

persist: 엔티티를 영속상태로 만든다
merge: 식별자 값으로 엔티티를 조회할 수 있으면 불러서 병합하고 없으면 새로 생성해서 병합한다. 이 때 기존 엔티티가 아닌 영속화된 새로운 엔티티를 반환한다

save 메서드의 목적은 새로운 데이터는 persist, 식별할 수 있는 데이터면 merge()를 호출함을 알 수 있다.

항상 em.merge()가 호출되는 문제

Category를 한번도 영속한 적도 없고 db에도 존재하지도 않고 완전히 처음 생성했는데 persist()가 아닌 merge()가 호출되었다. 이 문제는 entityInformation.isNew(entity))에서 찾을 수 있었다.

entityInformation.isNew(entity)에서 entity가 새로운 데이터임을 판단하는 기준은 Id 즉 엔티티 식별자가 null인 경우이다.

	// AbstractPersistable.java
	@Transient // DATAJPA-622
	@Override
	public boolean isNew() {
		return null == getId();
	}

내 프로젝트의 경우에 엔티티에서 기본 Id 생성 전략을 사용하지 않고 ULID -> UUID 기반 Value Object를 만들어서 Id를 직접 생성했다.

@Embeddable
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Id implements Serializable, Wrapper, Comparable<Id> {

	private static final long serialVersionUID = -2772995063676474658L;

	private UUID uuid;

	public Id(UUID uuid) {
		this.uuid = uuid;
	}

	public Id(String uuid) {
		this.uuid = UUID.fromString(uuid);
	}

	public static Id generateNextId() {
		Ulid ulid = UlidCreator.getMonotonicUlid();
		return new Id(ulid.toUuid());
	}

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

	@Override
	public int compareTo(Id o) {
		return this.uuid.compareTo(o.uuid);
	}
}

그렇기에 새로 생성된 엔티티는 모두 ID를 가지고 있으므로 항상 식별자를 통해 데이터를 다시 조회해보고 수정해서 새로운 영속상태를 반환하는 merge를 사용하는 것이다.

문제 해결하기

save() 메서드는 어떤 조건이든 간에 entity를 반환한다. 즉 save의 의도는 어떤 조건에 따라 동작을 실행하든 반환된 엔티티는 영속화 상태를 보장한다는 점에 있다.

이에 따라 다음과 같이 코드를 수정한다.

		@Test
		@DisplayName("수정 요청에 title만 입력된 경우 title만 수정되는지 테스트")
		void updateCategoryWithOnlyTitleTest() {
			// given
			Category category = CategoryTestHelper.generateUnSharedCategory("title", Id.generateNextId(),
				Id.generateNextId());
			Category savedCategory = categoryRepository.save(category);
			Mockito.doReturn(savedCategory).when(categoryChecker).checkAuthority(any(), any());

			UpdateCategoryRequestDto updateCategoryRequestDto = UpdateCategoryRequestDto.builder()
				.title(Title.of("update title"))
				.build();

			// when
			CategoryResponseDto result = categoryService.updateCategory(updateCategoryRequestDto,
				category.getCategoryId(),
				category.getMemberId());

			// then
			Category updatedCategory = categoryRepository.findAll().get(0);
			assertThat(updatedCategory.getTitle()).isEqualTo(Title.of("update title"));
			assertThat(updatedCategory.isShared()).isFalse();
			assertThat(result)
				.extracting("title", "thumbnail", "isShared")
				.containsExactly(Title.of("update title"), null, false);
		}

savedCategory로 따로 반환하는 것이 귀찮다면 entityManager.persist(category)로 직접 영속화해도 결과는 같다.

결론

save()를 통해 엔티티 조건에 상관 없이 영속화가 보장된 데이터를 얻고 싶다면 반드시 리턴된 변수를 로컬 변수로 저장해서 활용하자.

profile
엘 프사이 콩그루

0개의 댓글