본문 바로가기

backend

동시성 문제 - Synchronized, 낙관적 락, 비관적 락, 분산 락 비교 분석

애플리케이션에서 사용자가 동시에 동일한 자원을 수정하려고 할 때 경쟁 상태(Race Condition)가 발생할 수 있으며, 개발자는 이러한 동시성 문제가 발생하지 않도록 설계해야 합니다.

 

이때 개발자가 선택할 수 있는 4가지의 주요 전략을 분석합니다.

 

 

락(Lock)이란 무엇이며 왜 필요할까?

 

락(Lock)은 여러 주체가 하나의 공유 자원에 동시에 접근하려 할 때, 순서를 제어하고 데이터의 정합성을 유지하기 위한 권한 제어 메커니즘입니다.

 

공유 자원에 대해 여러 작업이 동시에 일어날 때 한 작업의 결과가 다른 작업에 의해 임의로 변경되는 등의 현상(Race Condition)을 막기 위해서 락이 필요합니다!

 

공유 자원
여러 주체가 동시에 접근할 수 있는 데이터

Race Condition

공유자원에 동시에 접근하여 결과값에 영향을 줄 수 있는 상태

Critical Section
공유 자원에 접근하여 작업을 수행하는 코드의 영역

Lock
임계 영역(Critical Section)에 한 번에 하나의 주체만 진입할 수 있도록 하는 동기화 기법

 

 

Java 수준의 Synchronized와 모니터 락(Monitor Lock)

 

가장 기본이 되는 방식은 자바 언어 차원의 synchronized입니다.

단일 서버 내에서 JVM 메모리 자원을 보호할 때 사용할 수 있습니다.

 

자바의 모든 객체는 내부적으로 모니터(Monitor)를 가지고 있습니다. 이를 '인스턴스 락' 또는 '고유 락'이라고도 부릅니다.

이러한 모니터는 하나의 객체를 한 번에 하나의 스레드만 소유할 수 있게 제어합니다.

 

작동 방식

  1. 스레드가 synchronized 블록에 진입하려면 해당 객체의 모니터 소유권을 획득해야 합니다.
  2. 소유권을 획득하면 해당 스레드는 "락을 가졌다"고 표현하며, 동기화 블록(synchronized) 내부의 코드를 실행합니다.
  3. 이때 다른 스레드가 진입하려고 하면, 모니터가 이를 막고 해당 스레드를 대기열(Entry Set)에 넣어 Blocked 상태로 만듭니다.
  4. 먼저 들어간 스레드가 작업을 마치고 나와 모니터를 반납하면 대기열에 있던 스레드 중 하나가 다시 소유권을 얻습니다.
private final Object lock = new Object();

public void increment() {
    synchronized(lock) {
        count++;
    }
}

 

간단한 동기화 블록 예시입니다.

이때 synchronized 키워드 뒤의 괄호 안에 들어가는 객체가 모니터 역할을 합니다.

모든 스레드가 동일한 객체를 바라볼 수 있도록 공통 객체를 사용해야 하며, 이를 통해 임계 영역을 보호하게 됩니다.

만약 스레드마다 서로 다른 객체를 바라본다면 동기화가 이루어지지 않으므로 주의해야 합니다!

 

1) synchronized(this)

  • 현재 인스턴스 자체를 락으로 사용하는 경우입니다.
  • 별도의 객체 선언이 없어 간편하지만, 해당 인스턴스가 외부에 노출되어 있다면 외부 코드에서도 이 객체를 이용해 synchronized 블록을 만들 수 있고 이때 의도치 않게 서로 다른 로직이 같은 락을 공유하게 되어 성능 저하나 데드락이 발생할 위험이 있습니다.

2) private final Object lock = new Object();

  • 락 전용 객체를 별도로 생성하여 private으로 선언합니다.
  • 외부에서 락에 접근할 수 없으므로 클래스 내부에서만 동기화를 제어할 수 있어 캡슐화에 유리합니다.
  • 실행 도중 lock 객체가 다른 객체로 교체되면 동기화가 깨질 수 있으므로 반드시 final로 선언해야 합니다.

 

하지만 synchronized 블록으로 동시성을 제어하는 데는 한계가 있습니다.

JVM 내부에서만 스레드를 제어하므로 여러 대의 서버가 존재하는 환경에서는 적용할 수 없으며, 

DB 데이터 수정을 synchronized로 막는 것은 위험합니다.

 

