동시성 문제(낙관적락, 비관적락, 분산락)

조현희·2026년 2월 26일

동시성 문제

  • 여러 사람이 동시에 같은 데이터를 다룰 때
  • 비관적 락 (Pessimistic Lock): 무조건 충돌이 발생한다고 가정하여 먼저 잠그고 다른 사용자는 대기시킨다. → 선행 조치
  • 낙관적 락 (Optimistic Lock): 괜찮을 거라고 생각해 잠그지 않고, 나중에 충돌 여부를 확인한다. → 후행 조치
  • 분산 락 (Distributed Lock): 분산된 환경, 여러 서버 환경에서도 동일한 데이터를 하나만 수정하도록 조정한다.

레이스 컨디션

  • 경쟁 상태(동일한 자원을 누가 먼저 처리하느냐에 따라 결과가 달라지는 것)

실습

package silsups.plussilsup;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class RaceConditionExample {
    static int balance = 100; // 현재 100원이 있음. 10명이 1원씩 빼는 것을 10번 실행할 것임

    public static void main(String[] args) throws InterruptedException {
        //동시에 실행할 수 있는 환경을 만들어줌. 동시에 10명이 실행하도록 설정함
        ExecutorService executor = Executors.newFixedThreadPool(10);

        //총 100번 실행한다
        for (int i = 0; i < 100; i++) {
            executor.submit(() -> withdraw(1));
        }

        //현재 executor에 위의 코드들을 담아둔 것. 셧다운 하면 이제부터 실행해라 라는 의미임
        executor.shutdown();
        Thread.sleep(2000); // 모든 스레드가 끝날 때까지 잠시 대기

        System.out.println("최종 잔액: " + balance);
    }

    static void withdraw(int amount) {
        int current = balance;     // 1. 잔액 조회
        try {
            Thread.sleep(10);      // 2. 동시에 실행되도록 일시정지시켜서 다같이 기다리게 함
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        balance = current - amount; // 3. 잔액 갱신
    }
}
  • 실행해보면 100번 실행했는데 0원이 아니라 90원이라고 나옴

비관적 락 pessimistic lock

개념

  • 먼저 온 1번이 데이터를 읽고 락을 검
  • 계산 및 저장 후 락을 해제
  • 나중에 온 2번이 데이터에 접근

주의

  • 단순 조회에는 락을 걸면 안됨
  • 쓰기 작업이 동시에 발생할 구간에만 사용
  • 트랜잭션을 짧게 (@Transactional 이 붙은 메서드 짧게)

장점

  • 절대로 데이터가 안꼬임

단점

  • 느리고 병목 발생

사용법

  • repository에 락을 걸어주기'
  • pessimistic read: 읽기는 허용, 쓰기는 차단
  • pessimistic write: 읽기 쓰기 다 차단.
  • select * from A where id = 1 for update; 하면 pessimistic write 로 완전 독점하는 것.
  • 코드
public interface AccountRepository extends JpaRepository<Account, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Account findByIdForLOCK(@Param("id") Long id);

}
// 테스트 코드
@SpringBootTest
class PessimisticLockTest {

    @Autowired
    AccountService accountService;
    @Autowired
    AccountRepository accountRepository;

    @Test
    void 동시에_두_트랜잭션_출금_락있는버전() {
        // 초기 데이터 세팅
        Account account = accountRepository.save(new Account(100));

        ExecutorService executor = Executors.newFixedThreadPool(3);

        Runnable task1 = () -> {
            try {
                accountService.withdrawLock(account.getId(), 10);
            } catch (Exception e) {
                System.out.println(Thread.currentThread().getName() + " 실패: " + e.getMessage());
            }
        };

        Runnable task2 = () -> {
            try {
                accountService.withdrawLock(account.getId(), 10);
            } catch (Exception e) {
                System.out.println(Thread.currentThread().getName() + " 실패: " + e.getMessage());
            }
        };

        Runnable task3 = () -> {
            try {
                accountService.withdrawLock(account.getId(), 10);
            } catch (Exception e) {
                System.out.println(Thread.currentThread().getName() + " 실패: " + e.getMessage());
            }
        };

        // 동시에 실행
        executor.submit(task1);
        executor.submit(task2);
        executor.submit(task3);

        executor.shutdown();

        try {
            Thread.sleep(1500);
        } catch (InterruptedException e) {
            throw new RuntimeException();
        }

        // 결과 검증
        Account result = accountRepository.findById(account.getId()).orElseThrow();
        System.out.println("최종 잔액: " + result.getBalance());
    }

