낙관적 락 / 비관적 락 / 네임드 락 완전 정리
동시성 제어는 DB 설계의 핵심이다. 같은 데이터를 여러 트랜잭션이 동시에 수정하려 할 때 어떤 락 전략을 선택하느냐에 따라 성능과 정합성이 극단적으로 달라진다. 이 글에서는 비관적 락, 낙관적 락, 네임드 락을 MySQL/InnoDB + Java/JPA 기준으로 완전히 정리한다.
1. 비관적 락 (Pessimistic Lock)
개념
“충돌이 발생할 것이다” 라고 가정하고 데이터를 읽는 순간부터 락을 걸어 다른 트랜잭션의 접근을 차단한다.
T1: SELECT ... FOR UPDATE ← 즉시 X Lock 획득
T2: SELECT ... FOR UPDATE ← T1이 COMMIT/ROLLBACK 할 때까지 대기
공유 락(S Lock) vs 배타 락(X Lock)
┌───────────┬────────────┬────────────┐
│ │ S Lock │ X Lock │
├───────────┼────────────┼────────────┤
│ S Lock │ 호환 (O) │ 충돌 (X) │
│ X Lock │ 충돌 (X) │ 충돌 (X) │
└───────────┴────────────┴────────────┘
S Lock: SELECT ... LOCK IN SHARE MODE (읽기 락, 다른 S Lock과 공존 가능)
X Lock: SELECT ... FOR UPDATE (쓰기 락, 어떤 락과도 충돌)
InnoDB 락 종류
Record Lock
인덱스 레코드 하나에 거는 락.
-- id=5 인 레코드에만 X Lock
SELECT * FROM users WHERE id = 5 FOR UPDATE;
인덱스: 1 2 3 4 [5] 6 7 8
↑
X Lock (Record Lock)
중요: InnoDB는 레코드가 아닌 인덱스에 락을 건다. 인덱스가 없으면 Full Table Scan → 테이블 전체 레코드에 락 발생.
Gap Lock
인덱스 레코드 사이의 간격(Gap) 에 거는 락. 새 레코드 삽입을 방지한다.
-- id가 5~10 사이 범위 조회
SELECT * FROM users WHERE id BETWEEN 5 AND 10 FOR UPDATE;
인덱스: 1 2 3 4 5 (6 7 8 9) 10 11
↑──────────↑
Gap Lock
(5와 10 사이에 INSERT 불가)
Gap Lock은 Phantom Read 방지를 위해 존재한다. REPEATABLE READ 이상에서 동작.
Next-Key Lock
Record Lock + Gap Lock 의 조합. InnoDB의 기본 락 단위.
인덱스: ... 4 [5] (5~10 gap) [10] ...
↑──────────────────↑
Next-Key Lock
(레코드 5 + 5~10 사이 간격)
-- 실제로 Next-Key Lock이 걸리는 범위:
-- (-∞, 5], (5, 10], (10, +∞) 중 쿼리 범위에 해당하는 구간
SELECT * FROM users WHERE id BETWEEN 5 AND 10 FOR UPDATE;
비관적 락 장점/단점
장점:
- 충돌 시 재시도 로직 불필요
- 데이터 정합성 강하게 보장
- 충돌이 잦은 환경에서 낙관적 락보다 효율적 (재시도 비용 없음)
단점:
- 락 대기로 인한 처리량 감소
- 데드락 위험
- 락 보유 시간이 길어지면 병목 발생
- 분산 환경에서 적용 어려움
적합한 상황
- 같은 데이터를 동시에 수정하는 빈도가 높은 경우
- 금융 거래, 재고 차감 등 정합성이 절대적으로 중요한 경우
- 충돌 시 재시도 비용이 크거나 재시도 자체가 불가능한 경우
Java/JPA 구현 예제
// Entity
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int stock;
}
// Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
// 비관적 쓰기 락 (X Lock: SELECT FOR UPDATE)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);
// 비관적 읽기 락 (S Lock: LOCK IN SHARE MODE)
@Lock(LockModeType.PESSIMISTIC_READ)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithSharedLock(@Param("id") Long id);
}
// Service
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
@Transactional
public void decreaseStock(Long productId, int quantity) {
// 1. 락 획득과 동시에 조회 (SELECT ... FOR UPDATE)
Product product = productRepository.findByIdWithPessimisticLock(productId)
.orElseThrow(() -> new IllegalArgumentException("상품 없음"));
// 2. 재고 검증
if (product.getStock() < quantity) {
throw new IllegalStateException("재고 부족");
}
// 3. 재고 차감
product.setStock(product.getStock() - quantity);
// 4. 트랜잭션 종료 시 락 해제 + 변경사항 저장
}
}
실행되는 SQL:
SELECT * FROM product WHERE id = ? FOR UPDATE;
UPDATE product SET stock = ? WHERE id = ?;
타임아웃 설정 (무한 대기 방지):
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);
2. 낙관적 락 (Optimistic Lock)
개념
“충돌이 드물 것이다” 라고 가정하고 DB 락 없이 데이터를 읽은 뒤, 수정 시점에 다른 트랜잭션이 변경했는지 버전을 비교해 충돌을 감지한다.
CAS(Compare-And-Swap) 원리:
읽기: version=5, data='A' 읽어옴
수정: UPDATE ... SET data='B', version=6 WHERE id=? AND version=5
결과: 업데이트된 행이 0이면 충돌 감지 → 예외 발생
@Version 필드 동작 원리
초기: { id=1, name='Lee', version=0 }
T1: SELECT → { name='Lee', version=0 }
T2: SELECT → { name='Lee', version=0 }
T1: UPDATE SET name='Kim', version=1 WHERE id=1 AND version=0;
→ 성공 (affected rows = 1)
DB: { id=1, name='Kim', version=1 }
T2: UPDATE SET name='Park', version=1 WHERE id=1 AND version=0;
→ 실패 (affected rows = 0, version이 이미 1로 바뀜)
→ OptimisticLockException 발생
시간 축:
T1: [READ v=0]──────────────────[WRITE v=0→1: 성공]
T2: [READ v=0]────────────────────────[WRITE v=0→1: 실패!]
충돌 감지 → 예외
낙관적 락 장점/단점
장점:
- DB 락 없음 → 읽기 성능 극대화
- 충돌이 드문 경우 처리량 최대화
- 데드락 없음
단점:
- 충돌 시 재시도 로직 직접 구현 필요
- 충돌이 잦은 환경에서 재시도 폭풍으로 성능 역전
- 재시도 중 다른 데이터와 불일치 가능성
- 응답 시간 보장 어려움 (재시도 횟수 불확실)
적합한 상황
- 읽기 비율이 매우 높고 쓰기 충돌이 드문 경우
- 게시글 수정, 사용자 프로필 변경 등 개인화 데이터
- 충돌 시 재시도가 허용되는 비즈니스 로직
- 분산 환경 (DB 락에 의존하지 않으므로 확장성 좋음)
Java/JPA 구현 예제
// Entity
@Entity
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
@Version // 낙관적 락의 핵심 - JPA가 자동으로 버전 관리
private Long version;
}
// Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT a FROM Article a WHERE a.id = :id")
Optional<Article> findByIdWithOptimisticLock(@Param("id") Long id);
}
// Service
@Service
@RequiredArgsConstructor
public class ArticleService {
private final ArticleRepository articleRepository;
// 낙관적 락 + 재시도 로직
public void updateArticle(Long articleId, String newContent) {
int maxRetry = 3;
for (int attempt = 0; attempt < maxRetry; attempt++) {
try {
updateArticleInternal(articleId, newContent);
return; // 성공
} catch (OptimisticLockingFailureException e) {
if (attempt == maxRetry - 1) {
throw new IllegalStateException("동시 수정 충돌: 잠시 후 다시 시도해주세요.", e);
}
// 재시도 전 짧은 대기 (선택적)
}
}
}
@Transactional
private void updateArticleInternal(Long articleId, String newContent) {
Article article = articleRepository.findByIdWithOptimisticLock(articleId)
.orElseThrow(() -> new IllegalArgumentException("게시글 없음"));
article.setContent(newContent);
// 트랜잭션 커밋 시점에:
// UPDATE article SET content=?, version=version+1 WHERE id=? AND version=?
// affected rows = 0 이면 OptimisticLockingFailureException
}
}
실행되는 SQL:
-- 조회
SELECT * FROM article WHERE id = ?;
-- 수정 (version 조건 포함)
UPDATE article SET content = ?, version = 2 WHERE id = ? AND version = 1;
-- affected rows = 0 이면 예외 발생
@Version 없이 수동 구현 (레거시 대응):
// 수동 버전 관리 쿼리
@Modifying
@Query("UPDATE Article a SET a.content = :content, a.version = :version + 1 " +
"WHERE a.id = :id AND a.version = :version")
int updateWithVersion(@Param("id") Long id,
@Param("content") String content,
@Param("version") Long version);
// 서비스
int updated = articleRepository.updateWithVersion(id, newContent, article.getVersion());
if (updated == 0) {
throw new OptimisticLockingFailureException("버전 충돌");
}
3. 네임드 락 (Named Lock / User-Level Lock)
개념
테이블이나 레코드가 아닌 임의의 문자열(이름) 을 기준으로 거는 락. MySQL에서 GET_LOCK() 함수로 사용한다.
-- 락 획득 (최대 10초 대기)
SELECT GET_LOCK('payment_user_100', 10);
-- 반환값: 1(성공), 0(타임아웃), NULL(오류)
-- 작업 수행
-- ...
-- 락 해제
SELECT RELEASE_LOCK('payment_user_100');
-- 반환값: 1(성공), 0(락 없음), NULL(락 보유자 아님)
특징
일반 레코드 락: 네임드 락:
테이블 또는 레코드 임의의 문자열
트랜잭션과 연동 트랜잭션과 독립
InnoDB 엔진 관리 MySQL 서버 레벨 관리
COMMIT/ROLLBACK 해제 명시적 RELEASE_LOCK 필요
네임드 락의 범위:
- 동일 MySQL 서버 내 모든 세션 간에 공유
- 세션 종료 시 자동 해제 (명시적 해제 누락 안전장치)
IS_FREE_LOCK('name'),IS_USED_LOCK('name')으로 상태 확인
분산 환경에서의 활용
[App Server 1] ──┐
[App Server 2] ──┼──▶ [MySQL] → GET_LOCK('resource_key')
[App Server 3] ──┘
→ 여러 서버가 동일 MySQL에 접속하는 환경에서 분산 락 역할
→ Redis 분산 락과 유사하지만 MySQL 하나면 추가 인프라 불필요
네임드 락 장점/단점
장점:
- 테이블/레코드에 무관하게 임의 리소스에 락 적용 가능
- 여러 테이블에 걸친 복잡한 비즈니스 로직 동기화
- 추가 인프라(Redis 등) 없이 분산 락 구현 가능
- 타임아웃 내장 지원
단점:
- 명시적으로
RELEASE_LOCK호출 필요 (누락 시 타 세션 무한 대기) - MySQL 단일 서버에 종속 (진정한 분산 환경에선 Redis Redlock 권장)
- 재진입(Reentrant) 불가 (동일 세션에서도 같은 이름 락 재획득 불가)
- 많은 네임드 락 사용 시 MySQL 내부 메모리 압박
적합한 상황
- 여러 테이블에 걸친 복잡한 원자적 작업 (단일 레코드 락으로 표현 불가)
- 외부 시스템 호출(결제 API, 이메일 발송) 중복 방지
- 배치 작업의 중복 실행 방지
- Redis 없이 분산 락이 필요한 소규모 환경
Java/Spring 구현 예제
Repository:
public interface LockRepository extends JpaRepository<Object, Long> {
@Query(value = "SELECT GET_LOCK(:name, :timeout)", nativeQuery = true)
Integer getLock(@Param("name") String name, @Param("timeout") int timeout);
@Query(value = "SELECT RELEASE_LOCK(:name)", nativeQuery = true)
Integer releaseLock(@Param("name") String name);
}
Named Lock 템플릿 (try-finally 패턴):
@Component
@RequiredArgsConstructor
public class NamedLockTemplate {
private final LockRepository lockRepository;
public <T> T executeWithLock(String lockName, int timeout, Supplier<T> supplier) {
try {
Integer result = lockRepository.getLock(lockName, timeout);
if (result == null || result != 1) {
throw new IllegalStateException("락 획득 실패: " + lockName);
}
return supplier.get();
} finally {
lockRepository.releaseLock(lockName); // 반드시 해제
}
}
}
// Service
@Service
@RequiredArgsConstructor
public class PaymentService {
private final NamedLockTemplate namedLockTemplate;
private final PaymentProcessor paymentProcessor;
public void processPayment(Long userId, long amount) {
String lockName = "payment_user_" + userId; // 사용자별 고유 락 이름
namedLockTemplate.executeWithLock(lockName, 10, () -> {
// 이 블록은 동시에 하나의 스레드만 실행 보장
paymentProcessor.process(userId, amount);
return null;
});
}
}
별도 DataSource 사용 (커넥션 풀 고갈 방지):
// 네임드 락은 별도 커넥션을 사용해야 함
// (같은 커넥션에서 트랜잭션 + 네임드 락 혼용 시 커넥션 반납 타이밍 문제)
@Configuration
public class DataSourceConfig {
@Bean
@Primary
public DataSource dataSource() { /* 메인 DataSource */ }
@Bean
@Qualifier("lockDataSource")
public DataSource lockDataSource() {
// 락 전용 DataSource (풀 크기 작게 설정)
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(5);
return new HikariDataSource(config);
}
}
4. 세 가지 락 비교
| 항목 | 비관적 락 | 낙관적 락 | 네임드 락 |
|---|---|---|---|
| 충돌 감지 시점 | 읽기 시 (선제적) | 쓰기 시 (사후적) | 읽기 시 (선제적) |
| DB 락 사용 | O (레코드/갭 락) | X (버전 비교) | O (서버 레벨) |
| 데드락 가능성 | 높음 | 없음 | 낮음 |
| 충돌 多 환경 성능 | 좋음 | 나쁨 (재시도 폭풍) | 좋음 |
| 충돌 少 환경 성능 | 보통 | 최고 | 보통 |
| 재시도 로직 | 불필요 | 필요 | 불필요 |
| 분산 환경 | 어려움 | 가능 | MySQL 종속 |
| 구현 난이도 | 쉬움 | 중간 | 중간 |
| 락 범위 | 테이블/레코드 | 없음 | 임의 이름 |
| 트랜잭션 연동 | O | O | X (독립) |
5. 극한 시나리오
5-1. 비관적 락 데드락 시나리오
시나리오: 계좌 이체 (A→B, B→A 동시 요청)
T1 (A→B): T2 (B→A):
LOCK account_A ──┐ LOCK account_B ──┐
│ │
┌─────────┘ ┌─────────┘
▼ ▼
LOCK account_B ←── 대기 ── (T1이 보유)
(T2가 보유) ←── 대기 ── LOCK account_A
→ T1은 B를 기다리고, T2는 A를 기다림 = Deadlock!
해결 방법:
// 항상 작은 ID → 큰 ID 순으로 락 획득
@Transactional
public void transfer(Long fromId, Long toId, long amount) {
Long firstId = Math.min(fromId, toId);
Long secondId = Math.max(fromId, toId);
Account first = accountRepository.findByIdWithPessimisticLock(firstId)
.orElseThrow();
Account second = accountRepository.findByIdWithPessimisticLock(secondId)
.orElseThrow();
// firstId < secondId 순서로 항상 동일 → 데드락 없음
if (fromId < toId) {
first.decrease(amount);
second.increase(amount);
} else {
second.decrease(amount);
first.increase(amount);
}
}
또는 타임아웃으로 데드락 탈출:
@QueryHints({@QueryHint(
name = "jakarta.persistence.lock.timeout",
value = "5000" // 5초 대기 후 LockTimeoutException
)})
5-2. 낙관적 락 무한 재시도 시나리오
상황: 인기 상품 재고 차감 (100명이 동시에 1개씩)
T1~T100 동시 SELECT version=0
T1 UPDATE version=0→1: 성공
T2~T100 UPDATE version=0→1: 99개 실패 → 재시도
→ T2 재시도: version=1→2 성공
→ T3~T100 재시도: 98개 실패 → 재시도
→ T3 재시도: version=2→3 성공
... (O(N²) 쿼리 발생!)
해결 방법 1 - 재시도 횟수 제한 + 지수 백오프:
public void decreaseStock(Long productId, int quantity) {
int maxRetry = 5;
long waitMs = 50;
for (int i = 0; i < maxRetry; i++) {
try {
decreaseStockInternal(productId, quantity);
return;
} catch (OptimisticLockingFailureException e) {
if (i == maxRetry - 1) throw new StockException("재시도 초과");
try {
Thread.sleep(waitMs * (1L << i)); // 50, 100, 200, 400ms
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new StockException("인터럽트 발생");
}
}
}
}
해결 방법 2 - 충돌 多 상황에서는 비관적 락으로 전환:
충돌률 측정 → 충돌률 > 임계치(예: 20%) → 비관적 락으로 동적 전환
(또는 처음부터 재고 차감같이 충돌 多 케이스에는 비관적 락 사용)
5-3. 네임드 락 해제 누락 시나리오
T1: GET_LOCK('payment_100', 10) → 성공
T1: 작업 중 예외 발생
T1: RELEASE_LOCK 호출 안 됨 (버그)
T2: GET_LOCK('payment_100', 10) → 최대 10초 대기
T3: GET_LOCK('payment_100', 10) → 최대 10초 대기
...
→ T1 세션 유지되는 동안 모든 요청 지연
해결 방법 - try-finally 필수:
// 잘못된 예
public void processWithLock(String lockName) {
lockRepository.getLock(lockName, 10);
doSomething(); // 예외 발생 시 RELEASE_LOCK 호출 안 됨!
lockRepository.releaseLock(lockName);
}
// 올바른 예
public void processWithLock(String lockName) {
try {
lockRepository.getLock(lockName, 10);
doSomething();
} finally {
lockRepository.releaseLock(lockName); // 예외 발생해도 반드시 실행
}
}
추가 안전장치 - 세션 타임아웃:
-- 세션이 종료되면 MySQL이 자동으로 네임드 락 해제
SET SESSION wait_timeout = 30;
5-4. 높은 동시성에서 각 락의 성능 차이
시나리오: 100 TPS, 동일 레코드 경쟁
비관적 락:
처리량: 순차 처리 (락 대기)
지연: 평균 대기 = (N-1) * 트랜잭션시간 / 2
특징: 처리량 일정, 지연 증가
──────────────────────────────────
T=0: [T1 실행]
T=1: [T1 완료][T2 실행]
T=2: [T2 완료][T3 실행]
→ 안정적이지만 처리량 제한
낙관적 락:
처리량: 초기 높음, 충돌 증가 시 급락
지연: 예측 불가 (재시도 N회 가능)
특징: 충돌 적을 때 비관적 락보다 빠름
──────────────────────────────────
T=0: [T1~T100 동시 실행]
T=0: [T1 성공, T2~T100 실패]
T=1: [T2~T100 재시도 → 대부분 또 실패]
→ 폭풍 재시도로 성능 역전 가능
선택 기준:
충돌률 < 5% → 낙관적 락
충돌률 5~20% → 케이스 별 측정
충돌률 > 20% → 비관적 락
5-5. 마스터-슬레이브 환경에서 락 동작
마스터-슬레이브 복제:
[Master] ──복제──▶ [Slave1]
──▶ [Slave2]
비관적 락:
SELECT FOR UPDATE → 반드시 Master에서 실행 (Slave에서는 락 없음)
Slave에서 FOR UPDATE 실행 시: 복제 지연 동안 비일관성 가능
낙관적 락:
SELECT → Slave에서 가능 (읽기 성능 분산)
UPDATE → Master에서 실행
주의: Slave 복제 지연 동안 오래된 version을 읽을 수 있음
→ 불필요한 재시도 증가 가능
네임드 락:
GET_LOCK → 반드시 Master에서 (Slave는 락 상태 모름)
읽기 분산 불가 (락 획득 후 작업은 모두 Master에서)
권장 패턴:
// 쓰기 작업 전용 트랜잭션에서 Master 강제 사용
@Transactional
@Primary // Master DataSource 사용
public void updateWithLock(Long id) {
// Master에서 SELECT FOR UPDATE
}
// 읽기 작업은 Slave 허용
@Transactional(readOnly = true)
public Product findProduct(Long id) {
// Slave에서 읽기 가능
}
6. 락 선택 가이드
데이터 충돌 빈도?
│
├─ 높음 ──▶ 비관적 락
│ │
│ 재시도 비용 높음?
│ ├─ Yes → 비관적 락 확정
│ └─ No → 낙관적 락도 고려
│
└─ 낮음 ──▶ 낙관적 락
│
단일 레코드?
├─ Yes → 낙관적 락 확정
└─ No → 네임드 락 고려
│
여러 테이블/외부 리소스?
├─ Yes → 네임드 락
└─ No → 비관적 락 재고
실전 적용 예시:
| 시나리오 | 추천 락 | 이유 |
|---|---|---|
| 좌석 예매 (동시 경쟁 극심) | 비관적 락 | 충돌 多, 중복 예매 절대 불가 |
| 게시글 수정 | 낙관적 락 | 개인 데이터, 충돌 드묾 |
| 인기 상품 재고 차감 | 비관적 락 | 충돌 多, 정합성 최우선 |
| 사용자 포인트 적립 | 낙관적 락 | 충돌 가능성 낮음 |
| 결제 중복 방지 (여러 테이블) | 네임드 락 | 트랜잭션 경계 복잡 |
| 배치 작업 중복 실행 방지 | 네임드 락 | 프로세스 간 동기화 |
정리
비관적 락: "먼저 잠그고 작업한다"
→ SELECT FOR UPDATE → 작업 → COMMIT(락 해제)
→ 충돌 多, 정합성 최우선
낙관적 락: "작업 후 충돌을 확인한다"
→ SELECT → 작업 → UPDATE WHERE version=? → 충돌 시 재시도
→ 충돌 少, 읽기 많은 시스템
네임드 락: "이름으로 잠근다"
→ GET_LOCK('name') → 작업 → RELEASE_LOCK('name')
→ 여러 테이블/외부 리소스, 분산 동기화
락은 만능이 아니다. 락 없이 해결할 수 있다면 (예: 원자적 SQL UPDATE stock = stock - 1 WHERE stock > 0) 그것이 최선이다. 락은 꼭 필요한 곳에만 최소 범위로 최단 시간 동안 사용하는 것이 원칙이다.