Java/Spring

[JPA] 더티체킹과 update

jaamong 2023. 6. 26. 09:48

Spring Data JPA를 사용하여 만든 업데이트 API를 테스트하다가 변경이 아닌 새롭게 생성되는 것을 확인했다. 이와 관련하여 무엇이 문제였는지, 어떻게 해결할 수 있는지 작성해 보자.

 

상황

Article Entity

import jakarta.persistence.*;
import lombok.*;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "articles")
public class Article {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String writer;
    private String title;
    private String content;
    
    @Builder
    public Article(String writer, String title, String content) {
        this.writer = writer;
        this.title = title;
        this.content = content;
    }
}

 

ArticleService

import com.example.article.dto.ArticleDto;
import com.example.article.entity.Article;
import com.example.article.repository.ArticleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;

import java.util.List;

@Service
@RequiredArgsConstructor
public class ArticleService {

    private final ArticleRepository articleRepository;

	    ...

    public ArticleDto updateArticle(Long id, ArticleDto articleDto) {
        Article findArticle = findArticleById(id);

        Article findArticle = Article.builder()
                .writer(articleDto.getWriter())
                .title(articleDto.getTitle())
                .content(articleDto.getContent())
                .build();
                
        articleRepository.save(findArticle);

        return ArticleDto.from(findArticle);
    }
    
    	...
}

 

  1. 변경하고자 하는 Article 엔티티가 DB에 있는지 확인하고 있으면 해당 엔티티를 가져온다. 없으면 NOT FOUND를 던진다.
  2. 가져온 Article 엔티티에 생성자(Builder)를 사용하여 변경된 값을 할당한다.
  3. 변경된 Article 엔티티를 저장한다.
  4. 저장 후 Article 엔티티를 DTO로 변환하여 반환한다.

이렇게 엔티티와 서비스 로직이 있을 때 해당 서비스 로직을 테스트하면 해당 엔티티의 변경사항이 반영되는 것이 아닌 새로운 엔티티가 생성되었다. 실제로 쿼리를 확인해 보면 UPDATE 쿼리가 아닌 INSERT 쿼리가 나가는 것을 확인할 수 있었다. 이때 생각난 것은 평소 대충 외우고 넘어간 더티체킹이었다.

 

더티 체킹(Dirty Check)

Dirty란 상태에 변화가 생긴 것으로, Dirty Checking은 상태가 변경되었는지 확인하는 것이다. 변화의 기준은 최초 조회 상태이다. 

 

JPA는 엔티티를 조회할 때 해당 엔티티의 조회 상태 그대로 스냅샷을 만들어 놓는다. 그리고 트랜잭션이 끝나는 시점(commit)에 이 스냅샷과 비교하여 다른 점이 있으면 UPDATE 쿼리를 DB에 전달한다. (JPA는 트랜잭션이 끝나는 시점에 변화가 있는 모든 엔티티 객체를 DB에 자동으로 반영한다)

 

이러한 상태 변경 확인의 대상은 영속성 컨텍스트가 관리하는 엔티티에만 적용된다. 

  • 준영속 상태의 엔티티 (detach) : 영속성 컨텍스트가 관리하는 영속 상태의 엔티티가 영속성 컨텍스트에서 분리(detached)된 상태 
  • 비영속 상태의 엔티티 (new/transient) : DB에 반영되기 전 처음 생성된 엔티티 (객체만 생성한 상태)

준영속/비영속 상태의 엔티티는 더티 체킹 대상에 포함 되지 않는다. 따라서 값이 변경되어도 DB에 반영되지 않는다. 

 

어디서 준영속 상태가 된 걸까?

service 로직을 설명한 부분을 다시 보자.

  1. 변경하고자 하는 Article 엔티티가 DB에 있는지 확인하고 있으면 해당 엔티티를 가져온다. 없으면 NOT FOUND를 던진다.
  2. 가져온 Article 엔티티에 생성자(Builder)를 사용하여 변경된 값을 할당한다.
  3. 변경된 Article 엔티티를 저장한다.
  4. 저장 후 Article 엔티티를 DTO로 변환하여 반환한다.

DB에서 find 하여 엔티티를 가져오면 이 엔티티는 영속 상태가 된다. 영속성 컨텍스트는 스냅샷을 찍어서 초기 상태를 저장해 둔다. 그다음이 문제다. 

가져온 엔티티에 생성자(Builder)를 사용하여 변경된 값을 할당한다. 알다시피 생성자는 new를 사용하여 객체를 초기화하는 것이다. 이렇게 생성자로 변경된 값을 할당해 버리면 비영속 상태가 되는 것이다. 

비영속 상태의 객체를 save 하면 UPDATE가 아니라 INSERT 쿼리가 나가게 되므로, 변경이 아닌 새로운 엔티티가 만들어진다. 따라서 이런 상황이 발생하게 된다.

 

