lock 락앤롤

김희영·2025년 12월 24일

토막개발지식

목록 보기
26/27

경매에서 여러 사용자가 동시에 입찰을 하면
같은 데이터를 동시에 수정하려다 데이터 충돌이 발생할 수 있다.

→ 이를 해결하기 위해 락(Lock) 을 사용한다.


락(Lock)

수정 권한을 가진 하나의 요청만 데이터 변경을 허용하는 장치

동시에 여러 요청이 와도
하나만 수정 가능 → 데이터 정합성 보장

즉 충돌 걱정을 줄일 수 있다!

그리고 락에는 다음과 같은 종류가 있다.


비관적 락(Pessimistic Lock)

  • “충돌은 반드시 난다”라고 가정
  • 데이터를 조회하는 순간부터 DB 자체를 잠근다
  • 다른 트랜잭션은 락이 풀릴 때까지 대기

특징
충돌 시: 대기
구현 방식: 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;
그리고 입찰을 할 때 한 트랜잭션 안에 조회 - 검증 - 생성 이 로직 넣으면 된다.
그럼 트랜잭션 끝나면 락 해제 된다.


낙관적 락(Optimistic Lock)

  • “충돌은 잘 안 난다”라고 가정
  • DB에 락을 걸지 않는다
  • 대신 버전(version) 으로 충돌 여부를 판단한다

특징
충돌 시: 에러 발생
구현 방식: 버전 필드 기반 검증

장점
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 분산 락

  • 여러 서버에서 동시에 접근해도 하나만 실행되도록 제어 (= 멀티 서버에서 사용)
  • DB에 접근하기 전에 먼저 락을 선점
    -> Redis에 “락 상태를 나타내는 데이터”를 저장해 두고 쓰는 방식

특징
충돌 시: 락 획득 실패
구현 방식: Redis key 기반 락

장점
DB 부하 감소
멀티 서버 환경에서 효과적

단점
락 만료 시간 관리가 중요
Redis 장애 시 영향 범위 큼

보통 구조는 이렇게 된다.

  1. Redis에서 락 획득 시도
  2. 성공하면 DB 접근
  3. 트랜잭션 종료
  4. 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 락
분류DBappRedis
충돌 처리대기예외락 실패
profile
내는 반드시 엄청난 개발자가 되고 말것어

0개의 댓글