@Transactionl과 synchronized를 동시에 사용하는 경우 아래와 같은 시나리오로 동작합니다.

  1. 트랜잭션 시작 (프록시 객체가 수행)
  2. synchronized 블록 진입 (락 획득)
  3. DB 데이터 수정
  4. synchronized 블록 종료 (락 해제)
  5. 트랜잭션 커밋 (프록시 객체가 수행

락은 4번에서 해제되었는데 실제 DB에 데이터가 저장되는 커밋 시점은 5번이므로 4번과 5번 사이 다른 스레드가 synchronized 블록에 진입할 수 있는 가능성이 존재합니다.

이때 두 번째 스레드가 아직 첫 번째 스레드의 수정 사항이 커밋되지 않은 DB 데이터를 읽어가게 되면 동시성 문제가 발생하게 됩니다!

 

 

즉, 단일 서버 환경에서 애플리케이션 내의 공유 변수에 대한 동시성을 제어하는 경우에 사용하는 것이 적합합니다.

 

 

데이터베이스 수준의 비관적 락 & 낙관적 락

 

애플리케이션을 넘어 DB 레벨에서 데이터 정합성을 보장하기 위해서는 비관적 락과 낙관적 락을 적용할 수 있습니다.

 

비관적 락(Pessimistic Lock)

 

"충돌은 무조건 발생한다"고 가정하고 데이터를 읽는 시점부터 물리적인 락을 거는 방식입니다.

공유 락 또는 베타 락을 직접적으로 선택하여 사용하며 주로 베타 락을 획득합니다.

공유 락(S-Lock)
다른 스레드의 읽기는 허용하지만 수정을 막습니다.

베타 락(X-Lock)
다른 스레드의 읽기와 수정 모두를 차단합니다.

 

데이터 정합성이 완벽하게 보장된다는 장점이 있지만,

락을 획득할 때까지 다른 요청들이 대기해야 하므로 성능 저하가 발생할 수 있으며 서로의 자원을 기다리는 데드락(Deadlock) 발생 위험이 있습니다.

 

 

동일한 데이터를 수정하려는 요청이 많아 충돌이 빈번할 것으로 예상되는 경우, 데이터의 정합성이 무엇보다 중요한 경우에 사용합니다.

 

낙관적 락(Optimistic Lock)

 

"충돌이 거의 없을 것"이라 가정하고 실제 락을 거는 대신 버전 관리를 통해 정합성을 유지하는 방식입니다.

버전 번호를 관리하며 아래와 같은 메커니즘으로 정합성을 유지합니다.

  1. 데이터를 읽을 때 버전 번호를 함께 읽습니다.
  2. 수정할 때 WHERE절을 통해 내가 읽었던 버전이 맞는지 확인합니다.
  3. 그 사이에 누군가 수정했다면 버전이 다를 것이며, 이때 재시도 로직을 실행합니다.

물리적인 DB 락을 걸지 않으므로 성능상 유리하지만,

충돌이 발생하여 업데이트가 실패할 경우 실행할 재시도 로직을 구현해야 합니다.

 

 

충돌이 잦으면 오히려 재시도 비용이 커져 성능상 불리할 수 있으므로 수정 작업이 적고 대부분의 작업이 조회인 경우, 충돌이 거의 발생하지 않는 경우에 적합합니다.

 

 

분산 환경의 분산 락(Distributed Lock)

 

서버가 여러 대인 분산 시스템 환경에서는 각 서버의 Java 모니터 락은 서로 공유될 수 없습니다.

이때는 모든 서버가 공통으로 바라보는 외부 저장소(Redis 등)를 이용해야 합니다.

 

왜 DB 락 대신 분산 락을 써야 할까?

 

서버가 여러 대여도 하나의 DB를 쓴다면 DB 락으로 해결 가능할 것입니다. 하지만 분산 락을 쓰는 이유는 다음과 같습니다.

 

1) DB 커넥션 고갈 방지

  • DB 비관적 락은 락을 얻기 위해 대기하는 동안 DB 커넥션을 점유합니다. 대규모 트래픽 환경에서 요청이 폭주한다면 커넥션 풀이 고갈될 것입니다.
  • 분산 락은 Redis에서 미리 필터링하여 DB 부하를 줄일 수 있습니다.

2) DB 이외의 자원 보호

  • DB가 아닌 자원의 동시성을 DB 락으로 제어할 순 없습니다. 외부 API 호출, 파일 시스템 접근 제어 등 DB 이외의 자원을 보호하기 위해 분산 락을 사용할 수 있습니다.

3) 성능

  • 인메모리 기반인 Redis는 디스크 기반 DB 락보다 훨씬 빠릅니다.

 

Redis 기반 라이브러리 비교

 

분산 락을 구현하는 가장 대중적인 방법은 Redis를 사용하는 것입니다.

 

1) Lettuce (스핀 락 방식)

SETNX 명령어를 사용하여 락 획득을 시도합니다. 실패하면 일정 시간 뒤에 다시 시도하는 재시도 루프(Spin Lock)를 돕니다.

락을 얻을 때까지 계속해서 Redis에 요청을 보내므로 Redis에 부하를 줄 수 있으며, 재시도 로직을 직접 구현해야 합니다.

 

2) Redission (Pub/Sub 방식)

락이 해제되면 Redis가 대기 중인 서버들에게 알림(Publish)을 보냅니다.

알림을 받을 때만 락 획득을 시도하므로 Redis 부하가 적습니다.

 

 

4가지 전략 비교

 

비교 항목 Java Synchronized 낙관적 락 비관적 락 분산 락 (Redis)
제어 범위 단일 JVM 스레드 DB 레코드 (논리) DB 레코드 (물리) 분산 서버 전체
적합한 환경 단일 서버 내부 자원 충돌이 적은 조회 위주 충돌이 잦은 핵심 자원 대규모 트래픽, MSA
성능 매우 높음 (로컬) 높음 (락 오버헤드 X) 낮음 (대기 발생) 중간 (네트워크 I/O)
정합성 보장 서버 확장 시 불가능 보통 (예외 처리 필요) 매우 높음 높음
주요 장점 구현이 쉬움 DB 성능 저하 최소화 정합성 보장 확실 DB 가용성 보호

 

 

단순한 메모리 보호라면 synchronized를,

충돌이 적은 일반적 상황엔 낙관적 락을,

정합성이 최우선이라면 비관적 락을,

트래픽이 몰리는 대규모 시스템에서 DB 부하까지 고려해야 한다면 분산 락을.

 

결국 완벽한 기술은 없으며 각 전략의 트레이드 오프를 이해하고 서비스의 비즈니스 환경에 가장 적합한 전략을 선택하는 것이 최선의 방법이 될 것입니다!

 

 

 

 

'backend' 카테고리의 다른 글

웹 어플리케이션 보안 취약성  (0) 2024.11.02
ORM  (0) 2024.09.13
JDBC  (0) 2024.09.13
템플릿 엔진(Template Engine)  (0) 2024.09.13
MVC 패턴  (0) 2024.04.20