[식구하자_MSA] sns 서비스에서 좋아요 기능 동시성 문제 해결

이민우·2024년 5월 8일
3

🍀 식구하자_MSA

목록 보기
10/21

이번에는 식구하자 프로젝트 새로운 마이크로서비스 sns 시스템을 개발하면서 트러블 슈팅과 그걸 해결하기 위한 고민과 해결과정을 포스팅하려 합니다.

참고 Github URL: https://github.com/LminWoo99/PlantBackend/tree/msa-master

SNS 마이크로 서비스 요구사항


요구사항을 보면 모든 sns 서비스를 이용해보면 , 아시다시피 게시글 관련 좋아요 기능을 필수라고 볼수있습니다. 하지만 '좋아요 갯수' 라는 데이터는 사용자의 게시글, 댓글 , 유저 정보와는 다르게
모두가 공용으로 사용하는 공유 자원입니다. 이러한 공유 자원의 경우에는 멀티 스레드 환경에서는 Race Condition으로 인한 동시성 제어가 백엔드 개발자에겐 필수입니다❗️

Redis를 통한 분산 락 구현


🤷‍♂️ 왜 Redis와 Redisson을 선택했는가?

분산 환경에서 동시성 제어 문제를 해결하기 위해서는 다양한 선택지가 존재합니다. MySQL, Redis, Zookeeper 등이 대표적인 예시입니다. 그러나 이 중에서 RedisRedisson을 선택한 이유는 다음과 같습니다.

✅ 1. 기존 인프라와의 호환성

우선, 식구하자 프로젝트에서 이미 Redis를 사용 중이었기 때문에 추가적인 인프라 구축이 필요 없었습니다. 이는 새로운 시스템을 도입하면서 발생할 수 있는 복잡성을 줄이는 중요한 요인이었습니다. MySQL 역시 사용하고 있었지만, 락을 사용하기 위해서는 별도의 커넥션 풀을 관리해야 했고, 락으로 인한 부하를 RDS에서 처리해야 한다는 부담이 있었습니다. 반면, Redis메모리 기반의 빠른 응답성과 효율적인 락 관리가 가능하기 때문에 성능 면에서 유리하다고 판단했습니다.

✅ 2. Redisson vs Lettuce

Redis를 사용하기로 결정했다면 그 다음 고려해야 할 것은 RedissonLettuce 라이브러리 중 어떤 것을 사용할지였습니다. 두 라이브러리 모두 Redis와 통신하기 위해 널리 사용되지만, 분산락을 구현하는 방식에서 중요한 차이가 있습니다.

🚨 Lettuce의 단점

Lettuce에서 분산락을 사용하려면 setnxsetex 같은 저수준의 명령어를 이용해 개발자가 직접 락 구현을 해야 합니다. 이 과정에서 retry, timeout 등을 개발자가 수동으로 관리해야 하며, 이는 복잡하고 안전하지 않은 방식으로 이어질 수 있습니다. 또한, 스핀락 형태로 락을 점유하려다 실패하면 계속 락 점유를 시도하게 되어 Redis 서버에 불필요한 부하를 줄 수 있습니다.

Redisson의 장점

Redisson은 이러한 문제를 해결하기 위해 더 높은 수준의 Lock interface를 제공합니다. Redisson에서는 별도로 RLock이라는 객체를 통해 락을 쉽게 구현할 수 있으며, 타임아웃 설정이 가능해 특정 상황에서 락이 자동으로 해제되도록 할 수 있습니다. 또한, Redisson은 스핀락을 사용하지 않기 때문에 Redis에 불필요한 부하를 줄여 더 안전하게 분산락을 처리할 수 있습니다. 락이 해제될 때까지 기다리는 클라이언트들은 Redis에 반복적인 요청을 보내지 않고, 알림을 통해 락 획득 가능 상태를 전달받습니다.

결국, 이러한 점들이 Redisson을 선택하게 된 이유입니다. 특히 락 관리의 안전성성능 최적화가 주요 고려사항이었으며, Lettuce에 비해 Redisson이 더 적합한 선택이라고 판단했습니다.

👨🏻‍💻 Redisson을 사용한 동시성 제어 구현


1. 의존성 추가

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

2. Redisson을 통해 분산 락을 획득

@Transactional
    public void updateSnsLikesCountUseRedisson(Long id) {
        final String lockName = id.toString()+":lock";
        final RLock lock = redissonClient.getLock(lockName);
        String name = Thread.currentThread().getName();

        try {
            if (!lock.tryLock(10, 1, TimeUnit.SECONDS))
                return;
            SnsPost snsPost = snsPostRepository.findById(id).orElseThrow(ErrorCode::throwSnsPostNotFound);
            log.info("현재 좋아요수 : {} & 현재 진행중인 스레드: {}", snsPost.getSnsLikesCount(), name);
            snsPost.likesCountUp();


        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (lock != null && lock.isLocked()) {
                lock.unlock();
            }
        }
    }
