하나의 자원에 대해서 동시에 여러 스레드가 접근해 변경하려고 할 때 발생하는 이슈이다.
동시성 이슈는 데이터 정합성 문제를 일으키며 디버깅이 어려워 오류 감지가 어렵다.
그렇기 때문에 운영에서 반드시 해결해야 하는 문제이다.
여러 작업이 동시에 실행되며 결과가 실행 순서에 따라 달라진다.
두 사용자가 동시에 같은 재고를 1개 구매 시도 → 재고가 0이어야 하지만 1이 남거나 마이너스
두 작업이 같은 데이터를 읽고 수정한 뒤, 먼저 반영된 작업이 나중에 반영된 작업에 의해 덮어쓰여지는 현상이다.
A가 사용자 이름을 '철수'로 수정, B가 동시에 '영희'로 수정 → 마지막에 저장된 '영희'만 반영됨
이외에도, 트랜잭션 격리수준에 따른 동시성 문제 유형이 존재한다.
Dirty Read, Non-Repeatable Read, Phantom Read 등은 트랜잭션 격리수준에 따라 발생할 수 있는 문제들이다.
자세한 내용은 이전 포스트에서 다룬다.
자바 기본 동기화 키워드로, 메서드나 코드 블럭에 락을 걸어 단일 스레드만 진입 할 수 있도록 제한한다.
문법이 간단하고 직관적이나, 스레드 락이 풀릴 때까지 무한 대기하여 성능 저하에 영향을 끼칠 수 있고,
공정성 보장이 되지 않아 특정 락이 오랜기간 동안 락을 획득하지 못할 수 있다.
private int count = 0;
public synchronized void increment() { // ✅ synchronized 키워드를 사용해 메서드 블록 동기화 제어
count++;
}재진입이 가능하고 조건 제어나 타임아웃, 인터럽트를 통해 세밀하게 동기화 제어를 할 수 있는 락이다.
순서를 보장하는 공정성 설정이 가능하고, 세밀하게 동기화를 제어할 수 있다.
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock(); // ✅ ReentrantLock을 통한 락 획득
try {
count++;
} finally {
lock.unlock(); // ✅ ReentrantLock을 통한 락 해제
}
}CAS(Compare-And-Set) 알고리즘 기반으로 원자성을 보장하며 락 없이 연산이 가능하다.
락이 없어 성능이 우수하나, 복잡한 연산에는 적합하지 않다.
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // ✅ 락 없이 CAS 기반으로 원자성 연산을 한다.
}Database 락은 데이터베이스에서 여러 트랜잭션이 동일한 데이터에 접근할 때 충돌을 방지해 정합성을 보장하기 위해 사용하는 락이다.
일반적으로, 쓰기 트랜잭션에는 배타 락(X-Lock)이 걸려,
다른 트랜잭션이 동시에 동일한 데이터에 쓰기 작업을 수행하지 못하도록 막는다.
하지만 대부분 RDBMS는 MVCC를 지원하여,
쓰기 작업 중인 데이터라도 이전 트랜잭션 기준의 값을 읽을 수 있도록 보장한다.
- 데이터 충돌이 거의 없을 것이라 가정하고, 읽기 시점에는 락을 걸지 않으며, 쓰기 시점에 버전 충돌 여부를 확인하는 방식
- 충돌이 감지되면 예외를 발생시켜 트랜잭션을 롤백하거나 재시도해야 한다.
- 충돌이 빈번할 경우 반복적인 실패와 재시도로 인해 성능 저하가 발생할 수 있다.
- 데이터 충돌이 자주 발생할 것으로 가정하고, 트랜잭션 시작 시점에 락을 설정하여 다른 트랜잭션의 접근을 차단하는 방식
- 확실한 정합성이 필요할 경우에 적합하다.
- 락을 유지하는 동안 다른 요청은 대기 상태가 되므로, 요청량이 많은 환경에서는 데드락이나 병목 현상이 발생할 수 있다.
기능 별로 락 전략을 다르게 설정하는 것이 아닌, 동일한 자원에 대해서는 일관된 락 전략을 사용해야 한다.
예 : 잔액 충전은 낙관적 락 잔액 차감은 비관적 락 을 사용하는 것이 아니라 "잔액"이라는 동일한 자원에 대해 동일한 락 전략을 적용해야 한다.
충돌 가능성에 따라 결정할 수 있지만, 반드시 성공해야하는 요청이면 "비관적 락"을 사용해야 한다.
그렇지 않으면 낙관적 락을 사용한다.
락은 가능한 최소 범위로 설정해야 한다. 락 범위가 넓을 수록 성능 저하와 데드락 위험이 증가한다.
엔티티 클래스의 @Version 어노테이션을 사용하여 버전 필드를 정의한다.
버전 값은 트랜잭션 커밋 시 비교되어, 충돌 발생 시 OptimisticLockingFailureException.class 예외가 발생한다.
예외 발생 시, @Retryable 어노테이션을 사용하여 재시도 로직을 구현할 수 있다.
@Entity
public class Balance {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Version
private Integer version; // 🔒 낙관적 락을 위한 버전 필드
private Long userId;
private long amount;
}@Lock(LockModeType.PESSIMISTIC_WRITE) 어노테이션을 사용하여 비관적 락을 설정할 수 있다.
데이터 조회 시점부터 락을 걸어 다른 트랜잭션의 쓰기 접근을 차단한다.
@Repository
public interface BalanceRepository extends JpaRepository<Balance, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE) // 🔒 비관적 락 적용
@Query("SELECT b FROM Balance b WHERE b.userId = :userId")
Optional<Balance> findByUserIdWithLock(@Param("userId") Long userId);
}Redis 분산 락은 멀티 인스턴스 환경에서 자원 접근을 제어하기 위한 동시성 제어 수단으로,
Redis의 단일 스레드 기반 연산(SET NX 등)을 활용해 원자성을 보장하므로 분산 락 구현에 적합하다.
락의 안정성과 신뢰성을 높이기 위해 분산 락 전용 Redis 인스턴스를 별도로 구성하는 것이 권장된다.
또한 Redis 외에도 Zookeeper, etcd 등과 같은 분산 코디네이션 시스템을 활용한 분산 락 방식도 존재한다.
분산 락과 트랜잭션은 데이터 무결성과 정합성을 보장하기 위해, 반드시 아래 순서를 지켜야 한다.
재고 차감 로직에서 트랜잭션이 먼저 시작된 뒤 분산 락을 획득하면,
조회 시점에 락이 적용되지 않아 동일한 재고 수를 여러 요청이 동시에 읽는 문제가 발생할 수 있다.
이는 결국 Race Condition을 유발하여 잘못된 재고 차감 결과를 초래할 수 있다.
또한, 락 획득에 실패하더라도 이미 트랜잭션이 시작되어 DB 커넥션이 점유된 상태이므로,
락 획득 실패 후에도 불필요한 커넥션 사용으로 DB에 부하를 줄 수 있다.
트랜잭션이 완료되기 전에 분산 락이 먼저 해제되면,
다른 트랜잭션이 락을 선점하고 재고 차감을 시도할 수 있다.
이 경우, 아직 커밋되지 않은 데이터를 조회하게 되어 정합성이 깨질 위험이 있다.
즉, 트랜잭션 커밋 전에는 절대 락을 해제하면 안 된다.
이를 방지하기 위해서는 락 해제를 반드시 트랜잭션 종료 이후(afterCommit)로 미뤄야 한다.
Spring 에서 분산 락 구현하기 위해 아래와 같은 어노테이션과 AOP를 작성해야 한다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key(); // 분산 락을 식별할 키 (예: 쿠폰 ID)
LockType type(); // 키 prefix 구분용 타입 (예: COUPON, PRODUCT 등)
long waitTime() default 5L; // 락 획득을 시도할 최대 대기 시간
long leaseTime() default 3L; // 락 소유 유지 시간 (TTL)
TimeUnit timeUnit() default TimeUnit.SECONDS; // 시간 단위
LockStrategy strategy() default LockStrategy.PUB_SUB_LOCK; // 사용할 분산 락 전략
}@Aspect
@Order(Ordered.HIGHEST_PRECEDENCE) // 트랜잭션보다 먼저 실행되도록 설정 (락이 트랜잭션에 종속되지 않도록)
public class DistributedLockAspect {
private final LockKeyGenerator generator;
private final LockStrategyRegistry registry;
@Around("@annotation(DistributedLock)")
public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
DistributedLock lock = signature.getMethod().getAnnotation(DistributedLock.class);
// 메서드 인자 기반으로 실제 사용할 락 키 생성
String key = generator.generateKey(signature.getParameterNames(), joinPoint.getArgs(), lock.key(), lock.type());
// 락 전략에 따른 락 템플릿 설정
LockTemplate template = registry.getLockTemplate(lock.strategy());
// 락을 획득한 뒤 비즈니스 로직 실행
return template.executeWithLock(key, lock.waitTime(), lock.leaseTime(), lock.timeUnit(), joinPoint::proceed);
}
}Redis 분산 락은 락 획득 방식에 따라 다양한 전략으로 구현할 수 있다.
공통 인터페이스인 LockTemplate을 정의하고, 각 전략에 맞는 구현체를 작성한다.
public interface LockTemplate {
// 락을 획득하고 비즈니스 로직을 실행하는 메서드
<T> T executeWithLock(String key, long waitTime, long leaseTime, TimeUnit timeUnit, LockCallback<T> callback) throws Throwable;
// 락 전략을 반환
LockStrategy getLockStrategy();
// 락 획득
void acquireLock(String key, long waitTime, long leaseTime, TimeUnit timeUnit) throws InterruptedException;
// 락 해제
void releaseLock(String key);
}그리고, LockTemplate 인터페이스의 구현체인 DefaultLockTemplate에서는
락 해제시, 트랜잭션 범위 밖에서 해제를 보장하기 위해 TransactionSynchronizationManager를 사용하여 트랜잭션 커밋 후에 락을 해제하도록 한다.
public abstract class DefaultLockTemplate implements LockTemplate {
@Override
public <T> T executeWithLock(String key, long waitTime, long leaseTime, TimeUnit timeUnit, LockCallback<T> callback) throws Throwable {
try {
acquireLock(key, waitTime, leaseTime, timeUnit);
return callback.doInLock();
} finally {
// 트랜잭션이 활성화되어 있다면, 트랜잭션 커밋 후 락을 해제하도록 한다.
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
releaseLock(key);
}
});
} else {
releaseLock(key);
}
}
}
public abstract void acquireLock(String key, long waitTime, long leaseTime, TimeUnit timeUnit) throws InterruptedException;
public abstract void releaseLock(String key);
}가장 단순한 락 방식으로, 락 획득 실패 시 즉시 예외를 발생시킨다.
락 획득에 실패하더라도 일정 시간/횟수 동안 계속해서 재시도하는 방식이다.
단순한 루프 기반 재시도이지만, 부하가 적은 환경에서는 유용하게 사용할 수 있다.
@Slf4j
@Component
@RequiredArgsConstructor
public class SpinLockTemplate extends DefaultLockTemplate {
private static final String UNLOCK_SCRIPT = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
""";
private final StringRedisTemplate redisTemplate;
private final LockIdHolder lockIdHolder;
@Override
public LockStrategy getLockStrategy() {
return LockStrategy.SPIN_LOCK;
}
@Override
public void acquireLock(String key, long waitTime, long leaseTime, TimeUnit timeUnit) {
long startTime = System.currentTimeMillis();
String lockId = UUID.randomUUID().toString();
lockIdHolder.set(key, lockId);
log.debug("락 획득 시도 : {}", key);
while (!tryLock(key, lockId, leaseTime, timeUnit)) {
log.debug("락 획득 대기 중 : {}", key);
if (timeout(startTime, waitTime, timeUnit)) {
throw new IllegalStateException("락 획득 대기 시간 초과 : " + key);
}
Thread.onSpinWait();
}
}
@Override
public void releaseLock(String key) {
if (lockIdHolder.notExists(key)) {
log.debug("락 해제 실패 : 락을 보유하고 있지 않음 : {}", key);
return;
}
String lockId = lockIdHolder.get(key);
unlock(key, lockId);
lockIdHolder.remove(lockId);
log.debug("락 해제 : {}", key);
}
private boolean tryLock(String key, String lockId, long leaseTime, TimeUnit timeUnit) {
return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key, lockId, leaseTime, timeUnit));
}
private boolean timeout(long startTime, long waitTime, TimeUnit timeUnit) {
return System.currentTimeMillis() - startTime > timeUnit.toMillis(waitTime);
}
private void unlock(String key, String lockId) {
redisTemplate.execute(
new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class),
Collections.singletonList(key),
lockId
);
}
}Redis의 Publish/Subscribe 기능을 활용하여, 락 해제 이벤트를 수신한 후 락 획득을 재시도하는 방식이다.
Redisson 라이브러리 내부적으로 이 방식을 기반으로 구현되어 있다.
@Component
@RequiredArgsConstructor
public class PubSubLockTemplate extends DefaultLockTemplate {
private final RedissonClient redissonClient;
@Override
public LockStrategy getLockStrategy() {
return LockStrategy.PUB_SUB_LOCK;
}
@Override
public void acquireLock(String key, long waitTime, long leaseTime, TimeUnit timeUnit) throws InterruptedException {
RLock lock = redissonClient.getLock(key);
log.debug("락 획득 시도 : {}", key);
boolean acquired = lock.tryLock(waitTime, leaseTime, timeUnit);
if (!acquired) {
throw new IllegalStateException("락 획득 실패 : " + key);
}
}
@Override
public void releaseLock(String key) {
RLock lock = redissonClient.getLock(key);
if (lock.isHeldByCurrentThread()) {
lock.unlock();
log.debug("락 해제 : {}", key);
}
}
}[출처]
항해 플러스 : https://hanghae99.spartacodingclub.kr/plus/be


