보통 서버 개발을 하다 보면 자주 마주치는 단어들이 존재한다. 동시성, 정합성 등… 특정 동시성 상황에서 데이터 정합성을 보장할 때 특히 락들을 활용하는 타이밍이 자주 온다.
그래서 크게 자주 사용되는 락들을 알아보고 어떻게 동작하는지 어떤 상황에 적합한지를 예제를 통해 알아볼 것이다.
1. 락이 필요한 이유
예를 살펴보자.
알리익스프레스 같은 쇼핑 어플의 월간 사용자수는 약 4억 명정도이다. 그 이유는 가격이 저렴한 것도 있지만, 추가적인 할인을 해주는 쿠폰을 나눠주기도 한다. 이 쿠폰까지 사용하면 어느 쇼핑몰보다 저렴하게 상품을 구매할 수 있다. 그러나 쿠폰 재고 소진으로 쿠폰을 못 받을 수 있다.
여기서 생각을 해보면, 쿠폰 이벤트를 시작할 때 동시에 수많은 사용자가 쿠폰 발행을 할 텐데 쿠폰 잔여 개수를 정확하게 관리하는 것은 어려운 문제일 수 있다. 이때 동시성이라는 문제를 접하게 된다.
1-1. 동시성(Concurrency)이란?
동시성(Concurrency)은 두 가지 이상의 작업이 동시에 실행하는 것처럼 보이는 기술을 의미한다. 하지만 실제로 두 사건이 동시에 일어나는 것은 아니며, 빠르게 전환하면서 동시에 일어나는 것처럼 보일 뿐이다. 이는 소프트웨어 기술에서 구현할 수 있다.
동시성과 자주 비교되는 병렬성(Parallelism)은 실제로 여러 작업이 동시에 실행하여 처리 속도를 높이는 것을 의미한다. 컴퓨터에서 여러 작업을 동시에 실행하려면 물리적인 자원인 여러 CPU 코어가 필요하다.
정리하자면, 동시성은 시간을 분배받아 여러 작업들이 하나씩 실행되어 동시에 실행되는 것처럼 보이는 기술이고, 병렬성은 여러 CPU 코어를 사용해서 같은 시간에도 여러 작업이 실제로 동시에 실행되어 작업 속도를 단축시킨다.
1-2. 만약 동시성 제어를 하지 않는다면 어떻게 될까?
경쟁 상태(Race condition)라는 상황이 펼쳐지며 우리를 반길 것이다.
경쟁 상태(Race condition)는 작업의 시작 시간, 순서, 실행 시간 등에 영향을 받아 출력결과가 일정하지 않게 되는 상황이다. 데이터 관점에서는 여러 트랜잭션이 동시에 같은 데이터를 읽거나 수정하려고 할 때 발생하는 문제이다. 이 경우, 데이터의 무결성이 손상될 수 있다.
예를 들어, 두 명의 사용자가 동시에 같은 쿠폰의 잔여 개수를 확인하고 발행하려고 한다고 가정해 본다면 아래와 같은 현상이 발생할 수 있다.
- 사용자 1 → 쿠폰 조회 (1개) → 쿠폰 발급 (잔여 0개)
- 사용자 2 → 쿠폰 조회 (1개) → 쿠폰 발급 (잔여 0개)
사용자 1이 먼저 쿠폰을 조회하는 타이밍에 맞춰 사용자 2도 쿠폰을 조회하여 쿠폰을 발급받는 플로우다. 둘 다 0개 이상의 쿠폰 개수로 인하여 쿠폰 발급이 가능한 상태에 도달해, 결국 1명이 아닌 2명이 쿠폰을 발급받게 되는 것이다.
그래서 이 같은 현상을 제어하기 위해 락을 사용하는 것이다.
1-3. 락이란?
- 여러 커넥션에서 동시에 동일한 자원을 요청할 경우 순서대로 하나의 커넥션만 변경할 수 있게 해주는 기능
- 동시성을 제어하기 위한 기능
조금 더 쉽게 풀어서 설명하자면, 옷 가게에서 옷을 입어보기 위해 직접 피팅룸에 들어가 잠그는 것이라고 이해하면 편하다.
락의 종류는 크게 2가지이다.
- 공유 Lock (Shared Lock, Read Lock, S-Lock)
- 배타 Lock (Exclusive Lock, Write Lock, X-Lock)
공유 Lock : 데이터를 변경하지 않는 읽기 작업을 위해 잠그는 것을 뜻한다.
⇒ 쉽게 말하자면, 공유 Lock은 “나”의 세션에서도 읽기만 하고 “다른”세션에서도 읽기만 한다면 큰 상관이 없다. 단, 수정은 안된다를 의미한다. 결국 목적은 “데이터를 변경하지 않는 읽기 작업”이기 때문에 데이터를 변경하는 작업을 막는 것이다. (S-Lock O
, X-Lock X
)
베타 Lcok : 데이터를 변경하는 작업을 위해 잠그는 것을 뜻한다.
⇒ 공유 Lock 보다 더 깐깐한 친구이다. 데이터를 변경하기 위해 “나”를 제외한 다른 세션은 읽거나 변경하려고 하는 행위를 하지 못하게 막는 것이다. (S-Lock X
, X-Lock X
)
1-4. 블로킹과 데드락
위에서 살펴본 Lock들을 통해 특정 데이터를 무결하게 관리하거나 정합성을 보장할 때 Lock을 사용한다는 것을 알게 되었다. 하지만 여기서 문제가 발생한다. 보통 DB 작업을 수행할 때 데이터의 무결성과 정합성을 보장하기 위해 트랜잭션을 사용한다. 따라서 Lock도 하나의 트랜잭션 안에서 걸리고, 해제된다.
블로킹은 Lock 간의 경합이 발생해서 특정 트랜잭션이 작업을 진행하지 못하고 대기하는 상태를 의미한다. 아래와 같은 상태들이 블로킹 상태를 만들 수 있다.
- 특정 데이터에 공유 Lock이 설정된 상태에서 해당 데이터에 배타 Lock을 설정하려고 할 때
- 특정 데이터에 배타 Lock이 설정된 상태에서 해당 데이터에 공유 Lock을 설정하려고 할 때
- 특정 데이터에 배타 Lock이 설정된 상태에서 해당 데이터에 공유 Lock을 설정하려고 할 때
결과적으로 “나”라는 세션에서 S-Lock을 걸고 트랜잭션이 시작되었다면, “B”라는 세션에서 X-Lock을 걸고 트랜잭션을 시작하려고 할 때 “나”라는 세션이 커밋되고 끝나기까지 기다리는 상황을 의미한다.
결국 이 대기 시간을 “블로킹”이라고 표현한다. 블로킹이 발생한다면 당연히 해당 애플리케이션 서비스에서 특정 데이터를 사용하는 작업이 모두 지연될 것이기 때문에 이러한 블로킹 상태를 해결하는 것이 중요하다.
블로킹으로 인해 발생할 수 있는 문제가 또 있다. 바로 데드락이다.
데드락은 두 트랜잭션 모두가 블로킹 상태에 진입하여 서로의 블로킹을 해결할 수 없는 상태를 의미한다.
데드락 상황의 예시는 아래와 같다.
- 세션 1에서 Member 데이터에 X-Lock 설정 & 세션 2에서 Product 데이터에 X-Lock 설정
- 세션 1에서 X-Lock 설정이 되어있는 Product 데이터에 S-Lock을 설정
- Product 데이터에는 이미 X-Lock 설정이 되어 있으므로 트랜잭션 1은 블로킹 상태 진입
- 세션 2에서도 X-Lock 설정이 되어 있는 Member 데이터에 S-Lock 설정
- Member 데이터에는 이미 X-Lock 설정이 되어 있으므로 트랜잭션 2도 블로킹 상태 진입
- 트랜잭션 1, 2의 블로킹 상태는 상대 트랜잭션이 종료되어야 해결되는데 서로의 트랜잭션이 블로킹 상태이기 때문에 종료되지 않으므로 데드락 상태가 됨.
⇒ 결과적으로 잠겨 있는 서로 다른 데이터 서로 다른 세션에서 접근하려고 할 때 데드락이 발생한다.
2. 락의 종류
위에서 언급했던 문제들을 해결하기 위해 사용하는 것이 락이고, 동시성 환경에서 이를 적절한 곳에 활용해야 문제를 해결할 수 있다는 것도 알았다. 그럼 주로 많이 사용되는 락의 종류를 확인해 보고 장단점을 알아보자.
2-1. 비관적 락
비관적 락은 데이터베이스 트랜잭션에서 데이터 접근을 제어하여 충돌을 방지하고 데이터 무결성을 보장하는 동시성 제어 전략이다.
이는 트랜잭션이 시작되기 전에 필요한 데이터에 대한 락을 획득하고, 트랜잭션 종료 시 락을 해제하는 방식으로 작동한다. 락을 획득하지 못한 트랜잭션은 대기 큐에 저장되고, 락이 해제되는 대기하게 된다.
장점
- 데이터 무결성 보장: 데이터를 읽는 동안 다른 트랜잭션이 해당 데이터를 변경할 수 없어 일관성을 강력하게 보장한다.
- 데이터 손실 방지: 부분적으로 완료된 트랜잭션으로 인한 데이터 손실을 방지한다.
단점
- 성능 저하: 잦은 락 획득/해제 작업으로 인해 시스템 성능이 저하될 수 있다.
- 데드락 발생 가능성: 여러 트랜잭션이 서로의 락을 잡고 있는 경우 데드락이 발생할 수 있다.
데이터의 무결성이 중요하고 충돌 가능성이 높은 금융 시스템과 같은 환경에서 사용한다.
2-2. 낙관적 락
낙관적 락은 데이터베이스 트랜잭션에서 충돌을 최소화하면서 성능을 향상하는 동시성 제어 전략이다.
이는 트랜잭션이 시작될 때 데이터의 버전을 확인하고, 트랜잭션 완료 시 데이터를 업데이트하기 전에 다시 버전을 확인하는 방식으로 작동한다. 만약 다른 트랜잭션에서 데이터를 업데이트하여 버전이 변경되었으면, 현재 트랜잭션은 롤백되고 다시 시도하도록 한다.
장점
- 성능: 충돌이 드물게 발생하는 환경에서 높은 성능을 제공한다.
- 데드락 방지: 락을 사용하지 않기 때문에 데드락 발생 가능성이 낮다.
단점
- 충돌 발생 시 오버헤드: 충돌 발생 시 롤백 및 재시도가 필요하여 추가적인 작업이 발생한다.
- 데이터 무결성 위험: 충돌 감지를 위한 버전 확인 과정에서 데이터 무결성 위험이 발생할 수 있다.
충돌 발생 빈도가 낮고 데이터 무결성보다 성능을 훨씬 더 우선시하는 경우에 적합하다.
2-3. 분산 락
분산 락은 여러 컴퓨터 또는 노드로 구성된 분산 시스템에서 공유 자원에 대한 동시 접근을 제어하는 메커니즘이다. 이는 데이터 무결성을 보장하고 경쟁 조건을 방지하는 데 중요한 역할을 한다.
장점
- 원자성 보장: 분산 환경에서 여러 서버가 공통된 자원에 접근할 때 원자성을 보장한다.
- 확장성: 서비스가 확장되어도 일관된 동시성 제어가 가능하다.
단점
- 구현 복잡성: 분산 락을 구현하고 관리하는 것은 복잡하며, 오류 발생 시 시스템 전체에 영향을 줄 수 있다.
- 부하: 락을 관리하는 외부 시스템에 부하가 집중될 수 있습니다.
- 정합성: 외부 시스템에 구현하기에 외부 시스템에 문제(브레인 스플릿 등)와 같은 문제가 생길 수 있다.
여러 서버가 동일한 자원에 접근해야 하는 대규모 분산 시스템에서 사용된다. 예를 들어, 선착순 신청 시스템과 같이 동시성 이슈에 민감한 도메인에서 유용하다.
3. 실제 구현과 Spring에서의 사용
간단한 예제를 통해 Spring에서는 어떻게 사용하는지 알아보자.
예제는 특정 상품에 개수를 동시성 환경에서 각각의 락들을 활용해 보장하는 것이다. 이때 구현과 문제점을 알아보자.
3-1. 낙관적 락의 구현
Spring Data JPA에서 낙관적락을 사용하는 법은 간단하다. @Version
Annotation을 통해 버전 컬럼을 추가해주기만 하면 된다. 낙관적 락이 발생하는 경우 ObjectOptimisticLockingFailureException
예외가 발생하고, 해당 에러를 catch 해서 어플리케이션에서 후처리 해주면 된다.
class VersionWithProduct(
name: String,
count: Int
) : UlidPrimaryKeyEntity() {
@Column(name = "name", nullable = false)
var name: String = name
protected set
@Column(name = "count", nullable = false)
var count: Int = count
protected set
@Version
@Column(name = "\"Version\"", nullable = false)
var version: Long? = null
protected set
fun decrease() {
validateStockCount()
this.count -= 1
}
private fun validateStockCount() {
if (this.count < 1) {
throw IllegalStateException("Not enough stock")
}
}
}
// In Service
@Transactional
fun updateProductCountWithOptimisticLock(productId: String) {
val product = versionWithProductRepository.findByIdOrNull(productId) ?: throw IllegalArgumentException("Product not found")
product.decrease()
versionWithProductRepository.save(product)
}
테스트 코드
test("updateProductCountWithOptimisticLock : 낙관적 락 적용 테스트") {
// 데이터베이스에 초기 데이터 저장
var counting = 0
val product = versionWithProductRepository.save(VersionWithProduct("Product", 100))
// 동시성을 테스트하기 위한 설정
val numberOfThreads = 100
val executorService = Executors.newFixedThreadPool(numberOfThreads)
val latch = CountDownLatch(numberOfThreads)
for (i in 0 until numberOfThreads) {
executorService.submit {
try {
// 동시성 업데이트 호출
productService.updateProductCountWithOptimisticLock(product.id)
} catch (e: OptimisticLockingFailureException) {
counting++
} finally {
latch.countDown()
}
}
}
latch.await()
executorService.shutdown()
// 최종적으로 저장된 제품 상태 확인
val updatedProduct = versionWithProductRepository.findById(product.id).get()
println("잔여 프로덕트 갯수 = ${updatedProduct.count}")
println("낙관적 락 실패 횟수 = $counting")
updatedProduct.count shouldNotBe 0 // ObjectOptimisticLockingFailureException 발생
}
위 같이 구현하면 낙관적 락만으로는 동시성 환경에서 우리가 해결해야 할 특정 상품에 개수를 정확하게 보장할 수 없다. 그리고 에러 발생했을 때 적절히 재시도 로직을 넣어줘야 한다. 결과적으로 데이터 충돌이 자주 발생하지 않는 상황에서 사용해야 한다.
3-2. 비관적 락
Spring Data JPA에서 Repository에 @Lock
어노테이션으로 모드를 지정해 주면 된다. 트랜잭션이 시작될 때 S Lock 또는 X Lock을 걸고 시작하며, DB 가 제공하는 락 사용한다.
@Entity
@Table(name = "product")
class Product(
name: String,
count: Int
) : UlidPrimaryKeyEntity() {
@Column(name = "name", nullable = false)
var name: String = name
protected set
@Column(name = "count", nullable = false)
var count: Int = count
protected set
fun decrease() {
validateStockCount()
this.count -= 1
}
private fun validateStockCount() {
if (this.count < 1) {
throw IllegalStateException("Not enough stock")
}
}
}
// In Repository
@Lock(LockModeType.PESSIMISTIC_READ)
@QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "10000"))
fun findReadLockById(productId: String): Product?
// In Service
@Transactional
fun updateProductCountWithPessimisticLock(productId: String) {
val product = productRepository.findReadLockById(productId) ?: throw IllegalArgumentException("Product not found")
product.decrease()
}
위처럼 구성해 주면 Select for update Query로 인하여 해당 상품에 개수를 보장할 수 있게 된다. 다만 성능적으로 이슈가 발생할 수 있다.
테스트 코드
test("updateProductCountWithPessimisticLock : 비관적 락 적용 테스트") {
// 데이터베이스에 초기 데이터 저장
val product = productRepository.save(Product("Product", 100))
// 동시성을 테스트하기 위한 설정
val numberOfThreads = 100
val executorService = Executors.newFixedThreadPool(numberOfThreads)
val latch = CountDownLatch(numberOfThreads)
for (i in 0 until numberOfThreads) {
executorService.submit {
try {
// 동시성 업데이트 호출
productService.updateProductCountWithPessimisticLock(product.id)
} finally {
latch.countDown()
}
}
}
latch.await()
executorService.shutdown()
// 최종적으로 저장된 제품 상태 확인
val updatedProduct = productRepository.findById(product.id).get()
println("잔여 프로덕트 갯수 = ${updatedProduct.count}")
updatedProduct.count shouldBe 0
}
원하던 0이라는 개수를 확인할 수 있다!
3-3. 분산 락
낙관적 락은 실패 시 예외처리 + 재시도로 인한 성능 저하의 우려가 있고, 비관적 락은 row 자체에 락을 걸기 때문에 성능 저하가 있다. 이때 사용하는 것이 분산 락이다.
분산 락 구현 방법은 여러 가지가 있다.
- Zookeeper : 분산 서버 관리시스템으로 분산 서비스 내 설정 등을 공유해 주는 시스템.
- 인프라 필요 및 러닝 커브 존재. 성능 튜닝 필요.
- Redis : Zookeeper와 마찬가지로 별도의 인프라를 구축하고 관리해야 하지만 아래의 장점이 있다.
- 인메모리 DB로 속도가 빠르다. (초당 100,000 QPS의 속도)
- 싱글스레드 방식으로 동시성 문제가 현저히 적다.
이 중 필자는 Redis가 기존 사내 인프라 환경에 구축되어 있기 때문에 Redis를 사용한 예제를 구성했다.
Redis 클라이언트 구현체 중에서도 선택할 수 있다.
- Lettuce
- 스핀락 사용
- setnx(SET if Not eXists)은 레디스에서 제공하는 원자적 연산
- 값이 존재하는지 확인 → 없다면 세팅
⇒ 재시도 횟수를 정하는 로직을 더할 수는 있지만 기본적으로 스핀락은 일정 시간 이후 레디스에게 setnx 요청을 하게 된다. 요청이 많을수록 레디스에 더 많은 부하가 발생한다.
- Redisson
- Lock 인터페이스 지원
- Pub/Sub방식의 락 획득 및 해제 구조
결과적으로 Redis에 무리가 덜 가는 Redission을 사용하여 예제를 구현했다.
@Around("@annotation(DistributedLock)")
@Throws(Throwable::class)
fun lock(joinPoint: ProceedingJoinPoint): Any? {
val signature = joinPoint.signature as MethodSignature
val method = signature.method
val distributedLock = method.getAnnotation(DistributedLock::class.java)
val key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.parameterNames, joinPoint.args, distributedLock.key)
val rLock: RLock = redissonClient.getLock(key)
log.info("Redisson Lock : ${method.name} + key=$key + rlock=$rLock" )
return try {
val available = rLock.tryLock(distributedLock.waitTime, distributedLock.leaseTime, distributedLock.timeUnit)
if (!available) {
log.warn("Failed to acquire lock for key: $key")
throw InterruptedException("Failed to acquire lock for key: $key")
}
aopForTransaction.proceed(joinPoint)
} catch (e: InterruptedException) {
throw InterruptedException()
} finally {
try {
rLock.unlock()
log.info("Redisson Lock UnLock : ${method.name} + key=$key" )
} catch (e: IllegalMonitorStateException) {
log.info("Redisson Lock Already UnLock : ${method.name} + key=$key" )
}
}
}
// In Service
@DistributedLock(key = "#productId")
fun updateProductCountWithDistributedLock(productId: String) {
val product = productRepository.findByIdOrNull(productId) ?: throw IllegalArgumentException("Product not found")
product.decrease()
}
핵심 로직은 위와 같으며, 어디서 락을 걸고 무엇이 키로 잡히는지 확인할 수 있도록 구현했다. 또한 AOP를 통해 annotation을 달기만 하면 분산 락을 사용할 수 있다.
테스트 코드
test("updateProductCountWithPessimisticLock : 분산 락 적용 테스트") {
// 데이터베이스에 초기 데이터 저장
val product = productRepository.save(Product("Product", 100))
// 동시성을 테스트하기 위한 설정
val numberOfThreads = 100
val executorService = Executors.newFixedThreadPool(numberOfThreads)
val latch = CountDownLatch(numberOfThreads)
for (i in 0 until numberOfThreads) {
executorService.submit {
try {
// 동시성 업데이트 호출
productService.updateProductCountWithDistributedLock(product.id)
} finally {
latch.countDown()
}
}
}
latch.await()
executorService.shutdown()
// 최종적으로 저장된 제품 상태 확인
val updatedProduct = productRepository.findById(product.id).get()
println("잔여 프로덕트 갯수 = ${updatedProduct.count}")
updatedProduct.count shouldBe 0
}
테스트가 통과하는 모습을 볼 수 있으며 Key 또한, updateProductCountWithDistributedLock + key=LOCK:01JFQ01S7WRHZP0K4RM0TDTFCW + rlock=org.redisson.RedissonLock@5af09fdd
와 같이 잘 보이는 것을 확인할 수 있다.
위에서 사용된 코드는 다음 Repository에서 확인 가능하다.
https://github.com/LeeJejune/Spring-Studying/tree/main/spring-lock-mode-example
Spring-Studying/spring-lock-mode-example at main · LeeJejune/Spring-Studying
🍃 스,,,스ㅡ,,,스프링,,! Contribute to LeeJejune/Spring-Studying development by creating an account on GitHub.
github.com
결론
락의 필요성부터, 락의 종류, 문제점, 락의 사용법, 활용법 등을 알아보았다.
락(Lock)은 동시성 제어를 위한 필수적인 메커니즘이지만, 상황에 따라 적절한 락 전략을 선택하는 것이 매우 중요하다.
충돌이 적고 읽기 작업이 많은 환경에서 낙관적 락은 좋은 선택이다.
예를 들어 게시판의 게시글 수정이나 개인 프로필 업데이트와 같이 동시 수정 가능성이 낮은 경우에 적합합니다. 구현이 간단하고 성능이 좋지만, 충돌 발생 시 재시도 로직을 직접 구현해야 하는 점을 고려해야 한다.
비관적 락은 데이터 정합성이 매우 중요하고 충돌이 자주 발생하는 환경에서 유용하다. 금융 거래나 재고 관리와 같이 데이터의 정확성이 필수적인 경우에 적합하다. 다만, 성능 저하에 대한 점을 염두해야 한다.
분산 락은 여러 서버나 인스턴스 간에 동시성을 제어해야 하는 분산 환경에서 필수적이다. 특히 마이크로서비스 아키텍처에서 공유 자원에 대한 접근을 제어할 때 유용하지만, 구현 복잡도가 높고 네트워크 지연 등의 추가적인 고려사항이 있다.
결론적으로, 락 전략을 선택할 때는 다음 사항들을 고려해야 한다
- 비즈니스 요구사항 (데이터 정합성의 중요도)
- 시스템 아키텍처 (단일 서버 vs 분산 환경)
- 예상되는 동시 접근 빈도
- 성능 요구사항
- 개발 및 유지보수 복잡도
어떤 락을 선택하든, 가능한 한 락의 범위는 최소화하고 락이 걸리는 시간을 줄이는 것이 중요하며, 시스템의 확장성과 장애 상황도 고려한 설계가 필요하다. 또한 실제 서비스에서는 단일 락 전략만 사용하기보다는 상황에 따라 여러 락 전략을 적절히 조합하여 사용하는 것이 일반적이다.
'서버 개발(생각과 구현)' 카테고리의 다른 글
JPA N+1의 의도와 문제 해결 (0) | 2024.12.26 |
---|---|
외부 API와 트랜잭션의 관계를 조심하자 (0) | 2024.12.23 |
서킷 브레이커(CircuitBreaker)가 필요한 경우 (0) | 2024.12.21 |
Spring Retry(재시도)가 필요한 경우 (0) | 2024.12.20 |
외부 API 호출을 위한 OpenFeign, RestClient, WebClient들의 차이점 (0) | 2024.12.20 |