본문 바로가기
카테고리 없음

🔒 어떤 락을 어디에, 어떻게?

by 우주최강건덕 2025. 5. 9.

시작은 단순했다.

단위 테스트는 모두 통과했지만
통합 테스트 환경에서 동시에 요청이 들어오면 간헐적인 오류가 발생하곤 했다.

우선 낙관적 락을 고려했다.

일부 도메인에서는 @Version을 활용해 재시도 기반 흐름을 구성해봤지만

충돌 가능성, 재시도 설계/구현의 복잡도, UX 처리의 불명확성 등을 고려했을 때
적합하지 않은 시나리오가 많았다.

그런 경우 비관적 락과 트랜잭션을 조합해 문제를 해결했고
동시성 문제를 일정 수준 안정적으로 제어할 수 있었다.


하지만 여전히 아쉬움이 남아 Redis 기반 분산락을 시도하게 됐다.

  • 응답 시간 개선
  • DB 부하 감소
  • 멀티 인스턴스 대응 가능

하지만 실제로 비관적 락을 제거하고 Redis 락을 대체하려 하자 또 다른 문제가 발생했다.

  • 동시성 테스트 실패
  • 테스트 시나리오 재작성
  • TTL 설정, 락 해제 검증, race condition 회피 등 설계 복잡성 증가

그래서 질문은 바뀌었다.

✅ “어떤 락이 더 좋은가?”가 아니라

🔍 “어떤 자원에, 어떤 조건에서, 어떤 방식으로 락을 써야 할까?”가 진짜 고민이었다.


🟦 낙관적 락, 정말 낙관해도 괜찮을까?

1. 낙관적 락의 개념

낙관적 락은 동시성 충돌이 ‘드물 것’이라는 가정 하에, 잠금을 걸지 않고 작업을 먼저 수행한 뒤

최종 저장 시점에 데이터가 바뀌었는지를 확인하여 충돌을 감지하는 방식이다.

  • 실제로는 "버전 번호"나 "타임스탬프" 를 기준으로 충돌을 감지한다.
  • 충돌이 없으면 정상 커밋되고, 충돌이 감지되면 예외를 던지고 트랜잭션은 롤백된다.

2. JPA에서의 적용 방식

JPA에서는 @Version 어노테이션을 통해 낙관적 락을 쉽게 구현할 수 있다.

@Entity
public class Product {

    @Id
    private Long id;

    private int stock;

    @Version
    private Long version; // 이 필드로 낙관적 락을 적용함
}

이렇게 정의하면 JPA는 아래와 같은 쿼리를 생성한다:

UPDATE product
SET stock = ?, version = version + 1
WHERE id = ? AND version = ?
  • 이 쿼리는 version 값이 일치해야만 업데이트가 수행된다.
  • 만약 누군가 먼저 업데이트를 완료해 version이 바뀌었다면 해당 쿼리는 영향을 주지 못하고 0 row가 업데이트된다.
  • 이 경우 OptimisticLockingFailureException 예외가 발생한다.

3. 낙관적 락이 적합한 상황

  • 충돌 가능성이 낮고
  • 재시도에 따른 UX 손실이 크지 않으며
  • 성능을 중요하게 생각하는 경우
케이스 낙관적 락 적합 이유
인기 상품 조회수 증가 경합이 거의 없음, 실패 시 재시도 간단
게시글 좋아요 수 증가 변경 충돌 발생 확률 낮음
마이페이지 정보 갱신 사용자별 독립 작업, 충돌 가능성 낮음

4. 낙관적 락의 한계와 복잡성

🔴 재시도 정책의 부담

낙관적 락의 본질은 실패를 감수하고 빠르게 시도하는 것이다.

실패 시 어떻게 재시도할지를 명시적으로 설계해야 한다.

int maxRetry = 3;
int retryCount = 0;
while (retryCount < maxRetry) {
    try {
        service.update();
        break;
    } catch (ObjectOptimisticLockingFailureException e) {
        retryCount++;
        Thread.sleep(100); // backoff
    }
}

이렇게 되면 비즈니스 로직과 충돌 처리 로직이 섞이게 되며,

테스트 코드도 그 복잡도를 따라가야 하는 구조가 된다.


🔴 실패 시 UX가 모호해짐

