동시성에 대한 해결방법을 알아보자

엄태권·2024년 2월 17일
4
post-thumbnail

컴퓨터 과학에서 동시성이란 프로그램, 알고리즘 또는 문제의 여러 부분이나 단위가 결과에 영향을 주지 않고 순서와 상관없이 또는 부분적으로 실행될 수 있는 기능을 말합니다.

동시성과 교착 상태를 설명하는 예시로 식사하는 철학자 문제 가있습니다.

다섯 명의 철학자가 하나의 원탁에 앉아 식사를 한다. 각각의 철학자들 사이에는 포크가 하나씩 있고, 앞에는 접시가 있다. 접시 안에 든 요리는 포크를 두개 사용하여 먹어야만 하는 스파게티 이다.

그리고 각각의 철학자는 다른 철학자에게 말을 할 수 없으며, 번갈아가며 각자 식사하거나 생각하는 것만 가능하다. 따라서 식사를 하기 위해서는 왼쪽과 오른쪽의 인접한 철학자가 모두 식사를 하지 않고 생각하고 있어야만 한다.

또한 식사를 마치고 나면, 왼손과 오른손에 든 포크를 다른 철학자가 쓸 수 있도록 내려놓아야 한다. 이 때, 어떤 철학자도 굶지 않고 식사할 수 있도록 하는 방법은 무엇인가?

동시성

동시성이란 애플리케이션이 둘 이상의 작업을 동시에 진행 중임을 의미합니다. 보통의 동시성을 설명할때 주로 CPU를 예로 들어 설명합니다.

실제 우리의 컴퓨터는 사용자의 입장에서 멈춤없이 동시에 여러 프로그램이 실행되는 것처럼 보이지만, 내부의 CPU는 빠르게 여러 프로그램들을 돌아가며 테스크를 수행합니다.

동시성이란 여러 작업을 동시에 실행하는 것을 의미하지만 반드시 동일한 시간에 실행될 필요는 없습니다. 즉 CPU의 코어가 1개일 경우 한 작업을 먼저 실행하고 다음 작업을 실행하거나, 한 작업과 다른 작업을 번갈아 가며 실행하는 등의 방식으로 실행됩니다.

이런 현상은 우리의 애플리케이션 전반에서도 일어나는 일반적인 현상이 될 수 있습니다. 사용자가 CPU의 빠른 테스크 작업을 눈치채지 못하는 것처럼, 많은 사용자의 요청에 대해 우리의 Java 혹은 데이터베이스, 애플리케이션 또한 마찬가지로 많은 요청들이 동시에 발생한다는 착각을 할 수 있습니다.

이러한 동시성은 멀티 스레드 프로그래밍이 가능한 Java, 데이터베이스의 정합성, 혹은 선착순 예약시스템, 재고 시스템 등의 애플리케이션 서비스에서 고려해야할 대상입니다.

Java의 동시성 문제

Java는 멀티 스레드 프로그래밍이 가능한 언어입니다. JVM은 보통 어떤 서버(기기) 상에서 실행되고 있는 프로세스를 말합니다. 이러한 JVM은 최소 한 개(main 스레드) 이상 여러 스레드를 가질 수 있습니다.

그렇다면 많은 스레드가 공유한 자원에 동시에 접근하게 되면 어떤 문제가 발생할 수 있을까요?

public class Main {

    public static void main(String[] args) {
        Counter counter = new Counter();

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Final Counter Value: " + counter.getCount());
    }

    public static class Counter {
        private int count = 0;
        
        public void increment() {
            count++;
        }

        public int getCount() {
            return count;
        }
    }
}

위 코드만 간단하게 본다면, 정상적으로 동작했을 경우 count의 수는 20000이 되어야 합니다. 실제 코드를 실행하고 결과를 확인 하면 아래와 같습니다.

Final Counter Value: 16950

매 실행마다 다른 결과값을 받아 볼 수 있습니다. 그 이유는 Java의 특성인 멀티 스레드 프로그래밍에 있습니다. 두 스레드가 하나의 자원에 동시에 접근하며, 동기화 되지 않은 데이터의 결과입니다.

Java의 동시성 해결과 동기화

이러한 멀티 스레드 환경에서의 동시성 문제를 해결하기 위해 Java는 객체에 대해 thread-safe할 수 있게 스레드 간 동기화를 시키는 방법을 사용합니다.

Java에서 모든 Class의 최상위 클래스는 Object Class입니다. 모든 Java class 들은 직접 또는 간접적으로 Object Class를 상속 받고 있습니다.

이러한 Object Class 내부를 보면 아래와 같은 메소드 들을 확인할 수 있습니다.

public final native void notify();
public final native void notifyAll();
public final void wait();
public final native void wait(long timeoutMillis);
public final void wait(long timeoutMillis, int nanos)

각각의 메서드를 간단하게 설명하면 wait() 메서드를 호출하면 현재 락을 가지고 있던 스레드는 해제하고, 스레드는 다시 스레드 스케줄링에 의해 재 사용하게 됩니다.

