데이터 동시성 제어 - 낙관락, 비관락, 분산락 (Java/Spring)
업데이트:
개요
멀티스레드 또는 분산 환경에서 동시에 같은 데이터에 접근할 때 데이터 일관성을 보장하는 것은 매우 중요합니다. 본 글에서는 Java/Spring 환경에서 사용되는 대표적인 동시성 제어 메커니즘인 낙관락(Optimistic Locking), 비관락(Pessimistic Locking), 분산락(Distributed Lock)을 심층 분석하고 실제 샘플 코드로 구현합니다.
1. 동시성 문제와 데이터 일관성
1.1 동시성 문제 유형
데이터베이스에서 발생할 수 있는 주요 동시성 문제:
| 문제 | 설명 | 시나리오 |
|---|---|---|
| Lost Update(손실된 업데이트) | 두 트랜잭션이 같은 데이터를 수정하면 마지막 쓰기만 반영 | A, B가 동시에 계좌 잔액 수정 |
| Dirty Read(더티 읽기) | 커밋되지 않은 데이터를 다른 트랜잭션이 읽음 | 롤백된 데이터를 읽음 |
| Non-repeatable Read | 트랜잭션 중 같은 데이터를 여러 번 읽을 때 값이 달라짐 | 트랜잭션 진행 중 다른 트랜잭션이 데이터 수정 |
| Phantom Read | 트랜잭션 중 새로운 행이 추가/삭제되어 조회 결과가 달라짐 | 범위 조회 중 새로운 행 삽입 |
[타임라인 예시: Lost Update 문제]
시간 | 트랜잭션 A | 트랜잭션 B
--------|----------------------|----------------------
T1 | 읽기: 잔액 = 10,000 |
T2 | | 읽기: 잔액 = 10,000
T3 | 쓰기: 10,000 - 5,000 |
T4 | COMMIT |
T5 | | 쓰기: 10,000 + 3,000
T6 | | COMMIT
--------|----------------------|----------------------
결과 | 최종 잔액 = 13,000 (A의 출금 5,000은 반영 안됨!)
2. 낙관락(Optimistic Locking)
2.1 개념 및 원리
낙관락은 대부분의 데이터 충돌이 드물다고 가정하고, 충돌이 발생하면 그때 처리하는 방식입니다.
동작 원리
1. 데이터 조회 시 버전(Version) 값 함께 읽음
2. 비즈니스 로직 처리 (락 없음)
3. 업데이트 시 버전값으로 충돌 검사
UPDATE table SET col = value, version = version + 1
WHERE id = ? AND version = ?
4. 버전 불일치 → ObjectOptimisticLockingFailureException
5. 재시도(Retry) 메커니즘으로 복구
2.2 내부 구조
버전 필드(Version Field) 메커니즘
JPA의 @Version 애노테이션을 사용하면 다음과 같은 동작:
[초기 상태]
ID | ACCOUNT_NUMBER | BALANCE | VERSION
1 | ACC-001 | 10,000 | 1
[첫 번째 업데이트 후]
ID | ACCOUNT_NUMBER | BALANCE | VERSION
1 | ACC-001 | 5,000 | 2 ← 자동 증가
[동시 업데이트 시도]
UPDATE account SET balance = 8000, version = 3 WHERE id = 1 AND version = 1
→ 0 rows updated (version = 2, 1이 아님)
→ OptimisticLockingFailureException 발생
2.3 구현 예제
엔티티 정의
@Entity
@Table(name = "optimistic_account")
@Getter @Setter @NoArgsConstructor
public class OptimisticAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String accountNumber;
@Column(nullable = false)
private Long balance;
/**
* @Version: 낙관락의 핵심
* - JPA가 자동으로 관리
* - 엔티티 업데이트마다 증가
* - WHERE 절에 포함되어 동시성 충돌 감지
*/
@Version
private Long version;
public void withdraw(Long amount) {
if (balance < amount) {
throw new IllegalArgumentException("잔액이 부족합니다");
}
this.balance -= amount;
}
public void deposit(Long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("입금액은 0보다 커야 합니다");
}
this.balance += amount;
}
}
저장소(Repository)
@Repository
public interface OptimisticAccountRepository
extends JpaRepository<OptimisticAccount, Long> {
Optional<OptimisticAccount> findByAccountNumber(String accountNumber);
/**
* 낙관락을 명시적으로 적용
* LockModeType.OPTIMISTIC: 버전 필드만 체크
* LockModeType.OPTIMISTIC_FORCE_INCREMENT: 버전 증가만 함
*/
@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT a FROM OptimisticAccount a WHERE a.accountNumber = :accountNumber")
Optional<OptimisticAccount> findByAccountNumberWithOptimisticLock(
@Param("accountNumber") String accountNumber);
}
서비스 구현
@Service
@RequiredArgsConstructor
@Slf4j
public class OptimisticLockService {
private final OptimisticAccountRepository accountRepository;
private static final int MAX_RETRY_COUNT = 3;
@Transactional
public void transfer(String fromAccountNumber, String toAccountNumber, Long amount) {
int retryCount = 0;
while (retryCount < MAX_RETRY_COUNT) {
try {
OptimisticAccount fromAccount = accountRepository
.findByAccountNumberWithOptimisticLock(fromAccountNumber)
.orElseThrow(() -> new IllegalArgumentException("계좌를 찾을 수 없습니다"));
OptimisticAccount toAccount = accountRepository
.findByAccountNumberWithOptimisticLock(toAccountNumber)
.orElseThrow(() -> new IllegalArgumentException("계좌를 찾을 수 없습니다"));
// 비즈니스 로직 처리 (락 없음)
fromAccount.withdraw(amount);
toAccount.deposit(amount);
// 저장 시 버전 체크
accountRepository.save(fromAccount);
accountRepository.save(toAccount);
log.info("이체 완료: {} → {}, 금액: {}",
fromAccountNumber, toAccountNumber, amount);
return;
} catch (ObjectOptimisticLockingFailureException e) {
retryCount++;
log.warn("낙관락 충돌! 재시도 {}/{}", retryCount, MAX_RETRY_COUNT);
if (retryCount >= MAX_RETRY_COUNT) {
throw new RuntimeException("최대 재시도 횟수 초과", e);
}
}
}
}
}
2.4 생성되는 SQL
-- 조회
SELECT * FROM optimistic_account WHERE account_number = ? FOR UPDATE;
-- 업데이트 (버전 체크)
UPDATE optimistic_account
SET balance = ?, version = version + 1
WHERE id = ? AND version = ?;
2.5 특징
| 항목 | 설명 |
|---|---|
| 장점 | • 충돌 빈도 낮을 때 성능 우수 • DB 락 없어 동시성 높음 • 데드락 위험 없음 |
| 단점 | • 충돌 시 Exception 처리 • 재시도 로직 구현 필요 • 충돌 빈번하면 성능 저하 |
| 사용 시기 | • 조회 > 수정 작업 • 충돌 가능성 낮음 • 높은 동시성 필요 시 |
3. 비관락(Pessimistic Locking)
3.1 개념 및 원리
비관락은 데이터 충돌이 자주 발생한다고 가정하고, 미리 행을 잠금하는 방식입니다.
동작 원리
1. 데이터 조회 시 즉시 행 잠금 획득
SELECT ... FOR UPDATE (배타락) 또는
SELECT ... FOR UPDATE SHARED (공유락)
2. 다른 트랜잭션의 접근 차단
3. 비즈니스 로직 처리
4. 업데이트 수행
5. 트랜잭션 종료 시 잠금 자동 해제
3.2 내부 구조
락 모드 타입
┌─ PESSIMISTIC_READ (공유락, Shared Lock)
│ • SELECT ... FOR UPDATE SHARED
│ • 다른 공유락 허용, 배타락 차단
│ • 읽기 전용 작업 시 사용
│
└─ PESSIMISTIC_WRITE (배타락, Exclusive Lock)
• SELECT ... FOR UPDATE
• 모든 다른 락 차단
• 수정 작업 시 사용
잠금 대기 흐름
[시간 흐름]
스레드 A 스레드 B
─────────────────────────────────────────────────
SELECT ... FOR UPDATE
(배타락 획득) ✓
↓
비즈니스 로직 처리 (1초)
↓ SELECT ... FOR UPDATE
↓ (대기 중...)
UPDATE 수행
COMMIT (락 해제)
↓ (배타락 획득) ✓
↓ 비즈니스 로직 처리
↓ UPDATE 수행
↓ COMMIT
완료 완료
3.3 구현 예제
저장소(Repository)
@Repository
public interface PessimisticAccountRepository
extends JpaRepository<PessimisticAccount, Long> {
Optional<PessimisticAccount> findByAccountNumber(String accountNumber);
/**
* 배타락(PESSIMISTIC_WRITE)
* 생성되는 SQL: SELECT ... FOR UPDATE
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM PessimisticAccount a WHERE a.accountNumber = :accountNumber")
Optional<PessimisticAccount> findByAccountNumberWithPessimisticWriteLock(
@Param("accountNumber") String accountNumber);
/**
* 공유락(PESSIMISTIC_READ)
* 생성되는 SQL: SELECT ... FOR UPDATE SHARED (지원하는 DB의 경우)
*/
@Lock(LockModeType.PESSIMISTIC_READ)
@Query("SELECT a FROM PessimisticAccount a WHERE a.accountNumber = :accountNumber")
Optional<PessimisticAccount> findByAccountNumberWithPessimisticReadLock(
@Param("accountNumber") String accountNumber);
}
서비스 구현
@Service
@RequiredArgsConstructor
@Slf4j
public class PessimisticLockService {
private final PessimisticAccountRepository accountRepository;
/**
* 배타락 기반 이체
* - 두 계좌 모두 배타락 획득
* - 다른 트랜잭션의 모든 접근 차단
*/
@Transactional
public void transfer(String fromAccountNumber, String toAccountNumber, Long amount) {
// 데드락 방지: 계좌번호 기준으로 일관된 순서로 획득
PessimisticAccount fromAccount = accountRepository
.findByAccountNumberWithPessimisticWriteLock(fromAccountNumber)
.orElseThrow(() -> new IllegalArgumentException("계좌를 찾을 수 없습니다"));
PessimisticAccount toAccount = accountRepository
.findByAccountNumberWithPessimisticWriteLock(toAccountNumber)
.orElseThrow(() -> new IllegalArgumentException("계좌를 찾을 수 없습니다"));
// 이미 배타락을 획득했으므로 안전하게 처리
fromAccount.withdraw(amount);
toAccount.deposit(amount);
accountRepository.save(fromAccount);
accountRepository.save(toAccount);
log.info("이체 완료: {} → {}", fromAccountNumber, toAccountNumber);
}
/**
* 공유락 기반 잔액 조회
* - 읽기 전용 작업
* - 다른 공유락은 허용, 배타락만 차단
*/
@Transactional(readOnly = true)
public Long getBalance(String accountNumber) {
PessimisticAccount account = accountRepository
.findByAccountNumberWithPessimisticReadLock(accountNumber)
.orElseThrow(() -> new IllegalArgumentException("계좌를 찾을 수 없습니다"));
return account.getBalance();
}
}
3.4 생성되는 SQL
-- 배타락 (PESSIMISTIC_WRITE)
SELECT * FROM pessimistic_account
WHERE account_number = ? FOR UPDATE;
-- 공유락 (PESSIMISTIC_READ)
SELECT * FROM pessimistic_account
WHERE account_number = ? FOR UPDATE SHARED;
-- 업데이트 (일반 UPDATE, 잠금 이미 획득)
UPDATE pessimistic_account
SET balance = ?
WHERE id = ?;
3.5 데드락(Deadlock) 방지
/**
* 데드락 발생 상황
* [시간] [트랜잭션 A] [트랜잭션 B]
* T1 Lock(ACC-001)
* T2 Lock(ACC-002)
* T3 Lock(ACC-002)← 대기 (ACC-002는 B가 소유)
* T4
* T5 Lock(ACC-001)← 대기 (ACC-001은 A가 소유)
* ↓ DEADLOCK 발생!
*
* 해결: 항상 일관된 순서로 잠금 획득
*/
@Transactional
public void transferSafely(String from, String to, Long amount) {
// 계좌번호를 정렬하여 항상 같은 순서로 획득
String[] sorted = {from, to};
java.util.Arrays.sort(sorted);
PessimisticAccount account1 = accountRepository
.findByAccountNumberWithPessimisticWriteLock(sorted[0])
.get();
PessimisticAccount account2 = accountRepository
.findByAccountNumberWithPessimisticWriteLock(sorted[1])
.get();
// 안전하게 처리
}
3.6 특징
| 항목 | 설명 |
|---|---|
| 장점 | • 충돌 자주 발생할 때 성능 우수 • 명시적인 잠금으로 데이터 일관성 보장 • 재시도 로직 불필요 |
| 단점 | • DB 잠금으로 동시성 감소 • 데드락 위험 존재 • 락 대기로 응답 지연 가능 |
| 사용 시기 | • 충돌 자주 발생 • 데이터 일관성 중요 • 트랜잭션 짧음 |
4. 분산락(Distributed Lock)
4.1 개념 및 원리
분산락은 마이크로서비스나 다중 인스턴스 환경에서 여러 서버에 걸쳐 동시성을 제어하는 방식입니다. 일반적으로 Redis, Zookeeper 등의 외부 저장소를 사용합니다.
동작 원리
1. 외부 저장소(Redis)에서 락 획득 시도
2. 락 획득 성공 여부 즉시 반환
3. 락 획득 시 비즈니스 로직 처리
4. 로직 완료 후 락 해제
5. 락 획득 실패 시 재시도 또는 에러 처리
4.2 구현 방식
Redisson을 사용한 분산락
Redisson은 Redis 기반의 분산락을 제공하는 라이브러리입니다.
설정
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://localhost:6379")
.setConnectionPoolSize(64)
.setConnectionMinimumIdleSize(10)
.setRetryAttempts(3)
.setRetryInterval(1500);
return Redisson.create(config);
}
}
4.3 AOP 기반 구현 (권장)
✅ 공통 기능을 AOP로 처리
매번 각 메서드에서 분산락 로직을 반복 작성하는 것은 비효율적입니다. AOP(Aspect-Oriented Programming)를 사용하면 분산락 처리를 공통화할 수 있습니다.
Step 1: @DistributedLock 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
/**
* Redis 락 키 (SpEL 지원)
* - keys = "transfer" → 고정 키
* - keys = "#accountNumber" → 파라미터 기반
* - keys = "#from + '-' + #to" → 조합 가능
*/
String keys();
/**
* 락 획득 시도 시간 (초)
* 기본값: 10
*/
long waitTime() default 10;
/**
* 락 자동 해제 시간 (초)
* 기본값: 3
*/
long leaseTime() default 3;
/**
* 락 획득 실패 시 동작
* - THROW: RuntimeException 발생 (기본값)
* - SKIP: 락 없이 진행 (위험!)
*/
enum LockFailurePolicy {
THROW, SKIP
}
LockFailurePolicy failurePolicy() default LockFailurePolicy.THROW;
}
Step 2: DistributedLockAspect 구현
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
@Profile("redis")
public class DistributedLockAspect {
private final RedissonClient redissonClient;
private final PlatformTransactionManager transactionManager;
private final ExpressionParser expressionParser =
new SpelExpressionParser();
@Around("@annotation(distributedLock)")
public Object around(ProceedingJoinPoint joinPoint,
DistributedLock distributedLock) throws Throwable {
// Step 1: 메서드 파라미터에서 락 키 생성 (SpEL)
String lockKey = generateLockKey(distributedLock.keys(),
joinPoint.getArgs(),
((MethodSignature)joinPoint.getSignature())
.getParameterNames());
// Step 2: 락 객체 생성
RLock lock = redissonClient.getLock("lock:" + lockKey);
try {
// Step 3: 락 획득
boolean lockAcquired = lock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
TimeUnit.SECONDS
);
if (!lockAcquired) {
throw new RuntimeException("락 획득 실패: " + lockKey);
}
try {
// Step 4: 트랜잭션 시작 (락 획득 후!)
DefaultTransactionDefinition txDef =
new DefaultTransactionDefinition();
TransactionStatus txStatus =
transactionManager.getTransaction(txDef);
try {
// Step 5: 실제 비즈니스 로직 실행
Object result = joinPoint.proceed();
// Step 6: 트랜잭션 커밋 (DB 반영)
transactionManager.commit(txStatus);
return result;
} catch (Throwable e) {
transactionManager.rollback(txStatus);
throw e;
}
} finally {
// Step 7: 락 해제 (커밋 이후!)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("락 획득 중 인터럽트", e);
}
}
/**
* SpEL로 락 키 생성
* 예: "#accountNumber" → 메서드 파라미터의 실제 값
*/
private String generateLockKey(String spel, Object[] args,
String[] paramNames) {
StandardEvaluationContext context =
new StandardEvaluationContext();
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
Object value = expressionParser
.parseExpression(spel)
.getValue(context);
return value != null ? value.toString() : spel;
}
}
Step 3: 서비스에서 @DistributedLock 사용
@Service
@RequiredArgsConstructor
@Slf4j
public class DistributedLockService {
private final DistributedAccountRepository accountRepository;
/**
* 이제 비즈니스 로직만 작성!
* 분산락 처리는 AOP Aspect가 자동으로 처리
*
* 실행 순서:
* 1. @DistributedLockAspect가 메서드를 가로챔
* 2. 락 획득
* 3. 트랜잭션 시작
* 4. 이 메서드 실행
* 5. 트랜잭션 커밋
* 6. 락 해제
*/
@DistributedLock(keys = "transfer")
public void transfer(String fromAccountNumber, String toAccountNumber,
Long amount) {
DistributedAccount fromAccount = accountRepository
.findByAccountNumber(fromAccountNumber)
.orElseThrow();
DistributedAccount toAccount = accountRepository
.findByAccountNumber(toAccountNumber)
.orElseThrow();
// 비즈니스 로직만 깔끔하게!
fromAccount.withdraw(amount);
toAccount.deposit(amount);
accountRepository.save(fromAccount);
accountRepository.save(toAccount);
}
@DistributedLock(keys = "#accountNumber")
public void deposit(String accountNumber, Long amount) {
DistributedAccount account = accountRepository
.findByAccountNumber(accountNumber)
.orElseThrow();
account.deposit(amount);
accountRepository.save(account);
}
}
✅ AOP 기반 접근법의 장점
| 항목 | 설명 |
|---|---|
| 코드 중복 제거 | 분산락 로직을 한 곳에서 관리 |
| 유지보수 용이 | 모든 메서드에 일관된 처리 |
| 가독성 향상 | 서비스에서는 비즈니스 로직만 표현 |
| 일관된 순서 보장 | 모든 메서드에서 동일한 락/커밋 순서 |
| SpEL 지원 | 파라미터 기반 동적 락 키 생성 |
4.4 Redis 분산락 메커니즘
AOP Aspect의 실행 흐름
@DistributedLock(keys = "transfer")
public void transfer(...) { ... }
↓ [1] 메서드 호출 가로챔 (@Around)
[2] SpEL로 락 키 생성
keys = "transfer" → 고정 키 "transfer"
keys = "#accountNumber" → 실제 값 "ACC-001"
↓ [3] 락 획득 시도
[4] 락 획득 성공
├─ [5] 트랜잭션 시작
├─ [6] joinPoint.proceed() 실행
├─ [7] 트랜잭션 커밋 ✅
├─ [8] 락 해제 ✅
└─ 반환
[4'] 락 획득 실패
├─ failurePolicy = THROW
└─ RuntimeException 발생
⚠️ 주의사항: @Transactional과 분산락의 관계
분산락을 구현할 때 @Transactional 어노테이션은 사용하면 안 됩니다.
왜 문제인가?
❌ 잘못된 순서 (@Transactional 사용):
1. 락 획득 ← OK
2. DB 쿼리 실행 (메모리 상에만 존재)
3. finally 블록 실행
4. 락 해제 ← 다른 스레드가 락 획득 가능!
5. @Transactional의 AOP Proxy가 트랜잭션 커밋
↓
이 시점에서 DB에 반영됨
문제:
- 단계 4에서 다른 스레드가 락을 획득함
- 단계 5 이전에 다른 스레드가 "아직 커밋되지 않은 데이터"에 접근
- Dirty Read, Lost Update, Data Inconsistency 발생 가능
✅ 올바른 순서 (AOP 기반 관리):
DistributedLockAspect에서:
1. 락 획득 ← OK
2. 트랜잭션 시작
3. 메서드 실행 (joinPoint.proceed())
4. 트랜잭션 커밋 ← DB에 최종 반영!
5. 락 해제 ← 이제 다른 스레드가 안전하게 접근 가능
장점:
- 커밋이 완료되고 락이 해제됨
- 다른 스레드는 최신 데이터만 접근 가능
- AOP가 모든 순서를 보장
Redisson의 Watch Dog 메커니즘
Redisson은 락 만료 전에 자동으로 시간을 연장하는 Watch Dog 메커니즘을 제공합니다:
[타임라인]
T0 락 획득 (waitTime=10s, leaseTime=3s)
Redis에 저장: key="lock:transfer", expire=3s
T1 비즈니스 로직 처리 중... (2.5초 경과)
T2 [Watch Dog 동작]
잔여 시간 < 1.5초 감지
→ 자동으로 leaseTime 연장
expire=3s 갱신
T3 비즈니스 로직 계속... (2.5초 경과)
T4 [Watch Dog 동작]
→ 자동으로 leaseTime 연장
T5 작업 완료 → unlock()
Redis에서 key 삭제
Watch Dog 설정
# application.yml
spring:
redis:
host: localhost
port: 6379
// Redisson 기본 설정
// lockWatchdogTimeout = 30초 (기본값)
// 이 시간 내에 작업이 완료되지 않으면 자동 연장
4.5 다양한 분산락 구현
1. 세마포어(Semaphore)
/**
* 제한된 자원 접근 제어
* - N개의 동시 접근만 허용
*/
RSemaphore semaphore = redissonClient.getSemaphore("rate-limit");
semaphore.setPermits(5); // 5개 동시 요청만 허용
if (semaphore.tryAcquire()) {
try {
// 작업 수행
} finally {
semaphore.release();
}
}
2. ReadWrite Lock
/**
* 읽기/쓰기 락
* - 여러 읽기는 동시 허용
* - 쓰기는 배타적 접근만 허용
*/
RReadWriteLock lock = redissonClient.getReadWriteLock("resource");
// 읽기
lock.readLock().lock();
try {
Object data = retrieveData();
} finally {
lock.readLock().unlock();
}
// 쓰기
lock.writeLock().lock();
try {
saveData(obj);
} finally {
lock.writeLock().unlock();
}
3. Fair Lock (공정한 락)
/**
* 락 획득 순서를 보장하는 공정한 락
* - FIFO 순서로 락 획득
* - 기아(Starvation) 방지
*/
RLock fairLock = redissonClient.getFairLock("fair-lock");
if (fairLock.tryLock(10, 3, TimeUnit.SECONDS)) {
try {
// 작업 수행
} finally {
fairLock.unlock();
}
}
4.6 특징
| 항목 | 설명 |
|---|---|
| 장점 | • 분산 환경 완벽 지원 • 다중 인스턴스 간 동시성 제어 • 유연한 타임아웃 설정 • 다양한 락 타입 제공 • AOP로 공통화 가능 |
| 단점 | • Redis 별도 인프라 필요 • 네트워크 지연 고려 • Watch Dog 메커니즘 이해 필요 |
| 사용 시기 | • 마이크로서비스 아키텍처 • 다중 인스턴스 배포 • 높은 동시성 + 안정성 필요 |
5. 비교 분석
5.1 종합 비교표
| 특성 | 낙관락 | 비관락 | 분산락 |
|---|---|---|---|
| 환경 | 단일 DB | 단일 DB | 다중 인스턴스 |
| 메커니즘 | 버전 필드 | 행 잠금 | Redis 락 |
| 동시성 | 매우 높음 | 중간 | 중간~높음 |
| 응답 속도 | 빠름 | 느림(대기) | 중간 |
| 충돌 빈도 | 낮음 | 높음 | 보통 |
| 재시도 | 필요 | 불필요 | 불필요 |
| 데드락 | 없음 | 가능 | 없음 |
| 구현 복잡도 | 낮음 | 낮음 | 중간(AOP) |
| 인프라 | DB만 | DB만 | DB + Redis |
5.2 선택 가이드
┌─ 충돌 빈도가 낮은가?
│ ├─ Yes → 낙관락 선택
│ └─ No → 다음 항목 확인
│
└─ 분산 환경인가?
├─ Yes → 분산락 선택 (Redis/AOP)
└─ No → 비관락 선택
예시 시나리오
1. 온라인 쇼핑몰 상품 정보 수정
→ 낙관락 (조회 많음, 수정 드묾)
2. 은행 계좌 이체
→ 비관락 또는 분산락 (높은 정확성 필요, 충돌 예상)
3. 분산 시스템의 마이크로서비스 간 상태 관리
→ 분산락 (여러 인스턴스, 높은 동시성)
4. 게임 서버의 인벤토리 관리 (다중 인스턴스)
→ @DistributedLock으로 간단하게 처리!
6. 샘플 프로젝트 활용
6.1 빌드 및 실행
# Maven으로 빌드
mvn clean package
# In-Memory 프로필 (낙관락, 비관락만)
java -jar target/distributed-lock-samples-1.0.0.jar \
--spring.profiles.active=in-memory
# Redis 프로필 (@DistributedLock AOP 사용)
java -jar target/distributed-lock-samples-1.0.0.jar \
--spring.profiles.active=redis
6.2 API 테스트
# 낙관락 이체
curl -X POST "http://localhost:8080/api/accounts/optimistic/transfer?from=OPT-001&to=OPT-002&amount=1000"
# 비관락 이체
curl -X POST "http://localhost:8080/api/accounts/pessimistic/transfer?from=PES-001&to=PES-002&amount=1000"
# 분산락 이체 (@DistributedLock 적용됨)
curl -X POST "http://localhost:8080/api/accounts/distributed/transfer?from=DIS-001&to=DIS-002&amount=1000"
7. 핵심 요약
✅ 분산락 구현의 최적 패턴
- @DistributedLock 어노테이션 정의 - 메타데이터 제공
- @DistributedLockAspect 구현 - 공통 로직 처리
- DistributedLockService에 적용 - 비즈니스 로직만 작성
장점
✅ 코드 중복 제거 - 분산락 로직이 한 곳에서 관리됨
✅ 유지보수 용이 - 모든 메서드에 일관된 처리
✅ 가독성 향상 - 서비스는 비즈니스 로직에만 집중
✅ 순서 보장 - 락 획득 → 트랜잭션 시작 → 커밋 → 락 해제
✅ 안정성 - 프로덕션 환경에서 사용 가능
│ │ └── controller/
│ │ └── AccountController.java
│ └── resources/
│ └── application.yml
└── src/test/
└── java/com/gracefulsoul/lock/service/
├── OptimisticLockServiceTest.java
└── PessimisticLockServiceTest.java
## 6.2 주요 의존성
```xml
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.24.3</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
6.3 API 사용 예제
낙관락 API
# 이체
curl "http://localhost:8080/api/accounts/optimistic/transfer?from=OPT-001&to=OPT-002&amount=1000"
# 입금
curl -X POST "http://localhost:8080/api/accounts/optimistic/deposit?accountNumber=OPT-001&amount=5000"
# 출금
curl -X POST "http://localhost:8080/api/accounts/optimistic/withdraw?accountNumber=OPT-001&amount=2000"
비관락 API
# 이체
curl -X POST "http://localhost:8080/api/accounts/pessimistic/transfer?from=PES-001&to=PES-002&amount=1000"
# 잔액 조회
curl "http://localhost:8080/api/accounts/pessimistic/balance?accountNumber=PES-001"
분산락 API
# 이체
curl -X POST "http://localhost:8080/api/accounts/distributed/transfer?from=DIS-001&to=DIS-002&amount=1000"
# 입금
curl -X POST "http://localhost:8080/api/accounts/distributed/deposit?accountNumber=DIS-001&amount=3000"
7. 성능 고려사항
7.1 벤치마크 시나리오
| 시나리오 | 낙관락 | 비관락 | 분산락 |
|---|---|---|---|
| 낮은 충돌 (1% 이하) | 🟢 매우 우수 | 🟡 보통 | 🟡 보통 |
| 중간 충돌 (5~10%) | 🟡 보통 | 🟢 우수 | 🟡 보통 |
| 높은 충돌 (20% 이상) | 🔴 나쁨 | 🟢 우수 | 🟢 우수 |
| 높은 동시성 | 🟢 우수 | 🟡 보통 | 🟡 보통 |
| 다중 인스턴스 | 🔴 불가능 | 🔴 불가능 | 🟢 우수 |
7.2 최적화 팁
낙관락 최적화
// 1. 배치 처리로 재시도 횟수 줄이기
@Transactional
public void batchTransfer(List<TransferRequest> requests) {
for (TransferRequest req : requests) {
// 각각 재시도 가능
transfer(req.from, req.to, req.amount);
}
}
// 2. 버전 필드의 데이터타입 최적화
@Version
private Short version; // Long 대신 Short 사용 (작은 범위)
비관락 최적화
// 1. 타임아웃 설정
@PessimisticLock
@QueryTimeout(value = 5, timeUnit = TimeUnit.SECONDS)
Optional<PessimisticAccount> findWithTimeout(String id);
// 2. 배치 업데이트
@Modifying
@Query("UPDATE PessimisticAccount SET balance = balance - :amount WHERE id IN :ids")
int updateBatch(@Param("ids") List<Long> ids, @Param("amount") Long amount);
분산락 최적화
// 1. Watch Dog 타임아웃 조정
Config config = new Config();
config.setLockWatchdogTimeout(20000); // 20초 (기본: 30초)
// 2. 락 타입별 최적화
RLock lock = redissonClient.getFairLock(key); // 공정성 필요 시
8. 주요 제품 및 기술
8.1 분산락 솔루션
| 제품 | 특징 | 용도 |
|---|---|---|
| Redis + Redisson | 재진입 가능, Watch Dog, 다양한 락 타입 | 일반적인 분산락 |
| Apache Zookeeper | 높은 안정성, 강한 일관성 | 복잡한 분산 조정 |
| Consul | 서비스 디스커버리 통합, 세션 기반 락 | 마이크로서비스 |
| etcd | Kubernetes 친화적, ACID 트랜잭션 | K8s 환경 |
8.2 데이터베이스별 비관락 지원
| DB | PESSIMISTIC_READ | PESSIMISTIC_WRITE | 타임아웃 |
|---|---|---|---|
| MySQL (InnoDB) | SELECT … FOR UPDATE SHARED | SELECT … FOR UPDATE | NOWAIT / SKIP LOCKED |
| PostgreSQL | SELECT … FOR UPDATE SHARED | SELECT … FOR UPDATE | NOWAIT / SKIP LOCKED |
| Oracle | SELECT … FOR UPDATE | SELECT … FOR UPDATE | 타임아웃 설정 가능 |
| SQL Server | WITH(NOLOCK) | WITH(XLOCK) | 타임아웃 설정 가능 |
9. 개념 정리
9.1 핵심 용어
| 용어 | 설명 |
|---|---|
| 트랜잭션(Transaction) | 여러 DB 작업의 원자적 실행 단위 |
| 격리 수준(Isolation Level) | 동시 트랜잭션 간 데이터 보호 수준 (DIRTY_READ, REPEATABLE_READ 등) |
| 데드락(Deadlock) | 두 개 이상의 트랜잭션이 서로 대기하는 상태 |
| 락(Lock) | 다른 트랜잭션의 접근을 제한하는 메커니즘 |
| 배타락(Exclusive Lock) | 읽기/쓰기 모두 차단하는 락 |
| 공유락(Shared Lock) | 읽기만 허용하고 쓰기는 차단하는 락 |
| Watch Dog | 자동으로 락 시간을 연장하는 메커니즘 |
9.2 ACID 특성과의 관계
A - Atomicity (원자성)
모두 성공 또는 모두 실패
→ 낙관락, 비관락, 분산락 모두 보장
C - Consistency (일관성)
데이터 규칙 준수
→ 비관락과 분산락이 강함
I - Isolation (격리성)
트랜잭션 간 독립성
→ 낙관락은 약함, 비관락은 강함
D - Durability (내구성)
커밋 후 데이터 지속
→ DB가 담당
참조
공식 문서
- Spring Data JPA - Locking
- Hibernate - Optimistic and Pessimistic Locking
- Redisson - Distributed Locks
기술 블로그
- Baeldung - A Guide to Locks in Spring Data JPA
- Redisson - Lock Mechanisms
- DZone - Distributed Locking in Microservices
샘플 코드 저장소
참고 자료
- MySQL 공식 문서: SELECT … FOR UPDATE
- PostgreSQL 공식 문서: Explicit Locking
- Redis 공식 문서: Redis as a Lock Manager
결론
데이터 동시성 제어는 안정적인 애플리케이션 운영의 핵심입니다. 각 락 메커니즘의 특성을 이해하고 상황에 맞게 선택하는 것이 중요합니다:
- 낙관락: 충돌이 드문 조회 위주의 작업
- 비관락: 충돌이 빈번한 수정 작업
- 분산락: 다중 인스턴스 환경에서의 안전한 동시성 제어
본 글의 샘플 코드를 참고하여 자신의 프로젝트에 맞는 방식을 구현하길 권장합니다.
소스 코드는 GracefulSoul/distributed-lock-samples에서 확인 가능합니다.
댓글남기기