[JPA, Dirty Checking] Dirty Checking결과가 같아도 강제로 updated_at 컬럼을 업데이트해보자.

당근박쥐·2024년 5월 21일
0
post-thumbnail

JPA Update Query 왜 안날라가는거야 ㅜㅜ

개요

내 서비스는 갱신한지 24시간이 지난 소환사의 정보는 업데이트(= Riot API로부터 소환사의 정보를 가져와 DB에 반영하는 작업) 할 수 있다.

그래서 riotUtils.getSummoner 메서드를 통해 새로운 데이터를 가져오고 있고,
"업데이트한지 24시간이 지났는가?"를 확인하기 위해서는 항상 summoner테이블의 updated_at을 갱신해야 했다.
그래서 다음과 같이 코드를 작성했다.

코드

//BaseEntity
@MappedSuperclass
public abstract class BaseEntity {

    private LocalDateTime created_at;
    private LocalDateTime updated_at;

    @PrePersist
    public void prePersist() {
        LocalDateTime now = LocalDateTime.now();
        created_at = now;
        updated_at = now;
    }

    @PreUpdate
    public void updateTimestamp() {
        updated_at = LocalDateTime.now();
    }

    public LocalDateTime getUpdatedAt() {
        return updated_at;
    }
}

Summoner Entity는 BaseEntity를 맵핑하고 있다.

//SummonerService
public SummonerDTO updateSummoner(Long summonerId) {

        Summoner summoner = summonerRepository.findById(summonerId).orElseThrow(() -> new NotFoundException("해당 유저는 존재하지 않습니다."));

        SummonerApiTotalDTO apiResult = riotUtils.getSummoner(summoner.getSummonerName(), summoner.getTagLine());

        summoner.setSummonerName(apiResult.getSummonerName());
        summoner.setTagLine(apiResult.getTagLine());
        summoner.setTier(apiResult.getTier());
        summoner.setRank1(apiResult.getRank());
        summoner.setMmr(apiResult.getMmr());
        summoner.setLevel(apiResult.getLevel());
        summoner.setWins(apiResult.getWins());
        summoner.setLosses(apiResult.getLosses());
        summoner.setIconId(apiResult.getIconId());

        return SummonerDTO.from(summoner);
    }

문제

update api를 호출하여 실행을 해보았지만, 어떤 데이터는 updated_at 컬럼이 잘 갱신되는데, 어떤 데이터는 updated_at 컬럼이 업데이트 되지 않고 있다.

application.properties에 다음을 추가하여 실행되는 쿼리를 확인하였다.

spring.jpa.show-sql = true
spring.jpa.properties.hibernate.format_sql= true

쿼리 확인 결과
summonerRepository.findById코드에 의해 항상 제대로 SELECT쿼리는 실행되었지만,
summoner.setXXX에 의한 UPDATE쿼리는 실행되는경우가 있고, 안되는 경우가 있었다.

"jpa update not working"키워드로 좀 더 자료를 찾아보니, 원인은 JPA의 변경 감지(Dirty Checking)떄문이였다.

Dirty Checking?

더티체킹은 Transaction 안에서 엔티티의 변경이 일어나면, 변경 내용을 자동으로 데이터베이스에 반영하는 JPA 특징이다.

즉 내 상황에서 보자면, 영속화된 summoner Entity와 apiResult의 값이 같아서 summoner.setXxx()를 해도 Dirty Checking후 SummonerEntity에 대하여 변경된 부분이 없다 JPA가 판단해 UPDATE 쿼리가 실행되지 않은것이다.

다시 상기시키자면, 내가 원하는 동작은 JPA Dirty Checking 결과와 무관하게 항상 updated_at은 수정되는것이다.

해결 방법