낙관적 락 충돌로 인한 실패는 사용자 입장에서 "왜 실패했는지" 알기 어렵다.

  • 주문 결제 실패?
  • 정보 갱신 실패?
  • 업데이트 타이밍이 겹쳤기 때문에?

🔴 테스트와 디버깅 어려움

  • 단위 테스트로는 충돌 조건을 재현하기 어렵다.
  • 통합 테스트나 부하 테스트 환경에서만 발견되는 충돌이 많음.
  • “충돌 발생 가능성은 낮지만 발생했을 때 영향은 큰” 시나리오에서 설계 검증이 어려움.

5. 낙관적 락을 적용할지 판단하는 기준

판단 기준 낙관적 락 적합 비적합
충돌 가능성 낮다 높다
재시도 복잡도 낮다 높다
처리 대상 수 적다 많다
사용자 경험 영향 미미 크다
시스템 구조 분산 환경에서 간단한 흐름 중앙 집중 or 다수 요청 경합

🔒 비관적 락, 확실하지만 조심스러운 선택

1. 개념

비관적 락은 말 그대로

“언젠가는 충돌이 날 것”이라는 전제를 깔고

미리 해당 자원을 점유하는 방식이다.

  • 락을 걸고 작업을 시작하기 때문에 다른 트랜잭션이 해당 자원에 접근하면 대기하거나 예외 발생
  • 동시성 문제가 발생할 여지를 원천 차단하는 전략
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Product findByIdWithLock(@Param("id") Long id);

이렇게 하면 해당 Row에 대해 SELECT ... FOR UPDATE 쿼리가 실행된다.


2. 어떤 락이 걸리는가?

SELECT * FROM product WHERE id = ? FOR UPDATE;
  • 이 쿼리는 트랜잭션이 끝날 때까지 해당 Row에 쓰기 락이 걸린다.
  • 다른 트랜잭션이 해당 Row를 수정하거나 삭제하려고 하면 대기하거나 Deadlock이 발생할 수 있다.
  • MySQL InnoDB의 경우 레코드 락(Row Lock) 이며 Index가 없거나 조건이 애매하면 테이블 락으로 확장될 수 있음.

3. 비관적 락이 적합한 상황

상황 비관적 락이 적합한 이유
재고 감소, 선착순 쿠폰 경쟁 조건이 심각하고 충돌 발생 시 금전적 손실이 큼
동일 자원에 대한 높은 쓰기 빈도 충돌 가능성이 높고 반드시 순서 보장이 필요
중복 결제 방지 동시 요청에 대한 강력한 제어가 필요

4. 락은 걸었지만.. 문제가 시작됐다

⚠️ 1. Deadlock

락 순서가 꼬이면 순식간에 데드락이 발생한다.

Tx1: 상품1 → 상품2
Tx2: 상품2 → 상품1
  • 같은 트랜잭션에서 다중 row를 lock할 경우 획득 순서 보장이 없으면 교착상태 발생
  • 해결책: 항상 동일한 정렬 순서로 조회하거나 하나의 쿼리로 묶기

⚠️ 2. Lock Timeout

  • 동시 요청이 많으면 대기하다가 타임아웃 발생
  • 사용자 요청에서 재시도 처리가 없으면 "알 수 없는 에러"로 UX 최악
  • 비즈니스 로직과 예외 핸들링을 같이 고민해야 함
try {
    // 비관적 락으로 상품 조회 후 수정
} catch (CannotAcquireLockException e) {
    // 락 획득 실패 - 사용자에게 안내
}

⚠️ 3. 트랜잭션 누수

  • 락은 트랜잭션 범위 안에서만 유지되기 때문에,
  • 락을 걸고 트랜잭션이 길어지면 시스템 전체의 성능 저하를 야기한다.
  • 특히 락 걸린 상태에서 외부 API 호출 등 blocking 작업이 있으면 락 홀드 시간이 비정상적으로 늘어남

5. 테스트 환경에서의 혼란

실제로는 락을 걸었지만 단위 테스트 환경에서는 락이 제대로 동작하지 않는 착각을 겪기 쉽다.

  • H2 DB 등에서는 FOR UPDATE가 무시되거나 다중 커넥션이 아닌 환경에서는 의미 없는 테스트가 된다.
  • 통합 테스트에서만 확인 가능한 문제들이 존재함

6. 실무 적용 예시

