DB 락 메커니즘 이해와 실습

리본24·2025년 1월 31일

Spring

목록 보기
4/7
post-thumbnail

1. 락(Lock) 개념과 중요성

1.1 락(Lock)의 개념

락(Lock)은 데이터베이스에서 동시 실행되는 트랜잭션 간의 충돌을 방지하고, 데이터 정합성을 보장하기 위해 특정 자원(테이블, 행 등)에 대한 접근을 제어하는 메커니즘입니다.

  • 기본 원리: 트랜잭션이 데이터에 접근할 때 락을 걸어 다른 트랜잭션이 동일 데이터에 동시에 접근하지 못하도록 제한합니다. 이를 통해 데이터 손상이나 무결성 문제가 발생하는 것을 방지합니다.
  • 필요성: 데이터베이스는 다수의 사용자나 프로세스가 동시에 접근하는 환경에서 운영되므로, 특정 데이터에 대한 잘못된 읽기(Read)나 쓰기(Write)를 방지하기 위해 락이 필요합니다.

예시

여러 사용자가 동시에 상품을 주문하는 상황을 가정해봅시다.

  • 사용자 A가 상품의 재고를 확인하고 감소시키는 트랜잭션을 실행하는 동안, 사용자 B가 같은 상품에 대한 접근을 시도하면 데이터 정합성이 깨질 수 있습니다.
  • 이때 락을 걸어 사용자 A의 작업이 완료될 때까지 사용자 B가 해당 데이터에 접근하지 못하게 하면 데이터 충돌을 방지할 수 있습니다.

1.2 락의 중요성

1. 동시성 제어

락은 여러 트랜잭션이 동시에 실행될 때 데이터의 일관성과 정확성을 보장합니다.

  • 예시: 한 트랜잭션이 데이터를 수정하는 동안 다른 트랜잭션이 동일 데이터를 읽거나 수정하지 못하도록 제한함으로써 데이터 손상을 방지합니다.

2. 데이터 정합성 유지

락을 통해 충돌 상황에서도 데이터가 항상 일관된 상태를 유지할 수 있습니다.

  • 예시: 은행 계좌 이체 시, 송금 계좌에서 금액이 감소하고 수신 계좌에서 금액이 증가하는 작업이 동시에 발생해야 하며, 락을 사용하면 두 작업이 안전하게 처리될 수 있습니다.

3. 시스템 안정성 보장

락은 트랜잭션의 원자성을 보장하여 작업 도중 발생할 수 있는 오류를 최소화합니다.

  • 예시: 트랜잭션 도중 시스템 오류가 발생하더라도 락을 통해 잘못된 상태로 데이터가 저장되지 않도록 보호합니다.

2. 비관적 락과 낙관적 락 비교

2.1 비관적 락(Pessimistic Lock)

비관적 락은 데이터에 접근하기 전에 해당 자원을 선점하여 다른 트랜잭션이 접근하지 못하도록 차단하는 방식입니다. 데이터를 보호하는 데 초점을 맞추며, 충돌 가능성이 높은 환경에서 주로 사용됩니다.

  • 락을 통해 데이터에 대한 읽기/쓰기를 제한하여 동시성 문제를 예방합니다.
  • 트랜잭션이 종료될 때까지 다른 트랜잭션은 해당 데이터에 접근할 수 없습니다.

특징

  1. 안정성
    • 데이터 손상 가능성이 높은 경우에 적합하며, 데이터를 보호하는 데 강력한 효과가 있습니다.
  2. 구현 방식
    • SELECT ... FOR UPDATE를 사용하여 데이터를 읽으면서 동시에 락을 걸어 수정이 완료될 때까지 접근을 차단합니다.
  3. 단점
    • 충돌 가능성이 낮은 환경에서는 불필요한 락으로 인해 성능 저하를 초래할 수 있습니다.
    • 데드락(Deadlock) 발생 위험이 있으며, 이는 서로 다른 트랜잭션이 상대방의 락 해제를 기다리며 교착 상태에 빠지는 상황입니다.

