티스토리 뷰
핵심 개념:
- 동시성 문제: 여러 트랜잭션(작업 단위)이 동시에 같은 데이터를 접근하고 수정할 때, 데이터의 일관성이 깨지거나 예상치 못한 결과가 발생하는 문제입니다. 예를 들어, 두 사용자가 동시에 상품 재고를 1개씩 감소시키려 할 때, 최종 재고가 예상과 다르게 1개만 줄어드는 경우가 생길 수 있습니다.
- 락(Lock): 이런 동시성 문제를 해결하기 위해 특정 데이터나 자원에 대해 동시에 접근하는 것을 제어하는 메커니즘입니다.
1. 비관적 락 (Pessimistic Lock)
개념:
- 이름 그대로 동시성 충돌이 발생할 것이라고 "비관적으로" 가정하고 시작합니다.
- 데이터를 읽거나 수정하기 전에 먼저 해당 데이터에 락을 겁니다.
- 락을 건 트랜잭션이 종료될 때까지 다른 트랜잭션은 해당 데이터에 접근(읽기 또는 쓰기, 락 종류에 따라 다름)이 차단됩니다.
- 마치 화장실에 들어갈 때 문을 잠그는 것과 같습니다. 내가 사용하는 동안 다른 사람은 들어오지 못하도록 막는 방식입니다.
- 데이터베이스 레벨에서 제공하는 락 기능을 사용합니다 (예:
SELECT ... FOR UPDATE).
장점:
- 데이터 충돌을 원천적으로 방지하므로 데이터 무결성을 확실하게 보장합니다.
- 구현이 비교적 간단할 수 있습니다 (충돌 시 재시도 로직이 필요 없을 수 있음).
단점:
- 락을 건 동안 다른 트랜잭션이 대기해야 하므로 동시성(Concurrency)이 떨어집니다. 이는 시스템 전체 성능 저하로 이어질 수 있습니다.
- 락을 너무 오래 유지하면 다른 작업들이 계속 대기하게 됩니다.
- 교착 상태(Deadlock) 발생 가능성이 있습니다. (서로 다른 트랜잭션이 서로가 점유한 락을 기다리는 상황)
언제 사용할까?
- 충돌이 빈번하게 발생할 것으로 예상되는 경우.
- 데이터 정합성이 매우 중요하여 충돌 시 롤백 비용이 큰 경우.
- 트랜잭션 길이가 짧아서 락 유지 시간이 길지 않은 경우.
Spring Boot (JPA) 예제 (Kotlin):
JPA에서는 @Lock 어노테이션이나 EntityManager의 lock 메소드를 사용하여 비관적 락을 구현할 수 있습니다.
1) Entity 정의:
import jakarta.persistence.*
@Entity
class Product(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
var name: String,
var stock: Int
)
2) Repository 정의:
LockModeType.PESSIMISTIC_WRITE는 쓰기 락(exclusive lock)을 걸어 다른 트랜잭션이 읽거나 쓰는 것을 모두 막습니다. PESSIMISTIC_READ는 공유 락(shared lock)으로, 다른 트랜잭션이 읽는 것은 허용하지만 쓰는 것은 막습니다.
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import jakarta.persistence.LockModeType
import java.util.Optional
interface ProductRepository : JpaRepository<Product, Long> {
// ID로 조회 시 비관적 쓰기 락을 건다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
override fun findById(id: Long): Optional<Product> // 기본 findById 오버라이드
// 또는 커스텀 쿼리 메소드에 적용
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findProductForUpdateById(id: Long): Optional<Product>
}
3) Service 로직:
락은 트랜잭션 범위 내에서 유지됩니다. @Transactional 어노테이션이 필수적입니다.
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class ProductService(private val productRepository: ProductRepository) {
@Transactional
fun decreaseStock(productId: Long, quantity: Int) {
// findById 호출 시 PESSIMISTIC_WRITE 락 획득 시도
// 다른 트랜잭션이 이미 락을 걸었다면, 여기서 대기하게 된다.
val product = productRepository.findById(productId)
.orElseThrow { RuntimeException("Product not found") }
println("현재 재고: ${product.stock}, 감소 요청: $quantity")
if (product.stock < quantity) {
throw RuntimeException("재고 부족")
}
product.stock -= quantity
// 변경 감지(Dirty Checking) 또는 save 호출로 업데이트
// 트랜잭션이 종료될 때 락이 해제된다.
println("재고 감소 완료. 최종 재고: ${product.stock}")
// productRepository.save(product) // 명시적으로 호출해도 됨
}
}
동작:decreaseStock 메소드가 호출되고 productRepository.findById(productId)가 실행될 때, 해당 Product 레코드에 데이터베이스 레벨의 쓰기 락이 걸립니다. 만약 다른 트랜잭션이 동시에 이 메소드를 호출하여 같은 상품의 재고를 줄이려 하면, 먼저 락을 획득한 트랜잭션이 완료될 때까지 findById 단계에서 대기하게 됩니다.
2. 낙관적 락 (Optimistic Lock)
개념:
- 이름 그대로 충돌이 잘 발생하지 않을 것이라고 "낙관적으로" 가정합니다.
- 데이터를 수정하기 전에 락을 걸지 않습니다. 누구나 자유롭게 데이터를 읽어갈 수 있습니다.
- 대신, 데이터를 수정하고 실제 데이터베이스에 업데이트하는 시점에 자신이 읽었던 데이터가 그 사이에 다른 트랜잭션에 의해 변경되지 않았는지 확인합니다.
- 변경되었다면 업데이트를 실패시키고, 개발자가 정의한 대로 재시도하거나 오류 처리를 합니다.
- 주로 데이터에 버전(Version) 정보를 두어 변경 여부를 확인합니다. (타임스탬프를 사용하기도 함)
장점:
- 락을 걸고 기다리는 과정이 없으므로 비관적 락보다 동시성(Concurrency)이 높습니다. 이는 시스템 전체 성능 향상에 도움이 됩니다.
- Deadlock 발생 가능성이 거의 없습니다.
단점:
- 업데이트 시점에 충돌이 감지되면 개발자가 재시도 또는 예외 처리 로직을 직접 구현해야 합니다.
- 충돌이 빈번하게 발생하면 재시도 때문에 오히려 성능이 저하될 수 있습니다.
언제 사용할까?
- 충돌이 거의 발생하지 않을 것으로 예상되는 경우.
- 데이터를 읽는 작업이 쓰는 작업보다 훨씬 많은 경우.
- 충돌 시 재시도하는 비용이 크지 않은 경우.
Spring Boot (JPA) 예제 (Kotlin):
JPA에서는 @Version 어노테이션을 사용하여 낙관적 락을 쉽게 구현할 수 있습니다.
1) Entity 정의:
@Version 어노테이션이 붙은 필드를 추가합니다. JPA가 엔티티를 업데이트할 때마다 이 필드의 값을 자동으로 1씩 증가시킵니다. 타입은 주로 Long 이나 Int를 사용합니다.
import jakarta.persistence.*
@Entity
class Product(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
var name: String,
var stock: Int,
@Version // 낙관적 락을 위한 버전 필드
var version: Long = 0L // 초기값은 0 또는 명시하지 않아도 JPA가 관리
)
2) Repository 정의:
낙관적 락을 위한 특별한 어노테이션은 Repository 인터페이스에 필요하지 않습니다. JPA가 @Version 필드를 보고 알아서 처리합니다.
import org.springframework.data.jpa.repository.JpaRepository
interface ProductRepository : JpaRepository<Product, Long> {
// 별도의 설정 없이 기본 CRUD 메소드 사용
}
3) Service 로직:
업데이트 시 충돌이 발생하면 JPA는 ObjectOptimisticLockingFailureException (또는 유사한 예외)을 발생시킵니다. 이를 catch하여 처리해야 합니다.
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.dao.OptimisticLockingFailureException // JPA 구현체(Hibernate)에 따라 예외 타입이 다를 수 있음 (jakarta.persistence.OptimisticLockException)
@Service
class ProductService(private val productRepository: ProductRepository) {
@Transactional
fun decreaseStockOptimistic(productId: Long, quantity: Int) {
try {
// 1. 데이터를 읽는다 (이때 버전 정보도 함께 읽어온다)
val product = productRepository.findById(productId)
.orElseThrow { RuntimeException("Product not found") }
println("읽은 시점 재고: ${product.stock}, 버전: ${product.version}")
if (product.stock < quantity) {
throw RuntimeException("재고 부족")
}
// 2. 재고를 감소시킨다
product.stock -= quantity
println("재고 감소 시도. 변경 후 재고: ${product.stock}")
// 3. 데이터를 업데이트한다 (save 또는 @Transactional 종료 시 자동 flush)
// 이때 JPA는 UPDATE 쿼리에 WHERE 조건으로 버전 정보(version)를 추가한다.
// 예: UPDATE product SET stock = ?, version = version + 1 WHERE id = ? AND version = ? (읽었던 버전)
productRepository.save(product) // 명시적으로 save 호출 (또는 변경 감지로 처리)
println("업데이트 성공. 최종 재고: ${product.stock}, 새 버전: ${product.version + 1}") // 실제 버전 증가는 커밋 시점에 반영
} catch (e: OptimisticLockingFailureException) {
// 다른 트랜잭션이 먼저 업데이트하여 버전이 변경된 경우 이 예외가 발생한다.
println("업데이트 충돌 발생! 재시도 또는 사용자 알림 필요.")
// 여기에 재시도 로직을 넣거나, 사용자에게 오류를 알리는 등의 처리를 해야 한다.
throw RuntimeException("재고 업데이트 중 충돌이 발생했습니다. 다시 시도해주세요.", e)
}
}
// 간단한 재시도 로직 예시 (실제로는 백오프 전략 등 고려 필요)
@Transactional
fun decreaseStockWithRetry(productId: Long, quantity: Int, maxRetries: Int = 3) {
var retries = 0
while (retries < maxRetries) {
try {
decreaseStockOptimisticInternal(productId, quantity) // 내부 로직 호출 (트랜잭션 전파 주의)
return // 성공 시 종료
} catch (e: OptimisticLockingFailureException) {
retries++
println("충돌 발생. 재시도 (${retries}/${maxRetries})...")
if (retries >= maxRetries) {
println("최대 재시도 횟수 초과")
throw RuntimeException("재고 업데이트에 실패했습니다 (최대 재시도 초과).", e)
}
// 짧은 대기 시간 추가 가능 (exponential backoff 등)
Thread.sleep(50L * retries)
}
}
}
// @Transactional(propagation = Propagation.REQUIRES_NEW) // 경우에 따라 새 트랜잭션 필요할 수 있음
// 또는 같은 클래스 내 호출이므로 별도 private 메소드로 분리 필요할 수 있음
private fun decreaseStockOptimisticInternal(productId: Long, quantity: Int) {
// 실제 로직 (위 decreaseStockOptimistic 내용과 유사)
val product = productRepository.findById(productId).orElseThrow { RuntimeException("Product not found") }
if (product.stock < quantity) throw RuntimeException("재고 부족")
product.stock -= quantity
productRepository.save(product)
}
}
동작:
두 트랜잭션이 거의 동시에 decreaseStockOptimistic 메소드를 호출한다고 가정해 봅시다.
- 트랜잭션 A가 상품 ID 1 (버전 1, 재고 10)을 읽습니다.
- 트랜잭션 B도 상품 ID 1 (버전 1, 재고 10)을 읽습니다.
- 트랜잭션 A가 재고를 9로 변경하고
save()를 호출하여 커밋합니다. DB에는 재고 9, 버전 2가 저장됩니다. (UPDATE ... WHERE id=1 AND version=1 실행 성공) - 트랜잭션 B가 재고를 9로 변경하고
save()를 호출합니다. JPA는UPDATE ... WHERE id=1 AND version=1쿼리를 실행하려 하지만, DB에는 이미 버전이 2로 변경되었으므로 이 조건에 맞는 레코드가 없습니다. 따라서 업데이트되는 행(row)이 0개가 되고, JPA는OptimisticLockingFailureException을 발생시킵니다. catch블록에서 이 예외를 잡아서 재시도하거나 사용자에게 오류를 알립니다.
3. 요약 비교
| 특징 | 비관적 락 (Pessimistic Lock) | 낙관적 락 (Optimistic Lock) |
|---|---|---|
| 기본 가정 | 충돌이 발생할 것이다. | 충돌이 거의 발생하지 않을 것이다. |
| 락 시점 | 데이터 접근(읽기/쓰기) 전 | 데이터 업데이트(쓰기) 시점에 충돌 확인 |
| 충돌 처리 | 다른 트랜잭션 접근 차단 (대기) | 업데이트 실패 후 예외 발생 (재시도/오류 처리 필요) |
| 동시성 | 낮음 (락으로 인한 대기 발생) | 높음 (락 대기 없음) |
| 성능 | 충돌이 잦으면 안정적, 평소에는 락 비용 발생 | 충돌이 적으면 성능 좋음, 충돌 잦으면 재시도 비용 발생 |
| 구현 복잡도 | 충돌 처리 로직은 단순할 수 있으나, Deadlock 가능성 고려 필요 | 충돌 시 재시도/예외 처리 로직 구현 필요 |
| 주요 사용처 | 충돌 빈번, 데이터 정합성 매우 중요, 트랜잭션 짧음 | 충돌 드묾, 읽기 위주, 성능 중요 |
| JPA 구현 | @Lock(LockModeType.PESSIMISTIC_...), entityManager.lock |
@Version 어노테이션 |
어떤 락 전략을 선택할지는 애플리케이션의 특성, 예상되는 동시 접근 빈도, 데이터의 중요도 등을 종합적으로 고려하여 결정해야 합니다.
'개발 인프라 > 데이터베이스' 카테고리의 다른 글
| 관계형 데이터베이스 란? (1) | 2025.05.02 |
|---|---|
| 주요 관계형 데이터베이스(MySQL, PostgreSQL, Oracle, SQL Server)에서 데드락을 확인 방법 (0) | 2025.04.30 |
| 데이터베이스 락(Lock)이란? (0) | 2025.04.29 |
- Total
- Today
- Yesterday
- AI 에이전트
- 스브링부트
- springai
- Java
- 언리얼엔진
- ai통합
- 타입 안전성
- 일급 객체
- redis
- JVM
- RESTfull
- 디자인패턴
- Subagent
- cqrs
- unreal engjin
- 코틀린
- method Area
- Claude Agent SDK
- 언리얼엔진5
- First-class citizen
- 코프링
- 카프카 개념
- generated_body()
- model context protocol
- Stack Area
- Heap Area
- 자바
- MCP
- JAVA 프로그래밍
- vite
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
