티쳐포보스 회원가입 API를 맡아 개발했었다.

MVP 기능을 대략적으로 완수하고 현재 리팩토링하며 개선지점을 찾고 있는 중, 회원가입 API의 동시성 문제에 대해 고민해보게 되었다.

 

현재 회원가입 서비스 로직은 아래와 같이,

if (memberRepository.existsByEmailAndStatus(request.getEmail(), Status.ACTIVE))
    throw new AuthHandler(ErrorStatus.MEMBER_EMAIL_DUPLICATE);
if (memberRepository.existsByPhoneAndStatus(request.getPhone(), Status.ACTIVE))
    throw new AuthHandler(ErrorStatus.MEMBER_PHONE_DUPLICATE);
if (!request.getPassword().equals(request.getRePassword()))
    throw new AuthHandler(ErrorStatus.PASSWORD_NOT_CORRECT);
if (!(request.getAgreementUsage().equals("T") && request.getAgreementInfo().equals("T") && request.getAgreementAge().equals("T")))
    throw new AuthHandler(ErrorStatus.INVALID_AGREEMENT_TERM);

 

1. 이메일 중복 여부

2. 전화번호 중복 여부

3. 비밀번호 재입력 일치 여부

4. 약관동의 체크 여부

와 같은 검증 조건이 적용되며 로직이 이어진다.

 

이때 회원가입에서 동시성 문제를 피할 수가 없는데... 아래과 같은 케이스가 있을 것이다.

거의 동시에 사용자1과 사용자2가 "test@naver.com"이라는 같은 이메일을 가지고 회원가입을 진행한다고 가정하자.

이메일이 존재하면 에러 처리를 진행하지만, DB에 없는 이메일이기에 사용자1, 사용자2 둘 다 모두 에러 없이 비밀번호 암호화까지 완료한다.

간발의 차로 사용자1이 계정 생성에 성공하고, 뒤이어 사용자2도 성공하여 DB에 같은 이메일로 계정이 두 개 생긴다.

 

이와 같은 동시성 문제는 어떻게 처리하면 좋을까?

Jmeter, JUnit을 활용하여 테스트 코드를 직접 작성해 동시 읽기 문제를 해결해보고자 한다.

사실 실제 서비스에서 같은 이메일 계정으로 회원가입을 할 가능성은 거의 희박하지만 ,,,  DB 락, 유니크 키 등 개념을 활용하여 서비스 진행 중 발생할 수 있는 케이스를 해결해보고자 한다!

 

 

 

우선 Jmeter를 사용해 스레드 100개를 2번 호출하여 중복으로 계정이 얼마나 생성되는지 확인해보았다.

수많은 호출 중 보이는 초록 불빛..

그러고 DB를 확인해본 결과

 

10개의 중복 계정이 생성되었다..!

실제 서비스에서 같은 이메일로 계정 생성하는 경우는 드물 거 같지만,, 10개나..

그러나 이러한 문제는 매우 크리티컬하게 작용할 것이기에, 자바의 ExecutorService와 CountDownLatch를 활용하여 회원가입 테스트를 진행해보았다.

 

 

우선, 여러 요청이 들어왔을 때 애플리케이션, 데이터베이스 단계 관점에서 해결 방법을 생각해볼 수 있다.

JVM의 동기화 기술은 여러 개의 JVM이 동시에 실행되는 멀티 스레드 환경에서 문제가 되기에,

데이터의 무결성과 일관성을 위해 트랜잭션을 지원하는 DB에서 처리하는게 좋다고 판단했다.

 

단 긴 시간 락을 점유하는 트랜잭션 비용이 크거나 잘못될 경우, 격리 수준, 전파 수준과 같은 데드락이 발생하는 락을 사용하기 보다는 데이터베이스 키 무결성을 이용하는 게 좋다고 한다.

JPA와 Database를 활용하여 동시성 문제를 해결할 수 있는 방법들로 하나씩 풀어나가 보겠다!

 

 