2.2 예제 : 비관적 락을 활용한 재고 관리

사용 시나리오

비관적 락은 여러 사용자가 동시에 동일한 데이터를 수정하려고 시도할 때 데이터 정합성을 유지하는 데 적합합니다.

특히, 상품 재고를 관리하는 상황에서 여러 사용자가 동시에 상품을 주문하면, 비관적 락을 활용하여 재고 부족 문제를 방지할 수 있습니다.

동작 원리

  • 데이터 수정 전에 SELECT ... FOR UPDATE 구문을 사용하여 데이터에 락을 걸어 다른 트랜잭션이 접근하지 못하도록 차단합니다.
  • 락이 해제될 때까지 다른 트랜잭션은 대기하거나 실패합니다.

예제: 재고 감소 시나리오

  1. 사용자가 상품을 주문하면 트랜잭션이 시작됩니다.
  2. SELECT ... FOR UPDATE를 사용해 상품의 재고 정보를 읽고 락을 겁니다.
  3. 재고를 감소시킨 후 트랜잭션을 커밋합니다.
  4. 다른 트랜잭션은 락이 해제될 때까지 대기합니다.
-- 트랜잭션 A: 재고 감소 처리
START TRANSACTION;
SELECT stock FROM products WHERE id = 101 FOR UPDATE; -- 해당 행에 Exclusive Lock 적용
UPDATE products SET stock = stock - 1 WHERE id = 101;
COMMIT;

-- 트랜잭션 B: 재고 접근 시도
START TRANSACTION;
SELECT stock FROM products WHERE id = 101 FOR UPDATE; -- 트랜잭션 A가 커밋될 때까지 대기
UPDATE products SET stock = stock - 1 WHERE id = 101;
COMMIT;

장점

  • 데이터 정합성을 강력히 보장하며, 충돌 상황에서도 안정적으로 작동합니다.

단점

  • 다른 트랜잭션이 락 해제를 기다려야 하므로 대기 시간이 발생합니다.
  • 충돌 가능성이 낮은 환경에서는 불필요한 자원 점유로 성능 저하를 초래할 수 있습니다.

2.3 낙관적 락(Optimistic Lock)

낙관적 락은 데이터에 락을 걸지 않고 트랜잭션을 수행한 뒤, 충돌 여부를 확인하여 필요한 경우 작업을 다시 수행하는 방식입니다. 충돌이 드물다고 가정하는 환경에서 효율적으로 작동합니다.

  • 데이터를 읽을 때는 락을 사용하지 않으며, 데이터를 수정할 때 버전 번호를 통해 충돌 여부를 판단합니다.
  • 충돌 발생 시, 트랜잭션은 롤백되고 작업을 다시 시도해야 합니다.

특징

  1. 효율성
    • 자원을 점유하지 않으므로 성능이 뛰어나며, 충돌 가능성이 낮은 환경에서 유용합니다.
  2. 구현 방식
    • 데이터 테이블에 version 컬럼을 추가하고, 데이터 수정 시 현재 버전 값과 일치하는지 확인합니다.
    • 버전 값이 변경되었으면 충돌이 발생한 것으로 간주하고 작업을 롤백합니다.
  3. 단점
    • 충돌이 빈번한 환경에서는 재시도가 반복되어 오히려 성능 저하가 발생할 수 있습니다.

2.4 예제 : 낙관적 락을 활용한 데이터 충돌 해결

사용 시나리오

낙관적 락은 데이터 수정 빈도가 낮고, 충돌 가능성이 적은 환경에서 주로 사용됩니다.

여러 사용자가 동시에 데이터를 수정하려고 해도 락을 걸지 않고 작업을 진행하며, 충돌 발생 시 버전 정보를 활용해 충돌을 감지하고 해결합니다.

동작 원리

  1. 데이터를 읽을 때는 락을 사용하지 않습니다.
  2. 데이터를 수정하기 전에 version 컬럼 값을 확인합니다.
  3. 현재 version 값이 읽었던 값과 동일하면 업데이트를 수행하고, version 값을 증가시킵니다.
  4. version 값이 다르면 충돌로 간주하고 작업을 롤백합니다.

