프로젝트를 진행함에 있어 동시성 문제는 항상 해결해야 되는 부분이라고 생각합니다.
이번에 동시성 제어에 대해서 알아보려고 합니다.
동시성 제어에 들어가기 앞서 일단 스레드와 멀티스레드 개념부터 잡고 들어가 보겠습니다.
스레드 & 멀티스레드
스레드란?
- 프로세스의 실행 가능한 가장 작은 단위입니다.
멀티 스레드란?
- 한 프로세스 내에서 여러 스레드를 동시에 실행하는 것입니다.
멀티스레딩은 결국 멀티 스레드 환경을 구현하고, 관리하는 기법 또는 개념으로서 프로세스 내 작업을 여러 개의 스레드, 멀티 스레드로 처리하는 기법입니다.
트랜잭션
트랜잭션은 DBMS에서 데이터를 다루는 논리적인 작업의 단위를 의미합니다. 쉽게 말해, 여러 개의 데이터베이스 작업을 하나의 단일 작업처럼 처리하는 것을 말합니다.
여기서 중요한 점은, 만약 하나라도 실패한다면? 전체 작업을 취소해야 하는데 이런 전체 과정을 하나의 트랜잭션으로 처리하기도 합니다.
동시성 문제
보통 동시성은 여러 사용자가 있는 환경에서 두 개 이상의 트랜잭션이 동시에 실행될 때, 데이터의 일관성을 유지하지 못해서 생기는 문제입니다.
동시성 문제의 대표적인 예는 보통 아래와 같습니다.
1. 은행 계좌 이체 문제
2. 재고 감소 시스템
3. 웹 페이지 조회수 증가 문제
4. 온라인 투표 시스템 문제
가령, 같은 계좌에서 이체를 시도할 때, 동시성 제어가 없으면 계좌의 잔액이 잘 못 처리될 수 있습니다. 두 사용자가 동시에 돈을 인출했을 시 계좌의 잔액이 정확하게 반영되지 않게 되는 문제가 발생할 수 있습니다.
동시성 문제 해결하는 방법
1. Synchronized
@Synchronized 어노테이션을 활용해서 동시성 문제를 해결할 수 있습니다.
보통 메서드에 어노테이션을 붙이면, 해당 메서드가 여러 스레드에 의해 동시에 호출될 때, 스레드 간의 경합을 방지하여 동기화된 상태로 실행됩니다.
기본적으로 인스턴스 레벨에서 동기화가 이루어지며, static 메서드에 붙이면? 클래스 레벨의 동기화가 이루어집니다.
보통 Synchronized는 다중 스레드에서 동시에 값을 증가시키거나, 데이터베이스를 업데이트하는 경우에 많이 사용됩니다.
사용 예시는 아래와 같습니다.
@Synchronized
public Boolean decrease(Long idx, Long quantity) throws InterruptedException {
Stock stock = stockRepository.findById(idx).orElseThrow();
stockRepository.save(stock);
return checkEquals(0, stock.getQuantity());
}
2. 비관적 락
말 그대로 데이터 충돌 가능성을 비관적으로 보는 방식입니다.
데이터에 대한 충돌이 자주 발생할 가능성이 있는 경우, 충돌을 사전에 방지하기 위해 데이터에 락을 걸어 다른 트랜잭션이나 스레드가 해당 데이터를 수정하지 못하도록 하는 방법입니다.
트랜잭션이 끝나고 락이 해제될 때까지 다른 트랜잭션은 대기하거나, 데이터에 대한 접근이 거부됩니다.
* 비관적 락의 두 가지 유형
1. 읽기 락(Shared Lock)
- 데이터는 여러 트랜잭션이 동시에 읽을 수 있지만, 수정할 수는 없습니다.
- 읽기 락이 걸려 있는 동안, 다른 트랜잭션은 해당 데이터를 읽을 수만 있으며, 수정하려면 락이 해제될 때까지 대기해야 합니다.
@Service
public class StockService {
private final StockRepository stockRepository;
public StockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public Stock getStockWithPessimisticReadLock(Long stockId) {
// 비관적 읽기 락을 걸어 데이터 조회
Optional<Stock> stockOptional = stockRepository.findById(stockId);
if (stockOptional.isPresent()) {
return stockOptional.get();
} else {
throw new RuntimeException("Stock not found");
}
}
}
public interface StockRepository extends JpaRepository<Stock, Long> {
// 비관적 읽기 락 (Pessimistic Read)
@Lock(LockModeType.PESSIMISTIC_READ)
Optional<Stock> findById(Long id);
}
2. 쓰기 락(Exclusive Lock)
- 데이터는 한 트랜잭션만이 읽기 및 수정이 가능합니다.
- 쓰기 락이 걸려있는 데이터는 다른 트랜잭션에서 읽기조차 불가능하며, 이 또한 락이 해제될 때까지 기다려야 합니다.
@Service
public class StockService {
private final StockRepository stockRepository;
public StockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public Stock decreaseStock(Long stockId, int quantity) {
// 비관적 쓰기 락을 걸어 데이터 조회 및 업데이트
Optional<Stock> stockOptional = stockRepository.findById(stockId);
if (stockOptional.isPresent()) {
Stock stock = stockOptional.get();
if (stock.getQuantity() >= quantity) {
stock.setQuantity(stock.getQuantity() - quantity);
return stockRepository.save(stock);
} else {
throw new RuntimeException("Not enough stock available");
}
} else {
throw new RuntimeException("Stock not found");
}
}
}
public interface StockRepository extends JpaRepository<Stock, Long> {
// 비관적 쓰기 락 (Pessimistic Write)
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Stock> findById(Long id);
}
3. 낙관적 락
데이터 충돌이 자주 발생하지 않을 것이라는 낙관적인 가정으로 동작합니다.
데이터 충돌을 사전에 방지하지 않고 트랜잭션이 끝나는 시점에 데이터가 변경되었는지 확인하여, 충돌이 발생했을 경우에만 예외를 던집니다.
주로 버전 관리 방식을 통해 구현되며, JPA에서는 엔티티에 @Version 필드를 사용하여 이를 관리합니다.
낙관적 락 동작 원리
- 각 엔티티에는 버전을 부여하고, 트랜잭션이 데이터를 수정할 때마다 버전을 증가시킵니다.
- 트랜잭션이 끝날 때, 해당 엔티티의 버전 번호가 기존과 동일한지 확인합니다.
- 동일하다면 변경이 없는 것이므로 수정 성공
- 다르다면 다른 트랜잭션이 데이터를 수정한 상태이므로, 충돌이 발생했다 판단 -> 예외를 발생시킵니다.
아래와 같이 엔티티에 버전을 추가해 줍니다.
@Entity
public class Stock {
...
// 낙관적 락을 위한 버전 필드
@Version
private Integer version;
...
}
Service
@Service
public class StockService {
private final StockRepository stockRepository;
public StockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public Stock decreaseStock(Long stockId, int quantity) {
// 낙관적 락을 통한 데이터 수정
Stock stock = stockRepository.findById(stockId)
.orElseThrow(() -> new RuntimeException("Stock not found"));
if (stock.getQuantity() >= quantity) {
stock.setQuantity(stock.getQuantity() - quantity);
return stockRepository.save(stock); // 트랜잭션이 종료될 때 버전 검증이 이루어짐
} else {
throw new RuntimeException("Not enough stock available");
}
}
}
repository는 별도의 락을 걸 필요가 없어, 엔티티의 버전 필드를 통해 자동으로 관리됩니다.
낙관적 락의 동작 과정
1. 엔티티 조회
- 재고 데이터를 조회합니다.
2. 업데이트
- 재고 수량을 업데이트하고, save() 메서드를 통해 저장합니다.
3. 버전 검증
- 트랜잭션이 끝나는 시점에 JPA는 @Version 필드를 확인합니다.
- 저장된 엔티티의 버전 번호가 데이터베이스의 버전 번호와 일치하는지 확인합니다.
- 일치하면 성공적으로 커밋되고, 버전이 증가합니다.
- 일치하지 않는다면, OptimisticLockException을 던집니다.
각 락 방식의 직관적인 비교
특징 | 낙관적 락 | 비관적 락 |
충돌 발생 가능성 | 충돌이 드물 것이라고 가정 | 충돌이 자주 발생할 것으로 가정 |
락 시점 | 트랜잭션 종료 시 버전 검증 | 트랜잭션 시작 시점에서 바로 락 |
성능 | 락을 사용하지 않아서 성능에 유리 | 락 대기로 인해 성능 저하 발생 가능 |
충돌 처리 방법 | 충돌 시 예외를 던지고 처리 | 충돌 방지를 위해 락을 걸고 대기 |
이처럼 각 상황에 맞게 충돌 가능성과 성능의 중요성을 고려해서 낙관적, 비관적, 기타 다른 방식을 활용해서 동시성 처리를 해결할 수 있습니다.
추후, 시스템의 특성에 따라 선택할 수 있는, 보다 폭넓은 동시성 제어 방법을 다뤄보겠습니다.
'Daily' 카테고리의 다른 글
모놀리식 프로젝트 DDD 패턴으로 전환하기 (1) | 2024.11.10 |
---|---|
무중단 배포란? (0) | 2024.10.21 |
Spring Boot - 이메일(SMTP)비동기 전송 (0) | 2024.09.29 |
VMware Ubuntu가상환경 Jenkins 설치 (3) | 2024.09.02 |
git pull error (0) | 2024.08.25 |