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를 통해 유저의 문제 풀이 상태 생성이다.
순서 | Tx1 | Tx2 |
---|---|---|
1 | 유저 객체 추출 | |
2 | 유저 객체 추출 | |
3 | 문제 조회 성공 | |
4 | 문제 조회 성공 | |
5 | 유저와 문제 엔티티를 기반으로 문제 상태 존재 여부 false | |
6 | 유저와 문제 엔티티를 기반으로 문제 상태 존재 여부 false | |
7 | 새로운 문제 풀이 상태 엔티티 저장 | |
8 | 새로운 문제 풀이 상태 엔티티 저장 | |
9 | Tx1 Commit | |
10 | Tx2 Commit |
위 표에서 알 수 있듯이 문제는 5번과 6번에 있다.
특정 문제에 대해 한 유저는 하나의 문제 상태값 만을 가질 수 있으므로 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());
}
순서 | Tx1 | Tx2 |
---|---|---|
1 | 유저 객체 추출 | |
2 | 유저 객체 추출 | |
3 | 문제 조회 성공 | |
4 | 문제 조회 성공 | |
5 | 유저와 문제 엔티티를 기반으로 문제 상태 존재 여부 false | |
6 | 유저와 문제 엔티티를 기반으로 문제 상태 존재 여부 false | |
7 | 새로운 문제 풀이 상태 엔티티 저장 | |
8 | 새로운 문제 풀이 상태 엔티티 저장 | |
9 | Tx1 Commit | |
10 | 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();
}
정상적으로 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개만 저장되기를 기대하는 로직을 추가한 결과
정상적으로 잘 통과하는 것을 볼 수 있다.