예제: 버전 컬럼을 활용한 충돌 해결

  1. 테이블에 버전 컬럼 추가

    버전 번호를 관리하기 위해 테이블에 version 컬럼을 추가합니다.

    ALTER TABLE products ADD COLUMN version INT DEFAULT 0;
  2. 트랜잭션 A: 데이터 수정

    • 트랜잭션 A가 데이터를 읽고 version 값을 확인한 후 수정합니다.
    • 업데이트 시 version 값이 일치하면 수정에 성공하고, version 값을 증가시킵니다.
    START TRANSACTION;
    SELECT stock, version FROM products WHERE id = 101; -- stock: 10, version: 0
    
    UPDATE products 
    SET stock = stock - 1, version = version + 1 
    WHERE id = 101 AND version = 0;
    
    COMMIT;
  3. 트랜잭션 B: 동일 데이터 수정 시도

    • 트랜잭션 B가 동일 데이터를 수정하려 하지만, version 값이 변경되어 충돌이 발생합니다.
    • 작업은 롤백되고, 트랜잭션 B는 다시 데이터를 읽고 작업을 재시도해야 합니다.
    START TRANSACTION;
    SELECT stock, version FROM products WHERE id = 101; -- stock: 9, version: 1
    
    UPDATE products 
    SET stock = stock - 1, version = version + 1 
    WHERE id = 101 AND version = 0; -- 실패
    
    ROLLBACK;

장점

  • 자원을 점유하지 않으므로 성능이 우수하며, 충돌 가능성이 낮은 환경에서 효율적입니다.

단점

  • 충돌 발생 시 작업을 재시도해야 하므로 빈번한 충돌 환경에서는 오히려 성능이 저하될 수 있습니다.

    구분비관적 락 (Pessimistic Lock)낙관적 락 (Optimistic Lock)
    개념데이터를 선점하여 다른 트랜잭션의 접근을 차단충돌 가능성을 가정하며 작업 후 충돌 여부를 판단
    적용 환경충돌 가능성이 높은 환경충돌 가능성이 낮은 환경
    장점데이터 손상을 방지하며 안정성이 높음자원 점유 시간이 적고 성능이 뛰어남
    단점데드락 위험과 성능 저하 발생 가능충돌 발생 시 작업 재시도로 인해 성능 저하 가능
    구현 방식SELECT ... FOR UPDATE 사용버전 컬럼을 활용하여 충돌 여부 판단

비관적 락은 데이터 안정성이 중요한 환경에서, 낙관적 락은 충돌 가능성이 낮고 성능이 중요한 환경에서 적합하게 사용할 수 있습니다.


4. JPA에서의 락 종류와 사용 방법

Spring Data JPA 또는 일반 JPA에서는 비관적 락과 낙관적 락을 지원하며, 두 가지 락의 종류는 데이터 정합성을 보장하기 위해 상황에 따라 다르게 사용됩니다.

4.1 비관적 락 (Pessimistic Lock)

  • 비관적 락은 데이터를 수정할 때 다른 트랜잭션이 동시에 해당 데이터에 접근하는 것을 막기 위해 데이터베이스 수준에서 락을 겁니다.
  • 주로 동시성 충돌 가능성이 높은 환경에서 사용합니다.

비관적 락의 종류

락 종류설명SQL 문
PESSIMISTIC_READ다른 트랜잭션이 읽기는 허용하지만 수정은 허용하지 않음. SELECT ... LOCK IN SHARE MODE와 유사LOCK IN SHARE MODE
PESSIMISTIC_WRITE읽기와 쓰기 모두 차단하여 다른 트랜잭션이 해당 데이터에 접근하지 못하도록 막음. SELECT ... FOR UPDATE와 유사FOR UPDATE
PESSIMISTIC_FORCE_INCREMENT버전 필드를 강제로 증가시켜 낙관적 락과 유사하게 버전 충돌을 유도. 주로 버전 충돌 확인에 사용됨.(DB에 따라 다름)