    @Test
    void 동시에_두_트랜잭션_출금_테스트_락없음() throws InterruptedException {
        // 초기 데이터 세팅
        Account account = accountRepository.save(new Account(100));

        //동시에 접근하는 환경을 만들어줘야 함
        ExecutorService executor = Executors.newFixedThreadPool(3);

        Runnable task1 = () -> {
            try {
                accountService.withdrawNoLock(account.getId(), 10);
            } catch (Exception e) {
                System.out.println(Thread.currentThread().getName() + " 실패: " + e.getMessage());
            }
        };

        Runnable task2 = () -> {
            try {
                accountService.withdrawNoLock(account.getId(), 10);
            } catch (Exception e) {
                System.out.println(Thread.currentThread().getName() + " 실패: " + e.getMessage());
            }
        };

        Runnable task3 = () -> {
            try {
                accountService.withdrawNoLock(account.getId(), 10);
            } catch (Exception e) {
                System.out.println(Thread.currentThread().getName() + " 실패: " + e.getMessage());
            }
        };

        executor.submit(task1);
        executor.submit(task2);
        executor.submit(task3);

        executor.shutdown();

        //실무에서는 sleep 안씀 억지로 잡아두는 거라 성능이 떨어짐
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }


        Account result = accountRepository.findById(account.getId()).orElseThrow();
        System.out.println("최종 잔액: " + result.getBalance());
    }
}

낙관적 락 optimistic lock

개념

  • 데이터를 잠그지 않고 수정
  • 버전을 통해 수정이 완료되는 시점에 다른 트랜잭션이 변경했는지를 감지함(내가 읽어온 버전과 일치하는지)

주의

  • 충돌시 처리(주로 재시도) 필요
  • 엔티티별로 버전 컬럼을 한개만 둠(동시성 문제가 여러 컬럼에서 발생하더라도) -> 서로 상관없는 컬럼들이 업데이트되더라도 버전이 다르면 수정이 안됨
  • 버전 컬럼이 여러개 필요하다면 엔티티 분리

장점

  • 별도의 대기 없이 처리

단점

  • 충돌 발생시 재시도하므로 오히려 비관적 락보다 느림

사용법

  • 충돌이 자주 일어나지 않는 곳에 적합
  • 게시글 수정, 프로필 변경 등
  • @Version 을 이용. 예외시 ObjectOptimisticLockingFailureException 발생
  • 코드
  • 코드의 경우 task 늘리면 task 숫자와 execute 문도 여러번 추가해야 하고, 서비스에서 재시도 횟수도 늘려줘야 함
// 엔티티에 아래 필드 추가
    @Version
    private Long version; // 낙관적 락 버전 컬럼
    

// service(재시도)
package org.example.nbcamchapter5.domain.account.service;

import lombok.RequiredArgsConstructor;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class AccountService {

    private final AccountCoreService coreService;


    public void withdrawWithRetry(Long id, int amount) {
        int retry = 0;
        while (retry < 3) {
            try {
                coreService.withdraw(id, amount);
                return;
            } catch (ObjectOptimisticLockingFailureException e) {
                retry++;
                System.out.println("충돌 발생, 재시도: " + retry);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException ignored) {
                }
            }
        }
        //3번 다 실패하면
        throw new IllegalStateException("3회 재시도 후 실패");
    }
}

//coreservice(출금)
@Service
@RequiredArgsConstructor
public class AccountCoreService {

    private final AccountRepository accountRepository;

    @Transactional
    public void withdraw(Long id, int amount) {
        Account account = accountRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("계좌 없음"));

        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        // 여기서 버전이 다를 경우 ObjectOptimisticLockingFailureException 예외를 발생시킴
        account.decrease(amount);
    }
}

//테스트 코드
@SpringBootTest
class OptimisticLockTest {

    @Autowired
    AccountRepository accountRepository;
    @Autowired
    AccountService accountService;

    @Test
    void 낙관적락_정상동작_테스트() throws InterruptedException {
        Account account = accountRepository.save(new Account(100));

        ExecutorService executor = Executors.newFixedThreadPool(2);
        Runnable task = () -> accountService.withdrawWithRetry(account.getId(), 10);

        executor.submit(task);
        executor.submit(task);

        executor.shutdown();
        try {
            Thread.sleep(2000);
        } catch(InterruptedException e) {
            throw new RuntimeException(e);
        }
        //executor.awaitTermination(30, TimeUnit.SECONDS);파일에는 이건데 강의에선 아님

        Account result = accountRepository.findById(account.getId()).orElseThrow();
        System.out.println("최종 잔액: " + result.getBalance());
    }
}

분산 락 distributed lock

