[Spring Boot] Dirty Checking 미동작 오류 해결하기

고리·2023년 12월 9일
1

Server

목록 보기
11/12
post-thumbnail

최근 진행하는 프로젝트의 테스트 코드를 작성하다가 dirty checking이 동작하지 않는 문제가 있었다. 이번 포스팅은 더티 체킹이 동작하지 않는 이유에 대해서 알아보고 이것에 대한 해결 방법을 다룬다.


❗ 문제 발생

@Override
public GetParagraphsRes getParagraphs(Long authorId, Long chapterId) {
    Author author = entityService.getAuthor(authorId);
    Chapter chapter = entityService.getChapter(chapterId);

    List<Paragraph> paragraphs = paragraphRepository.findAllByChapter(chapter);
    log.info("chapter.getStatus() = " + chapter.getStatus());
    if (paragraphs.isEmpty()) {
        return getInitialChParagraph();
    }
    else if (chapter.getStatus() == ChapterStatus.COMPLETED) {
        return getCompletedChParagraph(paragraphs);
    }
    else if (chapter.getStatus() == ChapterStatus.IN_PROGRESS) {
        return getInProgressChParagraph(paragraphs, author);
    }
}

위의 코드는 특정 챕터의 모든 단락(Paragraph)를 가져오는 코드이다. 이걸 테스트하기 위해서 테스트 코드를 아래와 같이 작성했다.