비관적 락 사용 방법

예제: PESSIMISTIC_WRITE 사용

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;

import jakarta.persistence.LockModeType;

public interface ProductRepository extends JpaRepository<Product, Long> {

  @Lock(LockModeType.PESSIMISTIC_WRITE)
  @Query("SELECT p FROM Product p WHERE p.id = :id")
  Optional<Product> findByIdForUpdate(@Param("id") Long id);

  @Lock(LockModeType.PESSIMISTIC_WRITE)
  Optional<Product> findFirstByName(String name);    
}

설명:

  • @Lock(LockModeType.PESSIMISTIC_WRITE)는 데이터를 읽는 순간에 다른 트랜잭션이 해당 데이터를 수정하거나 읽지 못하도록 락을 겁니다.
  • 이 설정은 내부적으로 SELECT ... FOR UPDATE 쿼리를 생성합니다.

4.2 낙관적 락 (Optimistic Lock)

  • 낙관적 락은 데이터 충돌 가능성이 낮은 환경에서 사용되며, 버전 번호를 이용해 충돌을 감지합니다.
  • 트랜잭션이 데이터를 수정하기 전까지는 다른 트랜잭션의 접근을 제한하지 않습니다.
  • 주로 읽기 작업이 많고 쓰기 작업이 적은 시스템에서 사용됩니다.

낙관적 락의 종류

락 종류설명
OPTIMISTIC트랜잭션이 커밋될 때 버전 필드를 확인하여 충돌 여부를 검사함. 기본적인 낙관적 락 방식
OPTIMISTIC_FORCE_INCREMENT트랜잭션 시작 시 버전 필드를 강제로 증가시켜 이후 발생하는 모든 충돌을 감지함. 주로 데이터 갱신 시 명시적 충돌 확인
READ데이터를 읽을 때 버전을 검사하여 충돌을 방지하는 읽기 전용 락 (낙관적 읽기 락)

낙관적 락 사용 방법

1. 엔티티에 @Version 필드 추가

  • JPA는 @Version 필드를 이용해 버전을 관리하며, 이 필드가 있어야 낙관적 락이 작동합니다.
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private Integer stock;

    @Version  // 버전 필드 추가
    private Integer version;
}

2. 낙관적 락 설정 (@Lock)

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;

import jakarta.persistence.LockModeType;

public interface ProductRepository extends JpaRepository<Product, Long> {

  @Lock(LockModeType.OPTIMISTIC)
  Optional<Product> findFirstByNameOrderById(String name);
  
}

4.3 비관적 락 vs 낙관적 락 비교

구분비관적 락 (Pessimistic Lock)낙관적 락 (Optimistic Lock)
충돌 방지 방식데이터베이스 수준에서 즉시 락을 걸어 충돌을 방지버전 필드를 통해 충돌을 감지하고, 충돌 발생 시 예외를 던짐
동시성 성능동시성 성능이 낮을 수 있음 (특히 트랜잭션이 길어질 경우)동시성 성능이 더 우수 (충돌 발생 가능성이 낮은 환경에 적합)
사용 상황동시에 데이터를 수정하는 경우가 잦은 환경에서 사용읽기 작업이 많고 쓰기 작업이 적은 환경에서 사용
예외 처리트랜잭션이 대기할 수 있음 (다른 트랜잭션이 락을 해제할 때까지 기다림)버전 충돌 시 OptimisticLockException이 발생하며 재시도가 필요
주요 예제재고 관리, 은행 계좌 트랜잭션상품 구매 시 재고 확인, 다수의 유저가 동시에 문서를 편집하는 경우
SQL 쿼리SELECT ... FOR UPDATE, LOCK IN SHARE MODE와 유사내부적으로 버전 필드에 대한 비교 연산이 포함된 업데이트 쿼리 발생

4.4 비관적 락과 낙관적 락 선택 기준

