[JPA] 기본키 생성 전략과 DataIntegrityViolationException

Hocaron·2024년 1월 31일
1

Spring

목록 보기
37/44
  @Transactional
  public String save() {

    try {
      Member member = memberRepository.save(new Member("NICKNAME"));
      return member.nickname();
    } catch (DataIntegrityViolationException e) {
      log.info("중복키 에러 발생");
    }

    return "DEFAULT_NICKNAME";
  }

데이터가 정상 저장되면, 저장된 닉네임을 반환하는 메서드가 있다. 하지만 중복키 에러에 의해 에러 발생시에는 기본 닉네임(DEFAULT_NICKNAME) 을 반환하는 요구사항이 있다.

기본키 생성 전략이 어떤 것인지에 따라 try - catch 에서 에러가 발생할 수 있고, 잡히지 않을 수도 있다.

기본키 생성 전략

GenerationType.AUTO

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

특징: JPA 구현체에 의존하여 자동으로 전략 선택
쓰기 지연 영향: 선택된 전략에 따라 다름

Hibernate 5부터 MySQL에서의 GenerationType.AUTO는 IDENTITY가 아닌 TABLE을 기본 시퀀스 전략으로 가져간다.

GenerationType.IDENTITY

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

특징: 데이터베이스의 Identity 컬럼을 사용하여 ID를 생성 (예: MySQL의 AUTO_ INCREMENT)
쓰기 지연 영향: 거의 없음 (일부 데이터베이스에 따라 다름)

JPA는 트랜잭션이 commit 되는 시점에 쓰기 지연 저장소에 모아놓은 SQL을 한 번에 DB로 전송하며 실행한다. 이렇게 해야 어플리케이션과 DB 사이에 네트워크를 오가는 횟수가 줄어들고 성능면에서 이득을 볼 수 있기 때문이다.
하지만 IDENTITY전략은 DB에 기본키 생성을 위임하므로, Mysql의 경우 AUTO_INCREMENT를 활용하여 생성하는데,이 때, JPA 입장에선 DB에 INSERT SQL를 실행하기 전엔 도저히 AUTO_INCREMENT되는 값을 알 수 없으므로, persist() 시점에 insert 쿼리가 실행되는 것이다. (영속성 컨텍스트로 엔티티를 관리하려면 1차 캐시에 Id값을 key 값으로 들고 있어야 하기 때문에)

GenerationType.SEQUENCE

@Entity 
@SequenceGenerator( 
     name = “MEMBER_SEQ_GENERATOR", 
     sequenceName = “MEMBER_SEQ", //매핑할 데이터베이스 시퀀스 이름
     initialValue = 1, allocationSize = 50) 
public class Member { 
     @Id 
     @GeneratedValue(strategy = GenerationType.SEQUENCE, 
     generator = "MEMBER_SEQ_GENERATOR") 
     private Long id; 
...
 }

특징: 데이터베이스 시퀀스를 사용하여 ID를 생성 (예: 오라클 시퀀스)
쓰기 지연 영향: 일반적으로 있음

GenerationType.TABLE

