동시성 이슈를 예방하고 데이터 일관성을 유지하기 위해서 Lock을 이용한다. 그 중 분산락은 레코드 수준의 잠금보다는 군집 단위에 영향을 미치는 조작, 여러 DB에 걸친 수정 등 복잡한 레벨에서의 일관성을 보장하고자 할 때 활용도가 높다.
'개발 한 스푼' 서비스에서도 질문 발급 시 분산락을 적용해야할 상황이 생겼다.(실제 서비스에서 문제가 이미 발생했고 후 처리...) 이번 글에서는 분산락을 도입하게 된 배경과 이를 유연하게 적용하기 위해 구현한 방법을 공유해본다.
이 포스트의 마무리에서는 다음과 같은 방식으로 원하는 부분에 쉽게 분산락을 적용할 수 있다.
// 분산락 획득 후 트랜잭션 실행. 분산락 Key는 request에 존재
@Transactional
@DistributedLock(keyClass = [GetTodayQuestion::class])
fun getOrCreateTodayQuestion(request: GetTodayQuestion): QuestionInfo {
// ...
}
본 포스트는 아래 순서로 진행됩니다.
1. 기존의 문제점
1.1 문제 상황
1.2 문제 해결을 위한 방법들
2. 분산락 유연하게 구성하기
2.1 (문제1) 분산락 작용시점
2.2 (문제2) 동적인 분산락 Key 컨트롤
2.3 (문제3) Lock 저장소 선택, 변경가능성
2.4 Advisor 구현
1. 질문 발급 시 문제점
1.1 문제 상황
'개발 한 스푼'에서는 정책에 따라 유저별 하루에 N개씩만 질문을 발급한다. 즉, 유저:질문 = 1:N 관계이다. 어떤 유저의 질문 발급 요청이 들어오면 오늘 발급받은 질문들의 갯수를 가져와 체크한 뒤에 N개가 차지 않았다면 새로운 질문을 발급한다.
일반적인 상황에서 질문 발급을 한 건씩 요청한다면 최대 N개까지 생성될 것이다. 하지만 발급요청을 N번 이상 동시에 요청한다면 정확히 N개만 생성된다는 보장을 할 수 없다.(일종의 Write Skew 문제) 질문이 테이블에 생성되기 전에 다른 요청들이 갯수 체크를 통과한다면 요청들이 모두 질문을 발급할 것이기 때문이다.
1.2 동시 발급 문제 해결을 위한 방법
질문 발급에서의 동시성 이슈를 컨트롤하기 위해 몇 가지 방식을 고민해보았다.
트랜잭션 격리수준 Serializable
질문 발급 트랜잭션 로직을 Serializable(직렬성) 격리로 처리한다면 간단하게 처리할 수 있다. 하지만 이는 테이블 수준에 잠금이 걸려 요청을 건 유저레벨이 아니라 모든 유저의 요청을 직렬적으로 처리한다. 때문에 성능상 효율이 떨어진다.
비관적 락(Select For Update)
비관적 락은 접근하는 로우에만 락을 걸어 다른 트랜잭션에서 해당 로우에 대한 읽기/쓰기를 막을 수 있다.
비관적 락은 로우에 잠금을 걸기 때문에 먼저 로우가 존재해야한다. 하지만 질문발급의 경우 로우를 생성하는 것이기 때문에 일반적으로는 비관적락을 걸 수 없다. 이를 해결하기 위해 두 가지 방식을 사용해 볼수 있다.
1. Count 테이블을 따로 생성, 병목지점 만들기
(유저Id, 날짜, 발급된 질문 수) 컬럼을 가지는 테이블을 만들고 질문발급 트랜잭션의 첫 쿼리로 해당하는 로우를 읽어 잠금을 거는 방식을 사용해 볼 수 있다. 비관적 락을 사용하기 위해 락을 걸 로우를 일부러 만들어 사용하는 방식이라고 볼 수 있다.
2. 미래날짜를 이용한 범위 잠금(Gap Lock)
필자가 사용중인 MySQL에는 특별한 잠금이 있는데 바로 Gap Lock이다. 이는 아직 만들어지지 않은 로우를 포함하여 범위 잠금을 걸 수 있다. 그래서 생성일자를 이용해 어느시점까지 로우 생성을 제한할 수 있다.
하지만 Gap Lock은 항상 읽기 잠금이다. 트랜잭션 로직이 모두 실행되고 나서 쓰기를 하는데 이때 락이 걸리므로 조건에 맞지 않았을때 롤백과정이 반드시 발생한다. 이는 DB에 의미없는 부하를 줄 수도 있는 방식이다.
SELECT * FROM QuestionOpen qo WHERE qo.id = :userId AND qo.createdAt < 미래날짜 FOR UPDATE
분산락
분산락은 특정 로우가 아닌 접근하는 대상의 이름으로 락을 만들고 관련된 로직을 실행할 수 있는 권한을 얻는 일종의 네임드 락이다. 'issue_question:유저ID' 이름으로 분산락을 만들면 유저별로 질문 발급에 대한 동시성 이슈를 처리할 수 있다.
필자는 분산락을 사용하기로 결정했다. Count테이블을 이용한 비관적락도 후보였지만 다른 의미도 아닌 락을 위해 Count 테이블을 만들어 사용한다는 것이 마음에 걸렸다.
2. 분산락 유연하게 구성하기
1.락을 걸고 2.로직을 수행하고 3.락을 반납 하는 과정을 거친다. 비즈니스 로직에 분산락 획득/반납 같은 부가적인 로직 작성을 피하기 위해 AOP로 구현하기로 했다.
구현 전 우선 고려할 사항을 고민해보았다. 정리하자면 다음과 같이 3개의 부분에 신경을 썼다.
- 분산락이 적용되어야하는 시점
- 동적인 분산락 Key를 컨트롤할 방법
- Lock을 저장할 저장소, 변경가능성
2.1 (문제1) 분산락 적용시점
필자가 적용할 분산락은 반드시 트랜잭션 시작/커밋 전후에 동작해야한다. 만약 분산락이 트랜잭션 내부에서 획득/반납 될 경우 동시성 이슈가 그대로 발생할 수 있다.
예를 들어, A 트랜잭션에서 커밋 전에 분산락을 반납한다면 커밋이 일어나기 전에 다른 트랜잭션에서 분산락을 획득, 질문 발급 로직을 실행할 가능성이 있다.
따라서, 분산락 AOP가 @Transactional AOP보다 우선순위를 가지도록 처리해 줘야 한다.
@Aspect
@Component
@Order(1)
class DistributedLockAdvisor {
// ...
}
2.2 (문제2) 동적인 분산락 Key 컨트롤
분산락은 접근대상의 이름으로 락을 건다. 접근하는 대상에 따라 락 Key가 동적으로 변한다는 뜻이다. 접근 대상의 값은 런타임에서야 알 수 있기 때문에 Advice 로직에서 동적인 Key를 생성하기 까다롭다.
필자는 이 문제를 선억적으로 풀어내고 싶어 인터페이스를 이용했다. 분산락이 적용되는 메서드에서 인자로 들어가는 객체가 LockKey 라는 인터페이스를 상속해 키를 생성하는 메서드를 구현하는 것이다.
sealed interface LockKey {
val name: String // Advice에서 이 프로퍼티로 동적 키를 꺼냄
}
// 케이스별 LockKey
interface IssueQuestionLockKey : LockKey {
val keyMemberId: Long
override val name: String
get() = "issue_question:$keyMemberId"
}
// 인자로 넘겨지는 객체(LockKey 구현)
data class GetTodayQuestion(
val memberId: Long,
val today: LocalDate
): IssueQuestionLockKey {
override val keyMemberId: Long = memberId
}
DistributedLock 어노테이션에서는 LockKey를 구현한 객체의 Class타입을 받으면 Advice 로직에서 이를 활용하여 키를 뽑아낼 수 있다.
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class DistributedLock(
// LockKey를 상속받는 클래스타입
val keyClass: Array<KClass<out LockKey>> = [],
)
// LockKey를 구현한 인자의 타입을 넘겨 Advice 로직에서 이를 사용할 수 있도록 도움
@Transactional
@DistributedLock(keyClass = [GetTodayQuestion::class])
fun getOrCreateTodayQuestion(request: GetTodayQuestion): QuestionInfo {
// 로직
}
2.3 (문제3) Lock 저장소 선택, 변경가능성
필자는 돈이 없다. Redis나 다른 인메모리 DB나 속도 빠른 캐시DB를 사용하고 싶지만 인프라를 더 구축할 돈이 없다. 따라서 기존에 사용중인 MySQL의 Named Lock을 이용해서 구성했다. 하지만 만약 우리의 서비스가 잘 된다면 이 Lock 저장소를 교체할 가능성도 있으니 Lock 저장소를 추상화해서 적용했다.
Lock 저장소는 역할에 따라 인터페이스를 분리해서 아래처럼 추상화했다.
interface LockRepository {
fun <R> withLock(key: LockKey, block: () -> R?): R?
}
interface ListableLockRepository: LockRepository {
fun <R> withAllLock(keys: List<LockKey>, block: () -> R?): R?
}
interface DistributedLockRepository: ListableLockRepository
우선은 기존에 사용중인 DB인 MySQL을 이용할 것이기 때문에 이에 대한 구현체를 다음과 같이 구현했다. MySQL에서 락과 서비스의 Connection Pool을 나누어 사용하기 위해 분산락을 위한 DataSource를 따로 생성하여 주입받아 사용했다.
// DB로 사용중인 MySQL을 이용해 분산락을 처리하는 구현체
class SameOriginLockRepository(
@LockDataSource private val dataSource: DataSource
): DistributedLockRepository {
override fun <R> withLock(key: LockKey, block: () -> R?): R? {
dataSource.connection.use {
try {
getLock(it, key)
return block() // 비즈니스 로직이 실행 & 결과값 return
} finally {
releaseLock(it, key)
}
}
}
override fun <R> withAllLock(keys: List<LockKey>, block: () -> R?): R? {
if (keys.isEmpty()) return block()
else if (keys.size == 1) return withLock(keys[0], block)
dataSource.connection.use {connection ->
try {
keys.map { getLock(connection, it) }
return block()
} finally {
releaseAllLock(connection, keys)
}
}
}
// getLock, releaseLock 등 구현
}
여기서 DataSource로 처리한 이유는 Lock을 획득/반납할 때 동일한 Connection을 사용하기 위해서다. 만약 직접 커넥션을 컨트롤하지 않고 JDBC Template같은 기술을 이용할때 동일한 커넥션으로 Lock을 획득/반납하고자 한다면 @Transactional을 이용해야한다.
하지만 이미 비즈니스 로직에서 다른 DataSource를 이용하는 트랜잭션이 동작한다. 때문에 위 Lock 로직에서도 트랜잭션을 적용하면 비즈니스 로직에서는 항상 Required New로 트랜잭션을 새롭게 열어주어야한다. 이는 AOP 로직이 비즈니스 로직과의 결합도가 증가되는 것이기에 직접 Datasource를 다뤘다.
DataSource를 2개 이상 사용할때 추가적인 설정이 필요한데 해당 로직은 프로젝트 링크로 대체한다.
2.4 Advisor 구현
마지막으로 AOP로직은 다음과 같다. 실행되는 메서드의 인자에서 LockKey 인터페이스로 Key를 뽑아내고, Lock 저장소를 주입받아 이를 이용해 분산락을 처리한다.
@Aspect
@Component
@Order(1)
class DistributedLockAdvisor(
// Lock 저장소 주입받기
private val lockRepository: DistributedLockRepository
) {
@Around("@annotation(com.adevspoon.domain.common.annotation.DistributedLock)")
fun atTarget(joinPoint: ProceedingJoinPoint): Any? {
val lockAnnotation = getAnnotation(joinPoint)
// 메서드 인자에서 LockKey 뽑아내기
val lockKeyList = getLockKeyList(joinPoint.args, lockAnnotation.keyClass)
return lockRepository.withAllLock(lockKeyList) {
joinPoint.proceed()
}
}
private fun getAnnotation(joinPoint: ProceedingJoinPoint) =
(joinPoint.signature as MethodSignature).method
.getAnnotation(DistributedLock::class.java)
private fun getLockKeyList(arguments: Array<Any>, keyClasses: Array<KClass<out LockKey>>) =
arguments.filterIsInstance<LockKey>()
.filter { keyClasses.isEmpty() || keyClasses.contains(it::class) }
.toList()
.takeIf { it.isNotEmpty() }
?: throw DomainLockKeyNotSetException()
}
3. 마치며
MySQL의 Named Lock을 이용해 분산락을 구현해보았고 Lock 저장소의 변경가능성, 동적인 Lock Key를 처리하기 위해 나름대로 추상화를 적용해보았다.
동적인 Lock Key를 더 안전하게 처리하기 위해 인터페이스를 도입해보았는데 AOP로직과 비즈니스 로직간 결합이 생긴 것 같기도 하다.
전체코드는 아래 레포에서 확인할 수 있다.
3.1 참고
'Spring' 카테고리의 다른 글
Spring Batch, AWS Lambda로 무비용으로 운용하기 (2) | 2024.06.08 |
---|---|
이벤트 발행 로직 AOP로 분리하여 선언적으로 처리하기 (0) | 2024.04.24 |
예외 정보를 인터페이스와 infix함수로 확장성, 가독성 높게 관리하기 (0) | 2024.04.18 |
API별 인증 해제, 어노테이션으로 효율적으로 처리하기 (0) | 2024.04.09 |
문자열 응답 시 공통 응답형식이 적용되지 않는 문제 해결하기 (0) | 2024.04.05 |