성장일기

내가 보려고 정리하는 공부기록

백엔드/JPA

[Spring] DB와 JPA Persistence Context 불일치 해결하기 - trouble shooting

와나나나 2026. 3. 21. 14:14
반응형

뚱땅뚱땅 개발을 하고있던 어느 날,, 투표가 존재하는 공지사항 삭제가 안 된다는 연락을 받았다. 로그를 열심히 뒤져서 에러로그를 발견했다.

 

 에러 로그를 바탕으로 조금 찾아본 결과, DB와 Persistence Context 불일치로 인한 오류가 발생한 것이었다. JPA를 공부하다보면 영속성 컨텍스트, Persistence Context라는 단어를 많이 접하게 되는데, 이번 기회에 개념을 정리해보고자 한다.

 

 

영속성 컨텍스트 (Persistence Context) 

먼저 영속성이란, 데이터나 객체가 프로그램 종료 후에도 사라지지 않고 지속되는 특성을 의미한다. 그렇다면 영속성 컨텍스트는 JPA가 엔티티를 관리하는 저장소로 appication과 데이터베이스 사이에 있는 메모리 속 1차 캐시라고 이해하면 된다. 이 덕분에 엔티티를 여러 번 조회할 때 매번 DB에 쿼리를 날리는 것이 아니라, Persistence Context에서 가져온다. 즉, 1차 캐시 역할을 해서 성능 측면에서 도움을 준다!

 

 

Entity의 생명주기

JPA는 엔티티의 생명주기를 추척하며 4가지의 상태로 관리한다.

 

비영속

JPA가 아직 모르는 즉, 관리되지 않는 상태를 의미하며, 영속성 컨텍스트에 들어가지 않은 순수 자바 객체

 

영속

Persistence Context가 관리하며 변경사항을 추적하고 있는 상태

 

준영속

한때 영속상태였으나, 더이상 Persistence Context가 관리하지 않는 상태. 

예를 들면 트랜잭션이 종료되었다거나 명시적으로 컨텍스트에서 제거하는 경우 준영속 상태가 됨

 

삭제

DB에서 삭제 예정으로 마킹된 상태

 

 

Dirty Cheking - 변경감지

영속 상태의 엔티티는 트랜잭션이 커밋될 때 JPA가 스냅샷(최초 조회 시점 상태)과 현재 상태를 비교해서 변경된 필드가 있으면 자동으로 UPDATE 쿼리를 날린다.

 이를 Dirty Checking이라고 하며, 개발자가  save() 를 명시적으로 호출하지 않아도 변경이 DB에 반영되는 이유이다.

@Transactional
public void updateNotice(Long id, String newTitle) {
    Notice notice = noticeRepository.findById(id).get(); // 영속 상태
    notice.changeTitle(newTitle); // 그냥 setter
    // save() 호출 없어도 트랜잭션 커밋 시 UPDATE 쿼리 발생
}

 

 

 

@Modifying과 JPQL 벌크 연산

JPA에서 커스텀 DELETE/UPDATE 쿼리를 쓸 때 @Modifying을 붙인 경험이 있을 것이다. 이게 없으면 JPA는 이 쿼리를 읽기 전용으로 간주하고 예외를 던진다. 해당 어노테이션이 붙은 JPQL 벌크 연산은 Persistence Context를 거치지 않고 DB로 직접 쿼리를 날리게 된다.  그렇게 되면 JPQL DELETE는 DB의 레코드를 삭제하지만, Persistence Context에 남아있는 엔티티 객체는 그대로 남아있게 된다. 즉, 

DB 1차 캐시 사이에 불일치(Inconsistency)가 생긴다!

 

위의 오류는 해당 문제로 인해 발생했다. 그런데, 공지에 투표가 있는 경우에만 오류가 발생하고 이미지, 링크는 아무 문제가 없었다. 왜 투표만 터졌는지 코드를 찾아본 결과 원인을 알 수 있었다.

@Override
public void removeContentsByNoticeId(Long noticeId, Long memberId) {
    // Image, Link → 삭제하고 끝. 이후 해당 엔티티에 접근하는 코드 없음.
    saveNoticeImagePort.deleteAllImagesByNoticeId(noticeId);
    saveNoticeLinkPort.deleteAllLinksByNoticeId(noticeId);
 
    loadNoticeVotePort.findVoteByNoticeId(noticeId)
        .ifPresent(vote -> {
            // JPQL DELETE → DB에서 NoticeVote 레코드 삭제
            // 그러나 Persistence Context 캐시엔 아직 남아있음!
            saveNoticeVotePort.deleteAllVotesByNoticeId(noticeId);
 
            // vote.getVoteId()로 추가 UseCase 실행
            //  → 같은 트랜잭션 안에서 voteId로 엔티티를 다시 조회/조작
            //  → 캐시-DB 불일치 충돌 발생!
            deleteVoteUseCase.delete(new DeleteVoteCommand(vote.getVoteId(), memberId));
        });
}

 

delete 후 voteId를 재사용하려고 했기 때문이었다. 이렇게 DB와 1차 캐시 불일치가 만드는 에러 패턴이 몇 개 있었다.

 

1. DB에서 이미 삭제된 row를 캐시에서 찾아 UPDATE 시도 → StaleStateException

2. 삭제된 FK를 가진 캐시 엔티티로 부모 엔티티를 다시 로드정합성 오류

3. EntityManager removed 상태가 아닌 캐시 엔티티를 flush하려다 constraint 위반

 

 

 

문제 해결

해결방법은 간단했다. @Modifying에 Automatically = true 옵션을 넣어주는 것이다. 해당 옵션을 사용하면 벌크 연산(JPQL DELETE/UPDATE) 실행 후 자동으로  EntityManager.clear() 를 호출한다. 이를 통해 Persistence Context를 완전히 비울 수 있다. 1차 캐시에 올라가있던 엔티티들은 준영속상태로 변경된다. 이후 조회를 하면 DB에서 최신 데이터를 로드해올 수 있다.

 

해당 옵션을 사용할 때 주의사항도 존재한다.

 

      벌크 연산 전에 조회한 엔티티를 이후에 재사용할 경우

em.clear() 이후 해당 엔티티는 detached 상태가 된다. 이 상태에서 필드에 접근하면 LazyInitializationException이 발생할 수 있다.

      같은 트랜잭션 안에서 여러 벌크 연산이 있을 경우

각 연산마다 clearAutomatically를 쓰면 앞 연산에서 만든 캐시가 다 날아간다. 필요한 엔티티는 clear 이후 다시 조회해야 한다.

      성능 영향

clear 이후 캐시가 비어있으므로 이후 조회들은 DB에 직접 쿼리를 날린다. 1차 캐시의 이점을 포기하는 것이므로 빈번한 사용은 성능에 영향을 줄 수 있다.

 

 

결론적으로는 캐시와 DB의 불일치 문제였고,,, 해결할 수 있어서 다행이다!