notify(), notifyAll() 메서드는 둘 다 wait()으로 잠든 메서드를 깨웁니다. 둘의 차이는 잠든 스레드 하나만 깨우냐, 모두 깨우냐의 차이이다. notify() 의 경우 한 객체에 대해서 기다리는 스레드가 있다면, 그 중 하나의 스레드가 활성화 됩니다.

세 메서드가 가지는 공통적인 특징은 호출하는 스레드가 반드시 고유 락을 갖고 있어야 한다는 것입니다. 만일 고유한 락 없이 위 메소드들을 실행하게 되면 IllegalMonitorStateException이 발생하게 됩니다.

그렇다면 위 메서드들의 공통적인 특징인 고유 락을 가지고 있어야 한다는게 무엇일까요?

Lock

Java에서 말하는 고유한 락(lock)은 공유 리소스에 대한 액세스를 조정하고 여러 스레드가 동시에 액세스하지 못하도록 코드의 중요한 부분을 보호하는 데 사용됩니다.

이러한 메커니즘은 한 번에 하나의 스레드만 동기화된 블록 혹은 메서드에 액세스할 수 있도록 하여 데이터 손상과 경합을 방지합니다. Java는 이러한 동기화 달성을 위해 여러 메커니즘을 제공합니다.

[Java 1.2]
Java 플랫폼은 1.2버전에서 java.util.concurrent 패키지를 도입했으며, 해당 패키지에는 고수준 동시성 추상화인 Lock 인터페이스와 이를 구현한 ReentrantLock 클래스 등의 몇 가지 새로운 동기화 기초를 도입했습니다.

[Java 5]
이후 5버전에서 는 concurrent 프레임워크의 도입으로 동시성에 대한 주요 개선사항을 도입했습니다. java.util.concurrent.locks 패키지에는 ReadWriteLock, ReentrantReadWriteLock, Condition 등이 포함되었습니다.

이들은 모두 개발자가 동기화된 메커니즘보다 더 세분화된 동시성 제어를 구현할 수 있도록 지원하는 전체 동시성 유틸리티 클래스 세트입니다.

[Java 8]
낙관적인 잠금 메커니즘을 제공하는 StampedLock 클래스가 추가되었습니다.

아래는 스레드간의 동기화를 위한 일부 Lock의 방식입니다.

synchronized Blocks and Methods + Intrinsic Locks (Monitors)

Java는 동기화를 구현하기 위해 내재적 잠금(모니터)를 사용합니다. 모든 Java 객체에는 연결된 내재적 잠금이 있으며, 동기화된 블록/메서드는 이 잠금을 획득하고 해제합니다.

스레드가 동기화된 블록에 들어가면 잠금을 획득하고, 동일한 블록에 들어가려는 다른 스레드는 잠금이 해제될 때까지 차단됩니다. 이런 내재적 잠금은 내장된 효율적인 동기화 메커니즘을 제공합니다.

java는 동기화된 블록과 메서드 정의를 위해 키워드를 제공하며, 이는 우리가 많이 알고있는 synchronized 키워드 입니다. 해당 키워드를 통해 한 번에 하나의 스레드에서만 해당 메서드나 블록을 사용할 수 있습니다.

    public static class Counter {

        private int count = 0;

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

        public int getCount() {
            return count;
        }
    }

위 코드의 실행결과는 몇번을 실행해도 동일하게 20000이 출력됩니다. 그 이유는 synchronized 키워드로 인해 스레드간 데이터의 충돌을 방지해 주기 때문입니다.

여기에 위에서 설명한 Object Classwait(), notify()등을 적절히 사용해 동기화기능을 더 똑똑하게 사용할 수 있습니다.

ReentrantLock

ReentrantLock은 보다 유연하고 명시적인 잠금 관리 방법을 제공하는 java.util.concurrent.locks 패키지의 클래스입니다.

위에서 설명한 내재적 잠금과 달리 ReentrantLock은 시간 제한 잠금, 잠금 중단 및 공정성 정책과 같은 고급 기능을 사용할 수 있습니다.

    public static class Counter {

        private final ReentrantLock lock = new ReentrantLock();
        private int count = 0;

        public void increment() {
            lock.lock();

            try {
                count++;
            } finally {
                lock.unlock();
            }
        }

        public int getCount() {
            return count;
        }
    }

위 코드의 실행 결과도 동일하게 count 는 20000을 반환합니다. 실제 Synchronized의 경우 키워드를 통해 사용할 수 있다는 간편함이 있으며, 반대로 특별한 상황에서는 ReentrantLock를 통해 명시적인 동기화를 제공할 수 있습니다 또한 동기화된 블록을 잠그거나 해제할 때 더 유연하게 선택할 수 있습니다.

Database의 동시성 문제

Java와 마찬가지로 동시성 문제는 모든 데이터베이스 관리 시스템을 운영하면서 직면하게 되는 일반적인 문제입니다. 이러한 동시성 문제는 동일한 데이터베이스에서 여러 Transaction이 제한 없이 실행될 때 발생합니다.

