장미정원
분산락도 락이다. (Redisson, MSA 환경 동시성 제어 테스트) 본문
들어가며
동시성 문제는 서버 애플리케이션을 개발 할 때 항상 고민해야하는 문제입니다. 공유 자원을 여러 스레드가 접근하려할 때 경쟁상태가 발생하여 lost update와 같은 이상 현상이 발생할 수 있습니다.
이러한 동시성 처리를 위해 데이터베이스 Lock을 사용할 수 있습니다. Spring Data Jpa를 사용한다면 어노테이션으로 간단하게 낙관적 락, 비관적 락을 손쉽게 적용하여 동시성 처리를 할 수 있습니다. @Transaction 어노테이션 속성으로 isolation 단계를 SELIALIZATION으로 설정하여 데이터 정합성을 보장할 수도 있습니다. 추가로 자바 문법에서 지원하는 메서드에 synchronized 키워드를 붙혀 내부적으로 모니터락을 사용하여 동시성 처리를 할 수 있지만, scale-out 된 환경에서는 제대로 동작하지 않기 때문에 잘 사용하는 방법은 아닙니다.
락을 건다는 건 그만큼 병목 지점이 발생하는 것이기 때문에 항상 여러 솔루션 들의 트레이드 오프를 고려해야합니다.
이러한 동시성 문제를 해결할 수 있는 방법 중 하나인 분산락에 대해 알아보도록 하겠습니다.
분산락
분산락은 분산환경에서 공유 자원에 접근하려 할 때 데이터 정합성을 지키며 원자적 처리를 보장하는 방법입니다.
분산락은 락 서버를 두고 락 서버에서 락을 획득한 프로세스나 스레드만 임계구역에 접근하는 방식으로 동작합니다. 분산환경에서도 여러 인스턴스들의 임계구역 진입을 처리할 수 있고 무엇보다 구현이 직관적이고 간단하여 대부분 쓰기/읽기 작업이 RDB에 비해 빠른 In-Memory DB인 Redis를 사용해 구현합니다.
Redis는 사용자의 명령어를 처리하는 스레드가 signle thread로 구성되어 있어 setnx, incr 등의 명령어의 원자성이 보장됩니다.
MySQL로 분산락 구현?
MySQL에서는 문자열에 대해 락을 걸 수 있는 네임드 락이라는 기능을 제공하는데, 해당 기능을 사용하여 분산락 구현이 가능합니다. 하지만 락 자동 릴리즈(timeout) 기능은 지원하지 않고, 락 획득 시도를 아래서 살펴볼 스핀락 방식으로 확인하기 때문에 성능 저하가 발생합니다.
Redis Client: Lettuce, Redisson
스프링에서 Redis를 사용하기 위한 클라이언트로는 Lettuce, Redisson 등등의 라이브러가 있습니다. 이러한 Redis 클라이언트로 분산락을 구현할 때의 트레이드 오프에 대해서 알아보겠습니다.
Lettuce
Lettuce는 분산락 관련 기능을 제공하지 않아 로직을 직접 구현해야합니다. 그리고 Lettuce의 락 획득 방식은 락을 획득할 때 까지 스핀락 방식으로 락 획득 가능 여부를 확인합니다.
스핀락 (spin lock)
스핀락은 임계구역에 진입하지 못 할때 해당 임계구역에 진입 가능 여부(락 획득)을 루프를 돌며 무한히 재시도하는 락 종류입니다. 이러한 스핀락은 스레드의 busy waiting의 한 종류이므로 스레드에 부하가 발생하여 성능 저하를 야기합니다.
Trade-Off: Lettuce는 spring data redis 라이브러리에 기본적으로 포함되어 있는 클라이언트이며, 라이브러리의 크기가 상대적으로 가벼운 크기를 가지고 있습니다. 하지만 분산락에 대한 기능은 제공하지 않으며 구현시 락 획득 방식을 스핀락 형태로 구현해야하기 때문에 레디스 서버에 부하를 줄 수 있습니다.
Redisson
Redisson은 분산락 구현에 대한 인터페이스와 구현을 제공하며, 락 획득과 반환이 pub/sub 구조이기 때문에 락 점유 가능 여부를 지속적으로 확인하지 않아도 됩니다.
Trade-Off: Redisson은 분산락 기능을 제공하며 락 획득과 반환 작업을 pub/sub 구조로 처리하여 레디스 서버에 부하를 줄입니다. 하지만 spring data redis 라이브러리에 기본적으로 포함되어있는 Lettuce에 비해 여러 고급 기능들이 포함되어 상대적으로 라이브러리의 크기가 무겁습니다.
SPOF에 대한 고민
하지만 레디스 서버를 단일 노드로 두고 분산락을 적용한다면 레디스 서버가 SPOF(단일장애지점)가 될 수 있습니다. 모든 서버들이 하나의 레디스 노드에 대해 락 작업을 처리하면 레디스 노드가 다운되었을 때 모든 서버에 장애가 전파될 수 있습니다. 이러한 SPOF 문제를 방지하기 위해 Replication, Cluster 구조를 적용하기도 합니다. 하지만 이러한 분산락 SPOF 문제는 해결되지 않습니다.
Master-Slave 구조라고 가정했을 때 Master 노드에서 1번 클라이언트가 락을 획득합니다. 그리고 Master 노드의 데이터가 Slave에 복제되기 전에 Master 노드가 다운되었습니다. Slave가 Master 노드로 승격합니다. 그리고 2번 클라이언트가 Master 노드에 락을 획득한다면 클라이언트 1번이 획득한 락 정보는 사라져버려 임계구역에 1번, 2번 클라이언트가 동시에 접근할 수도 있습니다.
Red Lock
이러한 문제를 해결하기 위해 Redis에서 제공하는 분산환경에서 사용할 수 있는 락으로 RedLock 알고리즘이 있습니다. RedLock은 N개의 Redis 노드들에 락 요청을 했을 때 정족수 이상의 노드에서 락을 획득하였다면 락을 획득했다고 가정하는 알고리즘입니다.
하지만 RedLock 알고리즘을 사용한다고 해서 동시성 문제에서 100% 안전한 것은 아닙니다.
Trade-Off
단일 노드 레디스로 분산락 서버를 구현한다면(Replication 해도) SPOF가 될 수 있다는 것을 이해하고 사용해야합니다. 프로젝트 규모가 커 수 많은 트래픽으로 Redis 노드가 다운될 염려가 있거나 데이터 정합성이 중요하다면 Red Lock을 적용할 수 있을것 같습니다.
MSA 환경에서 동시성 문제와 Redis 분산락 적용
샘플 MSA 프로젝트에서 동시성 문제 사례를 살펴보고 Redisson 클라이언트를 활용해 분산락을 적용하여 해결해보도록 하겠습니다.
MSA 이커머스 시스템 - 주문
MSA 구조의 이커머스 시스템에서 주문 처리에 대한 이벤트 흐름을 정리한 사진입니다.
요구사항
- 사용자는 여러 상품을 한번에 주문할 수 있다. ex. [{상품ID: 1, 갯수: 3}, {상품ID: 2, 갯수: 2}]
- 각 서비스는 각각의 데이터베이스를 갖는다.
- Product Service, Payment Service는 2개의 인스턴스로 scale-out 되어 있다.
- 유저의 계좌에는 200,000원이 있다.
주문 흐름에 대해 간단하게 설명하자면
- 사용자는 구매할 상품, 갯수를 요청합니다.
- Order Service는 주문을 생성합니다.
- 주문 생성 이벤트를 받은 Product Service는 상품 재고를 차감합니다.
- 재고 차감 이벤트를 받은 Payment Service는 총 가격을 사용자 계좌에서 차감합니다.
동시성 제어 전
위와 같은 스펙의 body 데이터로 주문 API를 다중 스레드로 14번 요청하겠습니다.
요청 후 데이터베이스 사진입니다. 각 테이블 별로 살펴보겠습니다.
Order
- 총 14건의 주문 요청이 발생하였는데, 9건이 성공, 3건은 결제 실패 상태입니다.
Product
- 주문한 상품ID 3번은 닭발로 초기 재고는 15개, 가격은 12,000원입니다. 유저 초기 보유 금액 20만원이면 14개의 재고 모두 구매 가능할 것으로 예상할 수 있습니다. 하지만 실제 차감된 재고는 7개입니다.
Payment
- 14개의 닭발 구매시 총 168,000원이 차감되어 32,000원이 남았을 것이라 예상할 수 있지만, 실제로는 계좌에 116,000원이 남은 것을 확인할 수 있습니다.
분산락 적용
분산락은 Redisson 클라이언트를 사용해 적용하였습니다. 디펜던시 추가 후 여러 서비스에 사용 될 수 있는 락은 횡단 관심사가 될 수 있을 것이라 생각하여 스프링 AOP 기능을 활용하여 선언형 락 적용 방식으로 구현하였습니다.
DistributedLcok
- 분산락을 적용할 비즈니스 로직 메서드에 선언하는 어노테이션입니다. 락 key, 락 점유 시간, 대기 시간 등등을 커스텀할 수 있습니다.
DistributedLockKeyGenerator
- 분산락 어노테이션 선언 후 spel 표현식으로 작성된 키 value를 파싱 해주는 역할입니다.
DistributedLockAop
- 분산락 적용 로직이 있는 어드바이저 클래스입니다.
AopForTransaction
- Target 메서드의 트랜잭션 적용 여부와 상관없이 Target 호출을 별도의 트랜잭션에서 처리되도록 해당 클래스에 래핑하여 joinPoint를 실행합니다. -> 트랜잭션이 종료되기 전 락이 해제되는 현상을 방지
위와 같이 비즈니스 로직을 처리하는 메서드에 분산락 어노테이션을 선언하고 락 키를 spel 표현식으로 지정할 수 있습니다.
동시성 제어 후 - 분산락 적용
Product, Payment Service에 분산락 적용 후 위에서 보냈던 요청을 똑같이 보냈습니다. 주문 요청 14개 모두 잘 처리되었고 재고와 계좌 돈 차감도 예상대로 처리하여 동시성 문제를 해결하였습니다.
분산락, 하나의 트랜잭션 여러 Lock Key 획득 로직
Product 서비스에 분산락을 적용할 때 발생한 이슈에 대하 잠깐 이야기 하겠습니다. Product 서비스는 이벤트로 받은 N개의 상품에 대해 재고 차감을 원자적으로 처리해야합니다.
그렇기 때문에 재고를 변경할 product 컬럼에 대해 N개의 락을 획득해야합니다. 그렇기 때문에 List 형태로 파라미터에 넘어온 productId를 spel 표현식으로 작성하여 락 키로 설정하고, Lock AOP Advisor 클래스에서 재고 차감 락 요청을 분기하여 N개의 상품 컬럼에 대한 락을 획득하고, 반환하도록 구현하였습니다.
'Back-end' 카테고리의 다른 글
Spring Webflux, MVC + 성능 테스트 (0) | 2025.01.31 |
---|---|
싱글톤 빈 동시성 문제.. 내가 당할 줄은 몰랐지.. (0) | 2025.01.10 |
[DB] 파티셔닝, 샤딩, 레플리케이션 (0) | 2024.12.16 |
✨ MSA 환경에서 분산 트랜잭션 (2PC, SAGA) (1) | 2024.12.02 |
✨ 스프링에서 트랜잭션을 처리하는 방법 (0) | 2024.09.09 |