티스토리 뷰

이전 포스팅에서 Update Lock과 Exclusive Lock을 이용하여 동시성을 제어해본 후, 비교해보았습니다.

🔒Update Lock의 경우, 시간은 적게 소요되지만 업데이트 시에만 락이 걸린다는 점에서 현재는 조회수만을 제어하기에 문제가 없지만, Select 쿼리 후 Update가 이루어지는 경우에는 Update 락을 사용하게 되면 문제가 발생할 수 있었습니다. 🔒Exclusive Lock의 경우, 정확성은 보장되지만 row 자체에 락을 걸기 때문에 성능 저하가 발생하는 것을 확인했습니다.

 

현재 프로젝트에서는 Update Lock을 사용하여도 문제가 없지만,

학습 차원에서 인메모리 db인 레디스의 분산락을 적용해보았습니다.

 

 

조회수 동시성 제어를 위한 고민과 Update Lock 적용

공연 동행 구인 서비스의 1차 개발이 끝나고, 리팩토링 사항 중 한가지인 조회수 동시성 제어를 적용해보고자 합니다💪 동시성 문제란?? 동일한 데이터에 여러 개의 작업이 동시에 접근할 때 발

jerecord.tistory.com

 


분산락?

동시성 제어가 필요한 로직을 한 번에 한 스레드만 실행할 수 있도록 하기 위해 "분산락"을 사용할 수 있습니다. 분산락은 여러 서버가 공유 데이터를 제어하기 위한 기술로, 락을 획득한 프로세스나 스레드만이 공유 자원이나 Critical Section에 접근할 수 있도록 합니다.

 

 

대표적인 분산락 구현 방법 3가지

1. Zookeeper 

- 별도의 인프라 구축 필요

- 분산 시스템을 위한 일관된 서비스를 제공하는 오픈 소스 프로젝트

 

2. Redis 

- 별도의 인프라 구축 필요

- 명령을 실행 및 처리할때 single thread로 진행되므로 원자성을 유지 가능

- 인메모리 db로 고속으로 처리가 가능

 

3. MySQL의 Named Lock

- 위의 두 방법과 다르게, 추가적인 인프라 구성이 없지만, User Level Lock으로 분산락을 직접 구현 필요

 

현재 프로젝트에서 RDBMySQL을 사용하고 있고, Redis를 이용하여 토큰 값 저장을 하고 있어 추가적으로 인프라를 구축이 필요한 Zookeeper는 선택지에서 제외했습니다. Redis가 MySQL에 비해 메모리를 더 적게 사용하며, 락을 위한 저장 공간 측면에서도 더 적은 리소스를 필요로 하여 Redis의 분산락 기능을 활용하여 동시성 제어해보았습니다.

 

➕인프라 구축 및 유지 보수에 대한 추가 비용을 방지하기 위해 MySQL을 이용하여 분산락을 구현한 사례도 있습니다!

https://techblog.woowahan.com/2631/

 

MySQL을 이용한 분산락으로 여러 서버에 걸친 동시성 관리 | 우아한형제들 기술블로그

{{item.name}} 안녕하세요. 비즈인프라개발팀 권순규입니다. 현재 광고시스템에서 사용하고 있는 MySQL을 이용한 분산락에 대해 설명드리고자 합니다. 분산락을 적용하게된 원인 현재 테이블은 아래

techblog.woowahan.com

 


Redis 분산락 라이브러리 - Lettuce와 Redisson

Spring에서 제공하는 대표적인 분산락 구현을 위한 라이브러리로 Lettuce와 Redisson이 있습니다.

 

Lettuce

분산 락을 구현하기 위해서는 락을 획득하기 위해 Redis 서버로 반복적으로 요청을 보내야 스핀락의 형태로 구현해야 합니다. 이로 인해 재 요청이 많이 발생하는 경우 부하가 커질 수 있습니다.

 

Redisson

Pub-sub 기반의 Lock 구현을 제공 하여 스핀락으로 락을 획득하는 대신, 메시지 브로커 기능을 통해 락을 획득합니다. 즉, 락을 점유 중인 스레드가 락을 해제했을 때 대기 중인 스레드에게 알려주어 다음에 락 획득을 다시 시도하게 됩니다.

 

👉Lettuce는 락을 획득하기 위해 Redis 서버로 요청을 보내는 반면에, Redisson은 메시지 브로커를 통해 락을 관리하고 스레드 간에 효율적으로 락을 공유하는 방식으로 동작합니다.

 

효율적인 락 관리가 가능한 Redisson을 적용해보았습니다.

 


Redisson 적용하기!

gradle 설정

implementation 'org.redisson:redisson-spring-boot-starter:3.28.0'

 

 

RedisConfig

기존 레디스 설정에 RedissonClient 빈을 추가했습니다.

@Configuration
@RequiredArgsConstructor
public class RedisConfig {

    // ...

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress(
                "redis://" + redisProperties.getHost() + ":" + redisProperties.getPort());
        return Redisson.create(config);
    }
}

 

ViewCountRepository

조회수 CRUD를 위한 레디스 레포지토리

@Slf4j
@Repository
@RequiredArgsConstructor
public class ViewCountRepository {

    private final RedisTemplate<String, String> redisTemplate;
    private final RedissonClient redissonClient;

    public void save(String key, String value) {
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        valueOperations.set(key, value);
    }

    public String findByKey(String key) {
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        return values.get(key);
    }