1.Service 코드에 @Synchronized 사용

가장 간단한 방법이다.

 

해당 예약어를 사용하는 경우 동시에 오는 스레드 요청을 커밋하기 전 트랜잭션 전 단계에 Synchronized를 설정해야한다.

멀티 스레드 환경에서 하나의 자원을 사용하고자 할 때, 먼저 접근하여 사용하고 있는 스레드를 제외하고 나머지 스레드는 접근할 수 없도록 막는다.

 

그래서 사용하게 된다면, 자바 내부에서 동기화를 위해 block, unblock 처리를 하게 되는데 요청이 너무 많아지면 프로그램 성능 저하를 일으킬 수 있다..!

그래서 이 방법은 테스트만 진행해보고 패스하기로 했다.

아래 링크 답변을 통하여 멀티 스레드, 그리고 동기화 이슈에 대해 잘 이해할 수 있었다!

https://stackoverflow.com/questions/41767860/spring-transactional-with-synchronized-keyword-doesnt-work

 

Spring @Transactional with synchronized keyword doesn't work

Let's say I have a java class with a method like this (just an example) @Transactional public synchronized void onRequest(Request request) { if (request.shouldAddBook()) { if (database.

stackoverflow.com

 

 

2. 낙관적 락 적용

현재 사용하고 있는 DBMS는 MySQL로 기존 격리수준은 repeatable read이다.

기존 격리수준을 변경하지 않고 격리성을 증진할 수 있는 방법으로 락을 사용할 수 있다.

트랜잭션을 순차적으로 처리하도록 도와주고, 데이터의 일관성을 유지할 수 있도록 도와준다.

현재 이슈의 경우 트랜잭션 충돌이 심하게 나지 않을 것이라 가정하고, 낙관적 락으로 충분히 해결할 수 있다 생각했다.

 

낙관적 락을 적용하기 위해 @Version에 해당하는 컬럼을 추가한다.

그러면서 버전을 비교해가며 트랜잭션 충돌을 줄일 수 있는데,

회원가입 API의 경우 우선 이메일 중복으로 version에 해당하는 컬럼을 추가하기까진 동시성 이슈가 발생할 가능성이 적기도 하고

좋아요 기능이나 예약 기능과 같이 동시다발적으로 같은 데이터를 참조할 경우가 낮다고 판단하여 해당 방법 또한 패스하였다 ㅎㅎ..

 

 

3. 테이블에 Unique key 조건 추가

중복 검증하고 싶은 컬럼에 unique=true 조건을 추가하여 insert가 두 건 이상 되지 않도록 한다.

현재 내가 중복 체크하고 싶은 컬럼은 email과 phone 컬럼이다.

엔티티 @Table에 UniqueConstraint 조건을 설정하여 DuplicateKeyException을 감지할 수 있는데,

이와 같은 경우 (email, phone) 함께 엮어서 unique key를 생성한다.

테이블에 추가하는 경우 email과 phone중에 하나라도 중복되는게 있다면 Exception을 발생시키기에, 컬럼마다 추가하는 식으로 진행하였다.

 

해당 과정을 트랜잭션 관점으로 살펴보자면,

두 사용자가 같은 이메일로 회원가입 요청을 보내도 아직 데이터베이스에 해당 이메일에 해당되는 레코드가 존재하지 않는다. 그렇기에 두 트랜잭션 모두 read 이후 각각 write(member_1), write(member_2) 쓰기 작업을 수행한다.

 

그러나 unique 제약 조건을 설정함으로써 한 트랜잭션에서 쓰기 작업을 진행하고 commit이 된다면

다른 트랜잭션에서는 commit되는 시점에 unique 조건을 만족하지 않아 DataIntegrityViolationException 예외가 발생한다. 따라서 이를 통해 회원가입 동시성을 해결할 수 있다!

 

직접 회원가입 API에 적용하여, 테스트 코드와 함께 검증도 진행해보겠다.