개념

  • 여러 서버가 동시에 같은 작업을 하지 못하도록 문지기 하는 것

  • 서버 a가 작업중이면 서버 b는 기다리거나 실패 응답을 받음

  • 서버가 1대이면 낙관/비관적 락으로 가능하지만 서버가 여러 대가 되면 각 서버가 상태를 알 수 없으므로 각 서버에서 동일한 로직을 동시에 수행할 수 있음

  • 낙관적 락: 서버가 상태를 알 수 없으니 동시에 수행 가능
  • 비관적 락: db관여순간만 보호함
  • 분산 락은 비즈니스 로직 전체를 보호함(잔액조회, 결제, 쿠폰처리, 로그처리, 최종 업데이트까지)
  • 레디스에서 분산 락 관리

과정

  • 서버1, 2가 동시에 접근 가능 여부를 레디스에게 물어본다
  • 서버1이 먼저 들어오면 레디스에서 key를 생성하여 서버1이 작업중임을 체크함
  • 서버2는 레디스로부터 key를 확인함
  • 서버1이 작업이 끝나면 key를 회수함

주의

  • set if not exists(SETNX)
  • SETNX lock:account:1 요청 성공 시 락 획득
  • 실패했으면 이미 누가 사용중인 것
  • ttl(만료시간) 설정 가능. 장애가 난 경우 키를 계속 들고있으면 안되므로.
  • SETNX lock:account:1
  • 락을 건 주체만 락을 해제할 수 있도록 UUID 사용
  • 락 획득 실패 시 대기 후 재시도

단점 및 주의사항

  • ttl이 너무 짧으면 작업이 끝나지 않았는데 락이 풀림
  • 레디스 장애시 전체 동시성 제어가 무너짐
  • 아무 곳에나 락을 걸면 성능 저하, 레디스 부하

사용법

  • 의존성
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
  • 서비스

@Service
@RequiredArgsConstructor
public class AccountService {

    private final AccountRepository accountRepository;

    @Transactional
    @RedisLock(key = "lock:account")
    public void withdraw(Long accountId, int amount) {
        Account account = accountRepository.findById(accountId).orElseThrow();

        account.decrease(amount);
        System.out.println(Thread.currentThread().getName() + " → 출금 완료 (잔액: " + account.getBalance() + ")");
    }
    
}
  • RedisLockService
  • common 밑에 redis 패키지 추가
package org.example.nbcamchapter5.common.redis.service;

import java.time.Duration;
import java.util.Collections;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.nbcamchapter5.common.annotation.RedisLock;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Slf4j
public class RedisLockService {

    //저번에는 다양한 타입 매칭해주는 애를 따로 만들었었음. RedisTemplate<String, String> 이거랑 같음
    private final StringRedisTemplate redisTemplate;

    //락 획득 여부를 조사하는 메서드
    public boolean tryLock(String key, String value, long timeoutSeconds) {
        //레디스에 키가 있는지 검사하고
        // 락을 획득할 수 없으면. 키가 있으면 false(누군가 작업중)
        // 락을 획득할 수 있으면, 키가 없으면 true lock:account:1 형태로 키를 생성함
        Boolean result = redisTemplate.opsForValue()
            .setIfAbsent(key, value, Duration.ofSeconds(timeoutSeconds));

        //result 가 true면 true를 리턴, false면 false를 리턴
        return Boolean.TRUE.equals(result);
    }
    //락을 해제하는 메서드
    public void unlock(String key, String value) {
        //레디스에서 쓰는 문법을 그대로 가져온 것
        //누가 만들었는지 담겨있는 value를 가지고 레디스에 value가 일치하는지 검사
        // 일치한다면 키 삭제(del), 불일치한다면 0 리턴
        String script =
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                "   return redis.call('del', KEYS[1]) " +
                "else " +
                "   return 0 " +
                "end";

        redisTemplate.execute(
            new DefaultRedisScript<>(script, Long.class),
            Collections.singletonList(key),
            value
        );

        log.info("획득한 락 키 반납 :::: {}", Thread.currentThread().getName());
    }

}

  • 방금만든 함수를 레디스 쓸 때마다 넣어주기 불편하므로 커스텀 annotation, aop를 사용할 것임
  • common 밑에 annotation, aspect 패키지 추가
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLock {
    String key();      // 계좌 별 Lock key
    long timeout() default 5;
}
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class RedisLockAspect {

    private final RedisLockService lockService;


    @Around("@annotation(redisLock)")
    public Object run(
            ProceedingJoinPoint joinPoint,
            RedisLock redisLock
    ) throws Throwable {

        String keyPreFix = redisLock.key();
        //누가 키를 만들었는지 랜덤값 생성
        String value = UUID.randomUUID().toString();

        //RedisLock 어노테이션이 붙은 메서드의 첫번째 파라미터를 accountId로 둔다
        Object[] args = joinPoint.getArgs();
        Object accountId = args[0];

        //key: lock:account:1(accountId) 이런식이 됨
        String key = keyPreFix + accountId;

        //어노테이션에서 기본 타임아웃 5초로 설정함
        boolean locked = lockService.tryLock(key, value, redisLock.timeout());

        //locked == true라면 키가 없고 작업 가능
        //locked가 false라면 키가 있고 누가 이미 작업중인 것

        //락을 획득하지 못했다면(누가 작업중이라면)
        if (!locked) {
            log.info("락획득 실패 : {}", Thread.currentThread().getName());
            throw new IllegalStateException("이 계좌는 현재 처리 중입니다. 잠시 후 다시 시도해주세요.");
        }
        //락을 획득했다면
        try {
            //락을 획득했다면 accountService.withdraw 메서드를 실행
            log.info("락획득 성공 : {}", Thread.currentThread().getName());
            return joinPoint.proceed();
        } finally {
            //accountService.withdraw 메서드가 실행되었고 리턴까지 되었다면 락 반환
            lockService.unlock(key, value);
        }
    }
}
  • 테스트코드
  • 하나는 락획득을 못했으므로 끝
