본문 바로가기
프로젝트 요구사항 해결 과정 모음/Backend

JPA - 특정 FK(외래 키)에 대한 엔티티 리스트 업데이트 문제 해결 (OptimisticLockException)

by 재성스 2025. 2. 7.
반응형

요구사항

JPA에서 특정 FK(외래 키)에 대한 엔티티 리스트를 업데이트할 때, 다음과 같은 로직이 필요하다.

  1. 요청으로 받은 리스트의 엔티티들은 업데이트 또는 신규 추가되어야 함.
  2. 기존 DB에는 있지만 요청 리스트에는 없는 엔티티들은 삭제되어야 함.

문제 코드 (예시)

public void updateEntityList(Long parentId, List<ChildDto> childDtoList){
    long result = childRepository.deleteByParentId(parentId);
    if(result > 0){
        childDtoList.stream().forEach(childDto -> 
                        childRepository.save(childMapper.toEntity(childDto))
                );
    }
}

문제점

위 코드에서는 기존 엔티티를 일괄 삭제한 후 새로운 데이터를 삽입하는 방식이 사용되었다. 하지만 이렇게 하면:

  • 낙관적 락 충돌(Optimistic Locking Conflict) 이 발생할 수 있음. ( OptimisticLockException )
  • 이전 데이터가 모두 삭제되므로 트랜잭션이 실패할 경우 데이터 무결성이 깨질 위험이 있음.
  • 불필요한 DELETE & INSERT가 증가하여 성능 저하가 발생할 수 있음.

해결 코드 (예시)

해결 방법

  1. 기존 DB에서 해당 FK에 속한 모든 엔티티를 조회한다.
  2. 요청받은 엔티티 리스트와 비교하여:
    • 동일한 PK가 존재하면 업데이트 (updateFromDto() (사용자 정의 메서드) 활용).
    • 새로운 PK이면 저장.
    • 기존에는 있지만 요청 리스트에는 없는 엔티티는 삭제.
@Transactional
public void updateEntityList(Long parentId, List<ChildDto> childDtoList){
    
    // 1. 기존 DB에서 해당 parentId에 속한 모든 엔티티 조회
    List<Child> existingChildList = childRepository.findByParentId(parentId);
    
    // 2. 요청받은 DTO를 기존 엔티티와 비교하여 매핑
    Map<Long, ChildDto> updatedChildDtoMap = childDtoList.stream()
            .filter(dto -> dto.getId() != null)
            .collect(Collectors.toMap(ChildDto::getId, Function.identity()));

    // 3. 기존 엔티티 업데이트 및 삭제 처리
    for (Child existingChild : existingChildList) {
        if (updatedChildDtoMap.containsKey(existingChild.getId())) {
            existingChild.updateFromDto(updatedChildDtoMap.get(existingChild.getId()));
        } else {
            childRepository.delete(existingChild);
        }
    }

    // 4. 신규 추가 (ID가 없는 엔티티)
    List<Child> newChildList = childDtoList.stream()
            .filter(dto -> dto.getId() == null)
            .map(childMapper::toEntity)
            .collect(Collectors.toList());

    childRepository.saveAll(newChildList);
}

개선된 점

  1. Hibernate 세션 충돌 방지: 기존 객체를 유지하며 필드 값만 변경.
  2. Dirty Checking 활용: 변경된 필드만 DB에 반영되므로 save() 호출이 불필요.
  3. 성능 최적화: 불필요한 INSERTDELETE 연산 감소.

관련 엔티티 정의

@Entity
@Table(name = "child")
public class Child {

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

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Parent parent;

    public void updateFromDto(ChildDto dto) {
        this.name = dto.getName();
    }
}

새로 배운 개념: Dirty Checking (변경 감지)

Dirty Checking 이란?

JPA는 트랜잭션이 끝날 때(@Transactional 종료 시) 영속 상태의 엔티티를 자동으로 감지하여 변경된 필드만 업데이트한다. 따라서 save()를 호출하지 않아도 변경 사항이 DB에 반영된다.

Dirty Checking 적용 과정

@Transactional
public void updateEntity(Long id, String newName) {
    Child child = childRepository.findById(id).orElseThrow();
    child.setName(newName);  // 필드 값만 변경
} // @Transactional 종료 시 Dirty Checking으로 UPDATE 자동 실행

 

save()가 필요한 경우

1. 새로운 엔티티를 추가할 때:

childRepository.save(newChild);

2. 비영속 상태의 엔티티를 업데이트할 때:

chedChild = new Child();
detachedChild.setId(3L);
detachedChild.setName("Updated Name");
childRepository.save(detachedChild); // 비영속 상태이므로 반드시 save 필요

 

결론

  • Hibernate 세션 충돌을 방지하려면 기존 엔티티를 유지하며 필드 값만 변경하는 방식이 필요함.
  • Dirty Checking을 활용하면 save() 호출 없이도 자동으로 변경 사항이 반영됨.
  • 신규 엔티티의 경우에는 save()가 필요하며, 비영속 상태의 엔티티를 직접 업데이트하는 경우에도 save()를 호출해야 함.
반응형