일반적으로 복잡한 로직을 작성할 때 @Transactional
안에서 여러 개의 로직을 구현하는 경우가 많이 있다.
Spring Data Jpa
를 사용하여 해당 메소드 내에서 find, modify를 사용하는 경우 기본적으로 영속성 컨텍스트에서 관리를 해주는데 JPQL로 작성된 SQL이 있는 경우 쿼리 결과가 영속성 컨텍스트에 갱신되지 않아서 실제 DB와 메모리 상의 값이 다른 경우가 있다.
이런 케이스에 대해 예제를 만들어보고 어떻게 처리할 수 있는지 알아보자.
시나리오
- 사용자 등록
- 특정 서비스 메소드를 작성하고 사용자의 이름을 변경
- 해당 서비스 메소드 내에서 사용자 이름을 조회해보자.
JpaRepository의 save 이용시
소스 코드
@Transactional
public void modify(User user) {
User oldUser = find(user.getUserId());
log.info("old user: " + oldUser.getName());
userJpaStore.update(user);
User newUser = find(user.getUserId());
log.info("new user: " + newUser.getName());
}
public User find(int userId) {
return userJpaStore.findById(userId);
}
@Repository
@RequiredArgsConstructor
public class UserJpaStore {
private final UserRepository userRepository;
public User findById(int userId) {
return userRepository.findById(userId).map(UserJpo::toDomain)
.orElseThrow(() -> new IllegalArgumentException("user not found"));
}
public void update(User user) {
userRepository.save(new UserJpo(user));
}
}
실행
user의 이름을 홍길동 -> 홍길동2로 update해보자.
실행결과
조회를 해보면 new user가 홍길동2
로 잘 조회되는 것을 확인할 수 있다.
2023-09-05 14:33:56.560 INFO 24525 --- [nio-8080-exec-5] com.example.service.UserService : old user: 홍길동
2023-09-05 14:33:56.561 INFO 24525 --- [nio-8080-exec-5] com.example.service.UserService : new user: 홍길동2
JPQL 사용
소스코드
JPQL을 이용하여 사용자 이름을 저장해보자. 아래에서 updateUser를 통해 저장한다.
public interface UserRepository extends JpaRepository<UserJpo, Integer> {
@Transactional
@Modifying
@Query("UPDATE UserJpo t "
+ "SET t.name = :name "
+ "WHERE t.userId = :userId ")
void updateUser(
@Param("userId") int userId,
@Param("name") String name
);
}
@Transactional
public void modify(User user) {
User oldUser = find(user.getUserId());
log.info("old user: " + oldUser.getName());
userJpaStore.updateByQuery(user);
// 변경된 값을 find로 다시 조회
User newUser = find(user.getUserId());
log.info("new user: " + newUser.getName());
}
실행
user의 이름을 홍길동 -> 홍길동2로 update해보자.
실행결과
조회를 해보면 new user가 홍길동
로 조회가 된다. JpaRepository의 save를 통해 저장한 경우 잘 조회가 되었지만 JPQL에서는 정상적으로 조회가 되지 않는다.
2023-09-05 14:38:32.520 INFO 24633 --- [nio-8080-exec-5] com.example.service.UserService : old user: 홍길동
2023-09-05 14:38:32.529 INFO 24633 --- [nio-8080-exec-5] com.example.service.UserService : new user: 홍길동
원인 분석
왜 그런지 소스코드를 분석해봤다.
spring-data-jpa의 SimpleJpaRepository
소스코드를 열어서 보니 findById
는 EntityManager에서 값을 가져온다.
@Override
public Optional<T> findById(ID id) {
...
return Optional.ofNullable(type == null ? em.find(domainType, id, hints) : em.find(domainType, id, type, hints));
}
JpaRepository를 통해 수정한 경우는 EntityManager에 merge를 통해 값을 변경시키는데 JPQL을 사용하는 경우 EntityManager를 통하지 않고 직접 DB를 update한다. 그래서 조회할 시점에서 DB값과 EntityManager 값의 불일치가 생긴것이다.
@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
public void modify(User user) {
User oldUser = find(user.getUserId()); // 영속성 컨텍스트에 User를 저장 (홍길동)
log.info("old user: " + oldUser.getName());
userJpaStore.updateByQuery(user); // JPQL로 DB를 직접 업데이트
// 변경된 값을 find로 다시 조회
User newUser = find(user.getUserId()); // 영속성 컨텍스트에는 여전히 '홍길동'으로 저장
log.info("new user: " + newUser.getName());
}
JpaRepository
를 사용했을 경우에는 userJpaStore.update(user);
에서 영속성 컨텍스트에 값을 변경하기 때문에 find
시 영속성 컨텍스트를 조회하면 '홍길동2
'로 조회가 되었다.
해결방안
JPQL로 실행 시 영속성 컨텍스트와 값이 일치하지 않는 문제를 해결하기 위해 JPQL 실행 후 영속성 컨텍스트 값을 지워주는 방법이 있다.
이 방법이 @Modifying(clearAutomatically = true)
이다. (기본값: false)
기존 JPQL로 작성된 코드에서 clearAutomatically = true를 붙여주고 다시 실행해보자.
@Transactional
@Modifying(clearAutomatically = true)
@Query("UPDATE UserJpo t "
+ "SET t.name = :name "
+ "WHERE t.userId = :userId ")
void updateUser(
@Param("userId") int userId,
@Param("name") String name
);
실행결과
2023-09-05 15:03:49.503 INFO 25860 --- [nio-8080-exec-3] com.example.service.UserService : old user: 홍길동
2023-09-05 15:03:49.511 INFO 25860 --- [nio-8080-exec-3] com.example.service.UserService : new user: 홍길동2
실제 내부적으로 SQL이 어떻게 실행되는지 확인하기 위해 hibernate 쿼리를 찍어보자.
application.yml에 아래 부분을 추가한다.
spring:
jpa.show-sql: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.type.descriptor.sql: trace
1번: @Modifying을 사용한 경우
2023-09-05 15:06:36.099 DEBUG 25937 --- [nio-8080-exec-5] org.hibernate.SQL : select userjpo0_.user_id as user_id1_0_0_, userjpo0_.name as name2_0_0_ from user_jpo userjpo0_ where userjpo0_.user_id=?
Hibernate: select userjpo0_.user_id as user_id1_0_0_, userjpo0_.name as name2_0_0_ from user_jpo userjpo0_ where userjpo0_.user_id=?
2023-09-05 15:06:36.100 INFO 25937 --- [nio-8080-exec-5] com.example.service.UserService : old user: 홍길동
2023-09-05 15:06:36.106 DEBUG 25937 --- [nio-8080-exec-5] org.hibernate.SQL : update user_jpo set name=? where user_id=?
Hibernate: update user_jpo set name=? where user_id=?
2023-09-05 15:06:36.108 INFO 25937 --- [nio-8080-exec-5] com.example.service.UserService : new user: 홍길동
2번: @Modifying(clearAutomatically = true)을 사용한 경우
2023-09-05 15:08:03.493 DEBUG 25978 --- [nio-8080-exec-4] org.hibernate.SQL : select userjpo0_.user_id as user_id1_0_0_, userjpo0_.name as name2_0_0_ from user_jpo userjpo0_ where userjpo0_.user_id=?
Hibernate: select userjpo0_.user_id as user_id1_0_0_, userjpo0_.name as name2_0_0_ from user_jpo userjpo0_ where userjpo0_.user_id=?
2023-09-05 15:08:03.494 INFO 25978 --- [nio-8080-exec-4] com.example.service.UserService : old user: 홍길동
2023-09-05 15:08:03.500 DEBUG 25978 --- [nio-8080-exec-4] org.hibernate.SQL : update user_jpo set name=? where user_id=?
Hibernate: update user_jpo set name=? where user_id=?
2023-09-05 15:08:03.502 DEBUG 25978 --- [nio-8080-exec-4] org.hibernate.SQL : select userjpo0_.user_id as user_id1_0_0_, userjpo0_.name as name2_0_0_ from user_jpo userjpo0_ where userjpo0_.user_id=?
Hibernate: select userjpo0_.user_id as user_id1_0_0_, userjpo0_.name as name2_0_0_ from user_jpo userjpo0_ where userjpo0_.user_id=?
2023-09-05 15:08:03.503 INFO 25978 --- [nio-8080-exec-4] com.example.service.UserService : new user: 홍길동2
2번 방법으로 사용했을 경우 select를 한번 더 하는 것을 알 수 있다. 영속성 컨텍스트에 값이 없으므로 DB를 한번 더 조회를 하게 된다.
이렇듯 동일 트랜잭션에서 JPQL을 사용하여 값을 변경하고 조회하는 경우에 clearAutomatically
를 통해 예상한 결과를 조회할 수 있다는 것을 알 수 있다.
clearAutomatically
를 사용하는 경우 해당 객체의 값만 영속성 컨텍스트에서 삭제될 것으로 예상이 된다. 하지만 실제로는 영속성 컨텍스트 내의 모든 객체를 삭제한다. 그래서 여러 개의 값을 업데이트 하는 경우는flushAutomatically = true
도 같이 넣어줘야 한다. 그렇지 않고 clearAutomatically만 하게 되면 기존에 update된 값이 모두 초기화될 가능성이 있다.즉, 아래와 같이 사용해야 한다.
@Modifying(clearAutomatically = true, flushAutomatically = true)