조회 성능 향상시키기(7) Semaphore에서 Mutex로 갈아타기
개요
현재 단일 인스턴스 환경에서 비동기 쿼리 동시실행 제어를 Semaphore로 진행하고 있었다.
근데 생각해보니까 하나만 허용할거면 Mutex가 더 나을 거 같아서 리팩토링 하게 되었다
Mutex vs Semaphore
항목 | Mutex | Semaphore |
주 목적 | 상호 배제 (Mutual Exclusion) | 리소스 수 제한된 접근 제어 |
값 | 0 또는 1 (이진락) | 0 이상 (카운터) |
오버헤드 | 보통 더 적음 | 보통 더 큼 |
시스템 콜 | 대부분 커널 공간 필요 | 대부분 커널 공간 필요 |
사용자 수 | 1개 스레드만 소유 가능 | 여러 스레드가 접근 가능 |
뮤텍스가 보통 더 가벼운데, 오직 하나의 스레드만 접근 허용하기 때문에 0 또는 1의 간단한 상태만 관리하면 되기 때문이다.
커널 락 사용 방식이나 구현체에 따라 차이는 있지만, 카운트 관리가 필요 없는 뮤텍스는 세마포어보다 경량인 경우가 많다.
사실 코드가 매우 단순해서 굳이 성능 차이를 체감할 일은 거의 없지만 원래부터 Semaphore를 써야 할 이유가 없는 구조였고
애초에 이 코드를 시스템이 다중 스레드를 사용하는 부하 상황을 염두에 두고 작성한거라
(쿼리가 1초 이상 걸리는 특수한 경우 = 요청 몰렸을 때)
굳이 상대적으로 큰 Semaphore보다는 Mutex를 사용하는 쪽이 더 적절하다고 판단했다.
코드
Java에서 Mutex 구현은 다양한 방법으로 가능한데, ReentrantLock()으로 선택했다.
Reentrant는 '재진입'이라는 뜻이고.. 하나의 스레드가 이미 락을 가지고 있을 때, 다시 그 락을 획득해도 문제 없이 통과할 수 있는 락이다.
A메서드가 락을 잡고 있는데, 내부에서 B메서드를 호출하고 B도 같은 락을 잡는 구조일때 이걸 재진입 불가능 락으로 하면 데드락이 걸려버린다.
그래서 ReentrantLock은 멀티스레드 환경에서 복잡한 메서드 중첩 호출이 있는 경우에 안전한 락이다.
@Component
@RequiredArgsConstructor
public class PopularUpdateAsync {
private final StringRedisTemplate redisTemplate;
private final MarketRepository marketRepository;
private static final String MARKET_CACHE_KEY = "popular:market:items";
private static final int POPULAR_LIMIT = 200;
private final ReentrantLock marketLock = new ReentrantLock();
@Async
@Transactional(readOnly = true)
@EventListener(ApplicationReadyEvent.class)
public void updateMarketPopulars() {
if (!marketLock.tryLock()) {
return;
}
try {
Pageable pageable = PageRequest.of(0, POPULAR_LIMIT);
Page<MarketPopularResponseDto> result = marketRepository.findPopularMarketItems(getStartDate(), pageable);
redisTemplate.opsForValue().set(
MARKET_CACHE_KEY,
serialize(result.getContent())
);
} finally {
marketLock.unlock();
}
}
}
내 코드에서 재진입 필요는 없지만, tryLock()이 기존 세마포어의 tryAcquire()와 딱 맞아떨어지고(락 획득 실패 시 바로 return)
기존 로직을 그대로 대체 가능하면서 더 간결해졌다.