
Intro
항해플러스에서 진행중인 프로젝트인 콘서트 예약 서비스를 분산환경이라고 가정하고, 기존에 DB 락을 통하여 동시성 제어가 되어있던 부분을 분산락을 통해 제어하도록 리팩토링하고자 하였습니다.
이전에 인프런 강의를 수강하며 가볍게 스프링 프로젝트에 분산락을 적용해봤던 경험은 있었으나, 어디까지나 간단하게 "적용만" 해봤던 터라 프로젝트에 어떻게 "잘" 적용할 수 있을지에 대한 고민을 해본 것은 이번이 처음이였습니다.
이에 대하여 컬리 기술블로그의 풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson 글이 많이 도움이 되었는데, 해당 글을 참고하여 다음과 같은 부분들을 고려하고, 좀 더 범용적으로 사용할 수 있도록 수정하여 적용하게 되었습니다. (이해를 위하여 한번 읽어보고 오시는 것을 추천드립니다.)
propagation 변경 없이 트랜잭션 범위 밖에서 락 해제하기
락 해제를 트랜잭션 범위 밖에서 하기 위하여 트랜잭션의 propagation을 REQUIRES_NEW로 지정하는 방법도 있지만, (propagation 속성에 대해서 잘 모르신다면 망나니개발자님의 [Spring] 스프링의 트랜잭션 전파 속성(Transaction propagation) 완벽하게 이해하기 글을 읽어보시는 것을 추천드립니다.) 하나의 스레드에서 동시에 필요한 커넥션 수가 N개 이상이 될 수 있기에 커넥션 풀 사이즈에 따라서 동시에 들어온 요청의 수가 많을 경우 이미 획득한 커넥션의 반납을 무한히 기다리는 커넥션 풀 데드락이 발생할 위험이 있습니다.
또한 전파속성을 변경할 경우 상위 레이어부터 트랜잭션이 시작되고, 하위 레이어에서 @DistributedLock을 사용할 경우 상위 레이어는 명시적으로 @Transactional이 선언되어있음에도 불구하고 하위 레이어의 트랜잭션은 별개의 물리 트랜잭션으로 실행되기에 예외 발생 시 보상 트랜잭션 로직을 상위 레이어에 작성해주어야만 합니다. 개인적으로 이런 불편함 없이 @Transactional이 주는 편리함을 최대한 이용하며 분산락을 적용하고 싶었습니다.
마지막으로 트랜잭션과 전혀 상관없이 분산락이 필요한 경우, 불필요하게 DB 커넥션을 획득하기 때문에 분산락에 대한 처리 내부에 트랜잭션 처리를 같이 결합하지 않는 것이 좋겠다고 판단했습니다. (이 부분은 LazyConnectionDataSourceProxy를 통해 해결할 수도 있긴 합니다.)
따라서 저의 목적은 특정 프로젝트가 아닌 어느 프로젝트에서나 범용적으로 쉽게, 내부 동작을 정확히 잘 모르더라도 문제없이 사용할 수 있는 컴포넌트를 개발하는 것이였기에 propagation을 변경하는 방법 대신 다른 방법을 통하여 트랜잭션 범위 밖에서 락을 해제할 수 있도록 구성하고자 하였습니다.
특정 라이브러리나 기술에 종속적이지 않은 컴포넌트
분산락의 종류에는 대표적으로 Redisson을 이용한 Pub-Sub 방식, Lettuce를 이용한 SETNX 기반의 분산락, MySQL의 네임드락 방식이 있습니다. 모두 각각의 장단점이 있고 상황에 따라서 맞는 분산락 방식을 선택하여 사용해야 합니다.
저는 이번 프로젝트에 적용하는 것 뿐 아닌, 어느 프로젝트에서도 쉽게 적용할 수 있도록 범용적인 분산락 컴포넌트를 개발하고자 하였습니다. 따라서 분산락을 수행하는 인터페이스를 정의하고, 전략패턴을 이용하여 분산락 전략에 맞는 구현체가 실행될 수 있도록 구성하였습니다.
컴포넌트 핵심 구성
분산락 컴포넌트에서 핵심적인 클래스들의 의존 관계도입니다. 각각의 역할은 다음과 같습니다.
[Component Layer]
- DistributedLockAspect: LockTemplate를 주입받아 @DistributedLock 어노테이션이 선언되어있는 메소드를 LockTemplate에게 인자로 전달합니다.
- LockTemplate: 어떤 분산락 전략으로 락을 수행할지 DistributedLockClient 인터페이스의 구현체를 선별하고, 인자로 전달받은 로직을 락 획득 -> 로직 실행 -> 락 해제 순으로 진행합니다. 내부적으로 이미 스레드에서 획득한 락을 다시 획득하려고 시도하는 경우에는 락 획득 시도를 하지 않으며, 트랜잭션이 진행중인 경우에는 범위 밖에서 락이 해제될 수 있도록 락 해제를 지연시킵니다.
- DistributedLockClient: 분산 락을 수행하는 인터페이스 입니다. 락 획득을 시도하는 tryLock() 추상 메서드가 선언되어있습니다.
[Infrastructure Layer]
- RedissonLockClient: Redisson 라이브러리를 이용하여 Redis에서 Pub-Sub 방식의 분산락을 수행하는 DistributedLockClient 인터페이스의 구현체입니다.
- LettuceLockClient: Lettuce 라이브러리를 이용하여 Redis에서 스핀락 방식으로 분산락을 수행하는 DistributedLockClient 인터페이스의 구현체입니다.
- NamedLockClient: Mysql의 네임드락 방식으로 분산락을 수행하는 DistributedLockClient인터페이스의 구현체입니다.
이렇게 구성함으로써, 도메인 또는 어플리케이션 레이어에서는 분산락 컴포넌트를 가져다 쓰지만 추상에만 의존하기에 내부 구현 로직이 변경되더라도 전혀 변경할 필요가 없습니다.
분산락 컴포넌트 사용 방법
구체적인 구현을 들여다보기에 앞서, 사용 방법에 대하여 알아보겠습니다.
사용 방법은 간단합니다. 락이 필요한 메소드 위에 @DistributedLock 어노테이션만 선언해주면, AOP를 통해 로직이 락 범위 안에서 수행됩니다.
주의깊게 봐야 할 부분은 @Transactional과 같이 쓰일 수 있다는 점인데요, 전파속성을 변경하지 않고 락 해제를 트랜잭션 범위 밖에서 수행할 수 있도록 처리한 내용에 대해서는 후술하겠습니다.
또한 strategy 속성 값을 변경하는 것 만으로, 분산락 전략 자체를 손쉽게 변경할 수 있습니다. 어노테이션의 각 속성값에 대한 설명은 다음과 같습니다.
- resource: 락을 수행할 자원입니다. 타입을 String으로 할수도 있지만, 여러 메서드에서 자원에 대한 이름을 오탈자 등의 이슈로 다르게 지정하여 동시성 이슈가 발생하는 것을 방지하고자 Enum으로 관리하도록 하였습니다.
- key: 락이 필요한 자원의 키 값입니다. resource와 결합하여 "LOCK:${resource}:${key}"의 형태의 이름으로 락을 수행하게 됩니다.
- strategy: 분산락 전략 이름입니다. 해당 속성을 통하여 어떤 DistributedLockClient의 구현체로 분산락을 수행할 지 결정합니다.
- waitTime: 락을 획득하기 위하여 대기할 최대 시간입니다. 기본적 값을 5초로 지정하였습니다.
- leaseTime: 락을 소유할 수 있는 최대 시간입니다. 기본적으로 3초를 지정해주었고, 해당 시간이 지나면 락이 자동으로 해제됩니다.
- timeUnit: waitTime, leaseTime에 대한 시간 단위입니다. 기본 값을 초 단위로 지정해주었습니다.
만약 락의 범위를 메서드 전체가 아닌 로직 내부에서 직접 지정하고 싶다면 @DistributedLock 사용 대신에 아래 사진과 같이 LockTemplate를 주입받고, withDistributedLock() 메소드를 통해 락에대한 범위를 조절할 수도 있습니다.
락 해제를 트랜잭션 범위 밖에서 수행시키기 위한 방법
락 해제가 트랜잭션 범위 안에서 수행되는 것이 왜 문제가 되는지에 대해서는 [DB] 분산 락 구현 시 트랜잭션으로 인한 동시성 문제글에 설명이 잘 되어있어서 한번 읽어보시는 것을 추천드립니다.
어떻게 propagation을 변경하지 않고 @Transactional과 같이 사용하면서 락 해제를 트랜잭션 범위 밖에서 할 수 있도록 구현했을까요?
이를 알기 위해서 LockTemplate의 withDistributedLock() 메소드 코드를 살펴보겠습니다.
LockTemplate.withDistributedLock()
fun <T> withDistributedLock(
resource: LockResource,
key: String,
strategy: LockStrategy,
waitTime: Long = defaultWaitTime,
leaseTime: Long = defaultLeaseTime,
timeUnit: TimeUnit = defaultTimeUnit,
action: () -> T
): T {
// (1) 분산락 전략 선택
val lockClient = distributedLockClients[strategy.clientName] ?: throw IllegalStateException("분산 락 전략에 해당하는 구현체가 없습니다. strategyName=$strategy.strategyName")
// (2) 스레드에서 이미 획득한 락이라면 로직 실행
val lockName = generateLockName(resource, key)
val lockedNames = LockedNamesHolder.get()
if (lockedNames.contains(lockName)) {
return action()
}
// (3) 락 획득
val lockHandler = lockClient.tryLock(key, waitTime, leaseTime, timeUnit) ?: throw CoreException(ErrorType.GET_LOCK_FAIL)
// (4) 획득 락 이름 추가
LockedNamesHolder.add(lockName)
return try {
// (5) 로직 실행
action()
} finally {
// (6) 락 이름 제거
LockedNamesHolder.remove(lockName)
// (7) 트랜잭션 진행 여부에 따른 락 해제 처리
if (TransactionSynchronizationManager.isActualTransactionActive()) {
eventPublisher.publishEvent(lockHandler)
} else {
lockHandler.unlock()
}
}
}
(1) 분산락 전략 선택 : 넘겨받은 LockStrategy를 통하여 분산락 구현체를 선택합니다.
(2) 스레드에서 이미 획득한 락이라면 로직 실행 : 이미 획득한 락에 대하여 다시 락을 획득하려고 시도한다면 데드락이 발생하기에, 이미 획득한 락인 경우에는 바로 로직을 수행합니다. LockedNamesHolder는 내부적으로 ThreadLocal을 통하여 스레드에서 획득한 락 이름 목록을 thread-safe하게 관리합니다.
(3) 락 획득 : 선택한 분산락 구현체로 락을 시도합니다. 여기서 LockHandler를 반환받지 못했다면 예외를 발생시킵니다.
(4) 획득 락 이름 추가 : 획득한 락 이름을 LockedNamesHolder에 추가합니다.
(5) 로직 실행 : 인자로 전달받은 로직을 실행합니다.
(6) 락 이름 제거 : 추가했던 락 이름을 LockedNamesHolder에서 제거합니다.
(7) 트랜잭션 진행 여부에 따른 처리
- 트랜잭션이 진행중인 경우: 스프링의 ApplicationEventPublisher를 통해 이벤트를 발행하고, 인자로 LockHandler를 전달합니다.
- 트랜잭션이 진행중이 아니라면: LockHandler를 통해 획득한 락을 해제합니다.
락을 안전하게 트랜잭션의 범위 밖에서 해제하도록 처리할 수 있던 것은 바로 (7)에서 트랜잭션이 진행중인 경우 락을 바로 해제하지 않고 이벤트만 발행하도록 처리했기 때문입니다.
이렇게 발행한 이벤트에 대해서는 ReleaseLockEventListener에서 처리를 하게 됩니다.
ReleaseLockEventListener
import org.springframework.stereotype.Component
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener
@Component
class ReleaseLockEventListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
fun releaseLock(lockHandler: LockHandler) {
lockHandler.unlock()
}
}
@TransactionalEventListener에서는 TransactionPhase.AFTER_COMPLETION 옵션을 통하여 트랜잭션이 종료된 이후에 리스너의 로직을 수행할 수 있는 기능을 제공합니다.
따라서 해당 옵션을 통해 LockTemplate에서 발행된 이벤트는 트랜잭션이 종료된 이후 리스너가 수신하고, releaseLock이 호출되어 안전하게 트랜잭션 범위 밖에서 수행할 수 있었던 것입니다.
여기서 주의할 점은 @Async를 통해 비동기로 처리한다면 락을 획득한 스레드와 락 해제를 수행하는 스레드가 달라지기에 다른 스레드에서도 락 해제를 할 수 있도록 Infrastructure 레이어에서 구현해주어야 합니다.
저는 다른 스레드에서 락을 해제를 허용하지 않았기 때문에 @Async를 사용하지 않고 동기적으로 처리되도록 하였습니다.
LockHandler 추상화
LockTemplate 로직을 보면, tryLock()을 호출하고 LockHandler를 반환받도록 되어있는데 이는 인터페이스입니다.
package kr.hhplus.be.server.domain.support.lock
interface LockHandler {
fun unlock()
}
Infrastructure레이어의 구현 클래스들을 살펴봐도, LockHandler를 구현한 클래스는 없습니다.
그렇다면 어떻게 LockTemplate는 LockHandler를 반환받아 락을 해제할 수 있었을까요?
Redisson 라이브러리는 getLock()을 통해 RLock 객체를 반환하며, 이를 사용하여 획득한 락을 해제할 수 있습니다. 그러나 LockTemplate은 Redisson 라이브러리에 대한 의존성이 없기 때문에, 직접 RLock을 사용할 수 없습니다.
따라서 락을 관리할 수 있는 역할을 LockHandler라는 인터페이스로 추상화하고, 해당 인터페이스의 구현체에서 익명 클래스를 반환하도록 설계하여 아키텍처의 제약 사항을 준수하도록 하였습니다.
여기까지 분산락 컴포넌트의 핵심 클래스들과 동작원리에 대한 설명은 끝입니다. 이외 자세한 코드들은 깃허브에서 확인하실 수 있습니다.
주의사항 및 한계점
데드락 발생 가능성
@DistributedLock을 중첩해서 사용할 경우, 하나의 스레드에서 N개 이상의 락 획득을 수행할 수 있으므로 데드락 발생 가능성이 있습니다. 따라서 개발 시에 이러한 점을 유의하며 사용해야 합니다.
좀 더 쉽게 설명하기 위하여 데드락이 발생할 수 있는 상황에 대하여 도식화를 해봤습니다.
1. Thread-1이 Resource1에 대하여 락을 획득합니다.
2. Thread-2가 Resource2에 대하여 락을 획득합니다.
3. Thread-1이 Resource2에 대한 락을 획득하려고 합니다. Thread-2가 이미 락을 획득하였기 때문에 락이 해제될 때 까지 대기합니다.
4. Thread-2가 Resource1에 대한 락을 획득하려고 합니다. Thread-1이 이미 락을 획득하였기 때문에 락이 해제될 때 까지 대기합니다.
5. 서로가 이미 획득한 락에 대하여 해제될 때 까지 무한히 대기합니다.
이러한 문제를 해결하려면, 컬리 기술블로그의 글 처럼 락 획득 시 별개의 독립적인 트랜잭션에서 진행되도록 전파속성을 변경하거나 물리적으로 트랜잭션을 쪼개야 합니다. 저는 따로 전파속성을 변경하는 것 보다 중첩 사용을 허용하는 것이 단점보다는 장점이 더 많다고 판단했기에, 이 부분은 한계점으로 정의하고 데드락 발생 위험이 있는 부분에 대해서는 상위 레이어에서 @Transactional을 사용하지 않고 물리적으로 트랜잭션을 분리하는 식으로 유의하며 개발하기로 하였습니다.
JPA 1차캐시 또는 Repeatable Read에 의한 갱신분실
트랜잭션 범위 안에서 락 획득이 가능하기때문에, 갱신 분실문제가 발생할 우려가 있습니다. 이 또한 발생할 수 있는 상황에 대하여 도식화해봤습니다.
(1) Transaction 1이 DB에서 엔티티를 조회하고 영속성 컨텍스트에 1차캐시를 합니다.
(2) Transaction 2가 Transaction 1이 조회한 JPA Entity를 수정합니다.
(3) Transaction 1이 락을 획득하고, JPA Entity를 수정하고자 합니다. 여기서 락 범위 안에서 findById()와 같은 조회 로직이 작성되어 있었다고 하더라도, 이미 락을 수행하기 이전에 조회를 수행하여 1차캐시를 해두었기 때문에 수정 시 캐시된 데이터를 사용합니다. 여기서 갱신분실 문제가 발생할 수 있습니다.
이 문제를 해결하기 위하여 LockTemplate 내부에 락을 수행하기 전 영속성 컨텍스트를 초기화 해주는 로직을 넣을지에 대해서도 고민하였지만, 어차피 격리수준이 Repeatable Read일 경우에도 1차캐시 뿐만 아니라 동일한 문제가 발생하기 때문에 이러한 부분을 인지하고 유의하며 개발하기로 결정하였습니다.
마치며..
특정 라이브러리나 기술에 종속되지 않는 컴포넌트를 개발하려고 하다보니, 어떤 부분을 추상화하여 개발해야 할지에 대한 고민을 깊게 해볼 수 있어서 개인적으로 재미있었고, 전략패턴이나 템플릿 콜백패턴과 같은 디자인패턴을 직접 사용하여 개발하면서, 해당 디자인패턴이 왜 탄생했는지에 대해서도 공감할 수 있었습니다.
또한, 이제는 어느정도 기초 지식을 기반으로 발생 가능한 문제에 대하여 미리 생각하고 스스로 트레이드오프에 대하여 결정할 수 있는 능력이 조금은 생긴 것 같아 뿌듯하기도 하였습니다.
이번에 개발한 분산 락 컴포넌트는 앞으로도 지속적으로 개선해 나가며, 다양한 곳에서 활용할 수 있을 것 같습니다.
글 내용에서 틀린 부분이 있거나 궁금한 점이 있다면, 댓글로 남겨주시면 감사하겠습니다!
'개발 > ETC' 카테고리의 다른 글
트랜잭션 전파속성과 UnexpectedRollbackException (0) | 2024.08.11 |
---|---|
[Datagrip] MySQL sqldump Export & Import (0) | 2024.08.10 |
도커로 젠킨스 설치, 설정 가이드 (0) | 2023.10.12 |
테스트코드에서 Lombok 사용하기 (0) | 2023.09.01 |
CORS와 SOP (0) | 2023.08.04 |
개발을 하며 만났던 문제들과 해결 과정, 공부한 내용 등을 기록합니다.
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!