@SpringBootTest
public class DistributedLockTest {

    //빈 주입
    @Autowired
    AccountService accountService;

    @Autowired
    AccountRepository accountRepository;


    @Test
    void redis_분산락_테스트() throws InterruptedException {

        Account account = accountRepository.save(new Account(100));

        //두명
        ExecutorService executor = Executors.newFixedThreadPool(2);

        Runnable task = () -> {
            try {
                accountService.withdraw(account.getId(), 10);
            } catch (Exception e) {
                throw new RuntimeException(e);
                //System.out.println("실패");
            }
        };

        executor.submit(task);
        executor.submit(task);

        executor.shutdown();

        Thread.sleep(1000);

        Account result = accountRepository.findById(account.getId()).orElseThrow();

        System.out.println("최종 결과 : " + result.getBalance());

    }
}

락 획득시 전략

  • 즉시 실패: 중복실행하면 안되는 작업 또는 선착순
  • 재시도: 조금 기다리더라도 반드시 성공해야 하는 작업(재고 감소)
  • 재시도 너무 많이 하면 레디스에 부하
int retry = 0;
while (retry < 5) {
    boolean locked = lockService.tryLock(key, value, 5);

    if (locked) break; // 락 획득 성공

    Thread.sleep(100); // 100ms 기다림
    retry++;
}

if (retry == 5) {
    throw new IllegalStateException("잠시 후 다시 시도해주세요.");
}
  • 블로킹 대기: 락이 풀릴때까지 기다리기. 거의 안씀

참고 MVCC

  • 비관적 락 → 문 잠그고 작업
  • 낙관적 락 → 끝나고 보니 누가 건드렸는지 확인
  • MVCC → 복사본 만들어서 각자 작업. 자기가 보던 데이터만 보임. 잠금이 적어진다
  • 격리 수준에 따라 언제까지 기존 데이터를 보게 되는지 달라짐
  • 격리 수준
  • read committed: 똑같은 select 두번하면 결과가 달라질 수 있다(non-repeatable read)
  • repeatable read: 같은 트랜잭션 안에서는 결과 동일(mysql 기본)
  • serializable: 강한 격리수준. phantom read(두번 조회했는데 목록이 갑자기 늘거나 줄음)도 막는다

참고 락 적용

  1. 가장 적절
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select e from Event e where e.id = :id")
Event findByIdForUpdate(Long id);
  1. 네이티브쿼리: mysql 종속적
@Query(value = "select * from event where id = :id for update",
       nativeQuery = true)
Event findByIdForUpdate(Long id);
  1. entityManager 사용
entityManager.find(Event.class, id, LockModeType.PESSIMISTIC_WRITE);

record lock

  • 특정 레코드에 거는 락
  • 인덱스 레코드에 락이 걸리므로 인덱스가 없다면 많은 레코드에 락이 걸림
    select * from seat where id = 1 for update;
  • pessimistic_write를 걸거나 for update 로 걸면 db에 recore lock이 걸림

gap lock

  • 행과 행 사이의 공간을 잠금(팬텀 방지)
  • where id > 5 and id < 10 이럴 때 5와 10 사이의 공간을 잠금
profile
도전

0개의 댓글