
하나의 자원에 대해서 동시에 여러 스레드가 접근해 변경하려고 할 때 발생하는 이슈이다.
동시성 이슈는 데이터 정합성 문제를 일으키며 디버깅이 어려워 오류 감지가 어렵다.
그렇기 때문에 운영에서 반드시 해결해야 하는 문제이다. 
여러 작업이 동시에 실행되며 결과가 실행 순서에 따라 달라진다.
두 사용자가 동시에 같은 재고를 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