티스토리 뷰

728x90

 

 

 

핵심 개념:

  • 동시성 문제: 여러 트랜잭션(작업 단위)이 동시에 같은 데이터를 접근하고 수정할 때, 데이터의 일관성이 깨지거나 예상치 못한 결과가 발생하는 문제입니다. 예를 들어, 두 사용자가 동시에 상품 재고를 1개씩 감소시키려 할 때, 최종 재고가 예상과 다르게 1개만 줄어드는 경우가 생길 수 있습니다.
  • 락(Lock): 이런 동시성 문제를 해결하기 위해 특정 데이터나 자원에 대해 동시에 접근하는 것을 제어하는 메커니즘입니다.

1. 비관적 락 (Pessimistic Lock)

개념:

  • 이름 그대로 동시성 충돌이 발생할 것이라고 "비관적으로" 가정하고 시작합니다.
  • 데이터를 읽거나 수정하기 전에 먼저 해당 데이터에 락을 겁니다.
  • 락을 건 트랜잭션이 종료될 때까지 다른 트랜잭션은 해당 데이터에 접근(읽기 또는 쓰기, 락 종류에 따라 다름)이 차단됩니다.
  • 마치 화장실에 들어갈 때 문을 잠그는 것과 같습니다. 내가 사용하는 동안 다른 사람은 들어오지 못하도록 막는 방식입니다.
  • 데이터베이스 레벨에서 제공하는 락 기능을 사용합니다 (예: SELECT ... FOR UPDATE).

장점:

  • 데이터 충돌을 원천적으로 방지하므로 데이터 무결성을 확실하게 보장합니다.
  • 구현이 비교적 간단할 수 있습니다 (충돌 시 재시도 로직이 필요 없을 수 있음).

단점:

  • 락을 건 동안 다른 트랜잭션이 대기해야 하므로 동시성(Concurrency)이 떨어집니다. 이는 시스템 전체 성능 저하로 이어질 수 있습니다.
  • 락을 너무 오래 유지하면 다른 작업들이 계속 대기하게 됩니다.
  • 교착 상태(Deadlock) 발생 가능성이 있습니다. (서로 다른 트랜잭션이 서로가 점유한 락을 기다리는 상황)

언제 사용할까?

  • 충돌이 빈번하게 발생할 것으로 예상되는 경우.
  • 데이터 정합성이 매우 중요하여 충돌 시 롤백 비용이 큰 경우.
  • 트랜잭션 길이가 짧아서 락 유지 시간이 길지 않은 경우.

Spring Boot (JPA) 예제 (Kotlin):

JPA에서는 @Lock 어노테이션이나 EntityManagerlock 메소드를 사용하여 비관적 락을 구현할 수 있습니다.

 

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 메소드를 호출한다고 가정해 봅시다.

  1. 트랜잭션 A가 상품 ID 1 (버전 1, 재고 10)을 읽습니다.
  2. 트랜잭션 B도 상품 ID 1 (버전 1, 재고 10)을 읽습니다.
  3. 트랜잭션 A가 재고를 9로 변경하고 save()를 호출하여 커밋합니다. DB에는 재고 9, 버전 2가 저장됩니다. (UPDATE ... WHERE id=1 AND version=1 실행 성공)
  4. 트랜잭션 B가 재고를 9로 변경하고 save()를 호출합니다. JPA는 UPDATE ... WHERE id=1 AND version=1 쿼리를 실행하려 하지만, DB에는 이미 버전이 2로 변경되었으므로 이 조건에 맞는 레코드가 없습니다. 따라서 업데이트되는 행(row)이 0개가 되고, JPA는 OptimisticLockingFailureException을 발생시킵니다.
  5. catch 블록에서 이 예외를 잡아서 재시도하거나 사용자에게 오류를 알립니다.

3. 요약 비교

특징 비관적 락 (Pessimistic Lock) 낙관적 락 (Optimistic Lock)
기본 가정 충돌이 발생할 것이다. 충돌이 거의 발생하지 않을 것이다.
락 시점 데이터 접근(읽기/쓰기) 데이터 업데이트(쓰기) 시점에 충돌 확인
충돌 처리 다른 트랜잭션 접근 차단 (대기) 업데이트 실패 후 예외 발생 (재시도/오류 처리 필요)
동시성 낮음 (락으로 인한 대기 발생) 높음 (락 대기 없음)
성능 충돌이 잦으면 안정적, 평소에는 락 비용 발생 충돌이 적으면 성능 좋음, 충돌 잦으면 재시도 비용 발생
구현 복잡도 충돌 처리 로직은 단순할 수 있으나, Deadlock 가능성 고려 필요 충돌 시 재시도/예외 처리 로직 구현 필요
주요 사용처 충돌 빈번, 데이터 정합성 매우 중요, 트랜잭션 짧음 충돌 드묾, 읽기 위주, 성능 중요
JPA 구현 @Lock(LockModeType.PESSIMISTIC_...), entityManager.lock @Version 어노테이션

어떤 락 전략을 선택할지는 애플리케이션의 특성, 예상되는 동시 접근 빈도, 데이터의 중요도 등을 종합적으로 고려하여 결정해야 합니다.

728x90
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/04   »
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
글 보관함