JAVA/JPA 학습기록

[JPA] unique 동시성 이슈 해결 및 CountDownLatch 테스트 작성 (Feat. Unique Index)

kth990303 2023. 6. 4. 18:12
반응형

사이드 프로젝트를 하면서 동시성 이슈를 만났다.

모바일 프론트 측에서 api 테스트를 하다가, 즐겨찾기 엔티티가 연달아 두 번 등록되는 이슈가 있었던 것이다. 그리고 즐겨찾기 엔티티는 repository에서 Optional<Favorite>과 같은 꼴로 반환되게 했기 때문에, 두 개 이상의 결과가 반환되면  NonUniqueResultException 가 발생한다.

 

이러한 동시성 이슈가 발생하는 데에는, 두 스레드가 거의 동시에 진행됐기 때문이다. 

사실 트래픽이 조금이라도 많으면 이러한 동시성 이슈는 흔히 만날 수 있다. nGrinder로 save 관련 메서드에 수십개의 스레드로 테스트를 돌려보자. 아래 사진은 nGrinder로 회원가입 메서드를 수십개의 스레드로 5초동안 실행시킨 결과이다.

원래대로라면 같은 이메일, 같은 닉네임으로 회원가입이 될 수 없어야 한다.

하지만, 여러 스레드가 거의 동시에 작업이 이루어진다면? 위 사진을 보면 각각 created_time이 약 0.015초 간격으로 회원이 생성된 것을 확인할 수 있다. 아직 A 스레드에서 nickname, email을 save하는 과정이 처리되기 직전이고, B 스레드에서 validateDuplicateNickname, validateDuplicateEmail 과정을 거치면 당연히 validate에 걸리지 않으므로 두 스레드에서 모두 save가 진행돼버리는 것.

 

따라서 위 사진처럼 NonUniqueResultException을 만나게 된다.


낙관적 락, 또는 unique 제약조건으로 해결하기

예전에 나는 비슷한 이슈를 경험했었고, 이를 낙관적 락으로 해결한 적이 있다.

https://kth990303.tistory.com/390

 

[JPA] 프로젝트 동시성 이슈 해결을 위해 낙관적 락을 걸어보았다

롤링페이퍼 기반 서비스 플랫폼을 개발하던 중 아래 버그를 발견했다. ??? 대체 어떤 일이 일어난다는 거야... 하는 마음으로 같은 메시지를 막 광클했을 때 위와 같이 `에러가 발생했어요` 페이

kth990303.tistory.com

 

만약 어떠한 엔티티를 save함으로 인해, 타 Entity에 수정이 가해지는 경우라면 낙관적 락으로 충분히 해결할 수 있다. 낙관적 락은 @Version을 이용하여 특정 엔티티의 변경 감지를 통해 애플리케이션 단에서 락을 제어하는 수단이기 때문이다.

 

하지만, 보통은 특정 엔티티를 save한다고 해서 타 엔티티가 따로 update되거나 그러진 않는다. 회원이 save된다고 해서 갑자기 리뷰가 생성되거나 댓글이 작성되지는 않는것처럼 말이다. 이런 경우에는 unique 제약조건으로 동시성 이슈를 해결할 수 있다.

 

NonUniqueResultException을 반환하게 되면, 해당 엔티티와 연관된 모든 비즈니스 로직은 500번대 에러를 만날 수밖에 없다. 해당 엔티티를 조회할 때마다 에러를 반환하게 될 것이기 때문이다. 하지만, unique 제약조건을 설정한다면 최소한, DB에 두 개 이상의 데이터가 중복돼서 들어가는 이슈는 막을 수 있을 것이다. 그리고 해당 엔티티에 접근할 때 NonUniqueResultException이 발생하는 상황은 막을 수 있게 된다.


unique 제약조건을 이용한 코드

중복된 이메일, 또는 중복되는 닉네임으로는 가입할 수 없어야 한다고 하자.

그렇다면 unique 옵션을 아래와 같이 걸어주면 되겠다.

 

Member.class

1
2
3
4
5
@Column(name = "email", unique = true, nullable = false)
private String email;
 
@Column(name = "nickname", unique = true)
private String nickname;
cs

 

이제 email, nickname에 unique 옵션이 걸려있게 됐다. 따라서 DB 내에 중복되는 이메일이나 닉네임으로 저장되는 이슈는 존재하지 않는다.

 

하지만 저장이 안되는 것 뿐이지, 중복 요청이 안온다고 하지는 않았다!

따라서 중복된 이메일, 닉네임 요청이 들어올 경우 DataIntegrityViolationException을 반환하게 된다. 이를 try-catch로 처리하여 커스텀 예외를 반환하게 해주면 된다.

 

MemberService.class