상황권장 락설명
동시에 다수의 트랜잭션이 데이터를 수정하는 경우비관적 락 (Pessimistic)데이터 충돌 가능성이 높기 때문에 즉시 락을 걸어 충돌 방지
데이터 읽기 작업이 많고 쓰기 작업이 적은 경우낙관적 락 (Optimistic)충돌 가능성이 낮기 때문에 성능을 유지하면서 버전 관리를 통해 데이터 정합성 보장
트랜잭션 시간이 짧고 충돌 가능성이 거의 없는 경우낙관적 락 (Optimistic)충돌 가능성이 적기 때문에 굳이 락을 걸지 않아도 효율적으로 처리 가능
충돌 시 무조건 충돌을 피해야 하는 중요한 데이터 (예: 은행 계좌)비관적 락 (Pessimistic)무조건 데이터 충돌을 피해야 하기 때문에 즉각적인 데이터베이스 락이 필요함
  • 비관적 락: 즉시 데이터베이스에서 락을 걸어 다른 트랜잭션의 접근을 차단함으로써 충돌을 방지.
  • 낙관적 락: 데이터 수정 시점에 버전 필드를 비교하여 충돌을 감지하고, 충돌 발생 시 예외를 던져 재시도할 수 있도록 함.

상황에 따라 적절한 락을 선택하여 성능과 데이터 정합성을 모두 만족하는 설계를 하는 것이 중요합니다. 🚀


5. 실습 (Spring Boot)

5.1 비관적 락 실습: SELECT ... FOR UPDATE

비관적 락을 사용하여 동일한 데이터를 동시에 수정하려는 트랜잭션을 차단하고, 데이터 정합성을 유지합니다.

실습 내용

  • 트랜잭션 A에서 SELECT ... FOR UPDATE 구문을 사용하여 특정 데이터에 락을 겁니다.
  • 락이 걸린 동안 다른 트랜잭션은 해당 데이터에 접근할 수 없습니다.

ProductRepository에서 SELECT ... FOR UPDATE 사용

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import jakarta.persistence.LockModeType;

public interface ProductRepository extends JpaRepository<Product, Long> {

  @Lock(LockModeType.PESSIMISTIC_WRITE)
  @Query("SELECT p FROM Product p WHERE p.id = :id")
  Optional<Product> findByIdForUpdate(@Param("id") Long id);

  @Lock(LockModeType.PESSIMISTIC_WRITE)
  Optional<Product> findFirstByName(String name);
    
}

ProductService

@Service
@RequiredArgsConstructor
public class ProductLockService {

  private final ProductRepository productRepository;

  @Transactional
  public void updateStockWithPessimisticLock(Long productId, Integer quantity) {
    Product product = productRepository.findByIdForUpdate(productId)
        .orElseThrow(() -> new ServiceException(ServiceExceptionCode.NOT_FOUND_PRODUCT));

    if (product.getStock() < quantity) {
      throw new ServiceException(ServiceExceptionCode.OUT_OF_STOCK_PRODUCT);
    }

    product.setStock(product.getStock() - quantity);
    productRepository.save(product);
  }
}

ProductController

  @PatchMapping("/{id}/decrease")
  public ApiResponse<JSONObject> decreaseStock(@RequestBody ProductDecreaseStockRequest request) {
    productLockService.updateStockWithPessimisticLock(request.getProductId(),
        request.getDecreaseStock());
    return ApiResponse.Success(new JSONObject());
  }
@ToString
@Getter
@FieldDefaults(level = AccessLevel.PRIVATE)
public class ProductDecreaseStockRequest {

  Long productId;

  Integer decreaseStock;

}

ProductPessimisticLockTest

@SpringBootTest
class ProductLockServiceTest {

  @Autowired
  private ProductLockService productLockService;

  @Autowired
  private ProductRepository productRepository;

