실시간 동시 투표 - Spring 동시성 해결 과정

Minu·2024년 10월 28일
0

Spring

목록 보기
3/3

투표 전송 과정

실시간 중복 투표 가능 기능 구현 중, 동시성 문제가 발생했는데 이를 어떻게 해결 했는지에 관한 글입니다.

client측에서 backend로 투표를 보낼 때 구조는 다음과 같습니다.

1. 사용자가 원하는 만큼 중복 투표한다.
2. 일정시간마다 투표 결과를 백엔드로 전송한다.
3. 백엔드는 투표 결과를 업데이트 처리하고 DB commit 호출한다.

동시성 문제 발생

그런데 다음과 같이 동시에 투표를 보낼 경우 문제가 발생했습니다.

문제 상황 예시

  • 2024년 1월 1일 00시 00분
  • 사용자 : 3명
  • given
    • 사용자1 - “메시“ : 10, “호날두” : 2
    • 사용자2 - “메시“ : 20, “호날두” : 4
    • 사용자3 - “메시“ : 30, “호날두” : 6
  • when
    • 투표 메서드 호출
  • then
    • 예상한 결과 : 메시 10 + 20 + 30 = 60
    • 예상한 결과 : 호날두 2 + 4 + 6 = 12
      실제 결과 : 메시 30표
      실제 결과 : 호날두 6표

동시에 여러 사용자가 투표 요청을 보낼 경우, 동일한 객체를 호출하여 마지막으로 종료된 저장된 값이 이전 업데이트를 덮어쓰게 되어 문제가 발생했습니다.