데이터베이스에는 이러한 동시성으로 인해 발생할 수 있는 문제 몇 가지가 있습니다.

  • Lost Update Problem
  • Incorrect Summary Problem
  • Dirty Read Problem
  • Unrepeatable Read Problem
  • Phantom Read Problem

이러한 문제들로 인해 데이터의 무결성이 깨지고, 의도하지 않은 결과가 반환됩니다. 특히나 쇼핑몰 같은 경우는 상품 혹은 은행시스템에 있어 큰 문제가 발생할 수 있습니다.

Java의 메서드 동기화(synchronized 등)를 이용하여 데이터 충돌을 방지한다고 생각해 볼 수 있겠지만, 애플리케이션 운영의 입장에서 몇 가지 단점이 존재합니다.

기본적으로 성능 저하가 있습니다. 모든 스레드가 해당 메서드를 점유하고 있는 스레드의 작업이 끝날때 까지 기다려야 하기 때문이며, 여러 스레드가 서로를 기다리는 경우 교착상태 까지 고려를 해봐야합니다.

또한 보통의 애플리케이션의 경우 여러 인스턴스와 하나의 데이터베이스 서버로 구성되어 있는 경우가 많은데 이때 서로 다른 인스턴스 서버에서 실행되는 메소드의 데이터의 경우 데이터베이스의 입장에선 동시성이 발생하기 충분한 조건 입니다.

Database의 동시성 해결

우선 우리가 흔이 알고있는 관계형 데이터베이스는 모두 Trasaction Database라는 한 가지 공통점을 공유하고 있습니다. 즉, 모두 트랜잭션에 의존한다는 것입니다.

트랜잭션은 일반적으로 여러 개의 낮은 수준의 단계를 포함하는 논리적 단위를 의미합니다. 데이터를 생성, 업데이트 또는 검색하는데 사용 됩니다. 이러한 일련의 작업은 데이터베이스의 데이터가 항상 안정적이고 일관된 상태로 유지하도록 보장합니다.

이는 데이터베이스는 유효하며, 일관적인 데이터를 제공해야 하는것을 의미하며, 동시성의 상황에 있어서도 이를 유지하기 위해 여러 방법을 제공합니다.

Lock-Based Protocol

데이터베이스에서 잠금 기반 프로토콜은 필요한 잠금을 얻을 때까지 트랜잭션이 데이터를 읽거나 쓸 수 없도록 하는 메커니즘으로 정의할 수 있습니다.

이는 특정 트랜잭션을 특정 사용자에게 잠가 동시성 문제를 제거하는 데 도움이 됩니다. 잠금은 특정 데이터 항목에 대해 실행할 수 있는 작업을 나타내는 변수입니다.

데이터베이스의 잠금 기반 프로토콜에는 데이터 항목을 잠그고 해제하는 두 가지 모드가 존재합니다. 그리고 이러한 두 가지 모드를 통한 네 가지의 잠금 기반 프로토콜이 존재합니다.

Mode

  • Shared Lock
    흔히 lock-S()로 표시되는 공유 잠금은 관련된 정보에 대한 읽기 전용 액세스를 제공하는 잠금으로 정의됩니다. 여러 사용자가 읽을 수는 있지만, 정보나 데이터 항목을 읽는 사용자는 해당 정보에 대한 편집 혹은 변경 권한이 없습니다.

    공유 잠금은 read lock 이라고도 읽히며, 데이터 객체를 읽는 데만 사용되며, 이러한 특징으로 읽기 무결성은 공유 잠김을 통해 지원됩니다.

  • Exclusive Lock
    배타적 잠금의 경우 읽기와 쓰기 모두에 대한 액세스를 제공하는 잠금으로 정의됩니다. 한 번에 하나의 트랜잭션만 배타적 잠금을 획득할 수 있으며, 쓰기 연산을 완료한 후 데이터 항목의 잠금을 해제할 수 있습니다. 그동안 다른 트랜잭션은 해당 데이터 항목을 읽거나 쓸 수 없습니다.

Types

  1. simplistic Lock Protocol
    트랜잭션 중 데이터를 보호하는 가장 기본적인 방법으로 정의됩니다.

  2. Pre-Claiming Lock Protocol
    트랜잭션을 시작하기 전에 데이터베이스 관리 시스템에 모든 데이터 항목에 대한 모든 잠금을 요청합니다.

  3. Two-phase Locking Protocol
    잠금을 Growing PhaseShrinking Phase 2단계로 나누어 관리합니다.

  4. Strict Two-Phase Locking Protocol
    엄격한 2단계 잠금 프로토콜이라는 개념으로 계단식 롤백을 방지합니다.

problem

위와 같은 protocol들은 Starvation이 발생할 수 있는 여지가 있다는 문제점이 있습니다.

잠긴 항목에 대한 대기 체계가 올바르게 제어되지 않거나, 하나의 트랜잭션면 계속해서 배제되는 경우가 이에 해당합니다. 또한 락을 획득하기 위해 서로 리소스를 해제하기를 기다리면서 발생하는 Deadlock도 문제가 됩니다.

Pessimistic & Optimistic Concurrency Control

