배경
기능 요구사항:
- 제출(Submission) 하나를 대표 코드로 지정(isDefault = true)
- 기존에 대표 코드로 지정되어 있던 것은 자동 해제(isDefault = false)
방법 1
가장 나이브한 구현 방식을 생각해보자면, Submission들을 전부 로딩해서 순회하며 isDefault가 true인 것을 false로 업데이트 한 뒤, 대표로 지정하고자하는 제출을 isDefault = true로 설정하는 것이다.
- `List<Submission> findByUserAndProblem(...)` 같은 것으로 n건을 SELECT → READ 1
- 서비스에서 순회하며 isDefault가 true인 것만 false로 set
- 대표로 지정할 대상 엔티티도 set(isDefault=true)
- 커밋 시점 플러시에서 변경된 엔티티 수만큼 UPDATE SQL 전송 → UPDATE t + 1 (t는 isDefault=true였던 행의 수, 이 경우 2 )
전체 비용은 READ + UPDATE = 1 + (t + 1) = t + 2
그러나 이렇게 할 경우 제출이 쌓이면 불필요하게 모든 제출을 읽게 된다.
방법 2
조건식으로 기존에 isDefault=true인 submisison을 찾은 후 false로 설정한다.
- `findByUserAndProblemAndIsDefaultTrue(...)`로 현재 대표 1건 SELECT → READ 1
- 그 엔티티의 isDefault=false set → UPDATE 1
- 대상 제출 `findById(...)`로 SELECT → READ 1
- isDefault=true set → UPDATE 1회
전체 비용은 READ + UPDATE = 2 + 2 = 4
그리고 무엇보다 동시성에 취약할 수 있다.
GPT가 짜준 동시성 취약 시나리오..
문제는 “UPDATE 두 개가 커밋 시점에 순차 실행”된다는 점입니다.
상황 예시:
- 유저 A가 대표 변경 요청을 보냄. → 현재 대표 X를 SELECT 해서 false로 마킹, 대상 Y를 true로 마킹. (아직 UPDATE 안 나감)
- 유저 B도 동시에 같은 problemId/userId 조합에 대해 대표 변경 요청을 보냄. → 역시 현재 대표 X를 SELECT 해서 false로 마킹, 대상 Z를 true로 마킹.
- 두 트랜잭션이 거의 동시에 커밋을 때리면?
- A는 UPDATE (X→false, Y→true) 실행
- B는 UPDATE (X→false, Z→true) 실행
- 결과적으로 Y, Z가 둘 다 true인 상태가 될 수 있음.
즉, 엔티티를 읽어오고 메모리에서 바꾼 뒤에 나중에 UPDATE를 보내는 구조라서, 그 사이 다른 트랜잭션이 끼어들면 “대표가 둘 이상” 되는 순간이 발생할 수 있습니다. 즉 flush 시점까지의 지연 때문에 동시성 경합에서 “대표 중복”이 생길 수 있습니다.
방법 3
`@Modifying`을 이용하면 조건에 일치하는 submission 찾기와 값 업데이트를 한 번에 할 수 있다.
- 기존 isDefault 찾기 및 끄기 → READ 0, UPDATE 1
- 새로운 isDefault 찾기 및 켜기 → READ 0, UPDATE 1
조건에 맞는 엔티티를 SELECT 해서 가져오는 게 아니라 DB 엔진이 WHERE 절을 보고 직접 찾아서 업데이트하는 하기 때문에 SELECT 쿼리가 발생하지 않는다.
개념 요약
@Modifying
`@Modifying`은 `@Query`로 작성된 INSERT, UPDATE, DELETE 쿼리와 반드시 함께 사용되는 어노테이션이다. `@Query`로 작성한 SELECT 쿼리는 `@Modifying` 없이도 실행되지만, DML(데이터 조작어:INSERT, UPDATE, DELETE)을 실행할 경우 `@Modifying`이 없으면 QueryExecutionReqeustException이 발생한다.
JpaRepository에서 제공하는 기본 메서드나 메서드 네이밍으로 만들어진 쿼리에는 적용되지 않는다. 또한 clearAutomatically, flushAutomatically 속성을 변경할 수 있으며 주로 벌크 연산과 함께 사용된다.
벌크 연산
벌크 연산은 데이터베이스에서 다수의 행을 한번에 갱신(UPDATE)하거나 삭제(DELETE)하기 위한 작업이다. 벌크 연산은 단 건 데이터를 변경하는 더티 체킹 방식이 아니라, 조건에 맞는 여러 데이터를 직접 수정하는 방식이다.
중요한 점은, 벌크 연산은 영속성 컨텍스트를 무시하고 바로 DB에 쿼리를 실행한다는 것이다. 즉 DB에는 변경이 반영되지만, 영속성 컨텍스트에는 변경 이전 상태가 그대로 남아 있다.
clearAutomatically
Jpa에서 조회를 실행할 때 1차 캐시를 확인해서 해당 엔티티가 1차 캐시에 존재한다면 DB에 접근하는 것이 아니라 1차 캐시에 있는 엔티티를 반환한다.
벌크 연산이 1차 캐시를 포함한 영속성 컨텍스트를 무시하고 바로 DB 값을 변경했기 때문에 벌크 연산을 실행한 뒤 동일한 데이터를 다시 조회하면, 영속성 컨텍스트에서는 데이터 변경을 알 수 없다. 따라서 변경한 최신 값이 아니라 영속성 컨텍스트의 1차 캐시에 남아 있는 이전 값이 반환될 수 있다. 즉 데이터 동기화 문제가 발생한다.
이러한 동기화 문제는 `@Modifying(clearAutomatically = true)` 옵션을 통해 해결할 수 있다. 이 속성은 벌크 연산 직후 영속성 컨텍스트가 자동으로 초기화된다. 1차 캐시에 해당 엔티티가 존재하지 않아 DB에 조회 쿼리를 실행하게 되므로 변경이 적용된 최신 값을 가져올 수 있다.
`clearAutomatically`를 사용하는 경우 해당 객체의 값만 영속성 컨텍스트에서 삭제되는 것이 아니라 영속성 컨텍스트 내의 모든 객체를 삭제한다.
flushAutomatically
`clearAutomatically`이 영속성 컨텍스트의 모든 엔티티를 삭제하기 때문에 벌크 연산 전에 변경된 값들이 DB에 반영되지 않고 날아가는 문제가 발생할 수 있다. 이를 방지하기 위해 `flushAutomatically = true`를 통해서 벌크 연산 전에 변경 내용을 DB에 먼저 반영한다.
그러나 사실 `flushAutomatically = true`를 하지 않아도 벌크 연산 전에 값들이 자동으로 flush 된다. 그 이유는 Jpa 구현체인 Hibernate에서 flustAutomatically와 같은 역할을 하는 FlushMode의 Default 값이 `AUTO`이기 때문이다.
- AUTO(Default) : 쿼리가 실행될 때 flush 발생
- COMMIT : 트랜잭션이 커밋될 때만 flush 발생
기본 설정(AUTO)에서는 벌크 연산 실행 직전에 flush가 자동으로 수행되므로, `flushAutomatically = true`를 명시하지 않아도 값이 DB에 반영된다.
활용
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
update Submission s
set s.isDefault = true
where s.id = :targetId and s.user.id = :userId and s.problem.id = :problemId and s.status = :status
""")
int setDefault(@Param("userId") Long userId,
@Param("problemId") Long problemId,
@Param("targetId") Long targetId,
@Param("status") SubmissionStatus status);

실제로 select 쿼리가 나가지 않은 것을 확인할 수 있다!
회고
JPQL을 통해서 쿼리를 작성하는데 익숙해진 것 같다. 그리고 속성들을 공부하면서 영속성 컨텍스트를 조금 더 이해하게 된 것 같다!
'SPRING > TIL' 카테고리의 다른 글
| [Study] SNS FIFO : MessageGroupId 지정 (0) | 2025.10.09 |
|---|---|
| [Troubleshooting] QueryDsl 서브쿼리 limit 1 적용 안 됨 (0) | 2025.09.28 |
