안녕하세요.! 회사와 함께 성장하고 싶은 KOSE입니다.

이번 포스팅은 앞 선 포스팅에서 정리한 RedissonClient로 멀티스레드 환경 동시성 극복하기 2편을 작성하고자 합니다.

(앞선 RedissonClient로 repository에 동시성을 제어하는 포스팅 링크입니다. : https://gose-kose.tistory.com/24)

 

프로젝트를 진행할 때 복잡한 비즈니스 로직 작성은 피할 수 없습니다. 싱글 스레드 환경에서 데이터를 조회하고 수정하는 과정은 순차적으로 이루어지기 때문에 데이터의 정합성을 보장할 수 있습니다. 하지만 멀티 스레드 환경에서는 데이터에 대한 접근과 수정은 데이터의 정합성을 보장하기 어렵습니다.  따라서, synchronized 키워드로 순차처리가 진행되도록 하거나 RedissonClient의 lock을 이용해 분산락 처리로 이를 보완하는 해결책을 생각해 볼 수 있습니다.

이번 글은 제가 프로젝트를 진행하며 겪었던 멀티 스레딩 환경에서 redis에 저장된 값을 조회하고 값을 수정하여 다시 저장하는 메소드의 동시성 문제를 극복한 과정을 정리하도록 하겠습니다.

 

 

1. 공통 코드 

 

@Getter
@AllArgsConstructor
@RedisHash(value = "Vote")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Vote {

    @Id
    private String id;
    @Indexed
    private String gameId;
    private List<VotedResult> votedResults;

	''' 중략 '''
    public void updateVoteResults(String userId, String liarId) {
        votedResults.stream()
                .filter(vote -> vote.getLiarId().equals(liarId))
                .findFirst()
                .ifPresent(votedResult -> {
                    votedResult.addUserId(userId);
                    votedResult.updateCnt();
                });
    }

    public List<VotedResult> getMaxVotedResult() {
        return votedResults.stream()
                .collect(Collectors.groupingBy(VotedResult::getCnt))
                .entrySet()
                .stream()
                .max(Map.Entry.comparingByKey())
                .map(Map.Entry::getValue)
                .orElse(Collections.emptyList());
    }

	''' 중략 '''
}
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class VotedResult {
    private String liarId;
    private List<String> userIds = new ArrayList<>();
    private int cnt;

    public VotedResult(String liarId, int cnt) {
        this.liarId = liarId;
        this.cnt = cnt;
    }

	''' 중략 '''

}
public interface VoteRepository extends CrudRepository<Vote, String> {
    Vote findVoteByGameId(String gameId);
}
@Component
public interface VotePolicy {

    /**
     * game의 모든 턴이 끝나면, vote 객체를 생성하여 저장한다.
     */
    String saveVote(Game game) throws InterruptedException;

    /**
     * 유저가(userId)가 라이어(liar)를 투표하여
     * Vote 객체 값을 저장한다.
     */
    void voteLiarUser(String gameId, String userId, String liarId) throws InterruptedException;

    /**
     * 가장 많은 LiarId 투표를 받은 결과를 출력한다.
     */
    List<VotedResult> getMaxVotedLiarUser(String gameId);


}
@Component
@Slf4j
@Transactional
@RequiredArgsConstructor
public class VotePolicyImpl implements VotePolicy {

    private final VoteRepository voteRepository;
    private final RedissonClient redissonClient;

	''' 중략 '''

    /**
     * 유저가(userId)가 라이어(liar)를 투표하여
     * Vote 객체 값을 저장한다.
     */
    @Override
    public void voteLiarUser(String gameId, String userId, String liarId) {
        Vote vote = voteRepository.findVoteByGameId(gameId);
        vote.updateVoteResults(userId, liarId);
        voteRepository.save(vote);
    }

    /**
     * 가장 많은 LiarId 투표를 받은 결과를 출력한다.
     */
    @Override
    public List<VotedResult> getMaxVotedLiarUser(String gameId) {
        Vote vote = voteRepository.findVoteByGameId(gameId);
        return vote.getMaxVotedResult();
    }
}

 

먼저 CrudRepository를 확장하여 Redis에 값을 저장하는 방식을 사용하였습니다.

앞선 Vote 클래스의 gameId는 @Indexed로 선언되어 있습니다.

만약 Vote가 저장된다면 해당 어노테이션이 해당 필드의 값으로 Index를 Redis에 저장합니다.

 

 

 

2. 싱글 스레드에서 테스트 코드 작성하기

 

제가 테스트 하고자 하는 메서드는 votePolicy의 voteLiarUser()입니다. 해당 메서드에는 findVoteByGameId()로 redis에서 값을 조회하여 객체 인스턴스로 선언한 후, vote 인스턴스의 값을 바꾸고 다시 저장하는 복잡한 로직이 있습니다. 이를 싱글 스레드 환경에서 테스트하면, 다음의 결과를 얻을 수 있습니다.

 

@Test
@DisplayName("단일 스레드 환경에서 liar를 투표한다")
public void voteLiarUser() throws Exception {
    //given
    num = 5;
    votePolicy.saveVote(game);

    //when
    for (int i = 0; i < num; i++) {
        votePolicy.voteLiarUser(game.getId(), String.valueOf(i + 1), "2");
    }
    List<VotedResult> maxVotedLiarUser = votePolicy.getMaxVotedLiarUser(game.getId());

    //then
    assertThat(maxVotedLiarUser.size()).isEqualTo(1);
    assertThat(maxVotedLiarUser.get(0).getLiarId()).isEqualTo("2");
    assertThat(maxVotedLiarUser.get(0).getCnt()).isEqualTo(5);
}

 

즉, 해당 메소드는 싱글 스레드 환경에서 검증된 코드라고 판단할 수 있습니다.

이제, 멀티 스레딩 환경에서 voteLiarUser() 메서드를 테스트하도록 하겠습니다.

 

 

2. Synchronized로 테스트 코드 작성하기

 

@Autowired
    VotePolicy votePolicy;

    @Autowired VotePolicyImpl votePolicyImpl;
    @Autowired VoteRepository voteRepository;
    @Autowired RedissonClient redissonClient;

    private Game game;
    private int num;
    private Thread[] threads;

	''' 중략 '''
    

@Test
@DisplayName("멀티 스레딩 환경에서 liar를 투표한다. : synchronized")
public void voteUser_synchronized() throws Exception {
    //given
    num = 5;
    votePolicy.saveVote(game);

    //when
    for (int i = 0; i < num; i++) {
        int finalIndex = i;
        threads[i] = new Thread(() -> {
            voteLiarUser(game.getId(), String.valueOf(finalIndex + 1), "2");
        });
    }

    runThreads();
    List<VotedResult> maxVotedLiarUser = votePolicy.getMaxVotedLiarUser(game.getId());

    //then
    assertThat(maxVotedLiarUser.size()).isEqualTo(1);
    assertThat(maxVotedLiarUser.get(0).getLiarId()).isEqualTo("2");
    assertThat(maxVotedLiarUser.get(0).getCnt()).isEqualTo(5);
}


private void runThreads() throws InterruptedException {
    for (int i = 0; i < num; i++) threads[i].start();
    for (int i = 0; i < num; i++) threads[i].join();
}

/**
 * Syncronized와 성능 비교를 위한 테스트
 */
private synchronized void voteLiarUser(String gameId, String userId, String liarId) {
    Vote vote = voteRepository.findVoteByGameId(gameId);
    vote.updateVoteResults(userId, liarId);
    voteRepository.save(vote);
}

 

자바에서 synchronized는 반환 타입 앞에 선언하여 해당 메소드를 synchronized 한 방식으로 변경할 수 있습니다.

 

멀티 스레딩에서 해당 키워드는 동시성을 보장하는 기능을 수행할 수 있지만, 치명적인 단점 또한 존재합니다. 

Synchronized는 해당 메서드나 블록이 한번에 한 스레드씩 수행하도록 처리합니다. 따라서, 배타적 실행을 보장합니다. 

하지만, 메소드 단위가 한 스레드씩 수행하도록 하다 보니, 만약 gameId가 다른 요청이 왔을 때도 앞 선 요청이 수행될 때까지 

대기해야 하는 문제가 발생합니다. 이는 곧 성능 문제와 직결될 수 있습니다.

 

 

 

3. RedissonClient로 문제 해결하기

 

해당 문제점을 해결하는 방법은 locKey로 "VoteLiarUser:" + gameId로 설정함으로써, gameId에 따라 분산락을 설정할 수 있습니다.

 

 

RedissonClient를 적용하여 코드를 수정하면 다음과 같습니다.

@Override
public void voteLiarUser(String gameId, String userId, String liarId) throws InterruptedException {
    String lockKey = "VoteLiarUser: " + gameId;
    RLock lock = redissonClient.getLock(lockKey);

    try {
        boolean isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS);
        if (!isLocked) {
            throw new RedisLockException();
        }

        Vote vote = voteRepository.findVoteByGameId(gameId);
        vote.updateVoteResults(userId, liarId);
        voteRepository.save(vote);

    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}
@Test
@DisplayName("멀티 스레딩 환경에서 liar를 투표한다. : RedissonClient")
public void voteLiarUser_multiThread() throws Exception {
    //given
    num = 5;
    votePolicy.saveVote(game);

    //when
    for (int i = 0; i < num; i++) {
        int finalIndex = i;
        threads[i] = new Thread(() -> {
            try {
                votePolicy.voteLiarUser(game.getId(), String.valueOf(finalIndex + 1), "2");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
    }

    runThreads();
    List<VotedResult> maxVotedLiarUser = votePolicy.getMaxVotedLiarUser(game.getId());

    //then
    assertThat(maxVotedLiarUser.size()).isEqualTo(1);
    assertThat(maxVotedLiarUser.get(0).getLiarId()).isEqualTo("2");
    assertThat(maxVotedLiarUser.get(0).getCnt()).isEqualTo(5);
}

 

 

4.  Aop를 적용하여 분산락 과정을 비즈니스 로직에서 분리하기

 

try - catch - finally 코드는 분산락을 처리하는 과정에서 반복될 수 있습니다. 또한, 핵심 비즈니스 로직을 파악하는데 어려움을 줄 수 있으므로 AOP로 분리하여 사용할 수 있습니다.

@Configuration
@EnableAspectJAutoProxy
public class RedisAopConfig {

    @Bean
    public RedisLockAspect redisLockAspect(RedissonClient redissonClient) {
        return new RedisLockAspect(redissonClient);
    }

}

먼저 RedisLockAspect를 사용하기 위해 config를 작성합니다.

 

@Aspect
@Slf4j
@RequiredArgsConstructor
@Component
public class RedisLockAspect {

    private final RedissonClient redissonClient;

    @Around("execution(* liar.gamemvcservice.game.service.vote.VotePolicy.voteLiarUser(..)) && args(gameId, userId, liarId)")
    public void voteLiarUserWithRedisLock(ProceedingJoinPoint joinPoint, String gameId, String userId, String liarId) throws Throwable {
        String lockKey = "VoteLiarUser: " + gameId;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            boolean isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS);
            if (!isLocked) {
                throw new RedisLockException();
            }
            joinPoint.proceed();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

 

@Around(pattern)을 사용하여 지정된 패턴에 해당되는 메서드가 실행되기 전, 후 해당 메서드가 실행되도록 구성합니다.

패턴에 해당하는 부분은 pointcut 표현식으로 와일드 카드를 이용하여 작성할 수 있습니다.

  • *: 모든 것
  • ..: 0개 이상

특정 메서드를 지칭하는 것으로 execution을 활용하여 해당 메서드에 대한 접근 제어자, 메서드 인자 등을 설정할 수 있습니다.

중요한 포인트는 joinPoint.proceed();를 호출하여야 aop 프록시가 적용된 후, 해당 메서드를 이어서 진행할 수 있습니다.

@Override
public void voteLiarUser(String gameId, String userId, String liarId) {
    Vote vote = voteRepository.findVoteByGameId(gameId);
    vote.updateVoteResults(userId, liarId);
    voteRepository.save(vote);
}

 

 

5. 결론 

멀티 스레딩 환경에서 동시성 문제를 해결하기 위한 방법은 정말 어려운 문제입니다.

프로젝트를 진행하면서 정말 많은 에러에 부딪쳤고, 어느 정도 해결하는 방법을 찾았지만,

완전한 해결책이 아니며 부수적인 문제 또한 발생할 수 있습니다.

이번 글을 포스팅하며 정말 테스트 코드 작성의 중요성을 다시 한번 느낄 수 있었습니다.

 

부족하지만 읽어주셔서 감사드립니다.! 많은 피드백 부탁드립니다.! 수정 보완하고 많이 배우겠습니다.!

 

 

+ Recent posts