데이터베이스는 위와같은 Data의 Lock을 통해 동시성을 제어하며 이를 통해 데이터베이스의 쓰기 순서를 유지하고 데이터간의 불일치를 방지할 수 있습니다.

데이터베이스는 이처럼 동시에 두 명의 사용자가 데이터필드를 업데이트 할때 일관성을 유지하기 위해 비관적 락(Pessimistic)낙관적 락(Optimistic)을 통해 동시성을 처리할 수 있습니다.

Pessimistic Concurrency Control

비관적 동시성 제어는 기본적으로 트랜잭션 간의 충돌이 자주 발생할 수 있다고 가정하는 방식입니다. 이를 위해 사용자가 업데이트를 시작하면 데이터 레코드를 차단하는 방식을 통해 다른 사용자는 잠금이 해제될 때까지 해당 데이터를 업데이트할 수 없게하는 방법을 사용합니다.

비관적 동시성 제어 방식에는 세 가지의 잠금 모드가 사용되며, 이는 가각 Shared Lock, Exclusive Lock, Update Lock(Exclusive Lock과 유사)사용자는 적용된 잠금에 따라 잠긴 레코드를 읽을 수 있습니다.

MySQL에서는 FOR UPDATE문을 통해 해당 레코드에 독점 잠금을 적용할 수 있습니다. 아래는 비관적 동시성 제어를 사용하는 간단한 예시이며, MySQL의 예시코드입니다.

SELECT * FROM users WHERE user_id = 1 FOR UPDATE;

JPA 에서는 이러한 비관적 동시성 제어를 어노테이션을 통해 지원 하며, 아래와 같이 사용할 수 있습니다.

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithPessimisticLock(Long id);

이때 LockModeType에 따라 공유락 혹은 베타락을 설정할 수 있으며, 위의 코드상에서는 현재 WRITE 즉 Exclusive Lock으로 읽기와 쓰기 모두에 Lock을 걸게 되어있습니다.

이런 비관적 동시성 제어의 경우는 Lock을 활용하는 방식이며, 이는 Lcok으로 인해 발생할 수 있는 문제점등 몇가지 단점들이 존재합니다.

  • 잠금 기간이 길어질 경우 서비스의 성능 문제가 발생할 수 있습니다.
  • DeadLock이 발생될 수 있습니다.
  • 애플리케이션 확장성에 영향을 미칩니다.
  • 잠금과 그에따른 대기로 인해 높은 리소스가 소비됩니다.

이러한 비관적 동시성 처리 방식은 트랜잭션을 처리하기 전에 충돌을 차단하므로 나중에 트랜잭션을 롤백할 필요가 없으므로 데이터 충돌이 많은 애플리케이션에 적합합니다.

Optimistic Concurrency Control

낙관적 동시성 제어는 이름에서 알 수 있듯 동시성을 처리할 때 최상의 시나리오를 기대합니다. 즉 이는 트랜잭션 간의 충돌이 드물게 발생한다고 가정하고, 데이터 접근 시점에 락을 걸지 않고 비동기화된 방식으로 진행될 수 있도록 허용합니다. 이는 트랜잭션을 수행할 때 락을 걸어 차단하는 비관적 동시성 제어와는 다릅니다.

낙관적 동시성 제어는 사용자의 변경 사항이 데이터베이스에 커밋되기 직전에 트랜잭션 간의 충돌이 있는지 확인합니다. 충돌이 있는 경우 사용자가 개입(애플리케이션 단에서)하여 트랜잭션을 수동으로 완료해야 합니다.

이러한 낙관적 동시성 제어는 compare-and-set 연산을 통해 구현이 가능합니다. 타임스탬프 혹은 버전 번호를 사용하여 동시성을 제어하는 것이죠. 사용자가 데이터베이스에 변경 사항을 커밋하려고 할 때 두 항목을 비교하여 어떠한 레코드를 유지할지 결정하는 방식입니다.

MySQL에서는 낙관적 동시성 제어를 사용할 테이블에 버전 번호 혹은 타임스탬프를 기록하기 위한 컬럼을 추가하여 구현이 가능합니다.

create table users(
   user_id INT NOT NULL AUTO_INCREMENT,
   user_name VARCHAR(100) NOT NULL,
   version INT NOT NULL,
   PRIMARY KEY ( user_id )
);

처음에는 각 레코드의 version column이 1이 됩니다. 이후 해당 레코드에 업데이트 쿼리를 작성할 때 레코드의 버전을 변경합니다. 예를 들어 업데이트 전에 버전이 1이었다면 업데이트 쿼리를 사용하여 2로 변경이 필요합니다.(이때 다른 트랜잭션으로 부터 번호가 변경되지 않고 1로 유지되어야 합니다.)

SELECT * from users WHERE user_id = 1;

UPDATE users SET user_name = 'Chameera', version = version + 1 
WHERE user_id = 1 AND vrsion = 1;

비관적 동시성 제어와 마찬가지로 JPA 에서는 이러한 낙관적 동시성 제어를 어노테이션을 통해 간단하게 CAS(compare-and-set) 연산 구현이 가능하도록 지원 하며, 아래와 같이 사용할 수 있습니다.

