장애 개선/Error

JPA 더티체킹 문제와 스프링배치 대량 데이터 업데이트 해결 사례

Summer_berry 2025. 1. 14. 16:32

 


1. 발생 상황

  • 작업 내용:
    스프링 배치의 Tasklet에서 2만 건의 데이터를 100건씩 나눠서 처리하려고 함.
    1. API에서 데이터를 조회하여 DB에 저장.
    2. 저장된 데이터를 기반으로 응답 데이터를 다른 API에 전달.
  • 처리 방식:
    JPA를 사용하여 데이터 저장 및 업데이트를 처리하려고 했으나, 대량 데이터 작업에서 JPA의 더티체킹 문제로 인해 트랜잭션 충돌이 발생함.

2. 문제 상황 및 원인

1) JPA 더티체킹으로 인한 에러

  • 현상:
    entities.flush()와 entities.clear()를 사용하여 100건씩 트랜잭션을 처리하려 했으나, DTO를 사용하지 않고 동일한 엔티티를 반복적으로 처리하면서 더티체킹이 실패하고 이후 업데이트가 누락됨.
  • 원인:
    • flush() 이후 clear()가 호출되면서 영속성 컨텍스트가 초기화되어 엔티티 관리가 중단됨.
    • 이후 작업에서 이미 clear()된 영속 엔티티가 더티체킹되지 않아, JPA가 업데이트 작업을 수행하지 않음.

2) DTO 미사용 및 엔티티 관리 실패

  • 문제점:
    기존 영속 상태의 엔티티를 재사용하려 했으나, clear() 호출로 인해 영속성 컨텍스트에서 관리되지 않음.
  • 결과:
    • 첫 100건 이후부터 데이터 업데이트가 적용되지 않고 누락됨.
    • 영속 상태를 잃은 엔티티가 JPA 관리 대상에서 벗어나면서 예외 발생.

3. 해결 방안

1) DTO 사용 및 엔티티 재생성 + jdbcTemplate.batchUpdate 활용

JPA 대신 jdbcTemplate.batchUpdate를 활용하여 DTO로 데이터를 받아 처리하고, 대량 데이터를 효율적으로 업데이트.

  • 장점:
    • JPA의 영속성 컨텍스트 문제를 회피.
    • 배치 쿼리를 통해 대량 데이터를 빠르게 저장/업데이트 가능.
  • 구현 코드:
public void processBulkData(List<MyDto> dtos) {
    String sql = "INSERT INTO my_table (field1, field2) VALUES (?, ?) " +
                 "ON DUPLICATE KEY UPDATE field2 = VALUES(field2)";

    List<Object[]> batchArgs = new ArrayList<>();
    for (MyDto dto : dtos) {
        batchArgs.add(new Object[]{dto.getField1(), dto.getField2()});
    }

    // 100건 단위로 배치 업데이트 실행
    int batchSize = 100;
    for (int i = 0; i < batchArgs.size(); i += batchSize) {
        List<Object[]> batchChunk = batchArgs.subList(i, Math.min(i + batchSize, batchArgs.size()));
        jdbcTemplate.batchUpdate(sql, batchChunk);
    }
}
  • 설명:
    • DTO로 데이터를 받아서 처리하여 JPA의 영속성 컨텍스트와 완전히 분리.
    • jdbcTemplate.batchUpdate를 통해 한 번의 DB 호출로 100건씩 업데이트 수행.
    • 업데이트 성능을 극대화하고 JPA 관련 문제를 해결.

2) Spring Batch의 Chunk 방식으로 전환

JPA를 계속 사용하면서도 대량 데이터를 효율적으로 처리하기 위해, Tasklet 대신 Chunk 기반의 처리 방식을 사용.

  • 장점:
    • Chunk의 트랜잭션 단위로 데이터를 처리하여 메모리 사용량을 제어.
    • 각 Chunk 처리 후 자동으로 flush() 및 clear()가 수행되므로, 별도의 명시적 호출이 불필요.
  • 구현 코드:
@Bean
public Step chunkStep() {
    return stepBuilderFactory.get("chunkStep")
            .<InputDto, OutputEntity>chunk(100)
            .reader(reader())
            .processor(processor())
            .writer(writer())
            .build();
}

@Bean
public ItemWriter<OutputEntity> writer() {
    return items -> myRepository.saveAll(items);
}
  • 설명:
    • Chunk 단위로 데이터를 읽고 처리한 뒤 저장.
    • 트랜잭션이 Chunk 단위로 관리되어 메모리와 트랜잭션 문제를 예방.

4. 정리된 해결법

  • 1) DTO 사용 + jdbcTemplate.batchUpdate 활용:
    DTO로 데이터를 분리하여 JPA를 완전히 배제하고, jdbcTemplate.batchUpdate를 사용해 대량 데이터를 빠르고 안정적으로 업데이트.
  • 2) Chunk 기반 처리 방식 사용:
    Tasklet 대신 Chunk 방식을 사용하여 JPA의 강점을 살리면서도 대량 데이터를 효과적으로 처리.
  • 주의사항:
    • JPA를 사용하는 경우 flush() 및 clear() 호출 후에는 반드시 새로운 엔티티를 생성해야 함.
    • 기존 엔티티를 재사용하면 더티체킹 문제로 인해 업데이트가 누락될 수 있음.

이 두 가지 해결법을 적절히 활용하면 JPA 더티체킹 문제를 효과적으로 방지하면서 대량 데이터를 안정적으로 처리할 수 있습니다.