1
2
3
4
5
6
try {
    Member member = new Member(request.getEmail(), encodedPassword, request.getNickname(), request.getPhone());
    return new MemberSignUpResponse(memberRepository.save(member).getId());
catch (DataIntegrityViolationException e) {
    throw new DuplicateMemberException();
}
cs

CountDownLatch를 이용한 동시성 테스트 코드

테스트 코드를 작성하여 동시에 저장되는 이슈가 있는지 직접 확인해보자.

 

동시다발적으로 회원가입 메서드를 수행시키기 위해 비동기 로직을 수행하는 java.util.concurrent 에 위치하는 ExecutorService를 사용하였다. 회원가입 메서드를 수행하는 스레드 개수를 3개로 설정할 것이기 때문에, 스레드풀 크기는 3으로 설정했다. 

 

CountDownLatch는 스레드들이 역할을 수행하면, 설정한 count가 0이 될 때까지 타 스레드에서 대기하도록 해주는 클래스이다.

Memory consistency effects: Until the count reaches zero, actions in a thread prior to calling countDown() happen-before actions following a successful return from a corresponding await() in another thread.

출처: spring docs - CountDownLatch

 

3개의 스레드가 모두 작업을 마치지도 않았는데 테스트 결과를 확인할 수는 없는 노릇이기 때문에 CountDownLatch는 아주 중요한 역할을 해주는 셈. 따라서 ExecutorService로 설정한 3개의 스레드들에게 병렬적으로 회원가입 로직을 수행하는 역할을 맡긴 후, 3개의 스레드가 모두 작업을 마칠때까지 대기하여 결과를 테스트해볼 수 있다.

 

MemberConcurrentServiceTest.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Test
@DisplayName("회원이 동시에 여러 번 가입 시도해도 한 번만 가입된다")
void signUpWithConcurrent() throws InterruptedException {
    MemberSignUpRequest request = new MemberSignUpRequest("kth990303@naver.com""a1b2c3d4""케이""010-1234-5678");
    ExecutorService executorService = Executors.newFixedThreadPool(3);
    CountDownLatch latch = new CountDownLatch(3);
    List<Throwable> exceptions = Collections.synchronizedList(new ArrayList<>());
 
    for (int i = 0; i < 3; i++) {
        executorService.execute(() -> {
            try {
                memberService.signUp(request);
            } catch (DuplicateMemberException e) {
                exceptions.add(e); // 중복 예외를 리스트에 추가
            }
            latch.countDown();
        });
    }
    latch.await();
 
    List<Member> actual = memberRepository.findAll();
    assertAll(
            () -> assertThat(exceptions).hasSize(2),
            () -> assertThat(actual).hasSize(1)
    );
}
cs

테스트가 통과하는 것을 확인할 수 있다.

 

테스트 로그를 확인해보면, SQL Error 23505로 인해 유일성 무결성 위반이 발생해 DataIntegrityViolationException이 발생한 것을 확인할 수 있다. 그리고 이를 catch해서 우리가 보고 싶은 커스텀 예외 문구인 `이미 존재하는 회원입니다`가 잘 뜨는 것을 확인할 수 있다.

실제로 삽입돼서 결과가 두개 이상일 때 반환되는 NonUniqueResultException보다 훨씬 낫다. 


Unique 제약조건 생성으로 인한 Unique index 생성

MySQL 기준으로, Unique 제약조건을 생성하면 Unique index가 자동으로 설정된다.

 

unique 제약조건 설정 전

닉네임에 unique 제약조건을 걸기 전에, select 문으로 '케이'라는 닉네임을 가진 회원을 조회하는 쿼리를 호출해보았다.

 

데이터는 총 10,007건 중 1건이 존재하며, 인덱스를 설정하지 않아 Full Scan이 발생한 것을 확인할 수 있다.

Total Cost는 1039.05로 상당히 오랜 시간이 소요된다.

 

 

이제 nickname에 unique constraint를 설정해보겠다.

alter table member 
add constraint unique_nickname 
unique (nickname);

 

unique 제약조건 설정 후

show index from member; 결과

Non_unique 값이 0 (즉, unique 제약조건이 걸린) 인덱스가 생성된 것을 확인할 수 있다.

분명 우리는 index를 따로 설정해주지 않았음에도 불구하고, unique index가 생긴 것이다.

unique한 옵션이기 때문에 카디널리티(Cardinality)가 상당히 높아서 MySQL에서 자동으로 생성해준 것이 아닐까 싶다.

 

 

 

위와 마찬가지로, select 문으로 '케이'라는 닉네임을 가진 회원을 조회하는 쿼리를 호출해보았다.

실행계획으로 확인해본 결과, Full Scan하지 않고 Unique Index를 통해 조회하는 것을 확인할 수 있다.

Cost 또한 1039.05에서 0.35로 훨씬 감소한 것을 확인할 수 있다. 


뷰와 프로세스 로직을 별도 비동기 스레드로 작동시키는 안드로이드, iOS 특성상, 동시성 이슈는 흔히 볼 수 있는 에러일 것이다.

앱이 아닌 웹 애플리케이션을 개발한다 하더라도, 동시성 이슈는 충분히 만날 수 있다.

서버가 여러 대라면 더더욱 흔히 만나게 될 것이다.

 

unique 옵션을 제때 걸어줌으로써 화를 면하고, 테스트 또한 꼼꼼히 작성해주도록 하자.

반응형