@Entity
public class Stock {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long productId;

    private Long quantity;

    @Version
    private Long version; //CAS 연산을 위한 version 정보
    
    
    @Lock(LockModeType.OPTIMISTIC)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithOptimisticLock(Long id);

이때 persistemce provider가 Entity에서 낙관적 락의 충돌을 감지할 경우 OptimisticLockException 을 던지게되며, 이 예외로 인해 활성 트랜잭션은 롤백을 위한 롤백마크가 표시됩니다. 권장되는 예외처리 방법에서는 Entity를 다시 로드하거나 새로고침하여 업데이트를 재 시도하는 방법입니다.

위와 같은 특성의 낙관적 동시성 제어의 경우 아래 와같은 장점과 단점들이 존재합니다.
[장점]

  • 잠금이 필요하지 않아 교착 상황이 없음.
  • 애플리케이션 확장을 위한 지원 제공
  • 한 번에 여러 사용자에게 서비스 제공 가능

[단점]

  • 버전 또는 타임스탬프 유지 관리 필요
  • 동시성 처리 로직을 수동으로 구현해야 함.
  • 트랜잭션의 롤백에 대한 비용이 비쌈.

이런 낙관적 동시성 제어는 충돌이 적은 애플리케이션에 적합하며 데이터 충돌이 많지 않은 경우 필요한 롤백 횟수와 롤백에 드는 비용을 줄일 수 있습니다.

다중 버전 동시성 제어(MVCC) - 일관된 읽기

위와 같은 Lock 방식의 문제점들을 해결하기 위해 읽기 상황에서 하나의 레코드에 대해 여러 개의 버전을 관리하며, Lock을 사용하지 않고 일관된 읽기를 제공하는 MVCC방식이 있습니다.

데이터에 접근하는 사용자는 접근한 시점의 데이터베이스의 Snapshot을 읽으며, 이는 데이터에 대한 변경이 완료되기 전까지 변경사항에 대해 다른 사용자는 볼 수 없습니다.

MVCC는 사용자의 기록을 Undo 영역에 생성하며, 이전 버전의 데이터와 비교하여 변경된 내용을 새로운 버전으로 기록합니다. 이런 방식으로 하나의 데이터에 대해 여러 버전이 존재하며, 이후의 사용자는 가장 마지막 버전의 데이터를 읽습니다.(MVCC가 더 궁금하시다면)

Isolation Level

위에서 MVCC는 변경된 내용을 새로운 버전으로 기록하여 읽기에서 일관된 읽기를 제공합니다. 이때 MVCC는 Transaction의 ACID 특성중 하나인 I(Isolation Levle)에 따라 사용여부가 결정됩니다.

Isolation Level이란 트랜잭션의 격리 레벨이라는 의미로, 여러 트랜잭션이 동시에 변경하고 쿼리를 수행할 때 성능과 안정성, 일관성 및 결과의 재현성 간의 균형을 미세 조정하는 역할을 합니다.

SQL:1992(SQL-92) 표준에서 정의된 Isolation Level은 총 4가지로 정의 되었으며 각각 낮은 순서대로
READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE 이 존재합니다.

각 단계의 격리수준은 아래와 같습니다.

  • READ UNCOMMITTED
    가장 낮은 격리 수준으로 한 트랜잭션은 다른 트랜잭션이 수행한 아직 커밋되지 않은 변경 사항을 읽을 수 있습니다.(Dirty read, Non-repeatable read, Phantom read 현상 모두 재현 가능)

  • READ COMMITTED
    읽은 모든 데이터가 커밋되어 있음을 보장하는 격리 수준입니다. 커밋된 데이터만을 조회하므로 Dirty read가 재현되지 않습니다.

  • REPEATABLE READ
    반복 읽기를 보장하는 격리 수준입니다. 커밋된 데이터만을 조회하므로 Dirty readNon-repeatable read가 재현되지 않습니다.

