API 동시 요청으로 인한 데이터 무결성 문제해결

Simple·2023년 11월 14일
0

트러블슈팅

목록 보기
13/13
post-thumbnail

상황

Learningit 서비스를 운영하던 중 특정 문제만 조회가 안된다는 얘기를 들었다.
그래서 직접 문제를 앱을 통해 조회해봤더니 문제가 없었다. 다른 사람한테도 확인을 했을 때 역시 잘됐다.

로그를 모니터링 해보니

query did not return a unique result: 2

에러를 클라이언트에게 반환하고 있었고 DB를 본 결과

유저의 문제 풀이 상태를 저장하는 테이블에 problem_id와 user_id가 약 0.01초 간격으로 중복 데이터가 생성된 것을 발견했다.

코드

@Override
public void createProblemUser(AuthUser authUser, Long id) {
    User user = authUser.getUser();
    Problem problem = problemQueryPort.findProblemById(id)
            .orElseThrow(() -> new ApplicationException(ProblemErrorCode.PROBLEM_NOT_FOUND));
    problemUserQueryPort.findByProblemAndUser(problem, user)
            .orElseGet(() -> problemUserCommandPort.save(ProblemUser.createProblemUser(problem, user)));
}

문제풀이상태 테이블에 저장하기 전 problem 엔티티와 user 엔티티의 존재 여부를 확인하는 로직이 있음에도 불구하고 중복 데이터가 저장됐다.

테스트

@Test
@DisplayName("createProblemUser 메서드 테스트")
void createProblemUser() throws Exception{
    ExecutorService executorService = Executors.newFixedThreadPool(2);
    CountDownLatch latch = new CountDownLatch(2);

    for(int i=0; i<2; i++){
        executorService.execute(() -> {
            try{
                problemCommandService.createProblemUser(authUser,id);
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();
    assertThat(problemUserRepository.findAll().size()).isEqualTo(1);

}

기대하는 값은 1 이지만 2개가 저장됐다.

왜 그런 것일까?

원인

현재 유저가 문제를 조회할 때 flow는

GET을 통해 문제 조회 + POST를 통해 유저의 문제 풀이 상태 생성이다.

순서Tx1Tx2
1유저 객체 추출
2유저 객체 추출
3문제 조회 성공
4문제 조회 성공
5유저와 문제 엔티티를 기반으로 문제 상태 존재 여부 false
6유저와 문제 엔티티를 기반으로 문제 상태 존재 여부 false
7새로운 문제 풀이 상태 엔티티 저장
8새로운 문제 풀이 상태 엔티티 저장
9Tx1 Commit
10Tx2 Commit

위 표에서 알 수 있듯이 문제는 5번과 6번에 있다.

해결

DB Unique 제약 조건

특정 문제에 대해 한 유저는 하나의 문제 상태값 만을 가질 수 있으므로 Unique 제약을 통해 중복으로 문제 상태가 저장되지 못하도록 한다.

코드

@Table(name = "PROBLEM_USER", uniqueConstraints = {
        @UniqueConstraint(
                name = "PROBLME_USER_UNIQUE",
                columnNames = {"problem_id,user_id"}
        )
})

또한 위 제약 조건을 위배했을경우 DataIntegrityViolationException을 발생시키므로 해당 예외를 핸들링하기위해 GlobalExceptionHandler에도 코드를 추가해줬다.

@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> dataIntegrityException(DataIntegrityViolationException e){
    log.error(e.getMessage());
    return ResponseEntity.badRequest().body(ErrorResponse.fromDataIntegrityException());
}

적용 후

순서Tx1Tx2
1유저 객체 추출
2유저 객체 추출
3문제 조회 성공
4문제 조회 성공
5유저와 문제 엔티티를 기반으로 문제 상태 존재 여부 false
6유저와 문제 엔티티를 기반으로 문제 상태 존재 여부 false
7새로운 문제 풀이 상태 엔티티 저장
8새로운 문제 풀이 상태 엔티티 저장
9Tx1 Commit
10DataIntegrityViolationException 예외 발생

테스트

예외가 정상적으로 나오는지를 먼저 본다.

@Test
@DisplayName("createProblemUser 메서드 테스트")
void createProblemUser() throws Exception{
    ExecutorService executorService = Executors.newFixedThreadPool(2);
    CountDownLatch latch = new CountDownLatch(2);

    for(int i=0; i<2; i++){
        executorService.execute(() -> {
            try{
                problemCommandService.createProblemUser(authUser,id);
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();

}

정상적으로 DataIntegrityViolationException을 발생시키고

@Test
@DisplayName("createProblemUser 메서드 테스트")
void createProblemUser() throws Exception{
    ExecutorService executorService = Executors.newFixedThreadPool(2);
    CountDownLatch latch = new CountDownLatch(2);

    for(int i=0; i<2; i++){
        executorService.execute(() -> {
            try{
                problemCommandService.createProblemUser(authUser,id);
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();
    assertThat(problemUserRepository.findAll().size()).isEqualTo(1);

}

1개만 저장되기를 기대하는 로직을 추가한 결과

정상적으로 잘 통과하는 것을 볼 수 있다.

profile
몰입하는 개발자

0개의 댓글