0. 들어가기 전
- 현재 진행하고 있는 동선을 만드는 프로젝트에서 다음과 같은 요구사항이 존재했습니다.
(동선 생성 시 선택한 태그의 태그 선택 횟수가 1씩 증가하는 로직 존재하는 상황)
여러 사용자의 동선 생성 동시 요청 시에 태그 선택 횟수가 순차적으로 증가해야 한다.
위의 사진처럼 태그별로 선택 횟수가 표시되었습니다.
해당 태그는 사용자들이 동선을 생성할 때 태그를 지정하면 선택 횟수가 1씩 증가하는 로직을 가지고 있습니다.
이러한 상황에서, 여러 사용자가 동시에 요청을 보내게 되면 현재 상황에서는 데이터 정합성이 보장되지 않은 상황이었습니다.
멀티 스레드 테스트를 통해 테스트해봤을 때, 다음과 같이 테스트가 실패하는 것을 확인할 수 있었습니다.
그래서 이러한 동시성을 보장하기 위해서 여러 방법들을 생각해보고 적용해본 결과들을 기록해보도록 하겠습니다.
1. 생각했던 동시성을 보장하는 방법
기본적으로 동시성을 보장하는 방법으로는 Lock을 많이 사용합니다.
그래서 저의 이전 블로그 포스팅에서도 Lock을 사용한 동시성 보장 방법에 대해 소개한 적이 있었습니다.
https://ksh-coding.tistory.com/125
결과적으로 현재 프로젝트에서는 다음과 같은 동시성 보장 방법들을 고려해보게 되었습니다.
- RDB 비관적 락
- 낙관적 락, 비관적 락 중에서 낙관적 락을 선택하지 않은 이유는 충돌이 많이 발생할 것이라고 예상했기 때문입니다.
- '동선 태그 선택 횟수 증가'의 경우에는 동선 생성 시에 발생하는데, 비즈니스적으로 자주 사용되는 기능이라고 판단해서 충돌이 많이 발생할 것이라고 예상했습니다.
- 충돌이 많이 발생할 때 낙관적 락을 사용하면 그만큼 재시도가 많이 발생하여 성능이 좋지 않을 것이기 때문에 비관적 락을 고려했습니다.
- Redis를 사용한 분산 락
각 방법들을 적용했을 때의 장단점들을 살펴보고, 최종적으로 어떤 방법을 적용했는지 살펴보겠습니다.
2. 비관적 락을 사용한 동시성 보장
비관적 락을 사용한 동시성 보장은 DB단에서 직접 Lock을 걸어서 공유 자원 자체에 Lock을 거는 방법입니다.
DB에 직접 S-Lock, X-Lock을 설정해서 동시성을 보장합니다.
프로젝트에 적용해보면, 다음과 같이 JPA Repository단에서 어노테이션을 통해 간단하게 적용할 수 있습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT rt FROM RouteTag rt WHERE rt.name = :name")
Optional<RouteTag> findByName(final String name);
- 동선 태그 조회 시에 X-Lock을 설정해서 Lock 설정
비관적 락을 사용해서 동시성을 보장했을 때의 장단점을 살펴봅시다.
장점
- JPA를 사용하면 비교적 쉽게 Lock 구현이 가능하다.
단점
- DB 데이터 자체에 Lock을 설정하는 쿼리가 발생하기 때문에 DB Connection 비용과 Disk I/O가 발생한다.
3. Redis를 사용한 분산 락
비관적 락/낙관적 락과 다른 Lock으로 '분산 락'이 존재합니다.
분산 락의 목적은 다음과 같습니다.
여러 분산된 서버가 존재할 때, 여러 서버에서 공유 자원에 접근하여 발생하는 Race Condition 자체를 없앤다.
따라서 분산 락은 공유 자원 자체에 Lock을 설정하는 비관적 락/낙관적 락과 다르게 임계 영역(critical section)에 Lock을 설정합니다.
※ 분산 락을 구현할 때 왜 Redis를 사용할까?
분산 락을 구현할 때 많은 Reference를 살펴보면 대부분 Redis를 사용하여 구현하는 것을 확인할 수 있습니다.
- Lock을 구현하는 여러 기능들을 제공한다.
- 간단하게 Redis의 명령어를 사용하여 Lock을 구현할 수도 있다.
- Pub/Sub 구조로 Lock을 구현할 수도 있다.
- retry, timeout과 같은 부가 기능들을 제공한다.
- MySQL에서도 네임드 락으로 분산 락을 구현할 수 있지만, Redis에 비해 단점이 존재합니다.
- 다른 중요 비즈니스 데이터가 존재하는 DB에서 Lock을 관리하기 때문에 부담이 있습니다.
- Lock을 자동으로 해제할 수 없어서 명시적으로 해제해줘야 합니다.
- Redis의 구조가 싱글 스레드의 Multiplexing 기술을 사용해서 원자성 보장에 적합합니다.
- 클라이언트의 요청은 여러 스레드를 사용하여 전달받고, 실제로 작업을 처리하는 부분은 싱글 스레드로 동작하여 원자성을 보장합니다.
그래서 Redis를 활용해서 임계 영역에 접근하는 시점에 Lock을 설정하고 작업이 끝나면 Lock을 해제하는 식으로 구현할 수 있습니다.
Redis를 사용한 분산 락에는 다음과 같이 2가지 방식이 존재합니다.
- Lettuce - Spin Lock으로 지속적으로 Redis 서버에 요청을 보내서 Lock 여부 확인
- Redisson - RedLock 알고리즘을 사용해서 Lock 획득-해제를 pub-sub 구조로 수행
저는 이 2가지 방식 중에서 Redisson 방식을 사용해서 분산 락을 구현했습니다.
Lettuce를 사용한 스핀 락 방식을 사용하지 않은 이유는 다음과 같습니다.
- 경쟁하는 스레드들이 Lock을 획득하기 위해 지속적인 주기로 Redis 서버로 요청을 보내기 때문에 부하가 심하다.
- 개발자가 직접 retry, timeout과 같은 추가 기능을 구현해줘야 하는 번거로움이 있다.
Redisson 방식에서 사용하는 RedLock 알고리즘은 Redis의 Master-Slave 분산 환경에서 Lock을 관리하기 위해 나온 알고리즘입니다.
이 Redssion 방식은 분산 환경에 적합한 효과가 있지만, 단일 Redis에서도 매커니즘 상 성능적으로 유리한 부분이 있습니다.
Redisson 방식을 사용하면 Lettuce를 사용했을 때 위의 단점들을 보완할 수 있습니다.
- 지속적으로 Lock을 획득하기 위해 Redis 서버로 요청을 보내는 것이 아니라, pub-sub 구조로 Lock을 해제하는 쪽에서 Lock 해제 메시지를 발행하고, Lock을 얻기 위해 대기하는 쪽에서 해당 메시지를 받아서 Lock을 획득하기 때문에 부하가 훨씬 덜합니다.
- Redisson을 사용하게 되면 retry, timeout과 같은 부가 기능이 제공되는 인터페이스를 지원하기 때문에 개발자는 추상화된 메소드를 사용하기만 하면 부가 기능을 사용할 수 있습니다.
Redisson을 사용한 분산 락 구현
Redisson을 사용한 분산 락을 프로젝트에 적용해보겠습니다.
먼저, Redisson 의존성을 추가해줍니다.
build.gradle
implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'
서비스 로직에 직접 Lock 로직을 구현하기 전에, 현재 동선 생성 로직이 어떻게 되어 있는지 살펴봅시다.
RouteService - addRoute
@Transactional
public Long addRoute(final Long memberId, final RouteAddRequest request) {
// 비즈니스 검증 로직
...
// 태그 선택 횟수 증가 로직
}
이 상황에서 분산 락을 다음과 같이 구현할 수 있습니다.
@Transactional
public Long addRoute(final Long memberId, final RouteAddRequest request) {
// 비즈니스 검증 로직
...
// 분산 락 로직
String action = "addRoute";
final RLock lock = redissonClient.getLock("LOCK:" + action + ":" + tagName);
try {
final boolean available = lock.tryLock(10, 3, TimeUnit.SECONDS);
if (!available) {
throw new RuntimeException("Lock을 획득하지 못헀습니다.");
}
// 태그 선택 횟수 증가 로직
...
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
- redissonClinet.getLock(name) : 주입받은 RedissonClient로 Lock 인스턴스 생성하는 로직, 파라미터 이름으로 Lock Key 설정
- lock.tryLock(waitTime, leaseTime, TimeUnit) : Lock 획득 시도
- waitTime : Lock 획득을 위한 최대 대기 시간
- leaseTime : Lock 획득 후 작업 처리 최대 시간 (해당 시간이 지나면 자동으로 Lock을 해제)
- TimeUnit : 시간 단위
- lock.unlock() : finally 로직으로, 작업 처리 이후에 무조건 Lock을 해제하도록 처리
이렇게 로직을 구현한 후에 위의 동시성 테스트를 실행해보겠습니다.
위처럼 동시성이 보장되지 않아서 테스트가 실패하는 것을 확인할 수 있습니다.
테스트 실패 이유
Redisson을 사용해서 분산 락을 구현했는데 왜 테스트가 실패할까요?
이유는 바로 트랜잭션의 커밋 시점과 Lock의 해제 시점에 따라 데이터 정합성 보장 여부가 나뉘기 때문입니다.
아래의 그림은 해당 상황의 Lock 상황과 트랜잭션 상황을 나타낸 그림입니다.
(DB 격리 수준은 MySQL의 기본 격리 수준인 Repeatable Read)
조금 복잡하기 때문에 데이터 정합성이 보장되지 않는 시점까지 시간 순서대로 설명해보도록 하겠습니다.
Client 1 스레드 작업
- Client 1 스레드에서 Lock 획득
- Client 1 스레드 동선 생성 트랜잭션 시작
- 태그 조회 + 카운트 증가
- 선택 횟수 1로 조회
- 1개 증가한 2로 업데이트
- Client 1 Lock 해제
Client 2 스레드 작업
- Client 1 트랜잭션 커밋 전 Client 2 스레드에서 Lock 획득
- Client 2 스레드 동선 생성 트랜잭션 시작
- 태그 조회 + 카운트 증가 :
- 아직 Client 1의 트랜잭션이 커밋되기 전이므로 Client 1에서 작업해서 증가한 카운트 2가 아니라, 1로 조회한다.
- 선택 횟수 1로 조회 후 +1 : +2로 카운트 업데이트
요약하면, 먼저 Lock을 획득하여 작업한 스레드의 트랜잭션이 커밋되기 전에 Lock이 해제되고
Lock이 해제되어 다음 스레드가 트랜잭션에서 작업을 시작하기 때문에 데이터 정합성에 문제가 발생합니다.
분산 락 트러블 슈팅
그럼 어떻게 이러한 문제를 해결할 수 있을까요?
- AOP로 관심사를 분리하고, Transaction의 전파 수준을 REQUIRES_NEW로 설정해서 분산 락의 트랜잭션을 분리
위의 해결 방법은 신뢰할 수 있는 기업 기술 블로그에 자세하게 코드까지 소개되어 있어 링크만 남기겠습니다.
https://helloworld.kurly.com/blog/distributed-redisson-lock/
위의 AOP + REQUIRES_NEW로 분리한 방법을 사용하면, 정상적으로 테스트가 성공합니다.
하지만, 결론적으로 저는 프로젝트에 다음과 같은 이유로 해당 방법을 적용하지 않았습니다.
- REQUIRES_NEW을 사용하면서 DB Connection을 사용하기 때문에 하나의 작업에서 Connection을 2개를 사용하게 됩니다.
- 이로 인해서 Connection Pool 사이즈가 작다면 데드락 문제가 발생할 수 있습니다.
- 물론 적절한 Connection Pool 사이즈를 설정하면 해결할 수 있지만, 아직 트래픽을 예상할 수 없었기 때문에 설정하기가 애매했었습니다.
- 이러한 이유로 서비스 전체의 장애로 이어질 수 있기 때문에 해당 방법은 사용하지 않기로 결정했습니다.
4. 결론
결과적으로 비관적 락/분산 락 2가지 방법 중에서 비관적 락을 사용하여 동시성을 보장하게 되었습니다.
기존에 고민 과정과 구현 과정에서 최종적으로 비관적 락을 사용한 생각의 흐름은 다음과 같았습니다.
- 기본적으로 구현이 간단한 낙관적 락/비관적 락 중에서 고민
- '동선 생성' 기능은 동시 요청이 잦을 것이라고 예상했기 때문에 비관적 락을 적용
- 비관적 락 구현 후에 Redis를 사용한 분산 락이 관심사를 분리하고, 성능 상 이점이 있을 것 같아서 구현했지만, Side-Effect가 커서 구현 롤백
- 그러나, 트랜잭션 커밋 이전에 Lock이 해제되어 데이터 정합성 문제 발생
- AOP + REQUIRES_NEW로 해결할 수 있었지만, Connection Pool을 적절히 설정하지 못하면 데드락 문제 발생
- Redis를 사용한 분산 락의 Side-Effect를 해결하면서까지 적용할만한 당장의 성능 문제가 아니라고 생각했기 때문에, 구현이 간단한 비관적 락으로 결정
최종적으로 비관적 락으로 동시성을 보장했지만, 다뤄보지 못했던 Redis 분산락을 사용해볼 수 있어서 의미있었던 것 같습니다!
Reference
https://helloworld.kurly.com/blog/distributed-redisson-lock/
'이슈 & 트러블 슈팅' 카테고리의 다른 글
[이슈] JPA N+1 문제 해결 및 성능 비교 (feat. Batch Size) (1) | 2024.07.11 |
---|---|
[이슈] Redis 캐싱을 통해 조회 성능 개선하기 (0) | 2024.07.01 |
[이슈] 사용자의 중복 요청 방지하기(feat. 멱등키, Redis) (1) | 2024.06.07 |
[트러블 슈팅] 멀티 스레드 테스트 시 초기 데이터 오류(feat. 트랜잭션 격리 수준) (0) | 2024.06.06 |
[트러블 슈팅] SpringBoot에서 application.yml 이외의 yml 프로퍼티 적용하기 (0) | 2022.08.26 |