  • SERIALIZABLE
    가장 높은 격리 수준으로 동시에 실행되는 트랜잭션이 직렬로 실행되는 것처럼 동작함을 보장합니다. 이때 Lock을 통해 제어하기 때문에 위에서 발생했던 모든 현상이 재현되지 않습니다. MySQL의 경우 평범한 select(다만 실제 데이터베이스에서 사용하기엔 성능상의 문제가 있어 사용하진 않습니다.)

각 데이터베이스는 위와 같은 격리수준 레벨을 통해 MVCC의 사용여부가 결정되며 예시로 MySQL의 경우 lock을 사용하는 SERIALIZABEconsistent read를 제공하지 않는 READ UNCOMMITTEDMVCC를 사용하지 않습니다.

또한 Isolation Level의 경우 각 데이터베이스 벤더사에 따라 구현은 다를 수 있으며, 모든 데이터베이스가 공통적인 Default Isolation Level을 사용하지 않고 각자의 구현에 따라 사용하는 기본레벨이 다릅니다.

Oracle과 postgresql의 경우 Default Isolation Level은 READ COMMITTED이며, MySQL의 경우 REPEATABLE READ를 사용합니다.

분산 락을 통한 동시성 해결

지금 까지 설명한 Java의 스레드 동기화 또는 락을 활용해서 애플리케이션을 구현한다면, 동시성의 문제는 어느정도 해결되지 않을까? 하는 생각이 들 수 있습니다.

우선 Java의 스레드 동기화의 경우 단일 인스턴스에선 해결이 가능한 문제처럼 보일지 모르지만, 여러 인스턴스가 실행되는 분산환경에서는 각 인스턴스간의 동시성을 처리하기엔 적합하지 않은 방법입니다.

또한 락을 활용한 데이터베이스의 동시성 해결의 경우 성능저하 혹은 DeadLock의 영향이 있으며, 낙관적 동시성 제어를 통한 방법 또한, 충돌된 트랜잭션들은 대기 없이 실패하게 되거나 별도의 재시도 구현이 필요하며 재시도 자체의 실패등을 고려해야 하기에 이는 곧 코드의 복잡도로 이어집니다. rollback비용 또한 무시할 수 없습니다.

동시성에 의한 충돌이 더 많은 시스템에선 좀 더 확장성과 가용성이 높고 유연한 처리가 필요하며, 이를 위해 분산 락을 활용하는 경우가 있습니다. 또한 분산 락을 활용하고도 만약의 상황에서의 데이터 정합성이 틀어지지 않기 위해 데이터베이스의 동시성 제어를 함께 활용하는 경우가 있습니다.

분산 락은 분산 컴퓨팅 환경에서 동시성을 향상시키는 기술입니다. 클러스터의 여러 노드에 분산되어 있는 데이터베이스, 파일 또는 네트워크 리소스와 같은 공유 리소스에 대한 액세스를 규제하는 데 사용됩니다.

이처럼 분산 잠금은 여러 노드가 동일한 리소스를 동시에 읽고 쓰는 것을 방지하기 위해 사용되는 방법으로 대규모 분산 시스템 혹은 고가용성 및 성능이 중요한 시스템에서 개별 노드들이 다른 노드가 무엇을 하고있는지 알 수 있도록 동기화하는 메커니즘을 통해 동시성 문제를 해결합니다.

ZooKeeper Distributed lock

아파치의 정의에 따르면 ZooKeeper는 구성 정보 유지, 이름 지정, 분산 동기화 제공, 그룹 서비스 제공을 위한 중앙 집중식 서비스입니다. 이러한 모든 종류의 서비스는 분산 애플리케이션에서 어떤 형태로든 사용됩니다. 즉 쉽게 말해 분산 애플리케이션 관리를 위한 코디네이션 입니다.

ZooKeeper 분산 잠금은 임시 순차 노드를 기반으로 ZooKeeper에서 노드로 잠금을 구현합니다.
여러 클라이언트가 동시에 잠금을 획득하면 여러 개의 임시 시퀀스 노드가 순차적으로 생성되며, 이때 첫 번째 시퀀스 번호를 가진 노드만 성공적으로 잠금을 획득할 수 있습니다.

이때의 주키퍼를 사용하여 구현된 락은 일종의 우선순위 큐와 유사한 동작을 하며, 이는 각 클라이언트가 직접적으로 우선순위를 결정하는 것이 아닌, 생성된 시퀀스 번호를 통해 우선순위가 결정됩니다.

따라서 락을 획득하지 못한 다른 노드들은 다음으로 낮은 시퀀스 번호를 가진 락 디렉토리의 경로에 대해 exists() 함수를 호출하고, watch 플래그를 설정합니다. 이후 잠금이 해제 되면 잠금을 획득할 수 있습니다.

Redis Distributed lock

레디스는 흔히 많이들 알고있는 인 메모리 키 값 데이터 구조 스토어 입니다. Redis의 대표적인 특징은 바로 Single Thread를 사용하고 있다는 것입니다. 이러한 단일 스레드를 사용함으로써 Redis는 CPU 소모의 최소화, Context Switching 비용의 최소화 또한 멀티스레드 애플리케이션 환경에서 스레드 동기화를 위해 필요한 잠금에 대한 오버헤드를 줄일 수 있습니다.

또한 인 메모리로 실행되기 때문에 지연 시간이 짧고 처리량이 높다는 장점도 있습니다. 이는 데이터 요청이 디스크로 이동할 필요가 없다는 것을 의미하며, 더 많은 작업 처리와 응답 시간에 대한 단축으로 이어집니다.

이러한 Redis는 분산 잠금을 효과적으로 사용하기 위해 필요한 최소한의 세 가지 속성을 보장하고 있습니다.

