경매에서 여러 사용자가 동시에 입찰을 하면
같은 데이터를 동시에 수정하려다 데이터 충돌이 발생할 수 있다.
→ 이를 해결하기 위해 락(Lock) 을 사용한다.
수정 권한을 가진 하나의 요청만 데이터 변경을 허용하는 장치
동시에 여러 요청이 와도
하나만 수정 가능 → 데이터 정합성 보장
즉 충돌 걱정을 줄일 수 있다!
그리고 락에는 다음과 같은 종류가 있다.
특징
충돌 시: 대기
구현 방식: DB 레벨 락
장점
데이터가 꼬일 가능성이 거의 없음
경매처럼 정합성이 중요한 상황에 적합
단점
동시 요청이 많으면 병목 발생
대기 시간이 길어질 수 있음
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from Auction a where a.id = :id")
Optional<Auction> findByIdWithPessimisticLock(@Param("id") Long id);
이렇게 하면 조회때 다음 쿼리가 날라간다.
SELECT * FROM auction WHERE id = 1 FOR UPDATE;
그리고 입찰을 할 때 한 트랜잭션 안에 조회 - 검증 - 생성 이 로직 넣으면 된다.
그럼 트랜잭션 끝나면 락 해제 된다.
특징
충돌 시: 에러 발생
구현 방식: 버전 필드 기반 검증
장점
DB에 락을 걸지 않아 성능이 좋다
동시 요청이 많아도 병목이 적다
단점
충돌 발생 시 예외 처리 및 재시도 로직 필요
충돌이 잦으면 오히려 비효율적이다
@Entity
public class Auction {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long currentPrice;
@Version
private Long version;
}
이렇게 @Version 필드를 추가하면,
업데이트 시 다음과 같은 쿼리가 날아간다.
UPDATE auction
SET current_price = ?, version = version + 1
WHERE id = ? AND version = ?
그리고 서비스 단에서는 이렇게 쓴다.
@Service
@RequiredArgsConstructor
public class AuctionBidService {
private final AuctionRepository auctionRepository;
private static final int MAX_RETRY = 3;
public void bid(Long auctionId, Long bidPrice) {
int retryCount = 0;
while (true) {
try {
bidInternal(auctionId, bidPrice);
return;
} catch (ObjectOptimisticLockingFailureException e) {
if (++retryCount >= MAX_RETRY) {
throw e;
}
}
}
}
@Transactional
protected void bidInternal(Long auctionId, Long bidPrice) {
// 1. 조회 (version 포함)
Auction auction = auctionRepository.findById(auctionId)
.orElseThrow(() -> new IllegalArgumentException("경매 없음"));
// 2. 비즈니스 검증
if (!auction.isInProgress()) {
throw new IllegalStateException("진행 중인 경매가 아님");
}
if (bidPrice <= auction.getCurrentPrice()) {
throw new IllegalStateException("입찰 금액이 현재가보다 낮음");
}
// 3. 수정
auction.updatePrice(bidPrice);
// flush는 커밋 시점에 발생
}
}
만약 이미 다른 트랜잭션에서 수정해서
version 값이 바뀌어 있다면, JPA가 알아서 확인하고 OptimisticLockException 발생한다.
그럼 이제 재시도 하는 로직이다.
특징
충돌 시: 락 획득 실패
구현 방식: Redis key 기반 락
장점
DB 부하 감소
멀티 서버 환경에서 효과적
단점
락 만료 시간 관리가 중요
Redis 장애 시 영향 범위 큼
보통 구조는 이렇게 된다.
근데 레디스에게 락 달라고 계속 물어보는 건 비효율 적이다. (이걸 스핀락이라 한다.) 차라리 레디스가 자리 날 때 비었다고 알리는 게 더 유리하다.
이를 위해 Pub/Sub을 사용할 수 있다. 그럼 이렇게 된다.
[서버 A] ── 락 획득 ── 작업 중
[서버 B] ── 락 실패 ── 대기 상태
[서버 C] ── 락 실패 ── 대기 상태
서버 A unlock
↓
publish("auction:1:unlock")
서버 B, C
↓
알림 수신 → 다시 tryLock
// Redis에 존재하는 분산 락을 표현하는 RLock 객체를 가져온다.
// 이 시점에서는 실제로 Redis와 통신하지 않는다.
// 단순히 "이 이름의 락을 나중에 쓰겠다"는 핸들(handle)을 얻는 과정이다.
RLock rLock = redissonClient.getLock(lockName);
try {
// tryLock:
// - waitTime: 락을 얻기 위해 최대 얼마 동안 기다릴지
// - leaseTime: 락을 얻은 뒤 자동으로 해제될 때까지의 시간
// - timeUnit: 시간 단위
//
// 내부적으로 Redis SET NX 명령을 사용해 락을 시도한다.
// 이미 다른 스레드/서버가 락을 가지고 있다면,
// 최대 waitTime 동안 대기하면서 Pub/Sub으로 락 해제 이벤트를 기다린다.
if (!rLock.tryLock(waitTime, leaseTime, timeUnit)) {
// waitTime 안에 락을 얻지 못한 경우
// 이 시점에서는 아직 비즈니스 로직이 실행되지 않았으므로
// 그대로 실패 처리하고 빠져나간다.
return false; // 락 획득 실패
}
// ===== 락 획득 성공 =====
// 이 구간부터는 현재 서버(또는 스레드)만
// 해당 lockName에 대해 임계 구역을 실행할 수 있다.
// 보통 이 안에서 DB 조회 - 검증 - 수정 같은
// 정합성이 중요한 로직을 수행한다.
// ======================
// 락 획득 후 로직 수행
} catch (InterruptedException e) {
// 락을 얻기 위해 대기(waitTime)하던 중
// 스레드가 인터럽트 되었을 때 발생하는 예외
//
// 예:
// - 서버 종료
// - 요청 취소
// - 스레드 풀 shutdown
//
// 일반적으로는
// - 로그를 남기고
// - 현재 작업을 중단하는 방향으로 처리한다.
Thread.currentThread().interrupt();
} finally {
try {
// 락을 정상적으로 획득한 경우에만 unlock이 의미가 있다.
// leaseTime이 지나면 Redisson이 자동으로 락을 해제할 수도 있다.
// 그 상태에서 다시 unlock을 호출하면 예외가 발생할 수 있다.
rLock.unlock();
// 실제로 어떤 이름의 락이 해제되었는지 로그로 남긴다.
log.info("unlock complete: {}", rLock.getName());
} catch (IllegalMonitorStateException e) {
// 현재 스레드가 소유하지 않은 락을 해제하려 할 때 발생
// 보통 다음 경우에 발생한다:
// - leaseTime 만료로 이미 자동 해제된 경우
// - 락 획득에 실패했는데 unlock을 호출한 경우
//
// 심각한 오류는 아니므로
// 필요하다면 warn 로그 정도만 남긴다.
}
}
| 구분 | 비관적 락 | 낙관적 락 | Redis 락 |
|---|---|---|---|
| 분류 | DB | app | Redis |
| 충돌 처리 | 대기 | 예외 | 락 실패 |