https://github.com/Gseungmin/playlist/pull/5
<aside>
플레이리스트는 최대 1000개의 노래를 가질 수 있다. 이 과정에서 어떤 방식으로 PlayList의 개수를 제한할까?
물론 동시성 문제가 거의 발생하지 않는다. 그 이유는 플레이리스트를 수정하는 사람은 플레이리스트를 생성한 사람만 가능하기 때문이다.
하지만 계정을 공유하는 상황이 충분히 있을 수 있다. 따라서 플레이리스트에 노래를 삽입할때 동시성 문제를 고려해야 한다.
private void validateCount(long count) {
if (count >= MAX_PLAY_LIST_COUNT) {
throw new PlayListException(
PLAY_LIST_EXCEED_LIMIT.getCode(),
PLAY_LIST_EXCEED_LIMIT.getErrorMessage()
);
}
}
위 코드와 같이 어플리케이션 단에서 1차적으로 제어할 수 있다.
하지만 이는 동시성 문제를 해결해주지 못한다. 즉 999개의 노래가 있는 상태에서 동시에 추가했을때 문제가 될 수 있는 것이다.
DELIMITER $$
CREATE TRIGGER trg_playlist_item_max1k
BEFORE INSERT ON play_list_item
FOR EACH ROW
BEGIN
DECLARE cnt INT;
-- 같은 플레이리스트에 몇 곡이 있는지 계산
SELECT COUNT(*) INTO cnt
FROM play_list_item
WHERE play_list_id = NEW.play_list_id;
-- 1 000곡 이상이면 사용자 정의 예외 발생
IF cnt >= 1000 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '플레이리스트에는 최대 1000곡까지만 담을 수 있습니다.';
END IF;
END$$
DELIMITER ;
트리거는 좋다 너무 좋다. 또한 위의 로직은 정말 간단하므로, 비용측면에서도 싸다. 다만 문제가 하나 있다.
해당 로직을 어플리케이션 단에서 처리하는 것이 아닌 DB 단으로 넘기는 것이다.
즉, 로직이 DB 안에 숨으므로 가시성이 떨어지고 DB별 배포가 필요하다.
따라서 이런 부분에서 어플리케이션에서 유지보수할 수 있는 분산락이 더 좋을 수 있다.
</aside>
<aside>
사실 단일 데이터베이스로는 트리거 방식도 괜찮다.
트리거 로직 자체도 간단하고 별도의 락을 거는 방식이 없기 때문이다.
이와 반면 분산락은 하나의 자원에 하나만 접근 가능한 뮤텍스 방식이다. 이는 어플리케이션 단에서 제어가 가능하다는 장점이있다.
이제 3가지 이유를 통해 분산락을 선택한 이유를 말해보자
물론 운영가시성 문제가 발생할 수 있다. 데이터 베이스 단에 바로 적용하는 것이기 때문이다.
하지만 이미 어플리케이션 로직에 1000개에 대한 제한이 있다. 아래 코드가 그것이다
private void validateCount(long count) {
if (count >= MAX_PLAY_LIST_COUNT) {
throw new PlayListException(
PLAY_LIST_EXCEED_LIMIT.getCode(),
PLAY_LIST_EXCEED_LIMIT.getErrorMessage()
);
}
}
즉, 이미 어플리케이션에서 어떤 처리가 진행되는지 명시해 놓아기에 운영가시성은 크게 문제가 되지 않을 수 있다.
데이터베이스 수준에서 관리할 경우 제어가 까다롭다는 단점이 있다.
예를 들어 여러 데이터베이스를 사용할 때는 모든 DB가 동일한 기능을 지원하는지 일일이 체크하고, 이를 실제 적용하는 과정도 필요하다.
게다가 비즈니스 로직이 조금이라도 변경되면, 각 DB마다 이 변경 사항을 개별적으로 반영해야 하는 관리적 부담이 발생한다.
결국 대규모 트래픽 환경에서 다양한 데이터베이스를 사용하는 경우, DB 단에서의 관리 방식은 큰 부담이 될 수 있다
이와 달리 분산락 방식은 애플리케이션에서만 집중적으로 관리하면 되므로 확장성 면에서 유리하다.
로직에 수정이 생기더라도 애플리케이션 레벨에서만 수정이 이루어지므로, 별도의 데이터베이스마다 추가 작업을 진행할 필요가 없다.
결국 분산락 방식은 관리 측면에서 보다 효율적이고 확장 가능한 솔루션이다.
</aside>
<aside>
Redisson는 Java용 Redis 클라이언트로, 분산락을 위한 고급 기능을 내장하고 있다. 대표적으로 RLock
이라는 객체를 제공하며, Java의 Lock
인터페이스를 구현한 재진입 가능한 분산락이다.
즉 자바의 Lock 과 비슷하게 사용할 수 있는 것이다.
RLock lock = redissonClient.getLock("myLock");
lock.lock();
lock.unlock();
한 Redisson 인스턴스가 키를 성공적으로 설정하면 락을 얻은 것이고, 다른 프로세스들은 해당 키가 존재하므로 락 획득에 실패하게 된다. 또한 자동 만료, 락 타임아웃, 간편한 락해제등 다양한 기능을 제공한다.
Lettuce는 Spring Data Redis 등에서 기본 클라이언트로 사용하는 가벼운 Redis 클라이언트이다. 다만 Lettuce 자체는 전용 락 구현을 제공하지 않으므로, Redis 명령어를 직접 사용하여 분산락 로직을 구현해야 한다
즉 개발자가 직접 코드를 짜야하는 부분이 많아진다.
간단히 말해 Redisson은 편의성과 안정성을 제공하는 대신 라이브러리 오버헤드가 있고, Lettuce는 경량이고 빠르지만 개발자가 직접 관리해야 할 부분이 많은 방식이다.
</aside>
<aside>
RLock의 주요 메서드 중 하나인 tryLock()은 분산 락을 시도하는 기능이다. 락을 즉시 얻지 못하면 최대 waitTime 동안 대기했다가 그 사이 락이 해제되면 락을 획득하고, 얻은 락은 leaseTime 동안 유지한다. 지정된 대기 시간 내에 락을 얻지 못하면 false를 반환하여 락 획득 실패를 알리고, 락을 얻으면 true를 반환한다.
현재 스레드가 락을 기다리는 도중 인터럽트(Interrupt) 되면 tryLock()은 즉시 InterruptedException을 발생시킨다.
자바에서 InterruptedException은 스레드가 블로킹 상태에서 깨어나도록 인터럽트 신호를 받았을 때 던져지는 예외이다.
정리하면, tryLock() 호출 시 발생할 수 있는 InterruptedException은 락을 기다리는 중인 스레드가 외부로부터 인터럽트 신호를 받은 상황을 의미한다.
InterruptedException이 발생하면 catch에서 즉시 현재 스레드에 대해 Thread.currentThread().interrupt()를 호출하여 인터럽트 상태를 복구해주는 것이 중요하다.
이렇게 해야 상위 로직에서도 해당 스레드가 인터럽트되었음을 인지할 수 있다. 만약 그냥 무시하면(interrupt 신호를 삼키면) 스레드는 인터럽트 요청을 잊어버리고 계속 실행되어 취소 요청이 반영되지 않는 문제가 생길 수 있다.
가능하다면 애초에 메서드 시그니처에 throws InterruptedException을 선언해 상위로 예외를 던지는 것도 좋습니다. 어떤 경우든 인터럽트 신호를 놓치지 않고 전달하는 것이 안정적인 멀티스레드 프로그래밍의 핵심이다.
스레드 이름 | 현재 상태 | 하고자 하는 작업 |
---|---|---|
스레드 A | 작업 중 | 락 획득 후 작업 수행 중 |
스레드 B | 대기 중 | 락 획득 기다리는 중 |
스레드 C | 대기 중 | 락 획득 기다리는 중 |
위와 같이 스레드 3개가 있고 락을 획득하려고 한다고 해보자
<aside>
1️⃣ 처음 상태
<aside>
2️⃣ 스레드 B에 인터럽트 발생
<aside>
3️⃣ 스레드 B 예외 처리
catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
</aside>
<aside>
</aside>
</aside>
<aside>
</aside>