동시성 제어는 여러 사용자나 프로세스가 동시에 같은 데이터에 접근하고 수정할 때 발생할 수 있는 문제를 관리하는 기술이다.
여러 사용자가 동시에 같은 데이터를 수정할 때 발생하는 문제이다.
예를 들어) 영화관에서 10명이 동시에 같은 좌석을 예매하려고 시도, 쇼핑몰에서 재고가 1개인 상품을 여러 명이 동시에 구매 시도
두 개 이상의 프로세스가 공유 자원을 동시에 조작할 때, 실행 순서에 따라 결과가 달라지는 상황이다.
예시 상황:
1. 좌석 수가 1개 남았을 때
2. 사용자 A와 B가 동시에 예약 가능 여부를 확인 (둘 다 "가능" 결과를 받음)
3. 사용자 A와 B 모두 예약 처리를 진행
4. 결과: 1개 좌석에 2개의 예약이 생성됨 (데이터 무결성 손상)
@Entity
public class Reservation {
@Id
private Long id;
private int seatCount;
@Version // 이 필드가 버전 관리에 사용됨
private Long version;
// 다른 필드와 메서드들...
}
JPA에서는 이렇게 구현 된 엔티티를 수정할 때, 트랜잭션 커밋 시점에 버전을 확인한다. 만약 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException이 발생한다.
// Repository 메서드
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM Reservation r WHERE r.id = :id")
Optional<Reservation> findByIdWithLock(@Param("id") Long id);
// 서비스에서 사용
@Transactional
public void updateSeatCount(Long id, int newCount) {
// 읽는 시점에 락을 획득
Reservation reservation = reservationRepository.findByIdWithLock(id)
.orElseThrow(() -> new EntityNotFoundException());
reservation.setSeatCount(newCount);
// 트랜잭션이 끝날 때 락이 해제됨
}
Redis를 이용한 분산 락 예시(Redisson 라이브러리 사용)
@Service
public class ReservationService {
private final RedissonClient redissonClient;
private final ReservationRepository reservationRepository;
@Transactional
public Reservation createReservation(ReservationDto dto) {
// 락 키 생성 (보통 특정 리소스의 식별자를 사용)
String lockKey = "reservation:store:" + dto.getStoreId() + ":time:" + dto.getReservationTime();
RLock lock = redissonClient.getLock(lockKey);
try {
// 락 획득 시도 (10초 대기, 30초 유지)
boolean isLocked = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (!isLocked) {
throw new RuntimeException("락 획득 실패");
}
// 락을 획득한 후 비즈니스 로직 실행
int currentReservations = reservationRepository.countByStoreAndTime(...);
if (seatExceeded(currentReservations, dto.getPeopleCount())) {
throw new RuntimeException("좌석 초과");
}
Reservation reservation = new Reservation(...);
return reservationRepository.save(reservation);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("락 처리 중 인터럽트 발생", e);
} finally {
// 락 해제 (현재 스레드가 소유한 경우에만)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
@Service
public class ReservationService {
private final Map<String, ReentrantLock> lockMap = new ConcurrentHashMap<>();
@Transactional
public Reservation createReservation(ReservationDto dto) {
String lockKey = dto.getStoreId() + ":" + dto.getReservationTime();
ReentrantLock lock = lockMap.computeIfAbsent(lockKey, k -> new ReentrantLock());
try {
if (!lock.tryLock(5, TimeUnit.SECONDS)) {
throw new RuntimeException("락 획득 실패");
}
// 비즈니스 로직 수행
// ...
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("락 처리 중 인터럽트 발생", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
implementation 'org.redisson:redisson-spring-boot-starter:3.27.1'
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://localhost:6379");
return Redisson.create(config);
}
}
@Service
public class LockService {
private final RedissonClient redissonClient;
public LockService(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
public <T> T executeWithLock(String lockKey, long waitTime, long leaseTime,
Supplier<T> supplier) {
RLock lock = redissonClient.getLock(lockKey);
try {
boolean isLocked = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);
if (!isLocked) {
throw new RuntimeException("락 획득 실패");
}
return supplier.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("락 처리 중 인터럽트 발생", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
@Service
public class ReservationService {
private final LockService lockService;
private final ReservationRepository reservationRepository;
@Transactional
public ReservationResponse saveReservation(ReservationRequest request) {
String lockKey = "reservation:store:" + request.getStoreId() +
":time:" + request.getReservationTime();
return lockService.executeWithLock(lockKey, 5000, 10000, () -> {
// 예약 가능 여부 확인
int currentReservations = countReservations(...);
if (exceededCapacity(currentReservations, request.getPeopleCount())) {
throw new RuntimeException("좌석 초과");
}
// 예약 생성 및 저장
Reservation reservation = new Reservation(...);
reservationRepository.save(reservation);
return new ReservationResponse(...);
});
}
}