다음은 위 시나리오를 테스트코드로 작성해봤습니다.

 @Test
    void 동시_투표_요청이오면_문제가_발생한다() throws InterruptedException {

        // 3명의 유저가 있다고 가정
        int userCount = 3;

        // ThreadPool 크기
        int threadPoolSize = 10;
        ExecutorService executorService = Executors.newFixedThreadPool(threadPoolSize);
        CountDownLatch latch = new CountDownLatch(userCount);

        // given
        Long messiChoiceId = 1L;
        Long ronaldoChoiceId = 2L;

        // 유저1 투표 요청
        List<VoteRequest> user1Request = List.of(
            new VoteRequest(messiChoiceId, 10),
            new VoteRequest(ronaldoChoiceId, 2)
        );
        
        // 유저2 투표 요청
        List<VoteRequest> user2Request = List.of(
            new VoteRequest(messiChoiceId, 20),
            new VoteRequest(ronaldoChoiceId, 4)
        );

        // 유저3 투표 요청
        List<VoteRequest> user3Request = List.of(
            new VoteRequest(messiChoiceId, 30),
            new VoteRequest(ronaldoChoiceId, 6)
        );

        List<List<VoteRequest>> requests = List.of(user1Request, user2Request, user3Request);

        // 각 사용자별로 투표 메서드 실행
        for (List<VoteRequest> request : requests) {
            executorService.submit(() -> {
                try {
                    // when
                    voteService.voteBulk(request);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        // then
        int expectedVoteCountForMessi = 10 + 20 + 30;      // 60
        int expectedVoteCountForRonaldo = 2 + 4 + 6;      // 12

        Choice messiChoice = choiceRepository.findById(messiChoiceId).orElseThrow();
        Choice ronaldoChoice = choiceRepository.findById(ronaldoChoiceId).orElseThrow();

        assertThat(messiChoice.getVoteCount()).isEqualTo(expectedVoteCountForMessi);
        assertThat(ronaldoChoice.getVoteCount()).isEqualTo(expectedVoteCountForRonaldo);

        executorService.shutdown();
    }

실제로 예상한 결과 값은 모든 투표 값을 더한 메시 60표, 호날두 12표가 되어야 하는데 가장 나중에 종료된결과 값이 저장된걸 확인 할 수 있었습니다.

  • 메시 투표 총합 결과

  • 호날두 투표 총합 결과

해결 과정

1. synchronized 사용

Java 에선 이러한 동시성 문제를 해결하기 위해 synchronized 를 사용할 수 있습니다.
synchronized 먼저 실행한 Thread가 모니터를 소유하게 되고, 해당 Thread가 모니터를 반납할 때 까지 다른 Thread는 기다리는 방식입니다.

즉 synchronized 는 해당 부분의 코드가 한 번에 하나의 스레드에 의해서만 실행될 수 있도록 보장하도록 합니다.

synchronized 적용한 실제 코드

    @Transactional
    @Override
    public synchronized void voteBulk(List<VoteRequest> voteRequests) {

        // 매크로 검증
        voteValidator.validateVoteMacro(voteRequests);

        // 요청한 선택지 ID 값 목록 추출
        List<Long> choiceIds = voteConverter.convertToChoiceIds(voteRequests);
        List<Choice> choices = choiceRepository.findAllById(choiceIds);

        // 투표 수 choice ID에 맞게 업데이트
        Map<Long, Integer> voteCountMap = voteConverter.convertToVoteCountMap(voteRequests);

        choices.forEach(choice -> {
            int countToAdd = voteCountMap.getOrDefault(choice.getId(),0);
            choice.updateVoteCount(countToAdd);
        });

        choiceRepository.saveAll(choices);

        // 투표 정보 저장
        String ip = ipExtractor.extractIp();
        List<Vote> votesToSave = voteConverter.convertToVoteEntities(voteRequests, ip, timeProvider.getCurrentTime());

        voteRepository.saveAll(votesToSave);

    }

synchronized 사용 후 테스트 코드 결과

@Test
    void 동시_투표_synchronized_적용하면_성공한다() throws InterruptedException {

        // 50 명의 유저가 있다고 가정
        int userCount = 50;

        // ThreadPool 크기
        int threadPoolSize = 32;
        ExecutorService executorService = Executors.newFixedThreadPool(threadPoolSize);
        CountDownLatch latch = new CountDownLatch(userCount);

        // given
        Long messiChoiceId = 1L;
        Long ronaldoChoiceId = 2L;

        int messiCountRequest = 10;
        int ronaldoCountRequest = 2;

        // 각 유저들 동일한 투표 수 요청
        List<VoteRequest> voteRequest = List.of(
            new VoteRequest(messiChoiceId, messiCountRequest),
            new VoteRequest(ronaldoChoiceId, ronaldoCountRequest)
        );

        // 50명의 사용자가 동시에 voteBulk 메서드를 호출
        for (int i = 0; i < userCount; i++) {
            executorService.submit(() -> {
                try {
                    // when
                    voteService.voteBulk(voteRequest);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        // then
        int expectedVoteCountForMessi = userCount * 10;      // 50 * 10 = 500
        int expectedVoteCountForRonaldo = userCount * 2;      // 50 * 2 = 100

        Choice messiChoice = choiceRepository.findById(messiChoiceId).orElseThrow();
        Choice ronaldoChoice = choiceRepository.findById(ronaldoChoiceId).orElseThrow();

        assertThat(messiChoice.getVoteCount()).isEqualTo(expectedVoteCountForMessi);
        assertThat(ronaldoChoice.getVoteCount()).isEqualTo(expectedVoteCountForRonaldo);

        executorService.shutdown();
    }

이전엔 3명의 유저가 동시에 보내는 걸로 했는데 너무 적어서 이번엔 50명으로 변경 해봤습니다.

예상한 결과는 다음과 같습니다.

  • 50명의 유저가 동시에 메시에게 10표 요청 (50 * 10 = 500)
  • 50명의 유저가 동시에 호날두에게 2표 요청 (50 * 2 = 100)

결과

사용자가 더 늘었음에도 불구하고 아래와 같이 성공 했습니다.


하지만 다음과 같은 문제점이 있습니다.

synchronized 사용으로 인한 발생할 수 있는 문제점

1. 메소드 전체 Lock으로 인한 성능 저하

현재 다음과 같이 투표 로직 메서드 전체에 synchronized를 사용중입니다

// 투표 업데이트 전체 메서드에 synchronized 사용
public synchronized void voteBulk(List<VoteRequest> voteRequests){
   	// 그 외 코드들
   
   	// 투표 업데이트 메서드 호출
       choices.forEach(choice -> {
           int countToAdd = voteCountMap.getOrDefault(choice.getId(),0);
           choice.updateVoteCount(countToAdd);
       });

}

// 실제 투표 업데이트 메서드가 구현된 클래스
public class Choice {

    // 투표 수 업데이트
    public void updateVoteCount(int voteCount) {
       this.voteCount += voteCount;
   }
}

그래서 이를 다음과 같이 투표 수 업데이트 하는 로직에만 synchronized 를 적용하고 테스트 코드를 실행 했습니다.

// 메서드 전체 synchronized 제거
 public void voteBulk(List<VoteRequest> voteRequests){
 }
 
 public class Choice {

    // 투표 수 업데이트 부분에만 synchronized 적용
    public synchronized void updateVoteCount(int voteCount) {
       this.voteCount += voteCount;
   }
}

하지만 결과는 실패..

문제 원인

  • JpaRepository를 이용해 엔티티를 조회 할 때, 각각 스레드에서 조회한 엔티티는 서로 다른 인스턴스이다.
  • synchronized는 객체 인스턴스 단위로 락을 거는데, 서로 다른 인스턴스이므로 해당 인스턴스에만 동기화가 적용된다.

즉 여러 스레드가 동일한 데이터를 수정하지만, 다른 인스턴스에서 작업하므로 동기화가 제대로 이루어지지 못합니다.

2. 분산서버 동기화 문제점

synchronized는 대상 객체의 인스턴스 내에서만 유효합니다.


그림과 같이 만약 서버가 여러대이고, DB가 한대라면 synchronized는 각 서버의 WAS 내에서만 동작하므로, DB에 동시 write 시, 여전히 문제가 발생 할 수 있습니다.

2. 낙관적 락 사용

낙관적 락
트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법 입니다.
DB가 제공하는 락 기능을 사용하는 것이 아니라 JPA가 제공하는 버전 관리 기능을 사용합니다.
이로인해 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌을 알 수 없습니다.

애플리케이션 단에서 제공해주는 락 입니다.

비관적 락
트랜잭션의 충돌이 발생한다고 가정하고 우선 DB가 제공하는 락 기능을 사용하는 방법입니다.
동시성에 대한 안전성은 증가하나 트랜잭션에 락을 걸기 때문에 낙관적 락 보다는 성능 저하가 발생 할 수 있습니다.

저는 사이트의 상황을 고려하여 낙관적 락을 사용하기로 결정 했습니다.

실시간 중복 투표가 가능한 동시 투표인만큼 사용자에게 빠른 응답을 제공해야되고, 중복 익명 투표도 가능하기에 일부 투표 수가 누락되더라도 전체적인 서비스에는 큰 영향이 없기 때문입니다. 따라서 비관적 락을 사용하면 동시성은 낮아지겠지만 트랜잭션이 잠겨버려서 응답속도가 늦을거라고 생각하기 때문입니다.

낙관적 락 적용한 코드

엔티티

@Entity
@Table(name = "choice")
public class Choice {
    // 나머지 코드들..
	@Version
    private int version;
}

낙관적 락을 사용할 엔티티에 @Version 을 추가하게 되면, 엔티티가 수정될 때마다 버전이 자동으로 하나씩 증가하게 됩니다.
엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외가 발생합니다.

투표 로직

	private static final int MAX_RETRY = 20;
    
    @Transactional
    @Override
    public void voteBulk(List<VoteRequest> voteRequests) {
        int retryCount = 0;
        while (true) {
            try {
                // 매크로 검증
                voteValidator.validateVoteMacro(voteRequests);

                // 요청한 선택지 ID 값 목록 추출
                List<Long> choiceIds = voteConverter.convertToChoiceIds(voteRequests);
                List<Choice> choices = choiceRepository.findAllById(choiceIds);

                // 투표 수 choice ID에 맞게 업데이트
                Map<Long, Integer> voteCountMap = voteConverter.convertToVoteCountMap(voteRequests);


                choices.forEach(choice -> {
                    int countToAdd = voteCountMap.getOrDefault(choice.getId(),0);
                    choice.updateVoteCount(countToAdd);
                });

                choiceRepository.saveAll(choices);


                // 투표 정보 저장
                String ip = ipExtractor.extractIp();
                List<Vote> votesToSave = voteConverter.convertToVoteEntities(voteRequests, ip, timeProvider.getCurrentTime());

                voteRepository.saveAll(votesToSave);
                break;
            } catch (OptimisticLockingFailureException e) {
                ++retryCount;
                log.warn("충돌 발생!");
                if (retryCount > MAX_RETRY) {
                    log.error("투표 재시도 횟수 초과");
                    throw VoteTooManyRequests.of(e.getMessage());
                }else{
                    log.warn("투표 재시도");
                }

            }
        }
    }

충돌이 일어나면 Hibernate에서 StaleStateException 예외가 발생하게 되는데, Spring Data JPA는 이를 OptimisticLockingFailureException으로 감싸서 던집니다.

그래서 위와 같이 try-catch 문으로 예외를 처리한 후, 임의로 지정한 retryCountMAX_RETRY보다 적으면 투표 업데이트 로직을 계속해서 실행합니다.

만약 retryCountMAX_RETRY보다 크면 더 이상 반복하지 않고 예외를 발생시킵니다.
즉 현재 코드는 현재 코드는 충돌이 발생할 경우 최대 20번까지 업데이트를 시도하도록 구성되어 있습니다.

테스트 코드 결과

  • 로그
2024-10-30 14:48:27.774  WARN 30610 --- [ool-1-thread-32] o.p.api.vote.service.VoteServiceImpl     : 충돌 발생!
2024-10-30 14:48:27.774  WARN 30610 --- [ool-1-thread-32] o.p.api.vote.service.VoteServiceImpl     : 투표 재시도
Hibernate: select choice0_.id as id1_0_, choice0_.image_url as image_ur2_0_, choice0_.name as name3_0_, choice0_.topic_id as topic_id4_0_, choice0_.version as version5_0_, choice0_.vote_count as vote_cou6_0_ from choice choice0_ where choice0_.id in (? , ?)
Hibernate: update choice set image_url=?, name=?, topic_id=?, version=?, vote_count=? where id=? and version=?

충돌 -> SELECT -> UPDATE 가 성공적으로 되는 것을 확인 할 수 있습니다

  1. 충돌이 발생하면 (버전이 일치하지 않으면)
  2. 다시 최신 데이터를 조회하고 (새로운 버전 확인)
  3. 새로운 버전 정보로 업데이트를 시도

참고

https://stackoverflow.com/questions/35964692/jpa-optimistic-locking-vs-synchronized-java-method
https://product.kyobobook.co.kr/detail/S000000935744

0개의 댓글

관련 채용 정보