    public String increaseViewCount(String key) {
        RLock rLock = redissonClient.getLock(key + "_lock");
        String viewCount = "0";

        try {
            // 락 획득 요청
            // 락 획득할 수 없는 경우 waitTime 만큼 기다리고
            // 락 획득한 경우 leaseTime 만큼 락 점유
            boolean isLocked = rLock.tryLock(5, 3, TimeUnit.SECONDS);
            // 락 획득 실패
            if (!isLocked) {
                log.error("Failed to acquire lock for key: " + key);
                throw new IllegalStateException("Failed to increase view count");
            }
            // 락 획득 성공 후 조회수 증가 로직 수행
            ValueOperations<String, String> values = redisTemplate.opsForValue();
            values.set(key, String.valueOf(Long.parseLong(values.get(key)) + 1));
            viewCount = values.get(key);
        } catch (InterruptedException e) { // 락 획득 시도 중 인터럽트 받은 경우
            log.error(e.getMessage());
            throw new InterruptedException(e.getMessage());
        } finally {
            // 종료 시 락 해제하기
            if (rLock != null && rLock.isLocked()) {
                rLock.unlock();
            }
            return viewCount;
        }
    }
}

 

tryLock

- leaseTime: 시간 동안 락 점유, 이후 락 해제

- waitTime: 동안 락 점유 기다리기

- timeUnit: 시간 단위

 

서비스 로직에서의 적용

동행 구인글 상세 조회 서비스 로직 호출 시, 조회수가 1 증가합니다.

@Service
@RequiredArgsConstructor
public class AccompanyServiceImpl implements AccompanyService {

    // ...
    @Transactional
    @Override
    public AccompanyPostResponse getAccompanyPostWithRedisViewCount(Long currentMemberId,
            Long accompanyPostId) {
        AccompanyPost accompanyPost = accompanyPostRepository.findByIdAndIsActivatedIsTrue(
                        accompanyPostId)
                .orElseThrow(() -> new AccompanyPostNotFoundException(
                        AccompanyErrorCode.ACCOMPANY_POST_NOT_FOUND));
        String viewCount = viewCountRepository.increaseViewCount(accompanyPostId  + "_view_count"); // 여기!
        Long waitingCount = accompanyCommentRepository.countByAccompanyPostIdAndIsActivatedIsTrueAndIsAccompanyApplyCommentTrue(
                accompanyPostId);
        accompanyPost.updateViewCount(Long.valueOf(viewCount));

        return AccompanyPostResponse.of(accompanyPost, waitingCount,
                MemberProfile.of(accompanyPost.getWriter(), currentMemberId));
    }
}

 

테스트 코드로 확인

@Test
@DisplayName("동시에 여러 조회가 이루어지는 경우, 모든 조회수가 정상적으로 반영된다. - 레디스 사용")
void success_updateViewCount_given_redis() {
    // given
    Member member = MemberDataFactory.createMember();
    memberRepository.save(member);
    Concert concert = concertRepository.save(ConcertDataFactory.createConcert());
    int size = 1, requestCnt = 300;
    AccompanyPost accompanyPost = accompanyPostRepository.saveAll(
            createAccompanyPosts(member, size, concert)).get(0);
    viewCountRepository.save(accompanyPost.getId() + "_view_count", "0");
    Long viewCountBeforeRequests = Long.parseLong(viewCountRepository.findByKey(
            accompanyPost.getId() + "_view_count")), viewCountAfterRequests;

    // when
    long beforeTime = System.currentTimeMillis();
    List<CompletableFuture<Void>> getAccompanyPostRequestFutures = IntStream.range(0, 300)
            .mapToObj(i -> CompletableFuture.runAsync(() ->
                    accompanyService.getAccompanyPostWithRedisViewCount(member.getId(),
                            accompanyPost.getId())
            ))
            .toList();
    CompletableFuture.allOf(
            getAccompanyPostRequestFutures.toArray(
                    new CompletableFuture[getAccompanyPostRequestFutures.size()])
    ).join();
    viewCountAfterRequests = Long.parseLong(
            viewCountRepository.findByKey(accompanyPost.getId() + "_view_count"));
    long afterTime = System.currentTimeMillis();

    // then
    System.out.println("소요시간(ms): " + (afterTime - beforeTime));
    Assertions.assertThat(viewCountAfterRequests - viewCountBeforeRequests)
            .isEqualTo(requestCnt);
}

 


Update 락 vs Exclusive 락 vs Redisson 분산락

동시에 300개의 조회수 증가 요청을 한 경우에 대해 Update Lock, Exclusive Lock, Redisson 각각의 소요 시간을 비교해본 결과, 시간적인 면에서 인메모리 db를 사용함으로 Exclusive Lock과 비교하였을 때, 성능이 향상된 것을 확인했습니다.

 

 


결론👏

 현재 서비스 상에서, Update Lock으로도 충분하지만 학습 차원에서 인메모리 db인 레디스를 활용해보았습니다. 구현을 위해 Lettuce와 Redisson 중 락 획득 재요청에 따른 부하 방지를 위해 Redisson을 적용했으나, 조회수 처리 시에는 Lock을 거는 과정이 매우 짧고, 재시도가 발생할 가능성이 적어보였습니다. 조회수만을 위해 Redisson 라이브러리를 추가해서 사용하는건 불필요한 오버헤드이지 않을까 하는 생각도 들었습니다.