엄지월드

JPA 동시성 제어 주의점(ConstraintViolationException 에러) 본문

java/Spring

JPA 동시성 제어 주의점(ConstraintViolationException 에러)

킨글 2025. 12. 23. 07:39

아래와 같이 ConstraintViolationException 에러가 발생했다.

문제를 확인해보니 특정 엔티티를 업데이트하거나 삭제하려 할 때, 데이터베이스에 저장된 해당 엔티티의 상태가 애플리케이션이 엔티티를 로드했을 때와 달라졌을 때 발생하는 것이었다. 즉, 다른 트랜잭션에 의해 이미 변경되었거나 삭제되었다는 뜻인 것이다.

Caused by: org.hibernate.exception.ConstraintViolationException: could not execute statement [(conn=1338) Duplicate entry 'ejy1024@-ejy1024@_test1234@_...' for key 'PRIMARY'] [/* insert for com.connect.service.chatting.entity.RoomMembership */insert into room_memberships (joined_at,room_name,room_id,user_id) values (?,?,?,?)]
Caused by: org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.connect.service.chatting.entity.RoomMembership#RoomMembershipId(userId=ejy1024, roomId=ejy1024test12341766307683336)]

 

 

코드로 보자면 existsById로 확인하는 시점과 save를 호출하는 시점 사이에 다른 트랜잭션이 먼저 동일한 기본 키로 RoomMembership 엔티티를 삽입했을 때 발생할 수 있다. 

val exists = roomMembershipRepository.existsById(membershipId) // 해당 유저가 이미 이 방의 멤버인지 DB에서 확인

if (!exists) { // 아직 멤버가 아니라면
   val newMembership = RoomMembership(
       id = membershipId, // 복합 키
       chatRoom = chatRoom, // 관계 매핑된 ChatRoom 엔티티
       roomName = roomName,
       joinedAt = LocalDateTime.now() // 참여 시간 기록
   )
   roomMembershipRepository.save(newMembership) // 새로운 멤버십을 DB에 저장
   return true // 성공적으로 새로 추가됨을 반환
}

순서는 이렇다. 

  1. 트랜잭션 A : roomMembershipRepository.existsById(membershipId)를 호출한다.
    이 시점에는 해당 RoomMembership이 DB에 없어서 exists는 false를 반환한다. 그래서 if (!exists) 블록으로 진입한다. 
  2. 트랜잭션 B (거의 동시에): 트랜잭션 A가 save를 호출하기 전에, 트랜잭션 B가 먼저 같은 membershipId를 가진 RoomMembership을 생성하고 DB에 INSERT하는 데 성공한다.
  3. 트랜잭션 A (다시): 이제 트랜잭션 A가 roomMembershipRepository.save(newMembership)를 호출하여 DB에 INSERT를 시도한다.
  4. 에러 발생: 하지만 이미 트랜잭션 B가 동일한 PRIMARY KEY를 가진 RoomMembership을 삽입했으므로, DB는 PRIMARY KEY 제약 조건 위반을 감지하고 Duplicate entry '...' for key 'PRIMARY' 에러를 발생시킨다. 하이버네이트는 이를 ConstraintViolationException으로 변환하여 던지게 된다. 

해결을 위해서는 아래와 같이 existsById로 먼저 확인하고 save하는 방식(read-then-write) 대신, findById를 사용하여 엔티티의 존재 여부를 확실히 확인하고 그 결과에 따라 분기 처리하는 것이 가장 안정적이라고 한다.

이유는 existsById는 단순히 존재 여부만 확인하지만, findById는 실제 엔티티를 영속성 컨텍스트로 가져오려고 시도하기 때문이다.
다시 말하면 findById는 단순히 데이터베이스를 조회하는 것을 넘어, JPA/하이버네이트의 영속성 컨텍스트(Persistence Context)와 연동하여 동작한다. 
만약 현재 트랜잭션 내에서 동일한 ID를 가진 엔티티가 이미 로드되어 영속성 컨텍스트에 관리되고 있다면, DB 쿼리 없이 해당 엔티티를 즉시 반환하게 된다. 

val exists = roomMembershipRepository.findById(membershipId) // 해당 유저가 이미 이 방의 멤버인지 DB에서 확인

if (exists.isEmpty) { // 아직 멤버가 아니라면
   val newMembership = RoomMembership(
       id = membershipId, // 복합 키
       chatRoom = chatRoom, // 관계 매핑된 ChatRoom 엔티티
       roomName = roomName,
       joinedAt = LocalDateTime.now() // 참여 시간 기록
   )
   roomMembershipRepository.save(newMembership) // 새로운 멤버십을 DB에 저장
   return true // 성공적으로 새로 추가됨을 반환
}
return false // 이미 존재하는 멤버임을 반환

 

결론

[이전]
roomMembershipRepository.existsById(membershipId)

[이후]
roomMembershipRepository.findById(membershipId)

1. Race Condition 발생 가능성이 있는 "불필요한 INSERT 시도"를 더 효율적으로 차단한다. (이미 존재한다면 findById가 엔티티를 반환하므로, if (isEmpty) 로직에 의해 save가 호출될 기회 자체가 사라진다)
2. 하이버네이트의 영속성 컨텍스트와 더 깊이 통합되어 관리된다. (엔티티 객체 자체를 가져오기 때문에)

 

혹은 추가적으로 아래와 같이 낙관적 잠금을 활용해도 된다.

// ChatRoom.kt
@Entity
@Table(name = "chat_room")
class ChatRoom(
    @Id
    val roomId: String,

    // ... 다른 필드들 ...

    @Version // 이 부분을 추가 (Long 타입 필드를 선언하고 @Version 어노테이션 추가)
    var version: Long? = null,
) {
    // ...

 

Comments