이번 프로젝트는 분산 환경 즉, 서버가 여러대인 환경이다!
이전 프로젝트에서 사용한 ReentrantLock과 ConcurrentHashMap으로는 더이상 동시성 제어가 불가능하다.
얘네들은 단일 JVM 내의 메모리에서만 동작하기 때문에, 서버가 여러 대인 분산환경에서는 각 서버의 JVM이 독립적으로 동작하여
한 서버의 락이 다른 서버에는 전혀 영향을 미치지 못한다.
서버A: ReentrantLock lock = new ReentrantLock();
서버B: ReentrantLock lock = new ReentrantLock();
위와 같이 각 서버는 독립적인 락 인스턴스를 가지고, 한 서버에서의 락 호출이 다른 서버에 전파되지 않는다.
=> 동시성을 제어하는 새로운 방식이 필요하다.
여러 대의 서버가 한 자원에 접근 할 때 데이터베이스
가 중앙에서 락을 관리하므로 모든 서버에 일관되게 적용이 가능하다.
데이터베이스로 락을 제어하는 방식은 크게 낙관적 락, 비관적 락 두가지가 있다.
낙관적 락은 동시성 문제가 많이 발생하지 않을 것으로 보일 때 사용한다.
하헌우 멘토님의 말씀 : 낙관적 락은 실패해도 되는 경우에 사용한다.
락 획득을 위한 대기 시간은 없지만 충돌 시 롤백 후 재시도하며 이로 인해 성능이 저하된다.
주로 version이라는 컬럼을 이용하여 조회 시점에 version 값을 함께 읽어오고, 수정을 시도할 때 where 절에 version 조건을 포함한다.
다른 트랜잭션이 먼저 수정하면 version이 증가하여 where 조건이 불일치하여 실패하게 되는 로직이다.
[최초 상태]
ID: 1, currentCount: 29, version: 1
트랜잭션 A: SELECT * FROM schedule WHERE id = 1; // version = 1
트랜잭션 B: SELECT * FROM schedule WHERE id = 1; // version = 1
트랜잭션 A: UPDATE schedule SET current_count = 30, version = 2 WHERE id = 1 AND version = 1; // 성공
트랜잭션 B: UPDATE schedule SET current_count = 30, version = 2 WHERE id = 1 AND version = 1; // 실패
읽기가 많고 쓰기가 적은 경우, 데이터 충돌 가능성이 낮은 경우, 긴 대기 시간이 허용되지 않는 경우(예시: 게시글 수정, 개인정보 업데이트 등) 낙관적 락을 사용한다.
<!-- 버전 정보를 포함한 테이블 구조 -->
CREATE TABLE product (
id BIGINT PRIMARY KEY,
name VARCHAR(255),
stock INT,
version INT
);
<!-- Mapper XML -->
<update id="updateStock">
UPDATE product
SET stock = #{newStock},
version = version + 1
WHERE id = #{id}
AND version = #{currentVersion}
</update>
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
@Transactional
public void updateStock(Long id, int newStock, int currentVersion) {
int updatedRows = productMapper.updateStock(id, newStock, currentVersion);
if (updatedRows == 0) {
throw new OptimisticLockException("데이터가 이미 변경되었습니다");
}
}
}
@Entity
public class Product {
@Id
private Long id;
private String name;
private int stock;
@Version
private int version; // @Version 애노테이션만 추가하면 JPA가 자동으로 처리
}
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
@Transactional
public void updateStock(Long id, int newStock) {
Product product = productRepository.findById(id).orElseThrow();
product.setStock(newStock);
// 저장 시점에 버전이 다르면 OptimisticLockException 발생
productRepository.save(product);
}
}
비관적 락은 동시성 문제가 많이 발생할 것으로 예측될 때 사용된다.
하헌우 멘토님의 말씀 : 비관적 락은 반드시 성공하길 원할 때 사용한다.
예를 들어, 결제 상황 같은 경우.
유저도 결제를 원하고 회사도 결제를 원하겠지? => 반드시 성공. 비관적 락 쓴다.
그럼 언제나 비관적 락 쓰면 되는거 아니야? 비관적 락은 디비 부하가 높아짐.
앞에 사용 중인 트랜잭션(락)이 있다면, 그것이 끝날 때까지 영원히 대기한다.
조회 시점에 해당 레코드(동시에 접근할 확률이 높은 자원)에 대한 배타적 락을 획득하고, 트랜잭션이 완료될 때까지 다른 트랜잭션의 접근을 차단한다.
락 획득을 시도하는 다른 트랜잭션은 대기 상태이다.
// 주요 LockMode 유형
PESSIMISTIC_READ // 공유 락 (다른 트랜잭션의 읽기는 허용)
PESSIMISTIC_WRITE // 배타적 락 (읽기/쓰기 모두 차단)
PESSIMISTIC_FORCE_INCREMENT // 배타적 락 + 버전 증가
비관적 락은 높은 동시성 + 데이터 정합성이 중요한 경우 사용된다.
ex) 좌석 예약 시스템, 재고 관리, 특강/수강 신청, 포인트/머니 차감
외부 API 호출 등으로 긴 시간이 소요되거나 읽기가 많은 경우에는 비관적 락을 사용하는 것은 적절하지 않다.
이 프로젝트에서는 특강 신청 service의 findScheduleWithLockById()
부분에 PESSIMISTIC_WRITE
를 사용하여 비관적 락을 적용하였다.
@Transactional
public RegisterInfo register(RegisterCommand command) {
// 1. 락 획득과 동시에 데이터 조회
Schedule schedule = scheduleRepository.findScheduleWithLockById(command.getScheduleId())
.orElseThrow(() -> new IllegalArgumentException("해당 스케줄이 존재하지 않습니다."));
// 2. 안전한 검증
if (schedule.isCapacityFull()) {
throw new CapacityExceededException("정원이 초과되었습니다.");
}
// 3. 안전한 수정
schedule.increaseCurrentCount();
}
public Optional<Schedule> findScheduleWithLockById(Long scheduleId) {
Schedule result = queryFactory
.selectFrom(schedule)
.where(schedule.id.eq(scheduleId))
.setLockMode(LockModeType.PESSIMISTIC_WRITE) //비관적 락
.fetchOne();
return Optional.ofNullable(result);
}
}
PESSIMISTIC_READ를 적용하면 어떻게 되냐고?
// 동시 접근 시나리오 (currentCount가 29인 상황)
Transaction A: SELECT * FROM schedule ... (공유 락, currentCount = 29 확인)
Transaction B: SELECT * FROM schedule ... (공유 락, currentCount = 29 확인)
// 두 트랜잭션 모두 29 < 30 조건을 통과하고 등록을 시도
Transaction A: UPDATE schedule SET current_count = 30 ...
Transaction B: UPDATE schedule SET current_count = 31 ... // 정원 초과 발생!
읽기를 허용하기 때문에 여러 트랜잭션이 동시에 29명이라고 읽을 수 있고, 결과적으로 정원인 30명을 초과하는 상황이 발생할 수 있다.
// 동시 접근 시나리오 (currentCount가 29인 상황)
Transaction A: SELECT * FROM schedule ... FOR UPDATE (배타적 락 획득)
Transaction B: 락 획득 대기...
Transaction A: currentCount 확인 및 증가 후 커밋
Transaction B: 락 획득 -> currentCount가 30임을 확인 -> CapacityExceededException 발생
PESSIMISTIC_WRITE을 사용하면 읽는 시점부터 배타적 락을 획득하여 다른 트랜잭션의 접근을 원천 차단하고, 정원 검사와 증가가 원자적으로 실행될 수 있다.
이렇게 비관적 락을 적용함으로써, Race Condition 방지, 데이터 정합성(30명 정원 초과 방지 보장), Lost Update 방지(동시 수정으로 인한 데이터 손실 방지), 트랜잭션 격리(다른 트랜잭션의 간섭 차단)을 이룰 수 있다.
이 프로젝트는 정확히 30명 정원을 맞춰야하고, 이에 따라 동시 접근이 많을 수 있으며, 데이터 정합성이 사용자 경험보다 중요하므로 비관적 락을 선택하였다.
<!-- Mapper XML -->
<select id="selectForUpdate">
SELECT *
FROM product
WHERE id = #{id}
FOR UPDATE
</select>
<update id="updateStock">
UPDATE product
SET stock = #{newStock}
WHERE id = #{id}
</update>
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
@Transactional
public void updateStock(Long id, int newStock) {
Product product = productMapper.selectForUpdate(id);
productMapper.updateStock(id, newStock);
}
}
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Product findByIdForUpdate(@Param("id") Long id);
}
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
@Transactional
public void updateStock(Long id, int newStock) {
Product product = productRepository.findByIdForUpdate(id);
product.setStock(newStock);
productRepository.save(product);
}
}
트랜잭션 락과 db 락은 데이터 일관성을 보장하기 위한 메커니즘이지만 다른 목적과 사용 사례를 가진다.
목적 : 동시에 실행되는 트랜잭션들 사이에서 한 트랜잭션에서 변경 중인 데이터를 다른 트랜잭션에서 볼 수 있는지 여부를 제어한다.
같은 트랜잭션 내에서 같은 데이터를 두번 읽었을 때 결과가 다른 현상.
같은 행의 데이터가 변경되는 현상
// Transaction 1 (READ COMMITTED)
@Transactional
public void checkBalance() {
// 첫 번째 조회: 1000원
int balance1 = account.getBalance();
// 이 시점에 Transaction 2에서 금액을 수정하고 커밋
// 두 번째 조회: 2000원
int balance2 = account.getBalance();
// 같은 트랜잭션인데 다른 금액이 조회됨
}
// Transaction 2
@Transactional
public void updateBalance() {
account.setBalance(2000); // 1000원 → 2000원
// 커밋
}
같은 트랜잭션 내에서 같은 조회 쿼리를 실행했을 때 이전에 없던 레코드가 나타나거나 있던 레코드가 사라지는 현상
조회 결과에 행이 추가되거나 삭제되는 현상
// Transaction 1 (REPEATABLE READ)
@Transactional
public void countAccounts() {
// 첫 번째 조회: 10개
List<Account> accounts1 = accountRepository.findAll();
// 이 시점에 Transaction 2에서 새 계좌 추가하고 커밋
// 두 번째 조회: 11개
List<Account> accounts2 = accountRepository.findAll();
// 레코드 개수가 달라짐 (Phantom Read)
}
// Transaction 2
@Transactional
public void createAccount() {
accountRepository.save(new Account());
// 커밋
}
@Transactional
만 붙였을 때, 사용하는 DB의 기본 격리 수준을 따른다.
특정 데이터에 대한 동시 접근을 제어
범위
목적
성능영향
데이터 읽기의 일관성이 중요하다면 → 트랜잭션 격리 수준 조정
동시 수정을 방지해야 한다면 → DB 락 사용
DB 락은 다른 트랜잭션도 차단. 다른 트랜잭션이라 함은, 같은 서버의 다른 스레드, 다른 서버의 다른 프로세스 모두 포함.
스레드 하나에도 여러개의 트랜잭션을 가질 수 있다.(ex. required_new).
하나의 트랜잭션은 여러 스레드에 걸쳐 존재할 수 없다.
트랜잭션은 항상 하나의 스레드에 종속된다. (여러 스레드가 하나의 트랜잭션을 공유할 수 없다.)
트랜잭션 격리와, db락을 함께 쓸 때
특히 비관적 락을 쓴다면 해당 트랜잭션이 커밋 또는 롤백될 때 자동으로 해제된다.(트랜잭션의 범위와 같다)
동시성 테스트는 실제 DB나 Redis를 사용하여 통합테스트로 수행하는 것이 좋다. Mock으로는 실제 동시성 문제를 시뮬레이션하기 어렵다. DB나 레디스의 실제 동작을 mock으로 시뮬레이션하기가 어려운데 동시에 발생하는 여러 요청을 시뮬레이션 하기 어렵고, DB 락 메커니즘을 시뮬레이션할 수 없고, 실제 트랜잭션 동작을 재현할 수 없다. => TestContainers등을 사용한 통합 테스트 혹인 실제 DB를 사용한 테스트의 방법이 좋다.