고민과 해결

builder 패턴을 사용한 이유는 setter 사용을 피하기 위해서였다. 그런데 builder를 사용하지 않으려면 setter나 이런 류의 메서드가 필요했다. 이와 관련하여 글을 찾아봤는데 인프런에 김영한 님이 달아주신 댓글을 볼 수 있었다. 아래는 간단하게 정리한 글이다.

Builder를 통해 새로 엔티티를 생성하고 기존 엔티티를 변경하지 않으면, 이는 JPA와 관계없는 완전 새로운 엔티티이므로 더티체크가 발생하지 않는다.
따라서 setXxx나 changeXxx와 같은 메서드로 필드값을 변경하면, 더티체크가 일어나고 실제 UPDATE 쿼리가 실행된다.
명심하자! JPA의 엔티티는 설계 당시부터 변경을 가정하고 설계되었다. setXxx나 changeXxx와 같은 메소드로 해당 엔티티를 직접 조회하여 변경하는 것이 JPA가 의도한 설계이다.

 

위 글을 바탕으로 깨달음을 얻고 코드를 수정했다.

 

Article Entity

package com.example.article.entity;

import jakarta.persistence.*;
import lombok.*;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "articles")
public class Article {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String writer;
    private String title;
    private String content;

    @Builder
    public Article(String writer, String title, String content) {
        this.writer = writer;
        this.title = title;
        this.content = content;
    }

    //Id를 수정하는 메소드는 만들지 않는다. 

    public void changeWriter(String changedWriter) {
        //바로 수정하는 것이 아닌 제약조건 필요
        this.writer = changedWriter;
    }

    public void changeTitle(String changedTitle) {
        //바로 수정하는 것이 아닌 제약조건 필요
        this.title = changedTitle;
    }

    public void changeContent(String changedContent) {
        //바로 수정하는 것이 아닌 제약조건 필요
        this.title = changedContent;
    }
}

 

ArticleService

import com.example.article.dto.ArticleDto;
import com.example.article.entity.Article;
import com.example.article.repository.ArticleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

import java.util.List;

@Service
@RequiredArgsConstructor
public class ArticleService {

    private final ArticleRepository articleRepository;

	    ...

    @Transactional
    public ArticleDto updateArticle(Long id, ArticleDto articleDto) {
        Article findArticle = findArticleById(id);

        findArticle.changeWriter(articleDto.getWriter());
        findArticle.changeTitle(articleDto.getTitle());
        findArticle.changeContent(articleDto.getContent());

        return ArticleDto.from(findArticle);
    }
    
    	...
}

 

 

Article 엔티티에 변경을 담당하는 메서드를 추가했다. service 로직에는 builder 대신 변경 메서드를 사용했고, save 대신 @Transactional 애노테이션을 적용했다.

 

이렇게 코드를 고치고는 제대로 UPDATE 쿼리가 나갔고, 테스트할 때 제대로 변경이 되었다!

 

save() 대신 @Transactional?

@Transactional을 적용한 이유는 아까 더티 체킹에서 언급한 내용을 떠올려보자. 더티 체킹은 트랜잭션이 끝나는 시점에 스냅샷과 비교하여 UPDATE 쿼리를 날리므로 save 없이도 변경 사항을 반영할 수 있다.

이 부분에서 오해하면 안 되는 것은 JPA의 더티체킹은 save를 대체하는 것이 아닌 update를 대체하는 것이다. 자세한 내용은 잘 정리된 관련 글을 첨부한다! 

 

질문 - save() vs Transactional · Issue #47 · jojoldu/freelec-springboot2-webservice

@transactional에 대해 찾아보던 중 의문이 생겨 질문드립니다! JPA 엔티티를 업데이트할 때 Dirty Checking을 지원해 트랜잭션 안에서는 save를 명시적으로 호출하지 않아도 commit 시에 판단해서 업데이트

github.com

 

 

setter 사용을 피하는 것에만 집중하다 이런 일이 발생했다... 제대로 공부하지 않은 자의 최후...

 

 

 

 

 

참고

 

강의 중에 값타입의 수정에 대해서 다뤄주셨는데요. 문득, 엔티티 수정에 대해서 궁금해져 질문

안녕하세요. 스프링뉴비입니다.양질의 강의로 JPA 학습을 잘 이어오고 있습니다.다름이 아니라, 엔티티에서 변경감지에 대해 문득 궁금한 내용이 생겨 이렇게 질문 남겨드립니다.흔히 엔티티에

www.inflearn.com

 

더티 체킹 (Dirty Checking)이란?

Spring Data Jpa와 같은 ORM 구현체를 사용하다보면 더티 체킹이란 단어를 종종 듣게 됩니다. 더티 체킹이란 단어를 처음 듣는분들을 몇번 만나게 되어 이번 시간엔 더티 체킹이 무엇인지 알아보겠습

jojoldu.tistory.com