@Transactional
public void order(Long productId) {
    Product product = productRepository.findByIdWithLock(productId);
    if (product.getStock() <= 0) {
        throw new SoldOutException();
    }
    product.decreaseStock();
    // 이후 저장 로직
}

🧭 그럼 비관적 락을 쓰면 되는 걸까?

비관적 락은 분명히 안정적이다.

하지만 락이 가진 성격은 곧바로 설계의 제약으로 이어진다.

  • 어떤 시점에 락을 거는가?
  • 하나의 자원에만 락을 걸면 충분한가?
  • 여러 자원을 동시에 갱신해야 할 땐 어떻게 할까?

비관적 락은 기능 그 자체보다도

“락의 단위와 범위, 트랜잭션 구조” 를 어떻게 설계하느냐에 따라

성공과 실패가 갈린다.


🔐 Redis 분산락, 언제 어떻게 쓸까?

1. Redis 분산락의 핵심 개념

Redis 분산락은 중앙 집중형 데이터베이스가 아닌 외부 공유 자원(Redis)을 활용하여 멀티 인스턴스 환경에서도 락을 걸 수 있게 해주는 기술이다.

Boolean success = redisTemplate.opsForValue()
  .setIfAbsent(lockKey, clientId, Duration.ofSeconds(3));
  • SET NX EX 명령으로 락을 설정한다.
  • 실패하면 재시도하거나 실패 처리한다.
  • TTL을 설정하여 락 해제 실패 시 자동 만료된다.

→ 핵심은 "락을 점유했다는 신뢰성"을 어떻게 확보할 것인가다.


2. 왜 Redis 락을 고민하게 되었나?

앞서 비관적 락은 DB Row에 직접 락을 걸어 강력한 제어를 제공하지만

  • 트랜잭션 홀드 시간 동안 DB 성능 저하
  • 데드락 발생 위험
  • 인스턴스 수평 확장에 제약

이라는 단점이 있었고

낙관적 락은 재시도 흐름이 복잡하고 불확실했다.

이런 배경에서 Redis 락은 다음과 같은 이유로 적합한 대안이 될 수 있었다:

상황 비관적 락 한계 Redis 락이 유리한 이유
재고 감소 DB 락 충돌 시 전체 대기 빠르게 실패하고 로직 유연화 가능
선착순 쿠폰 높은 경합, 빠른 응답 필요 락 점유 여부만 빠르게 판단
인기 랭킹 집계 트래픽 높고, 실시간성 중요 Redis 접근은 훨씬 빠르고 유연함

 


3. Redis 락 사용 시 고려할 사항

 

  • 락 점유자 구분 (clientId 필수) → 동일 자원이 여러 노드에서 접근될 수 있으므로 락 점유자를 명확히 식별해야 함.
  • TTL 설정 및 연장 정책 → 락이 무한정 유지되는 상황은 시스템 위험 요소다.
  • 락 해제 시 검증 → 락을 해제할 때는 clientId가 동일한지 반드시 검증해야 함. 
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end
  • 락 선점 실패 시 로직 설계 → 재시도 vs 바로 실패 처리? 비즈니스 요구에 따라 결정.

4. 시퀀스 흐름 예시

Client -> Redis: SET lockKey clientId NX PX 3000
alt 락 획득 성공
  Client -> DB: 트랜잭션 처리 (재고 감소 등)
  Client -> Redis: Lua Script 통해 락 해제
else 실패
  Client: 실패 처리 or 재시도
end

DB 락은 트랜잭션 범위 내에서 자동으로 관리되지만
Redis 락은 별도의 리소스이므로 락 해제 시점을 개발자가 직접 제어해야 한다.


5. Redis 락의 장단점

✅ 장점

  • DB 부하 없음
  • 인스턴스 간 공유 가능 (멀티 서버 구조에 적합)
  • TTL 기반으로 데드락 방지
  • 빠른 응답이 중요한 환경에 적합

⚠️ 주의점

  • 락 유실 가능성 (ex. Redis 다운, TTL 만료)
  • 락 해제 로직 미흡 시 데이터 불일치 가능
  • 분산환경에서는 Redlock 알고리즘 등의 보강 필요

6. 언제 Redis 락을 선택할까?

조건 Redis 락 적합도
단일 자원 잠금, 빠른 처리 ✅ 매우 적합
높은 트래픽, 읽기-쓰기 간섭 적음 ✅ 적합
긴 트랜잭션 / 외부 API 포함 ❌ 부적합 (DB 락 필요)
확실한 순차성 보장 필요 ❌ 부적합 (DB 락이 더 안정적)