"이런 문제는 항상 이미 누군가가 겪은 문제고, 이에 대한 해결책이 이미 제공하고 있을것"이란 마인드와 함께 구글링하였고, 방법은 다양한 방법이 있었다.

  1. @EntityListeners()를 통한 방법
    특정 엔티티 이벤트(예: 업데이트) 발생 시 자동으로 메서드를 호출하여 updated_at을 갱신하기

  2. 명시적으로 updated_at 필드를 갱신하기

해결 방법들의 본질은 결국 "JPA에게 변경할 것을 알리는것"이였고, 이 중 더 간편한 2번 방법을 이용하여 해결하였다.

사실 코드하나만 추가하면 된다.

//SummonerService
public SummonerDTO updateSummoner(Long summonerId) {

        Summoner summoner = summonerRepository.findById(summonerId).orElseThrow(() -> new NotFoundException("해당 유저는 존재하지 않습니다."));

        SummonerApiTotalDTO apiResult = riotUtils.getSummoner(summoner.getSummonerName(), summoner.getTagLine());

        summoner.setSummonerName(apiResult.getSummonerName());
        summoner.setTagLine(apiResult.getTagLine());
        summoner.setTier(apiResult.getTier());
        summoner.setRank1(apiResult.getRank());
        summoner.setMmr(apiResult.getMmr());
        summoner.setLevel(apiResult.getLevel());
        summoner.setWins(apiResult.getWins());
        summoner.setLosses(apiResult.getLosses());
        summoner.setIconId(apiResult.getIconId());
        
        //명시적으로 updatedAt 필드를 갱신 
        summoner.updateTimestamp(); // 코드 추가

        return SummonerDTO.from(summoner);
    }

이렇게 하면 Summoner 엔티티가 처음 저장될 때(@PrePersist)와 업데이트될 때(@PreUpdate), 타임스탬프 필드가 자동으로 항상 갱신되어 원하던 동작대로 작동하게 된다.

그럼 UPDATE 쿼리가 두번 실행되진 않을까?

위 코드에서 summoner.setXxx할떄 UPDATE 쿼리 한번,
summoner.updateTimestamp()에 의해 UPDATE 쿼리 한번해서 총 두번의 update가 날라가지 않을까 싶었다. (불필요한 쿼리는결국 쓸데 없이 자원 낭비로 이어지니까!)
그러나 실제 동작은 UPDATE쿼리는 한 번만 실행되는데, 그 이유는

JPA는 엔티티의 필드를 수정할 때마다 개별적인 업데이트 쿼리를 실행하지 않고, 변경된 필드를 추적하여 트랜잭션 커밋 시점에 한 번의 업데이트 쿼리로 모든 변경사항을 반영하기 떄문이다.

이는 JPA의 기본 동작 방법이며, 더티 체킹 메커니즘 + 영속성 컨텍스트에 의해 가능한 동작이였다.

결론

JPA는 변경 감지(Dirty Checking)에 의해 Update쿼리가 안날라 가는 경우가 있다.

여담으로, 변경 감지 문제를 해결하기 집중하려고 무시하고 있었는데, 아래 코드 부분이 되게 뭔가 불편했다.(설명은 지금 할 수 없는데, 제 촉 레이더가 그럼)

summoner.setSummonerName(apiResult.getSummonerName());
summoner.setTagLine(apiResult.getTagLine());
summoner.setTier(apiResult.getTier());
summoner.setRank1(apiResult.getRank());
summoner.setMmr(apiResult.getMmr());
summoner.setLevel(apiResult.getLevel());
summoner.setWins(apiResult.getWins());
summoner.setLosses(apiResult.getLosses());
summoner.setIconId(apiResult.getIconId());

내가 왜 불편해하고 있는지를 찾아보고 포스팅할 예정이다.

참고 자료
https://velog.io/@chiyongs/JPA-JPA%EC%9D%98-UPDATE%EB%B0%A9%EC%8B%9D%EA%B3%BC-Dirty-Checking

profile
Starting the day with "git pull," it's good for mental health.

0개의 댓글