RedissonClient로부터 락 객체를 얻어온 뒤, try ~ catch 안에서 tryLock을 호출해주도록 하겠습니다. 락을 무사히 획득했다면, 기존에 작성되어있던 서비스 로직 코드가 호출됩니다. 그리고 finally 구문을 통해 락을 해제합니다. 락을 구현했으니 이제 테스트 코드도 잘 돌아가겠죠?

🔎 좋아요 기능 동시성 제어 테스트


테스트 코드

    @Test
    void updateSnsLikesCount() throws InterruptedException, IOException {
        //given
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch countDownLatch = new CountDownLatch(threadCount);
        Set<String> hashTags = new HashSet<>();
        List<MultipartFile> files = new ArrayList<>();
        hashTags.add("나무");
        SnsPostRequestDto snsPostRequestDto=SnsPostRequestDto.builder()
                .id(1L)
                .snsPostTitle("sns 게시글 테스트")
                .snsPostContent("테스트")
                .snsLikesCount(1)
                .snsViewsCount(1)
                .hashTags(hashTags)
                .build();

        snsPostService.createPost(snsPostRequestDto, files);
        //when
        for (int i = 0; i<threadCount; i++) {

            executorService.submit(() -> {
                try{
//                    snsPostServiceFacade.updateSnsLikesCountLock(1L);
                    snsPostService.updateSnsLikesCountUseRedisson(1L);

                }
                finally {
                    countDownLatch.countDown();

                }
            });
        }
        countDownLatch.await();
        Thread.sleep(10000);
        Optional<SnsPost> byId = snsPostRepository.findById(1L);
        assertThat(byId.get().getSnsLikesCount()).isEqualTo(101);
    }
}

분산락을 적용한 테스트 결과

이전에 분산락을 적용하기전에 비해 좋아요 증가량이 늘어났다는 점은 있지만, 여전히 동시성 이슈가 생겨 테스트 통과에 실패합니다. 왜 실패할까요.....?

답은 분산락 해제 시점과 트랜잭션 커밋 시점의 불일치 때문입니다.

코드를 보면 updateSnsLikesCountUseRedisson 메서드에 @Transactional 어노테이션이 붙어 있습니다. @Transactional 은 Spring AOP 중 하나로 프록시 방식으로 동작하기 떄문에 메서드 바깥으로 트랜잭션을 처리하는 프록시가 동작하게 됩니다.
반면 락 획득과 해제는 updateSnsLikesCountUseRedisson 메서드 내부에서 일어납니다. 때문에 스레드 1과 스레드 2가 경합한다면 스레드 1의 락이 해제되고 트랜잭션 커밋이 되는 사이에 스레드 2가 락을 획득하게 되는 상황이 발생합니다.
데이터베이스 상으로 락이 존재하지 않기 때문에 스레드 2는 데이터를 읽어오게 되고, 스레드 1의 변경 내용은 유실됩니다. 때문에 락 범위가 트랜잭션 범위보다 크도록 Facade를 만들어주도록 하겠습니다!

Facade를 이용해 트러블 슈팅 해결

퍼사드 구현

@Service
@RequiredArgsConstructor
public class SnsPostServiceFacade {
    private final SnsPostService snsPostService;
    private final RedissonClient redissonClient;
    public void updateSnsLikesCountLock(Long id) {
        final String lockName = "likes:lock";
        final RLock lock = redissonClient.getLock(lockName);

        try {
            if (!lock.tryLock(10, 1, TimeUnit.SECONDS))
                return;
            snsPostService.updateSnsLikesCountUseRedisson(id);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (lock != null && lock.isLocked()) {
                lock.unlock();
            }
        }
    }
} 

좋아요 증가 로직 변경

서비스 코드에서는 Redisson을 사용한 코드를 제거해주었습니다!

@Transactional
public void updateSnsLikesCountUseRedisson(Long id) {
    String name = Thread.currentThread().getName();
    SnsPost snsPost = snsPostRepository.findById(id).orElseThrow(ErrorCode::throwSnsPostNotFound);
    log.info("현재 좋아요수 : {} & 현재 진행중인 스레드: {}", snsPost.getSnsLikesCount(), name);
    snsPost.likesCountUp();
}

Facade 적용 후 테스트


테스트가 드디어 정상적으로 통과하는 것을 볼 수 있습니다!!!

참고


https://www.inflearn.com/questions/968606/%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4-%EB%B0%A9%EB%B2%95-%EC%B1%84%ED%83%9D-%EC%88%9C%EC%84%9C
https://sungsan.oopy.io/5f46d024-dfea-4d10-992b-40cef9275999

profile
백엔드 공부중입니다!

0개의 댓글

관련 채용 정보