락(Lock)은 데이터베이스에서 동시 실행되는 트랜잭션 간의 충돌을 방지하고, 데이터 정합성을 보장하기 위해 특정 자원(테이블, 행 등)에 대한 접근을 제어하는 메커니즘입니다.
예시
여러 사용자가 동시에 상품을 주문하는 상황을 가정해봅시다.
1. 동시성 제어
락은 여러 트랜잭션이 동시에 실행될 때 데이터의 일관성과 정확성을 보장합니다.
2. 데이터 정합성 유지
락을 통해 충돌 상황에서도 데이터가 항상 일관된 상태를 유지할 수 있습니다.
3. 시스템 안정성 보장
락은 트랜잭션의 원자성을 보장하여 작업 도중 발생할 수 있는 오류를 최소화합니다.
비관적 락은 데이터에 접근하기 전에 해당 자원을 선점하여 다른 트랜잭션이 접근하지 못하도록 차단하는 방식입니다. 데이터를 보호하는 데 초점을 맞추며, 충돌 가능성이 높은 환경에서 주로 사용됩니다.
특징
SELECT ... FOR UPDATE를 사용하여 데이터를 읽으면서 동시에 락을 걸어 수정이 완료될 때까지 접근을 차단합니다.사용 시나리오
비관적 락은 여러 사용자가 동시에 동일한 데이터를 수정하려고 시도할 때 데이터 정합성을 유지하는 데 적합합니다.
특히, 상품 재고를 관리하는 상황에서 여러 사용자가 동시에 상품을 주문하면, 비관적 락을 활용하여 재고 부족 문제를 방지할 수 있습니다.
동작 원리
SELECT ... FOR UPDATE 구문을 사용하여 데이터에 락을 걸어 다른 트랜잭션이 접근하지 못하도록 차단합니다.예제: 재고 감소 시나리오
SELECT ... FOR UPDATE를 사용해 상품의 재고 정보를 읽고 락을 겁니다.-- 트랜잭션 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;
장점
단점
낙관적 락은 데이터에 락을 걸지 않고 트랜잭션을 수행한 뒤, 충돌 여부를 확인하여 필요한 경우 작업을 다시 수행하는 방식입니다. 충돌이 드물다고 가정하는 환경에서 효율적으로 작동합니다.
특징
version 컬럼을 추가하고, 데이터 수정 시 현재 버전 값과 일치하는지 확인합니다.사용 시나리오
낙관적 락은 데이터 수정 빈도가 낮고, 충돌 가능성이 적은 환경에서 주로 사용됩니다.
여러 사용자가 동시에 데이터를 수정하려고 해도 락을 걸지 않고 작업을 진행하며, 충돌 발생 시 버전 정보를 활용해 충돌을 감지하고 해결합니다.
동작 원리
version 컬럼 값을 확인합니다.version 값이 읽었던 값과 동일하면 업데이트를 수행하고, version 값을 증가시킵니다.version 값이 다르면 충돌로 간주하고 작업을 롤백합니다.예제: 버전 컬럼을 활용한 충돌 해결
테이블에 버전 컬럼 추가
버전 번호를 관리하기 위해 테이블에 version 컬럼을 추가합니다.
ALTER TABLE products ADD COLUMN version INT DEFAULT 0;
트랜잭션 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;
트랜잭션 B: 동일 데이터 수정 시도
version 값이 변경되어 충돌이 발생합니다.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 사용 | 버전 컬럼을 활용하여 충돌 여부 판단 |
비관적 락은 데이터 안정성이 중요한 환경에서, 낙관적 락은 충돌 가능성이 낮고 성능이 중요한 환경에서 적합하게 사용할 수 있습니다.
Spring Data JPA 또는 일반 JPA에서는 비관적 락과 낙관적 락을 지원하며, 두 가지 락의 종류는 데이터 정합성을 보장하기 위해 상황에 따라 다르게 사용됩니다.
비관적 락의 종류
| 락 종류 | 설명 | 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 쿼리를 생성합니다.낙관적 락의 종류
| 락 종류 | 설명 |
|---|---|
| OPTIMISTIC | 트랜잭션이 커밋될 때 버전 필드를 확인하여 충돌 여부를 검사함. 기본적인 낙관적 락 방식 |
| OPTIMISTIC_FORCE_INCREMENT | 트랜잭션 시작 시 버전 필드를 강제로 증가시켜 이후 발생하는 모든 충돌을 감지함. 주로 데이터 갱신 시 명시적 충돌 확인 |
| READ | 데이터를 읽을 때 버전을 검사하여 충돌을 방지하는 읽기 전용 락 (낙관적 읽기 락) |
낙관적 락 사용 방법
1. 엔티티에 @Version 필드 추가
@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);
}
| 구분 | 비관적 락 (Pessimistic Lock) | 낙관적 락 (Optimistic Lock) |
|---|---|---|
| 충돌 방지 방식 | 데이터베이스 수준에서 즉시 락을 걸어 충돌을 방지 | 버전 필드를 통해 충돌을 감지하고, 충돌 발생 시 예외를 던짐 |
| 동시성 성능 | 동시성 성능이 낮을 수 있음 (특히 트랜잭션이 길어질 경우) | 동시성 성능이 더 우수 (충돌 발생 가능성이 낮은 환경에 적합) |
| 사용 상황 | 동시에 데이터를 수정하는 경우가 잦은 환경에서 사용 | 읽기 작업이 많고 쓰기 작업이 적은 환경에서 사용 |
| 예외 처리 | 트랜잭션이 대기할 수 있음 (다른 트랜잭션이 락을 해제할 때까지 기다림) | 버전 충돌 시 OptimisticLockException이 발생하며 재시도가 필요 |
| 주요 예제 | 재고 관리, 은행 계좌 트랜잭션 | 상품 구매 시 재고 확인, 다수의 유저가 동시에 문서를 편집하는 경우 |
| SQL 쿼리 | SELECT ... FOR UPDATE, LOCK IN SHARE MODE와 유사 | 내부적으로 버전 필드에 대한 비교 연산이 포함된 업데이트 쿼리 발생 |
| 상황 | 권장 락 | 설명 |
|---|---|---|
| 동시에 다수의 트랜잭션이 데이터를 수정하는 경우 | 비관적 락 (Pessimistic) | 데이터 충돌 가능성이 높기 때문에 즉시 락을 걸어 충돌 방지 |
| 데이터 읽기 작업이 많고 쓰기 작업이 적은 경우 | 낙관적 락 (Optimistic) | 충돌 가능성이 낮기 때문에 성능을 유지하면서 버전 관리를 통해 데이터 정합성 보장 |
| 트랜잭션 시간이 짧고 충돌 가능성이 거의 없는 경우 | 낙관적 락 (Optimistic) | 충돌 가능성이 적기 때문에 굳이 락을 걸지 않아도 효율적으로 처리 가능 |
| 충돌 시 무조건 충돌을 피해야 하는 중요한 데이터 (예: 은행 계좌) | 비관적 락 (Pessimistic) | 무조건 데이터 충돌을 피해야 하기 때문에 즉각적인 데이터베이스 락이 필요함 |
상황에 따라 적절한 락을 선택하여 성능과 데이터 정합성을 모두 만족하는 설계를 하는 것이 중요합니다. 🚀
비관적 락을 사용하여 동일한 데이터를 동시에 수정하려는 트랜잭션을 차단하고, 데이터 정합성을 유지합니다.
실습 내용
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);
}
}
SELECT ... FOR UPDATE로 락 걸고 수정합니다.낙관적 락을 사용하여 충돌 발생 시 버전 검사를 통해 데이터를 안전하게 수정하고, 재시도를 통해 충돌을 해결합니다.
실습 내용
테이블 구조 변경
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); // 하나의 트랜잭션만 성공할 수도 있음
}
}
결과
FOR UPDATE로 락을 건 상태에서는 트랜잭션 B가 대기합니다.