동시성 관리

신지훈·2026년 7월 3일

JAVA/Spring/JVM

목록 보기
15/15

동시성 관리가 일어나는 지점(어디서)'과 '구체적인 방법(어떻게)'을 계층별로 나누어 정리

1. 애플리케이션 레벨 (Application Layer)

코드나 프레임워크 수준에서 제어하는 방법으로 DB 부하를 줄일 수 있는 장점이 있다.

  • 낙관적 락 (Optimistic Lock): DB의 실제 락을 사용하지 않고, version 컬럼 등을 활용해 수정 시점에 데이터 정합성을 확인하는 방식 (예: JPA의 @Version)
    트래픽이 몰리는 환경에서는 부적합 너무 많은 충돌이 발생

  • 언어별 동기화 키워드: 자바의 synchronized나 ReentrantLock 등을 사용해 서버 프로세스 내에서 자원 접근을 차단 (단, 서버가 여러 대인 분산 환경에서는 효과가 없습니다.)

synchronized나 ReentrantLock

특정 코드 블록이나 메서드가 잠금의 대상으로 해당 코드를 실행하려는 스레드들끼리만 서로 순서를 기다린다. 이 방식들은 자바 가상 머신(JVM) 내부에서만 동작한다.

1. synchronized (암시적 락)

특징
간편함: 메서드나 특정 블록에 선언하면 끝
자동 해제: 로직이 끝나거나 예외가 발생하면 락이 자동으로 풀린다.
블로킹: 락을 얻을 때까지 무한 대기하며, 중간에 포기(Timeout)할 수 없다.

public synchronized void withdraw(long amount) {
    if (balance >= amount) {
        balance -= amount;
    }
}

2. ReentrantLock (명시적 락)

특징
유연함: 락을 거는 시점과 푸는 시점을 자유롭게 조절할 수 있다.
공정성(Fairness): 가장 오래 기다린 스레드에게 락을 주는 설정을 할 수 있다.
시도 가능(tryLock): 락을 얻기 위해 무한정 기다리지 않고, 일정 시간 동안만 시도할 수 있다. (데드락 방지에 유리)

// 생성자에 true를 넣으면 '공정성'이 보장됩니다.
private final ReentrantLock lock = new ReentrantLock(true);

