트랜잭션 내 JPA Identity 키 채집의 문제점

유알·2025년 3월 8일
2

[DB/JPA]

목록 보기
8/8

최근 JPA 관련해서 특이한 에러가 발생하였다.

문제 상황을 보자

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE
);
@Entity
@Table(name = "users")
// ...
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

email에 유니크 제약조건이 걸려있음을 유의하자.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class UserService {
	// ...

    @Transactional
    public void deleteAndInsertWithSameEmail(Long id) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("User not found with id: " + id));
                
        String email = user.getEmail();
        
        userRepository.delete(user);

        // 동일한 email을 가진 새로운 User 객체 생성 후 저장
        User newUser = new User();
        newUser.setEmail(email);

        userRepository.save(newUser); // 여기에서 에러가 발생한다.
    }
}

위와 같은 로직을 실행시키면 Unique 제약조건을 어겼다며 에러가 발생한다. (손코딩이라 로직 흐름만 참고)

이상한 일이다.
1. 분명이 기존에 레코드를 삭제했으므로, email이 겹칠 일이 없는데 어떻게 email이 겹친단 말인가
2. 설령 제약조건에 걸린다고 하더라도, 에러 발생지점이 save라는 점도 특이하다.
3. flush를 하지 않았으므로, transaction aop의 커밋시점에 에러가 발생했어야 한다.

처음에는 flush 시점에 뭔가 delete문이 나중에 나가는 문제로 해당 에러가 발생했다고 생각했지만, 위 2,3번 사항이 맞지 않았다.


에러가 발생한 원인은

@GeneratedValue(strategy = GenerationType.IDENTITY)

엔티티의 키 생성 전략이 GenerationType.IDENTITY 라는 점이다.

키 생성 전략에는 여러가지가 있지만 그 중 IDENTITY의 경우에는 db 자체의 auto increment key를 사용한다.
(hibernate에서 mysql 키 전략 기본값이 변경된 것 같다 -> 참고)

따라서 save시 엔티티에 대한 insert가 우선 이루어지고 키를 배정받게 된다.

참고

# save시 동작 방식
function save(entity):
    if (entity.id == null):  // ID가 없으면 새 엔티티로 판단
        persist(entity)  // INSERT 실행
        return entity
    else:
        if (1차 캐시에 entity.id 존재?):  // 동일 ID의 엔티티가 캐시에 있는지 확인
            merge(1차 캐시의 엔티티, entity)  // 캐시의 엔티티를 수정 (변경 감지)
        else:
            dbEntity = findById(entity.id)  // DB 조회
            if (dbEntity 존재?):
                merge(dbEntity, entity)  // 기존 엔티티 업데이트 (변경 감지 후 UPDATE 실행)
            else:
                persist(entity)  // DB에도 없으면 새 엔티티로 판단하고 INSERT 실행
        return entity
GenerationType동작 방식
IDENTITY즉시 INSERT 실행 후 ID 할당
SEQUENCE시퀀스에서 ID를 미리 가져오고, 트랜잭션 커밋 시 INSERT
TABLE별도 키 테이블에서 ID를 가져오고, 트랜잭션 커밋 시 INSERT
  1. 따라서 save호출시 즉시 insert문을 날리게 되고 (키를 따와야 하므로)
  2. 이때는 아직 delete가 반영되기 전이므로
  3. Db에서 unique violation 이 발생한다.

평소 어떤 점을 챙겨야 할까

어쨌든 내가 생각했을 때는,

  • Identity 전략을 피할 수 있는 상황이라면 피하고 (벌크 insert시 비효율 문제도 있으므로)
  • 피할 수 없다면, save를 호출하는 시점에 이전 코드의 변경사항이 반영되지 않았을 수 있음을 계속해서 인식하는 방법 밖에 없을 것 같다.
  • 만약 위와 같은 문제가 발생하는 경우, save 이전에 Flush를 호출함으로서 이를 해결할 수 있다.

내가 지금 생각했을 때 가능할 것 같은 시나리오는

  • 유니크 제약조건과 함께, Delete -> save하는 경우
  • update하고 -> save하는 경우??

이 정도 인것 같은데, 추가적인 시나리오가 생각나는 경우 댓글로 알려주면 좋겠다.

profile
더 좋은 구조를 고민하는 개발자 입니다

3개의 댓글

comment-user-thumbnail
2025년 3월 10일

안녕하세요 유알님, Rock 관련 포스트를 읽다가 글이 너무 좋아 여기까지 찾아왔네요.
User delete 시 soft delete 방식을 채택하고 있다면, delete 전 user.email()을 'deleted' + email로 update하여 이메일이 같은 새로운 User를 insert를 할 수 있다고 생각합니다. 다만, 벌크 연산을 언급하신 것을 보았을 때 해당 로직이 많이 호출될 것 같아서 무거워질수도? 있겠네요..

아마 당연하게도 유알님께서 위 방법을 생각하셨겠지만 뉴비의 생각을 한번 올려봅니다ㅎ

답글 달기
comment-user-thumbnail
2025년 3월 17일

선배님 잘 보고 갑니다!! 블로그 글 덕분에 많은 도움이 되고 있습니다

답글 달기
comment-user-thumbnail
2025년 3월 28일

saveAndFlush를 사용해도 될 것 같아요!

답글 달기