딥러닝 모델과 유저가 대결하는 보드게임 앱 프로젝트를 진행하는 과정에서 발생한 문제입니다. 저는 백엔드를 맡았고 게임의 전반적인 기능들과 게임을 진행하는 API를 구현하였습니다.
게임을 복기하는 기능을 구현하기 위해 다음과 같이 마지막 게임 인덱스를 유저 엔티티에 저장하려고 하였고, 게임이 종료되는 메서드가 호출되는 경우 게임과 연관되어있는 유저 엔티티의 최근 게임 인덱스를 수정하는 로직을 추가하였습니다.
@Entity
public class User{
@Column(name = "last_game_idx")
private Long lastGameIdx;
public Long updateLastGameIdx(Long lastGameIdx) {
this.lastGameIdx = lastGameIdx;
return lastGameIdx;
}
}
@Service
public class GameService{
public ReturnRankingDto endGame(GameEndDto gameEndDto) {
gameEndDto.turnListRight();
Game game = gameRepository.findById(gameEndDto.getGameIdx()).get();
// 승자를 저장함
game.insertWinner(gameEndDto.getWinner());
...
// 최근 게임을 저장
User user = game.getUserFk();
user.updateLastGameIdx(gameEndDto.getGameIdx());
}
}
발생한 문제점 : JPA의 변경감지가 작동하지 않음
위처럼 GameService 마지막 부분에 lastGameIdx를 수정하면 JPA의 변경감지( Dirty checking )를 통해 Game의 winner와 User 엔티티의 lastGameIdx가 변경될 것이라고 생각하였습니다. 하지만 실제로는 그렇지 않았고, 변경 감지가 발생하지 않았습니다.
( 업데이트 쿼리 자체가 발생 x )
다음과 같이 코드를 user 수정 로직 이후 userRepository.save(user);를 호출하여 해결할 수 있었지만 어떤 이유에서 발생하는 문제인지 궁금했습니다.
Game game = gameRepository.findById(gameEndDto.getGameIdx()).get();
game.insertWinner(gameEndDto.getWinner());
gameRepository.save(game);
// 최근 게임을 저장
User user = game.getUserFk();
user.updateLastGameIdx(gameEndDto.getGameIdx());
userRepository.save(user);
왜 그런 것일까요??
이유를 찾기 전에 변경 감지(Dirty Checking)가 발생하는 조건을 알아보겠습니다.
- 기본적으로 JPA에서는 엔티티 매니저(EntityManager)를 사용하여 영속성 컨텍스트(PersistenceContext)에 엔티티를 저장합니다. 이렇게 영속성 컨텍스트에 저장된 엔티티는 보통 트랜잭션을 커밋하는 순간 DB에 반영되고 이를 플러시(Flush)라고 합니다.
- JPA는 엔티티를 영속성 컨텍스트에 보관할 때 최초 상태를 복사하여 저장해두고 (SnapShot) 트랜잭션이 커밋되는 순간(Flush 시점)에 스냅샷과 엔티티를 비교하여 변경된 엔티티를 찾아 수정 쿼리를 생성하여 쓰기 지연 SQL 저장소에 추가합니다.
저의 코드에서 어느 부분이 위 조건을 만족하지 못한 것인지 확인해보겠습니다.
프로젝트에는 Spring data jpa를 사용하였고, Spring data jpa는 기본적으로 JpaRepository를 상속받으며 구현체로 SimpleJpaRepository를 사용합니다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
private static final String ID_MUST_NOT_BE_NULL = "The given id must not be null";
private final JpaEntityInformation<T, ?> entityInformation;
private final EntityManager em;
private final PersistenceProvider provider;
private @Nullable CrudMethodMetadata metadata;
...
}
위와 같이 SimpleJpaRepository는 내부에 EntityManager를 사용하고 있습니다.
즉 트랜잭션의 범위 또한 JPA만 사용하였을 때와 달리 Spring data jpa가 자동으로 지정하고 있었고 저는 이러한 부분을 잘 모른 상태로 사용하여 문제가 발생했던 것입니다.
1) JPA 만 사용한 코드 - 트랜잭션 시작, 커밋을 명시하여 명확하게 알 수 있음.
public void saveUser(){
EntityTransaction transaction = em.getTransaction();
transaction.begin(); // 트랜잭션 시작
.
em.persist(user)
.
transaction.commit() // 트랜잭션 커밋
}
2) Spring data jpa를 사용한 코드 - 트랜잭션이 어디까지 적용되는 건지 알 수 없음
public void saveUser(){
userRepository.save(user);
}
SimpleJpaRepository 내부의 메서드를 살펴보면 다음과 같습니다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
private static final String ID_MUST_NOT_BE_NULL = "The given id must not be null";
private final JpaEntityInformation<T, ?> entityInformation;
private final EntityManager em;
private final PersistenceProvider provider;
@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);
}
}
즉, 클래스와 메서드 단에서 @Transactional을 사용하여 repository 내부 메서드 단위에만 트랜잭션 범위가 적용되는 것이었습니다. 제가 이후 유저를 수정한 부분은 이미 트랜잭션의 범위 바깥으로 변경 감지 조건에 부합하지 못했던 것이었습니다.
( @Transactional에는 Propagation이라는 트랜잭션의 전파 레벨을 지정하는 속성이 있습니다. 기본 값은 Required로 기본적으로 부모 트랜잭션의 범위를 따르며 없는 경우 트랜잭션을 새로 생성하는 레벨입니다. )
Propagation propagation() default Propagation.REQUIRED;
트랜잭션의 범위를 따라서 @Transactional을 생성하여 트랜잭션의 범위를 Service 단의 메서드 범위로 확장하여 이를 해결할 수 있었습니다.
@Transactional
public ReturnRankingDto endGame(GameEndDto gameEndDto) {
Game game = gameRepository.findById(gameEndDto.getGameIdx()).get();
...
// 최근 게임을 저장
User user = game.getUserFk();
user.updateLastGameIdx(gameEndDto.getGameIdx());
}
다음과 같이 game에 save를 명시해주는 경우에도 save 메서드 내부의 flush()를 통해 변경 감지가 발생합니다. 하지만 가독성 부분에서 이해하기 어렵고, 굳이 save 메서드를 따로 호출해야하는 번거로움이 있습니다.
Game game = gameRepository.findById(gameEndDto.getGameIdx()).get();
game.insertWinner(gameEndDto.getWinner());
int winner = gameEndDto.getWinner();
.
.
.
// 최근 게임을 저장
User user = game.getUserFk();
user.updateLastGameIdx(gameEndDto.getGameIdx());
gameRepository.save(game);
실제로 하이버네이트의 구현체인 SessionImpl에 체크포인트를 찍어보면 doFlush()가 실행되는 것을 확인할 수 있습니다.
public class SessionImpl
extends AbstractSharedSessionContract
implements Serializable, SharedSessionContractImplementor, JdbcSessionOwner, SessionImplementor, EventSource,
TransactionCoordinatorBuilder.Options, WrapperOptions, LoadAccessContext {
private static final EntityManagerMessageLogger log = HEMLogging.messageLogger( SessionImpl.class );
// Defaults to null which means the properties are the default
// as defined in FastSessionServices#defaultSessionProperties
private Map<String, Object> properties;
private transient ActionQueue actionQueue;
private transient StatefulPersistenceContext persistenceContext;
.
.
.
private void managedFlush() {
if ( isClosed() && !waitingForAutoClose ) {
log.trace( "Skipping auto-flush due to session closed" );
return;
}
log.trace( "Automatically flushing session" );
doFlush();
}
}