public void processWithFairness() {
    try {
        // 가장 오래 기다린 스레드가 우선순위를 갖습니다.
        // 3초 뒤에 재시도
        if (lock.tryLock(3, TimeUnit.SECONDS)) {
            try {
                // 비즈니스 로직 수행
                System.out.println(Thread.currentThread().getName() + "이 락을 획득했습니다.");
            } finally {
                fairLock.unlock();
            }
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}
  • while(true)를 사용하면 성공할 때 까지 재시도 가능
  • 실무 환경에서는 성능상의 이유로 비공정 모드(기본값)를 사용, 상황에 맞는 전략 선택이 중요

애플리케이션 레벨의 동시성 관리의 한계

  1. "왜 이걸로 부족한가?"
    synchronized, ReentrantLock는 자바 가상 머신(JVM) 내부에서만 동작

서버가 2대일 때: 서버 A에서 synchronized로 막아도, 서버 B로 접속한 사용자는 동시에 같은 데이터를 수정할 수 있다. (서로 다른 JVM이기 때문)

직접 DB를 건드릴 때: 애플리케이션 코드를 거치지 않고 누군가 DB 툴(DBeaver 등)로 데이터를 직접 수정하면 애플리케이션의 synchronized는 아무런 힘을 쓰지 못합니다.

synchronized와 ReentrantLock은 애플리케이션 스레드 간의 질서를 지키는 도구지만 현대의 분산 환경(서버 여러 대)이나 데이터 자체의 무결성을 보장하기 위해서는 DB 락이나 분산 락 같은 추가적인 장치가 반드시 필요하다.

2. 데이터베이스 레벨 (Database Layer)

가장 확실하고 전통적인 방법으로 데이터의 무결성을 직접 보장한다.

  • 비관적 락 (Pessimistic Lock): 데이터를 읽을 때부터 SELECT ... FOR UPDATE 같은 구문으로 행을 직접 잠근다.

비관적 락 (선행 제어): 데이터를 읽는 시점에 미리 락을 건다.

개념: "충돌이 날 것 같으니 미리 잠그자"라는 추상적인 전략
포함 범위: 레코드 락뿐만 아니라 테이블 락, 페이지 락, 글로벌 락 등 DB의 모든 물리적인 잠금을 통칭하는 상위 개념

  • 데이터 무결성 최우선: 데이터 수정 중에 다른 사용자가 절대 건드릴 수 없으므로 정합성이 매우 높다.

  • 성능 저하 위험: 락을 획득할 때까지 다른 트랜잭션들이 대기(Wait)해야 하므로 트래픽이 많으면 시스템 전체가 느려질 수 있다.

  • 데드락(Deadlock) 가능성: 여러 트랜잭션이 서로가 가진 락을 기다리는 교착 상태가 발생할 수 있어 주의해야 한다.

  • 확실한 배타성: SELECT 시점부터 락을 걸기 때문에 조회한 데이터가 수정되는 동안 값이 변하지 않음을 보장받는다.

  • 레코드 락: 특정 행 하나만 잠금 (가장 많이 씀)

  • 페이지 락: 행들이 모여 있는 페이지 단위로 잠금

  • 테이블 락: 테이블 전체를 잠금 (수정 중에는 아무도 이 테이블에 못 들어옴)

JPA LockModeTypePostgreSQL 쿼리설명
PESSIMISTIC_READFOR SHARE다른 세션의 읽기(Shared Lock)는 허용, 수정/삭제는 차단
PESSIMISTIC_WRITEFOR UPDATE가장 일반적인 설정. 다른 세션의 모든 쓰기/잠금 읽기를 차단
PESSIMISTIC_FORCE_INCREMENTFOR UPDATE엔티티의 버전(Version) 필드를 강제로 증가시키며 잠금

재시도 방법

 @Retryable(
 				// 이 에러는 DB에서 데드락을 감지해 로그를 보내주면 에러 발생
        value = {PessimisticLockingFailureException.class}, // 이 에러 발생 시 재시도
        maxAttempts = 3, // 재시도 횟수
        backoff = @Backoff(delay = 5000) // 5초대기 후 재시도
    )
    @Transactional
    public void decreaseStock(Long id, Long quantity) {
        Stock stock = stockRepository.findByIdWithLock(id);
        stock.decrease(quantity);
    }

한 번 트랙재션에서 에러가 떠러 롤백을 해야 한다면 해당 트랜잭션은 더 이상 사용할 수 없다. 따라서 새로운 트랜잭션에서 작업을 새로 시작해야 하는데 @Transactional(propagation = Propagation.REQUIRES_NEW) 를 하지 않아도 @Retryable 이 이 작업을 대신 해준다.

다음과 같은 프록시 구조가 있기 때문에 가능
[ 호출자 ]
→ (1) Retry 프록시 : "실패하면 내가 다시 실행시켜 줄게" 
→ (2) Transaction 프록시 : "지금부터 트랜잭션 시작한다!"  
→ (3) 실제 비즈니스 로직 : "락 걸고 로직 수행!"

트랜잭션 격리 수준 (Isolation Level):

READ COMMITTED, REPEATABLE READ 등 설정에 따라 DB가 알아서 락 범위를 조절하도록 한다.

MVCC (Multi-Version Concurrency Control):

데이터의 스냅샷을 관리해 읽기와 쓰기가 서로를 방해하지 않게 한다. (MySQL, PostgreSQL의 핵심 기술)

3. 분산 환경 레벨 (Distributed Layer)

서버가 여러 대(스케일 아웃)인 환경에서 공통된 자원을 관리할 때 사용한다.

  • 분산 락 (Distributed Lock): Redis나 ZooKeeper 같은 외부 시스템을 활용해 여러 서버가 공통된 열쇠를 나눠 갖게 한다. (예: Redis의 Redlock)

  • 네임드 락 (Named Lock): DB의 메타데이터나 별도의 테이블을 활용해 특정 이름의 락을 획득한 프로세스만 작업하게 한다.

4. 아키텍처/메시징 레벨 (Architecture Layer)

구조적으로 동시성 문제를 회피하는 방법

  • 메시지 큐 (Message Queue): Kafka나 RabbitMQ를 사용해 요청을 순차적으로 줄 세우기(Queueing)하여 한 번에 하나씩 처리하게 한다. (이벤트 기반 아키텍처)
  • Redis 원자적 연산: Redis의 INCR 같은 명령어를 사용해 순식간에 몰리는 카운팅 작업을 DB 대신 메모리에서 처리한다.

낙관적 락 vs 비관적 락

1. 구현 주체의 차이 (누가 관리하는가?)

비관적 락 (Pessimistic Lock): 주체가 데이터베이스이다. SELECT ... FOR UPDATE 처럼 DB의 잠금 기능을 직접 호출한다. DB 엔진이 물리적으로 다른 트랜잭션의 접근을 막아버린다.

낙관적 락 (Optimistic Lock): 주체가 애플리케이션(Application)입니다.DB는 락을 걸지 않느다. 대신 애플리케이션이 데이터를 수정할 때 WHERE version = 1 처럼 조건을 걸어, 내가 읽었던 버전이 맞는지 확인하고 업데이트한다. DB는 그저 조건에 맞는 행이 없으면 0 rows updated를 반환한다.

2. 시점의 차이 (언제 막는가?)

비관적 락 (선행 제어): 데이터를 읽는 시점에 미리 락을 건다. "내가 쓸 거니까 아무도 건드리지 마!"

낙관적 락 (후행 제어): 데이터를 수정하는 시점(Commit 직전)에 충돌 여부를 확인한다. "일단 다들 수정해봐, 마지막에 넣는 사람만 내가 검사할게"

3. 실패 처리 방식의 차이 (충돌 시 어떻게 하나?)

비관적 락 (대기): 나중에 온 요청은 앞선 락이 풀릴 때까지 Wait(대기) 상태에 빠진다. (Timeout 설정 가능)

낙관적 락 (예외 발생): 버전이 맞지 않으면 즉시 Exception(예외)을 던진다. 애플리케이션 개발자가 이 예외를 잡아서 '다시 시도(Retry)'할지 '사용자에게 알릴지'를 직접 코딩해야 한다.

profile
주주주주니어 개발자

0개의 댓글