  @Test
  public void updateStockWithPessimisticLock() throws InterruptedException {
    //given
    Long productId = 101L;
    int threadCount = 2;

    Product firstProduct = productRepository.findById(productId).orElseThrow();

    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    //when
    for (int i = 0; i < threadCount; i++) {
      executorService.submit(() -> {
        try {
          productLockService.updateStockWithPessimisticLock(productId, 1);
        } finally {
          latch.countDown();
        }
      });
    }

    latch.await();  // 모든 스레드가 작업을 마칠 때까지 대기

    //then
    Product product = productRepository.findById(productId).orElseThrow();
    Assertions.assertThat(product.getStock()).isEqualTo(firstProduct.getStock() - 2);
  }
}
  1. 트랜잭션 A: 데이터 조회 및 수정
    • 트랜잭션 A에서 데이터를 SELECT ... FOR UPDATE로 락 걸고 수정합니다.
  2. 트랜잭션 B: 동일 데이터 접근 시도
    • 트랜잭션 A가 커밋하기 전까지 트랜잭션 B는 대기 상태에 놓입니다.
    • 트랜잭션 A가 작업을 완료하고 커밋하면 트랜잭션 B가 실행됩니다.

5.2 낙관적 락 실습: 버전 컬럼 활용

낙관적 락을 사용하여 충돌 발생 시 버전 검사를 통해 데이터를 안전하게 수정하고, 재시도를 통해 충돌을 해결합니다.

실습 내용

테이블 구조 변경

  • 낙관적 락을 구현하기 위해 version 컬럼을 추가합니다.
ALTER TABLE products ADD COLUMN version INT DEFAULT 0;

Product 엔티티에 버전 필드 추가

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private Integer stock;

    @Version  // 낙관적 락을 위한 버전 관리
    private Integer version;
}

ProductOptimisticLockService

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductOptimisticLockService {

    private final ProductRepository productRepository;

    public ProductOptimisticLockService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

  @Transactional
  public void updateStockWithOptimisticLock(String name, int quantity) {
    Product product = productRepository.findFirstByNameOrderById(name)
        .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다."));

    if (product.getStock() < quantity) {
      throw new ServiceException(ServiceExceptionCode.OUT_OF_STOCK_PRODUCT);
    }

    product.setStock(product.getStock() - quantity);
    productRepository.save(product);
  }
}

ProductOptimisticLockTest

@SpringBootTest
class ProductLockServiceTest {

  @Autowired
  private ProductLockService productLockService;

  @Autowired
  private ProductRepository productRepository;

  @Test
  public void updateStockWithOptimisticLock() throws InterruptedException {
    //given
    Long productId = 101L;
    int threadCount = 2;

    Product firstProduct = productRepository.findById(productId).orElseThrow();

    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    for (int i = 0; i < threadCount; i++) {
      executorService.submit(() -> {
        try {
          productLockService.updateStockWithOptimisticLock(firstProduct.getName(), 1);
        } catch (Exception e) {
          System.out.println("낙관적 락 충돌 발생: " + e.getMessage());
        } finally {
          latch.countDown();
        }
      });
    }

    latch.await();  // 모든 스레드가 작업을 마칠 때까지 대기

    Product product = productRepository.findById(productId).orElseThrow();
    Assertions.assertThat(product.getStock())
        .isBetween(firstProduct.getStock() - 2, firstProduct.getStock() - 1); // 하나의 트랜잭션만 성공할 수도 있음
  }
}
  1. 트랜잭션 A: 데이터 조회 및 수정
    • 트랜잭션 A는 데이터를 읽고, 버전이 일치하는지 확인한 후 업데이트합니다.

결과

  1. 비관적 락: 트랜잭션 A가 FOR UPDATE로 락을 건 상태에서는 트랜잭션 B가 대기합니다.
  2. 낙관적 락: 트랜잭션 B는 버전 불일치로 인해 실패하고 재시도를 통해 정합성을 유지합니다.
profile
기록하고 소화해보자! 소화가 안되거나 까먹으면 다시 꺼내서 보자! 오늘의 나는 어제의 나보다 강하다!

0개의 댓글