JPA 동시성 문제

.·2022년 3월 30일
0

배경

  • 파이프라인을 실행 시키고 해당 파이프라인의 Task들의 결과를 업데이트 하는 API를 개발하는 도중에 동시성 문제가 발생했고 이를 해결하면서 얻은 지식들을 정리하고자 합니다.
  • Tekton을 실행시킨 후 Callback 함수를 지정해주면 Pipelinerun, TaskRun의 상태변화에 따른 CloudEvent에 대한 정보를 Json 형태로 얻을 수 있습니다. (https://tekton.dev/docs/pipelines/events/)
  • CloudEvent 데이터를 파싱한 후 TaskRunId, Status, Message를 얻은 후 해당 TaskRun의 상태를 업데이트 시켜줬는데 update API가 동시에 호출될 경우 손실되는 정보가 발생하는 문제가 발생했습니다.
  • 트러블 슈팅을 진행하면서 이러한 문제점은 여러가지 트랜잭션이 동시에 실행될 때 발생하는 갱신 손실 문제 며, 낙관적 락 또는 비관적 락을 통해 이를 해결할 수 있다는 것을 알게 되었습니다.

코드

로직

  • “Created” status를 지니며 startTime, endTime, duration 값이 null인 Task가 생성
  • startTime이 비어있으면 startTime 업데이트
  • “Running” 상태로 update 시키면 상태만 update
  • “Succeeded” 상태로 update 시키면 endTime, duration 업데이트
  • “Started”, “Running”, “Succeeded” 상태로 업데이트 API 3개를 순서대로 호출 시킨 후 의도적으로 갱신 손실 문제를 발생시키기 위해 sleepTime 변수를 넣어 thread.sleep 함수로 지정한 시간동안 스레드를 정지

DTO

		@Getter
    public static class CreateUpdate{

        private String status;
    }

Service

		public TaskEntity findTask(Long id) {

        Optional<TaskEntity> findTask = taskRepository.findById(id);
        return findTask.orElse(null);
    }	

		@Transactional
    public TaskEntity updateTask(Long id, TaskDto.CreateUpdate createUpdate, int sleepTime){

        TaskEntity taskEntity = findTask(id);
        threadSleep(sleepTime);
        taskEntity.updateStatus(createUpdate.getStatus());
        taskEntity.updateTime(createUpdate.getStatus());
        return taskEntity;
    }

		public void threadSleep(int sleepTime){
        try {
            Thread.sleep(sleepTime);
        } catch (Exception e){
            log.info(e.getMessage());
        }
    }

Repository

public interface TaskRepository extends JpaRepository<TaskEntity, Long> 

Domain

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class TaskEntity {

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

    private String status;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime startTime;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime endTime;

    private Long duration;

    @Builder
    public TaskEntity(String status){
        this.status = status;
    }

    public void updateStatus(String status){
            this.status = status;
    }

    public void updateTime(String status) {

        if (this.startTime == null){
            this.startTime = LocalDateTime.now().withNano(0);
        }

        if (status.equals("Succeeded")){
            this.endTime = LocalDateTime.now().withNano(0);
            this.duration = Duration.between(this.startTime,this.endTime).getSeconds();
        }
    }
}

Test

@Test
    @DisplayName("Lock 적용하기 전")
    void test1() throws Exception {

        // given
        TaskEntity newTask = TaskEntity.builder()
                .status("Created")
                .build();
        taskService.createTask(newTask);

        // when
        final ExecutorService executor = Executors.newFixedThreadPool(3);

        executor.execute(()->taskService.updateTask(newTask.getId(), TaskDto.CreateUpdate.builder().status("Started").build(), 2000));
        Thread.sleep(500);

        executor.execute(()->taskService.updateTask(newTask.getId(), TaskDto.CreateUpdate.builder().status("Running").build(), 1000));
        Thread.sleep(500);

        executor.execute(()->taskService.updateTask(newTask.getId(), TaskDto.CreateUpdate.builder().status("Succeeded").build(), 100));

        // Thread 작업이 다 끝날때까지 최대 10초 대기
        executor.shutdown();
        executor.awaitTermination(10, TimeUnit.SECONDS);

        //then
        TaskEntity findTask = taskService.findTask(newTask.getId());

        assertAll(
                ()-> assertEquals("Started", findTask.getStatus()),
                ()-> assertNotEquals(null,findTask.getStartTime()),
                ()-> assertEquals(null,findTask.getEndTime()),
                ()-> assertEquals(null,findTask.getDuration())
        );

    }
  • “Created” status를 지니며 startTime, endTime, duration 값이 null인 Task가 존재
  • “Started” 상태로 업데이트 시키는 트랜잭션 실행 - 2초 소요
  • 0.5초 후 “Running” 상태로 업데이트 시키는 트랜잭션 실행 - 1초 소요
  • 0.5초 후 “Succeeded” 상태로 업데이트 시키는 트랜잭션 실행 - 0.1초 소요
  • “Started”, “Running”, “Succeeded” 상태로 업데이트 API 3개를 순서대로 호출했지만 각 API 처리 속도에 따라서 최종적으로 업데이트 되는 정보가 달라집니다.
  • 이 경우 “Started” 상태로 업데이트 시키는 함수가 가장 먼저 호출됬지만 트랜잭션이 가장 늦게 끝났기 때문에 최종적으로 “Started” 상태, 시간은 전부 null로 업데이트
  • status만 변경했는데 startTime, endTime, duration도 전부 null로 업데이트 된 이유는 JPA의 Dirty Checking 때문입니다.
  • JPA는 데이터를 조회해서 영속성 컨텍스트 안에 저장하고 트랜잭션이 끝났을 때 초기 상태와 최종 상태를 비교해서 update 쿼리를 날린다 - Dirty Checking
  • Dirty Checking로 생성되는 update 쿼리는 기본적으로 모든 필드를 업데이트

도식화

결론

  • 하나의 엔티티에 업데이트를 하는 트랜잭션이 동시에 2개 이상 진행 될 때 먼저 트랜잭션이 끝난 작업의 변경 내용이 DB에 반영되지 않는 문제점이 발생
  • 가장 마지막에 시작 된 트랜잭션의 작업이 보장 되는 것이 아니라 트랜잭션의 끝나는 시점에 따라 DB 내용이 달라집니다.
  • 최종적으로 이미 작업이 끝난 Task인 경우라도 상태가 Running 또는 Started로 남아 있는 상황이 발생
profile
지금부터 공부하고 개발한것들을 꾸준하게 기록하자.

0개의 댓글