🔍 어디에 락을 걸어야 할까?

락은 성능을 희생해서 정합성을 지키는 도구다.

따라서 락이 필요한 자원은 다음의 특성을 가져야 한다:

✅ 1. 공유 자원인가?

락은 공유되는 데이터가 동시에 수정될 위험이 있을 때만 필요하다.

  • X: 회원 로그인 기록 → 사용자별 독립 저장, 락 불필요
  • O: 상품 재고 → 여러 사용자가 동시에 구매 가능, 락 필요

✅ 2. 정합성 손실이 치명적인가?

수정 충돌로 인해 비즈니스 손실이나 UX 오류가 발생한다면 락을 걸어야 한다.

  • 주문 중복 생성 → 결제 이중 처리, 환불 문제
  • 쿠폰 초과 발급 → 마케팅 비용 초과, 사용자 불신
  • 재고 음수 발생 → 고객에게 없는 상품 판매

✅ 3. 락이 필요한 최소 단위는 무엇인가?

  • 상품 단위로 락을 걸면? → 한 상품의 재고만 제어 가능
  • 카테고리 단위로 락을 걸면? → 동시성은 줄지만 지나치게 범위가 넓어져 성능 저하

최소한의 단위로, 꼭 필요한 자원만 보호하라


✅ 4. 락을 거는 ‘시점’은 언제가 적절한가?

  • 너무 이른 시점에 락을 걸면 → 불필요한 대기와 병목
  • 너무 늦게 락을 걸면 → 이미 정합성이 깨졌을 수 있음

예시) 쿠폰 발급

  • ❌ 잘못된 시점: DB에서 남은 수량 조회 후, 로직 수행 중 후속 요청이 수량을 차감함
  • ✅ 올바른 시점: 수량 차감 시점에 트랜잭션 락 or 분산 락 적용

🧭 락이 필요한 자원과 그렇지 않은 자원을 구분하자

자원 락 필요 여부 이유
유저 프로필 개별 사용자 독립 자원
상품 재고 경합, 수량 오류 발생 가능
게시글 좋아요 수 경우에 따라 통계성 데이터는 무락 처리도 가능
쿠폰 발급 수량 마케팅/비용과 직결됨
인기 상품 조회수 ❌ or 낙관적 락 충돌 가능 낮고, 실패해도 재시도 용이

🔐 어디에, 어떤 락을 사용할까?

🧩 1. 정합성이 가장 중요한 자원에는 비관적 락

대표 자원 이유 적용 방식
상품 재고 재고가 음수가 되면 판매 불가 → 실질 피해 SELECT ... FOR UPDATE 사용
쿠폰 수량 초과 발급 시 마케팅 손실 JPA PESSIMISTIC_WRITE
주문 중복 방지 이중 결제 및 재고 소진 가능성 자원에 대한 강력한 락 필요

즉시성, 강한 정합성이 중요하고, 충돌 가능성이 높은 자원에 적합

➡ 단점은 성능 저하, 데드락 가능성, 트랜잭션 길이에 민감


🪶 2. 충돌 가능성은 낮지만 보호는 필요한 자원에는 낙관적 락

대표 자원 이유 적용 방식
마이페이지 정보 수정 사용자 단위 → 대부분 충돌 없음 @Version 필드 이용
좋아요 수 증가 경합은 적고, 실패 시 UX 영향 적음 실패 시 재시도 로직 포함
조회수 증가 통계용, 재시도해도 무방 간단한 실패 처리로 충분

➡ 데이터 정합성은 필요하지만 트래픽이 많거나 빠른 처리가 필요한 곳에 적합

➡ 단점은 재시도 설계 필요, 복잡한 UX 대응, 충돌 예외 처리


🌐 3. 분산 환경 + 동시 접근 제어에는 Redis 분산 락

대표 자원 이유 적용 방식

대표 자원 이유 적용 방식
선착순 쿠폰 발급 동시에 여러 서버에서 발급 요청 → 분산 락 필요 Redisson, Lettuce 등 사용
재고 감소 (분산 구조) DB 락은 인스턴스 로컬 스코프이기에 분산 서버 간 제어엔 적합하지 않음 Redis SETNX + TTL 조합
결제 중복 방지 (외부 API) 외부 호출 중 중복 요청 → 락으로 제어 분산 환경에서 글로벌 락 필요