우선 unique 키를 적용하여 부하 테스트를 먼저 진행해보겠다!

현재 데이터베이스에서 Member 테이블에 email = 'test@gmail.com'이라는 조건의 쿼리를 조회하면 2.75 cost가 소요된다. 

 

Member 엔티티 email과 phone 컬럼에 unique key 조건을 추가해준다.

 

쿼리는 위와 같다!

 

그리고 회원가입 API 부하 테스트를 맨 처음 동일한 조건으로 100개의 스레드를 두 번 호출해주면..!

 

 

위의 기존에 중복으로 추가되었던 계정은 unique 키 설정으로 부득이하게 변경

계정 하나만 새로 추가되는 것을 확인할 수 있다!

그러나 저장이 되지 않은 것일 뿐, API 중복 요청이 안오는 것은 아니다.

즉 데이터베이스 커밋 요청은 들어오니.. 테스트 코드를 작성함으로써 동시에 저장되는지 검증 또한 진행해보겠다.

 

우선 Java에서 비동기 로직을 수행하는 ExecutorServiceCountDownLatch를 활용해주었다.

회원가입 메서드를 수행하는 스레드는 총 5개로 설정하였고, 이미 회원가입된 이메일이 있다면 List에 담아주지 않는 로직으로 진행했다. 

 

@Test
@DisplayName("회원 가입 - 동시성 테스트")
void joinMemberWithConcurrent() throws InterruptedException {
    // given
    Member expected = authTestUtil.generateMemberDummy("email@gmail.com");
    AuthRequestDTO.JoinDTO request = request("백채연", "email@gmail.com", "asdf1234", "asdf1234", 2, "01012341234", "T"); // request로 입력한 Member data

    doReturn(expected).when(memberRepository)
            .save(any(Member.class));

    ExecutorService executorService = Executors.newFixedThreadPool(5);
    CountDownLatch latch = new CountDownLatch(5);

    // when
    log.info("회원가입 동시성 테스트 진행");
    ArrayList<Member> memberList = new ArrayList<>();
    for (int i = 0; i < 5; i++) {
        executorService.execute(() -> {
            Member member = memberRepository.save(authCommandService.joinMember(request));
            if (memberList.isEmpty()){
                memberList.add(member);
            }
            else{
                for (Member m : memberList) {
                    if (!m.getEmail().equals(member.getEmail())) {
                        memberList.add(member);
                    }
                }
            }
            latch.countDown();
        });
    }
    latch.await();

    // then
    assertEquals(memberList.size(), 1);
}

 

CountDownLatch를 활용하여 설정한 count가 0이 될 때까지 타 스레드에서 대기해준다.

ExecutorService로 설정한 5개 스레드가 모두 작업을 마칠때까지 대기하여 결과를 테스트한 결과, 정상적으로 하나의 계정만 리스트에 들어온 것을 확인할 수 있었다.

 

실행 계획을 살펴본 결과 cost가 0.35로 줄어들었다!

unique key 적용 전에는 테이블 full scan을 진행했었는데, 인덱스로 조회하다니..!

unique key 생성함과 동시에 mysql 자체에서 인덱스를 생성해준 것 같다. 

 

테이블의 인덱스를 확인해본 결과 email과 phone에 해당되는 unique index가 생성된 것을 확인할 수 있었다.

 

 

이렇게 최종적으로 unique constraint를 추가하여 수정한 부분은 DB에서 직접 예외를 발생시키기에 애플리케이션 예외로 다시 넘겨주어야 한다.

위에 언급하였듯 회원가입 API의 경우 동시성 이슈가 발생할 가능성이 적기도 하고, DB 단계에서 예외 처리해도 충분하다고 생각하는 트랜잭션 작업이라 생각했기에 이와 같이 진행했다.

추후 동일한 자원을 공유하며 조회하고, 수정하는 작업의 API를 진행할 때 락 관련 작업을 진행해 성능 개선과 동시에 공부를 더 진행해보고 싶다!

+ Recent posts