이번에는 식구하자 프로젝트 새로운 마이크로서비스 sns 시스템
을 개발하면서 트러블 슈팅과 그걸 해결하기 위한 고민과 해결과정을 포스팅하려 합니다.
참고 Github URL
: https://github.com/LminWoo99/PlantBackend/tree/msa-master
요구사항을 보면 모든 sns 서비스를 이용해보면 , 아시다시피 게시글 관련 좋아요 기능을 필수라고 볼수있습니다. 하지만 '좋아요 갯수'
라는 데이터는 사용자의 게시글, 댓글 , 유저 정보와는 다르게
모두가 공용으로 사용하는 공유 자원
입니다. 이러한 공유 자원의 경우에는 멀티 스레드 환경에서는 Race Condition
으로 인한 동시성 제어가 백엔드 개발자에겐 필수입니다❗️
분산 환경에서 동시성 제어 문제를 해결하기 위해서는 다양한 선택지가 존재합니다. MySQL, Redis, Zookeeper 등이 대표적인 예시입니다. 그러나 이 중에서 Redis와 Redisson을 선택한 이유는 다음과 같습니다.
우선, 식구하자 프로젝트에서 이미 Redis를 사용 중이었기 때문에 추가적인 인프라 구축이 필요 없었습니다. 이는 새로운 시스템을 도입하면서 발생할 수 있는 복잡성을 줄이는 중요한 요인이었습니다. MySQL 역시 사용하고 있었지만, 락을 사용하기 위해서는 별도의 커넥션 풀을 관리해야 했고, 락으로 인한 부하를 RDS에서 처리해야 한다는 부담이 있었습니다. 반면, Redis는 메모리 기반의 빠른 응답성과 효율적인 락 관리가 가능하기 때문에 성능 면에서 유리하다고 판단했습니다.
Redis를 사용하기로 결정했다면 그 다음 고려해야 할 것은 Redisson과 Lettuce 라이브러리 중 어떤 것을 사용할지였습니다. 두 라이브러리 모두 Redis와 통신하기 위해 널리 사용되지만, 분산락을 구현하는 방식에서 중요한 차이가 있습니다.
Lettuce에서 분산락을 사용하려면 setnx와 setex 같은 저수준의 명령어를 이용해 개발자가 직접 락 구현을 해야 합니다. 이 과정에서 retry, timeout 등을 개발자가 수동으로 관리해야 하며, 이는 복잡하고 안전하지 않은 방식으로 이어질 수 있습니다. 또한, 스핀락 형태로 락을 점유하려다 실패하면 계속 락 점유를 시도하게 되어 Redis 서버에 불필요한 부하를 줄 수 있습니다.
Redisson은 이러한 문제를 해결하기 위해 더 높은 수준의 Lock interface를 제공합니다. Redisson에서는 별도로 RLock이라는 객체를 통해 락을 쉽게 구현할 수 있으며, 타임아웃 설정이 가능해 특정 상황에서 락이 자동으로 해제되도록 할 수 있습니다. 또한, Redisson은 스핀락을 사용하지 않기 때문에 Redis에 불필요한 부하를 줄여 더 안전하게 분산락을 처리할 수 있습니다. 락이 해제될 때까지 기다리는 클라이언트들은 Redis에 반복적인 요청을 보내지 않고, 알림을 통해 락 획득 가능 상태를 전달받습니다.
결국, 이러한 점들이 Redisson을 선택하게 된 이유입니다. 특히 락 관리의 안전성과 성능 최적화가 주요 고려사항이었으며, Lettuce에 비해 Redisson이 더 적합한 선택이라고 판단했습니다.
implementation 'org.redisson:redisson-spring-boot-starter:3.16.3'
@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();
}
}
}
@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를 만들어주도록 하겠습니다!
@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();
}
테스트가 드디어 정상적으로 통과하는 것을 볼 수 있습니다!!!
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