멀티 인스턴스, API Gateway, 서버 간 경쟁 환경에 강력

➡ 단점은 락 해제 실패 대응, TTL 설정, 신뢰성 확보 추가 설계 필요


🔎 적용 전략 요약

판단 기준 비관적 락 낙관적 락 Redis 분산 락
충돌 가능성 높음 낮음 중간 ~ 높음
트래픽 대응력 낮음 중간 높음 (분산 처리에 적합)
재시도 복잡도 없음 있음 (명시적 필요) 없음 (락 실패 시 우회 가능)
정합성 요구 수준 매우 높음 보통 매우 높음
설계 난이도 낮음 (직관적) 중간 (재시도/예외처리) 높음 (TTL, 해제 검증 등)
분산 환경 적합성 ❌ 낮음 ❌ 낮음 ✅ 매우 높음

🧠 실무 판단 팁

  1. 시작은 비관적 락 + 트랜잭션 → 가장 안전하며 설계가 단순함.
  2. 낙관적 락은 트래픽/재시도 적고 UX가 중요하지 않을 때 고려
  3. Redis 락은 분산 환경에서 락이 ‘서버 범위’를 넘어야 할 때 → 단일 인스턴스라면 DB 락으로도 충분할 수 있음.

🎯 비교 대상 시나리오 : 선착순 쿠폰 발급

1. 비즈니스 요구사항

  • 선착순 100명에게만 발급
  • 중복 발급은 안 됨
  • 트래픽은 많음 (1만 동시 요청)

🧩 DB 기반 락 (비관적 락 or 낙관적 락)

🔗 흐름 시퀀스 (DB 락 기반)

[사용자 요청]
   ↓
[DB 트랜잭션 시작]
   ↓
[쿠폰 조회 + FOR UPDATE]
   ↓
[중복 확인 → 발급]
   ↓
[트랜잭션 커밋]

✅ 장점

  • 데이터 정합성이 강력하게 보장됨 (ACID)
  • 이미 도입된 트랜잭션 시스템에 자연스럽게 녹아듦

⚠️ 단점

  • 락 획득 경쟁 발생 → DB 부하 증가
  • DB 스케일업 없이는 수평 확장 어려움
  • 부하 테스트 시 Deadlock or Lock Timeout 가능성
  • 사용자 경험: 대기 or 실패가 느리게 전달됨

🔐 Redis 기반 락 (분산락)

🔗 흐름 시퀀스 (Redis 락 기반)

[사용자 요청]
   ↓
[Redis SET NX 시도]
   ↓
락 획득 성공 → [DB 트랜잭션 시작]
                 ↓
              [발급 처리]
                 ↓
           [Redis 락 해제 (Lua)]

✅ 장점

  • 락 획득 자체가 빠르고 독립적 (네트워크 분산 구조)
  • DB에 불필요한 부하 안 줌 → Scale-out 대응 유리
  • 트래픽 제어 역할까지 가능 (ex. 1 user 1 request 제한)

⚠️ 단점

  • 락 TTL 만료 시점, 해제 실패 등 설계 고려 사항 많음
  • 락 획득 후 DB 실패 → 중복 처리 여부 직접 관리 필요
  • Redis 다운 or 네트워크 지연 시 예외 처리 설계 필요

🧠 그래서 어떤 전략이 맞을까?

조건 Redis 락 DB 락
트래픽 많음 (1만+) 👍 (Scale-out 유리) ❌ (DB 병목 위험)
정합성 최우선 ⚠️ (직접 관리 필요) 👍 (ACID 기반 보장)
단일 노드 환경 👍
멀티 인스턴스 👍 (분산 환경에 적합)
코드 간결성 👍
락 제어 세밀함 필요 👍

🔄 결론: 같은 흐름, 다른 전략

  • Redis 락은 "락을 빨리 잡고, 쿼리는 나중에 최소한으로 간다"
  • DB 락은 "쿼리 자체가 락이다. 순서 보장과 정합성이 핵심이다"

🧠 결국 락은 ‘정합성, 트래픽, 구조’ 이 세 가지 축을 바탕으로 선택되어야 한다.

🔎 하나의 정답은 없다.

🛠️ 중요한 건 “왜 이 자원에 이 락을 썼는지”를 말할 수 있는 자신감이다.