  • Safety property : 상호 배제로 특정 순간에는 한 클라이언트만 잠금을 보유할 수 있습니다.
  • Liveness property A : 교착상태가 없습니다.(충돌이 일어나더라도 결국 항상 잠금을 획득합니다
  • Liveness property B : 내결함성. 대부분의 Redis 노드가 가동 중이면 잠김 획득이 가능합니다.

Redis는 Lua Script를 하용하여 atomic한 작업 처리를 지원합니다. Lua Script는 여러 가지 명령어를 조합할 수 있을 뿐 아니라, 스크립트 자체가 하나의 큰 명령어로 해석되기 때문에 스크립트가 atomic하게 처리된다는 특징이 있습니다.

이를통해 애플리케이션 로직의 일부를 Redis 내에서 실행할 수 있으며 Atomic 한 작업으로 Redis 데이터에 대한 복잡한 작업을 수행할 수 있습니다.

redis> EVAL "return 'Hello'" 0
"Hello"
redis> EVAL "return 'Scripting!'" 0
"Scripting!"

또한 Redis는 SETNX 명령을 통해 분산 락을 구현할 수 있으며, 해당 명령어는 SET if Not eXists로 특정 KeyValue가 존재하지 않을 경우 값을 설정할 수 있다는 의미입니다.

추가로 DeadLock 방지를 위해 EX 명령어를 활용해 Lcok의 Timeout 설정 까지 가능합니다. 이러한 명령어를 통해 Redis Client들은 각각 분산 락을 구현했습니다.

Spin Lock

흔히 우리가 이야기하는 Spin Lock 방식은 사실 운영체제에서 비롯된 이름입니다. 공식적으로 Redis Client중 Lettuce는 분산락 기능을 제공하지 않기 때문에 필요한 경우 Spin Lock 방식등을 활용해 직접 구현해 사용하는 방식입니다.

Spin Lock이란 조금만 기다리면 바로 쓸 수 있는데 굳이 Context Switching으로 부하를 줄 필요가 있는가? 라는 컨셉으로 개발된 것으로 진입이 불가능할 때 Context Switching을 하지 않고 루프를 돌며 재시도 하는 것을 의미 합니다.

Spin Lock은 Lock을 얻을 수 없다면, 계속해서 Lock을 확인하며 바쁘게 기다리는 busy waiting이라는 특성이 있으며, 이는 무한적으로 루프를 돌며 최대한 다른 스레드에게 CPU를 양보하지 않는 다는 것입니다.

즉, Lock이 곧 해재되어 사용가능해질 경우 Context Switching이 줄어 CPU의 부담을 줄일 수 있다는 장점이 있는 반면, Lock이 오랫동안 유지된다면 오히려 CPU의 시간을 많이 소모할 가능성이 높습니다.

따라서 무한 적으로 루프를 돌기 보단 일정시간 Lock을 획득할 수 없다면 잠시 sleep 하는 방식의 Exponential Backoff(지수 백오프) 혹은 ConstantBackOff 알고리즘을 사용하는 것이 좋습니다.

위의 내용은 Redis에도 동일하게 작용합니다. sleep을 주었다고는 하지만 정해진 시간 마다 계속해서 Redis에 요청을 보내는 것으로 작업이 오래 걸리고 요청수가 많아질 수 록 Redis에는 더 큰 부하를 가하게 됩니다.

그렇다고 레디스에 부담을 덜 기위해 sleep 시간을 크게 늘린다면 Lock을 획득하기 위해 대기하는 시간이 길어져 오히려 성능적으로 비효율적인 상황이 생기게 됩니다.

Spin Lock의 경우 예상 대기 시간이 짧은 경우 유용하며, 이를 위해 sleep에 대한 적절한 시간 설정 또한 중요합니다.

publish/subscribe

Redis의 대표적인 Lock 방식중 Pub/Sub Lock은 대표적으로 Redisson Client가 제공하고 있습니다.

pub/sub은 게시와 구독이라는 의미를 가지고 있으며 이는 실제 서버리스 및 마이크로서비스 아키텍처에서 비동기적으로 통신하기 위한 소프트웨어 메시징 패턴을 말합니다.

이런 Pub/Sub 방식은 수신자가 발행자의 새 메시지를 반복적으로 폴링할 필요가 없다는 효율적인 측면이 있습니다.

Redisson Client는 이처럼 Lock을 pub/sub 채널을 사용하여 제공하며, 잠금을 획득하기 위해 대기 중인 모든 Redisson 인스턴스의 다른 스레드에 알림을 보내는 방식을 사용합니다.

또한 잠금을 획득한 Redisson 인스턴스가 충돌하면 해당 잠금은 획득된 상태에서 영원히 멈출 수 있기에 이를 방지하기 위해 Redisson은 잠금 감시를 유지하여 잠금 보유자인 Redisson 인스턴스가 살아있는 동안 잠금 만료를 연장합니다. 기본적으로 잠금 감시 시간 제한은 30초이며 변경이 가능합니다.

이러한 Pub/Sub 방식은 락이 해제될 때마다 subscribe 중인 클라이언트들에게 락 획득을 시도해도 된다.는 알림을 보내기 때문에 Spin Lock 과는 다르게 지속된 요청으로 인한 Redis의 부하가 발생하지 않는다는 장점이있습니다. 또한 사용자가 별도의 Retry 로직을 작성하지 않아도 되는 편리함도 있습니다.

Redlock Algorithm

보통의 서비스에서 Redis를 활용할 경우 위와같은 방법 등으로 분산 락을 구현합니다. 다만 처음 이야기 했던 Redis의 분산 잠금을 효과적으로 사용하기 위한 필요한 최소한의 세 가지 속성중 우리는 내결함성에 대한 이야기를 하지 않았습니다.

위와 같은 분산 락들이 Single Redis로 구축되어 있을 경우 Redis에 문제가 생긴다면 이는 SPOF(single point of failure)단일 장애점이 될 수 있습니다. 이런 문제는 전반적인 시스템 전체에 영향을 줄 수 있습니다.

따라서 이러한 SPOF 문제를 해결하기 위해 Redis는 Cluster 혹은 Sentinel과 같은 방식을 통해 고가용성을 제공하고 있습니다. 다만 이렇게 여러 노드의 Redis가 동작하는 경우에도 Master-Replica의 동기화 작업 중 Master에 장애가 발생한다면 Lock이 유실되는 것은 막을 수 없습니다.

이를 위해 Redis는 Redlock Algorithm을 제공하며 이는 Cluster 환경의 여러 노드의 Redis 마스터 노드들이 Lock을 획득 하거나 해제할 수 있도록 보장합니다.

[Redlock Algorithm]

  • 현재 시간을 밀리초 단위로 가져옵니다.
  • 모든 N개의 인스턴스(Redis) 순차적으로 동일한 키 이름과 랜덤 값을 사용하여 자금을 얻으려 시도합니다. 이때 인스턴스와 통신하는 동안 긴 block을 하지 않도록 전체 잠금 자동 해제 시간에 비해 작은 타임아웃을 사용합니다. 이를 통해 인스턴스가 사용 불가 상태일 경우 빠르게 다음 인스턴스와 통신을 시도합니다.
  • 과반수의 인스턴스(적어도 3개)에서 잠금을 얻고, 잠금을 얻기 위해 소요된 총 시간이 잠금 유효시간 보다 작은 경우 잠금을 획득합니다.
  • 만일 클라이언트가 인스턴스의 절반 이상을 잠그지 못했거나, 유효시간이 음수인 경우에 모든 인스턴스는 잠금을 해제하려고 시도합니다.

위와 같은 방식의 Redlock Algorithm은 마틴 클레프만(데이터 중심 애플리케이션 설계 저자)의 글을 통해 그 한계가 있다는 분석이 나오기도 했습니다. 간단하게 보면 아래와 같습니다

마틴 클레프만의 Redlock Algorithm의 한계 분석

  • Redlock 알고리즘은 시간적 가정에 의존하여 작동하며, 네트워크 지연, 프로세스 일시 중단, 시스템 클럭 오차 등에 민감합니다.(예를들어 GC로 인한 stop-the-world 등)
  • Redlock는 동기적 시스템 모델에 의존한다고 가정하는데, 이는 실제 시스템 환경에서는 적용하기 어렵습니다.
  • Redlock의 안전성 및 신뢰성이 취약한 점을 지적하며, 이를 해결하기 위해서는 적절한 합의 알고리즘을 사용해야 한다고 제안합니다. 이는 클라이언트가 잠금을 획득할 때마다 증가하는 펜싱 토큰 또는 버전 등의 기능이 Redlock에는 없기에, 클라이언트가 중단되거나 지연될 경우 race condition이 발생하여 동시성 문제가 생길 수 있어 이러한 알고리즘이 필요하다는 것 입니다.

이에 Redis 측에서도 이에 대한 반박 글을 공유하기도 했습니다. 하지만 해당 글에서도 전문가의 의견 혹은 테스트가 필요하다는 점은 강조하고 있습니다.

종합해 봤을 때 Redlock Algorithm은 분산 락 환경에서 좋은 알고리즘이며, Redis에서 권장하고 있는 알고리즘입니다. 다만 무조건 적인 안전이라고 볼 수는 없으며, martin kleppmann의 이야기 처럼 문제가 생길 수 있는 부분 또한 존재합니다.

정리

Java에서 Database 그리고 애플리케이션에서의 분산 락 활용까지를 대표적인 것들을 통해 알아보았습니다. 이 글을 통해 각 서비스에 적합한 동시성 해결 방안을 결정하는데 도움이 되었으면 좋겠습니다. 또한 모든 선택에는 Trade-off가 존재하기에 상황에 맞는 방법을 찾기위한 깊은 고민또한 필요할 거 같습니다.

https://en.wikipedia.org/wiki/Concurrency_%28computer_science%29#Practice
https://stackoverflow.com/questions/749641/how-does-synchronized-work-in-java
https://medium.com/@svosh2/java-concurrency-in-practice-synchronization-and-locks-2276960080ac
https://dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.html
https://stackoverflow.com/questions/1957398/what-exactly-are-spin-locks
https://redis.io/docs/manual/patterns/distributed-locks/#analysis-of-redlock
https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
http://antirez.com/news/101
https://github.com/redisson/redisson/wiki/8.-Distributed-locks-and-synchronizers
https://www.youtube.com/watch?v=UOWy6zdsD-c

profile
https://github.com/Eom-Ti

0개의 댓글