@Entity 
@TableGenerator(
     name = "MEMBER_SEQ_GENERATOR", 
     table = "MY_SEQUENCES", 
     pkColumnValue = “MEMBER_SEQ", allocationSize = 1) 
public class Member { 
     @Id 
     @GeneratedValue(strategy = GenerationType.TABLE, 
     generator = "MEMBER_SEQ_GENERATOR") 
     private Long id; 
 }

특징: 특정 테이블을 사용하여 ID를 생성 (모든 데이터베이스에 적용 가능하나, 성능적인 손해가 있어서 잘 쓰지 않는다)
쓰기 지연 영향: 일반적으로 있음

그런데 만약 기본키 생성 전략이 없다면?

    @Id
    private Long id;

특징: 수동으로 기본키를 설정하는 경우
쓰기 지연 영향: 있음 (트랜잭션 내에서 데이터 변경이 발생하고, 해당 트랜잭션이 커밋되면 변경 사항이 데이터베이스에 즉시 반영)

다시 로직으로 돌아가서

에러가 발생하면 2가지가 만족해야 하는 2가지 요구사항

✅ 유니크키 에러 발생하면 try - catch 에 잡혀야한다.
✅ 유니크키 에러가 발생한 경우, 기본값을 응답한다.

기본키 생성 전략이 없는 경우

❎ 유니크키 에러 발생하면 try - catch 에 잡혀야한다.

  @Transactional
  public String save() {

    try {
      Member member = memberRepository.save(new Member("NICKNAME"));
      return member.nickname();
    } catch (DataIntegrityViolationException e) {
      log.info("중복키 에러 발생");
    }

    return "DEFAULT_NICKNAME";
  }

try - catch 에서 에러가 잡히지 않고, DataIntegrityViolationException 이 메서드에서 발생한다. 메서드 상위에 @Transactional 이 존재하기 때문에, 쓰기지연이 동작하여 메서드가 끝난 후에 save 쿼리가 날라가고, try - catch 밖에서 에러가 발생하게 된다.

✅ 유니크키 에러 발생하면 try - catch 에 잡혀야한다.

  @Transactional
  public String save() {

    try {
      Member member = memberRepository.saveAndFlush(new Member("NICKNAME"));
      return member.nickname();
    } catch (DataIntegrityViolationException e) {
      log.info("중복키 에러 발생");
    }

    return "DEFAULT_NICKNAME";
  }

saveAndFlush로 변경하면, 메서드가 끝나기 전에 saveAndFlush 에서 쿼리가 날라가게 된다. try - catch 에서 에러가 잡히지만, 하지만 기본값 응답이 아닌 롤백 에러가 발생한다.

그 이유는 내부 롤백 마크가 찍혀서 인데, 자세한 내용은 롤백 마크가 생긴 트랜잭션은 재사용이 불가능할까?를 참고해보자.

✅ 유니크키 에러가 발생한 경우, 기본값을 응답한다.

  public String save() {

    try {
      Member member = memberRepository.save(new Member("NICKNAME"));
      return member.nickname();
    } catch (DataIntegrityViolationException e) {
      log.info("중복키 에러 발생");
    }

    return "DEFAULT_NICKNAME";
  }

내부 롤백 마크가 찍히는 것을 방지하기 위해, @Transactional 을 제거하자. 이 외에도 memberRepository.save 하는 로직을 Propagation.REQUIRES_NEW 로 따로 메서드를 만들어서 내부 롤백마크로 처리하지 않도록 구현할 수도 있다.

트랜잭션 제거시에는 memberRepository.save() 호출 시에는 쿼리가 즉시 날라가서 memberRepository.saveAndFlush() 를 사용하지 않아도 된다.

중복키 에러 발생 시에 try - catch 에서 로그를 남기고, 기본값을 응답하는 것을 알 수 있다. 요구사항이 모두 충족되었다.

기본키 생성 전략이 GenerationType.IDENTITY 인 경우

✅ 유니크키 에러 발생하면 try - catch 에 잡혀야한다.

  @Transactional
  public String save() {

    try {
      Member member = memberRepository.save(new Member("NICKNAME"));
      return member.nickname();
    } catch (DataIntegrityViolationException e) {
      log.info("중복키 에러 발생");
    }

    return "DEFAULT_NICKNAME";
  }


중복키 에러 발생 시에 try - catch 에서 로그를 남기고, 기본값을 응답하는 것을 알 수 있다. 물론, 내부 롤백마크가 찍혀서 전체 롤백이 일어났지만, memberRepository.save() 에서 발생한 에러가 try - catch 에 잡힌다!

즉, 쓰기 지연이 발생하지 않은 것이다. 트랜잭션을 커밋한 후에 실제 DB로 쿼리가 나갈 줄 알았지만, generationType.IDENTITY는 em.persist일 때 쓰기 지연을 하지 않기 때문에 memberRepository.save() 메서드를 호출하자마자 바로 실제 DB에 쿼리가 나간 것을 확인할 수 있다. IDENTITY 전략은 DB column 수에 따라 식별자 id를 저장해주기 때문이다. 따라서 쓰기 지연이 아닌 실제 DB에 바로 쿼리를 날리고, repository save 메서드를 호출지점을 감싸고 있는 try - catch 에서 예외가 잡히게 된다.

✅ 유니크키 에러가 발생한 경우, 기본값을 응답한다.

  public String save() {

    try {
      Member member = memberRepository.save(new Member("NICKNAME"));
      return member.nickname();
    } catch (DataIntegrityViolationException e) {
      log.info("중복키 에러 발생");
    }

    return "DEFAULT_NICKNAME";
  }

내부 롤백 마크가 찍히는 것을 방지하기 위해, @Transactional 을 제거하자. 요구사항이 모두 충족되었다.

만약 기본값 대신 이미 존재하는 데이터를 조회하여 응답하고 싶다면?

  @Transactional
  public Member save() {

    try {
      Member member = memberRepository.save(Member.of(1L, "NICKNAME"));
      return member;
    } catch (DataIntegrityViolationException e) {
      return memberRepository.findByNickname("NICKNAME")
              .orElseThrow();
    }
  }


null id in entry (don't flush the Session after an exception occurs) 에러가 발생하게 된다. hibernate exception 이 일어나면 자동으로 세션을 clear 해주지 않는다! entity는 detached 상태로 세션에 남아있고.. 다음 flush 시점에서 이것이 오류로 남는다.

  @Transactional
  public Member save() {

    try {
      Member member = memberRepository.save(Member.of(1L, "NICKNAME"));
      return member;
    } catch (DataIntegrityViolationException e) {
      entityManager.clear();
      return memberRepository.findByNickname("NICKNAME")
              .orElseThrow();
    }
  }

이런 경우에는 entityManager.clear()를 통해서 엔티티 매니저에는 미완상태의 detached entity를 제거할 수 있다. 물론 clear() 를 해도 위의 코드에서는 내부 롤백 마크가 찍혀 전체롤백 에러가 발생하게 된다.

정리

  • 기본키 생성전략이 없는 경우, @Transactional 이 걸려있는 메서드 종료 후에 쿼리가 날라가기 때문에 try - catch 에서 잡히지 않는다.
  • 기본키 생성 전략이 GenerationType.IDENTITY 인 경우, em.persist일 때 쓰기 지연을 하지 않기 때문에 쿼리가 바로 날라가 try - catch 에서 잡힌다.
  • hibernate exception 이 일어나면 자동으로 세션을 clear 해주지 않는다

References

profile
기록을 통한 성장을

0개의 댓글