@Test
@DisplayName("작성이 완료된 회차 보기")
void getCompletedChParagraph() {
    // given
    List<Long> idList = Arrays.asList(paragraph1.getId(), paragraph2.getId());

    // when
    chapter.setStatusForTest(ChapterStatus.COMPLETED);
    paragraph1.setParagraphStatus(ParagraphStatus.SELECTED);
    paragraph2.setParagraphStatus(ParagraphStatus.SELECTED);
    GetParagraphsRes paragraphs = paragraphServiceV0.getParagraphs(author.getId(), chapter.getId());

    // then
    assertNull(paragraphs.getMyParagraph());
    assertNull(paragraphs.getBestParagraph());
    assertThat(paragraphs.getSelectedParagraphs().size()).isEqualTo(2);
    paragraphs.getSelectedParagraphs().stream().map(p ->
            assertThat(p.getId()).isIn(idList));

위의 코드는 한 챕터의 StatusCOMPLETED로 변경해 작성 완료된 Chapter의 정보를 가져오는 테스트 코드이다. 하지만 테스트 코드를 실행하면 Status가 변경되지 않는 것을 확인할 수 있다.

얕은 지식으로 생각해보면 managed entity의 value를 변경하면 dirty checking이 동작해서 변경 내용을 테이블에 자동으로 반영했고, 실제로 반영은 되지 않았다.


🔧 이런저런 시도

처음에는 변경 사항이 쓰기 지연(write-behind) 버퍼에 들어가지 않았다고 생각했다. 그래서 직접 확인해보려 했지만 아직 flush되지 않았기 때문에 확인할 수 있는 정보가 없었다.

그러다가 든 생각이 'Flush가 동작하지 않아서 이런 문제가 발생하지 않았을까?' 였다. 코드를 살펴보면 flush가 발생할 수 있는 조건이 없기 때문이다.

flush의 조건

  1. flush() 호출
  2. transaction commit
  3. jpql 실행

그래서 class 내에 EntityManager를 만들어서 flush를 직접 호출하려고 했다. 아래의 코드처럼 말이다.

@PersistenceContext
private EntityManager em;

em.flush();

하지만 문제는 해결되지 않았다. EntityManager.flush()는 영속성 컨텍스트의 변경 내용을 데이터베이스에 즉시 반영하라는 명령이지만, 실제로는 데이터베이스에 반영되지 않는다. 왜냐하면 JPA는 트랜잭션 범위 내에서만 데이터베이스와의 상호작용을 보장하기 때문이다.

실제로 Test 클래스와 Service 클래스에는 모두 @Transactional어노테이션이 붙어 있다. Service 로직에서 새로운 트랜잭션을 시작하게 되고, 새로운 영속성 컨텍스트를 생성하고, 그 안에서 chapter 엔티티를 다시 로드하기 때문에 문제가 발생했다고 추측했다.


❓ 문제 원인

결론부터 말하면 다 틀렸다.

Dirty Checking

우선 앞에서 이렇게 말했었다.

실제로 Test 클래스와 Service 클래스에는 모두 @Transactional어노테이션이 붙어 있다. Service 로직에서 새로운 트랜잭션을 시작하게 되고, 새로운 영속성 컨텍스트를 생성하고, 그 안에서 chapter 엔티티를 다시 로드하기 때문에 문제가 발생했다고 추측했다.

새로운 트랜잭션이 시작되었기 때문에 변경이 적용되지 않았다. 라고 정리할 수 있는데, 새로운 트랜잭션이 시작된 것은 맞다. 하지만 변경이 적용되지 않은 것은 다른 이유다. 아래의 코드를 보자

@Test
void testing() {
    em.merge(chapter);
    chapter.setStatus(ChapterStatus.COMPLETED);
    em.flush();

    ch = chapterRepository.getChapterById(chapter.getId()).get();
    System.out.println("ch.getStatus() = " + ch.getStatus()); // -> IN_PROGRESS
}

위 코드의 실행 결과는 IN_PROGRESS다. 즉 변경 사항이 적용되지 않았다. 왜 그럴까?

merge()를 사용해 entity를 managed state로 바꾸고, 값을 변경하고, flush()를 사용해 변경 내용을 커밋했는데, 왜 바뀌지 않았을까?

처음에는 @Transactional을 class단에서 사용하면 @BeforeEach가 적용된 메서드는 트랜잭션이 적용되지 않기 때문이라고 생각했다. 이곳에 가면 방금 말한 것을 확인할 수 있다.

하지만 진짜 원인은 내 코드에는 find가 없기 때문이다.

영속성 컨텍스트는 엔터티를 로딩할 때 해당 엔터티의 스냅샷을 생성하고, 이 스냅샷과 엔터티의 현재 상태를 비교함으로써 엔터티의 상태 변화를 감지한다. 이것이 바로 Dirty Checking이다.

여기에서 엔터티 수정 시 find를 하고 수정해야 하는 이유를 발견할 수 있다. 이는 영속성 컨텍스트가 관리하는 엔터티의 상태 변화를 올바르게 감지하기 위함이다. 만약 find 없이 엔터티를 수정한다면, 영속성 컨텍스트는 해당 엔터티의 원본 상태를 알 수 없으므로 Dirty Checking을 수행할 수 없다.

find를 통해 엔터티를 조회하면, 해당 엔터티는 영속성 컨텍스트에 등록되고 영속 상태가 된다. 그리고 이 상태에서 엔터티의 데이터를 수정하면, 영속성 컨텍스트는 원본 엔터티와 현재 엔터티의 상태를 비교하여 변화를 감지할 수 있다.

@Test
void testing() {
    chapter = chapterRepository.getChapterById(chapter.getId()).get();
    System.out.println("Before ch.getStatus() = " + chapter.getStatus()); // find

    chapter.setStatus(ChapterStatus.COMPLETED);

    Chapter ch = chapterRepository.getChapterById(chapter.getId()).get();
    System.out.println("After ch.getStatus() = " + ch.getStatus());
}

드디어 변경 감지가 잘 동작하는 것을 확인할 수 있다.

update 쿼리도 잘 생성되는 것을 볼 수 있다!

결국 문제 발생의 원인은 현재 entity의 상태와 비교해 변경 사항을 확인할 수 있는 snapshot이 없었기 때문에 Dirty Checking이 동작하지 않은 것이다.


💡 해결 방법

"testClass의 testMethod에서 변경한 내용이 serviceClass의 serviceMethod에 반영되지 않는 문제를 해결하자" 가 아닌, "testClass의 testMethod에서 변경한 내용이 저장되지 않는 문제를 해결하자." 에서 출발해야 오류를 올바르게 해결할 수 있다.

문제 해결을 위해서는 Dirty Checking을 사용해도 되지만 굳이? 라는 생각이 들었다. 변경한 내용을 save하고 serviceMethod를 호출하는 것이 더 직관적으로 알아볼 수 있다고 생각해서 testMethod를 이렇게 수정하였다.

@Test
@DisplayName("작성이 완료된 회차 보기")
void getCompletedChParagraph() {
    // given
    List<Long> idList = Arrays.asList(paragraph1.getId(), paragraph2.getId());

    // when
    chapter.setStatusForTest(ChapterStatus.COMPLETED);
    paragraph1.setParagraphStatus(ParagraphStatus.SELECTED);
    paragraph2.setParagraphStatus(ParagraphStatus.SELECTED);
    chapterRepository.save(chapter);
    paragraphRepository.save(paragraph1);
    paragraphRepository.save(paragraph2);
    GetParagraphsRes paragraphs = paragraphServiceV0.getParagraphs(author.getId(), chapter.getId());

    // then
    assertNull(paragraphs.getMyParagraph());
    assertNull(paragraphs.getBestParagraph());
    assertThat(paragraphs.getSelectedParagraphs().size()).isEqualTo(2);
    paragraphs.getSelectedParagraphs().forEach(p ->
            assertThat(p.getId()).isIn(idList));
}

🩹 결과

정리

사실 save()로 변경 내용을 저장하고 serviceMethod를 호출하면 됐지만, 어떻게든 Dirty Checking을 적용해보려다가 @Transactional, EntityManager 그리고 Dirty Checking의 작동 원리까지 다시 공부하게 된 것 같다.

잊지 말자 Dirty Checking은 스냅샷과 엔터티의 현재 상태를 비교함으로써 엔터티의 상태 변화를 감지한다. 그렇기 때문에 스냅샷이 없다면 Dirty Checking도 동작하지 않는다.

최종 코드

@Value("${cloud.aws.s3.bucket}")
public String bucket;

public void downloadFolder(String dirName) {
    try {
        dirName = URLDecoder.decode(dirName, StandardCharsets.UTF_8);
        File localDirectory = new File(dirName);
        log.info("Download folder start");
        MultipleFileDownload downloadDirectory = transferManager.downloadDirectory(bucket, dirName, localDirectory);
        downloadDirectory.waitForCompletion();
        log.info("Download folder finish");
        if (!Files.isDirectory(Paths.get(dirName))) {
            throw new AmazonS3Exception("'dirName' Object does not exist");
        }
    } catch (InterruptedException |  AmazonS3Exception e) {
        log.error(e.getMessage());
        throw new AmazonS3Exception(e.getMessage());
    }
}
profile
Back-End Developer

0개의 댓글