dev-sohee 님의 블로그

멀티 스레드 동시성 문제 해결 전략 (Redisson 분산락, Volatile, CAS 알고리즘) 본문

java

멀티 스레드 동시성 문제 해결 전략 (Redisson 분산락, Volatile, CAS 알고리즘)

dev-sohee 2024. 8. 3. 12:57

프로그램이 실제로 실행되어, 메모리나 CPU와 같은 자원을 할당 받으면 이를 프로세스라고 부릅니다. 스레드는 프로세스 내에서 실제로 작업을 수행하는 가장 작은 단위입니다. 모든 프로세스에는 한 개 이상의 스레드가 존재하고, 동시에 두 개 이상의 스레드가 처리되는 것을 멀티 스레드라고 합니다. 프로세스 내부의 스레드는 같은 자원을 공유하여 사용하기 때문에 동시에 여러가지 일을 수행할 수 있고, 웹 서버에서 빠른 응답이 가능하다는 장점이 있지만, 여러 스레드가 하나의 자원을 공유하고 있기 때문에 동시성 문제가 발생할 수 있습니다. 예를 들어, 여러 스레드가 공유 자원에 동시에 접근하여 그 자원의 상태를 변경하려고 한다면, 자원의 상태가 예측할 수 없는 결과를 초래할 수 있습니다.

이 글에서는 이렇게 문제가 될 수 있는 동시성을 제어하는 방법에 대해 알아보겠습니다.

* Redisson 분산락
* Volatile
* CAS 알고리즘

 

# Redisson 분산락

Redisson은 Redis 기반의 Java용 클라이언트 라이브러리입니다. Redisson은 다양한 데이터 구조를 지원하고, 특히 분산 환경에서 동시성을 제어하기 위한 다양한 락 메커니즘을 제공합니다. Redisson 분산락은 분산 환경에서 여러 인스턴스 간에 공유 자원의 동시 접근을 제어하기 위해 사용됩니다. 

예를 들어, A와 B라는 두 개의 서버가 있고, 둘 다 Redis에 연결된 상태에서 동일한 자원에 접근하려고 할 때, 한 서버가 락을 획득하면 다른 서버는 락이 해제될 때까지 기다립니다. 이렇게 하면 데이터 일관성이 유지될 수 있습니다.

**Redis란? : 오픈 소스, 인메모리 데이터 구조 저장소로, 주로 데이터베이스, 캐시, 메시지 브로커로 사용됩니다. Redis는 데이터를 메모리에 저장하여 매우 빠른 읽기/쓰기 속도를 제공하고 다양한 데이터 구조 지원 및 풍부한 기능으로 다양한 애플리케이션에서 사용됩니다.

 

동작 원리

1. 락 획득

: 클라이언트가 특정 리소스에 대해 락을 요청할 때, Redis의 SET 명령어를 NX(Set if Not Exist)와 PX(expiry time in milliseconds)옵션을 사용하여 해당 리소스를 잠급니다.

예를 들어, SET mylock "request1" NX PX 30000과 같이 호출하여 30초 동안 mylock 키를 설정하고, 다른 클라이언트가 이 키를 설정하지 못하도록 합니다.

2. TTL(Time To Live) 설정

: 락은 일정 시간 동안만 유지되며, 이 시간 동안 작업이 완료되지 않으면 자동으로 해제됩니다. 이를 통해 클라이언트가 예상치 못하게 중단되었을 때도 다른 클라이언트가 락을 획득할 수 있습니다.

**TTL이란? : 데이터나 자원의 유효 기간을 지정하는 시간입니다. 특정 데이터나 리소스가 일정 시간 후에 자동으로 만료되거나 삭제되도록 하는 데 사용됩니다. 예를 들어, Redis에서 다음과 같은 명령을 한다면, "SET mykey "some value" EX 60" Redis에 mykey라는 키를 설정하고, 그 값으로 "some value"를 저장하며, TTL을 60초로 설정하여 60초 후에 mykey는 자동으로 삭제된다는 뜻입니다.

3. 락 해제

: 작업이 완료되면 클라이언트는 락을 해제합니다. 이를 위해 DEL 명령어를 사용합니다. 락 해제 시 락을 획득한 클라이언트가 실제로 락을 해제하는지 확인하기 위해, 락의 값을 클라이언트 ID나 고유한 토큰으로 설정하고, 해제할 때도 같은 값을 확인합니다. 이를 통해 다른 클라이언트가 실수로 락을 해제하는 것을 방지합니다.

 

주요 특징 및 장점

1. Redis 기반

: Redis의 기본 기능을 이용하여 락을 구현합니다. 주로 SETNX(Set if Not Exists) 명령어와 TTL(Time-To-Live)을 사용하여 락을 관리합니다.

2. 자동 만료

: TTL을 설정하여 특정 시간 동안만 락을 유지할 수 있도록 하여, 락을 획득한 클라이언트가 죽거나 네트워크 문제가 발생한 경우에도 락이 자동으로 해제됩니다. 이를 통해 다른 클라이언트가 락을 영원히 대기하는 문제를 방지합니다.

3. 다양한 락 타입 지원

: 획득 순서를 보장하는 공정성 락(Fair Lock), 여러 스레드가 동시에 읽을 수 있지만 쓰기 작업은 독점적으로 수행하는 읽기/쓰기 락, MultiLock, RedLock 등 다양한 락 타입을 지원하여 필요에 따라 선택적으로 사용할 수 있습니다.

 

# Volatile

Volatile은 Java에서 변수에 사용할 수 있는 키워드로, 여러 스레드가 동시에 해당 변수에 접근할 때 발생할 수 있는 가시성 문제를 해결하기 위해 사용됩니다. volatile로 선언된 변수는 각 스레드의 로컬 캐시가 아닌 메인 메모리에서 직접 읽고 쓰도록 보장됩니다. 이는 특정 변수가 여러 스레드에 의해 읽히고 쓰일 때 항상 최신의 일관된 값을 보장합니다.

 

주요 특징

1. 가시성 보장

: Volatile keyword는 Java 변수를 Main Memory에 저장하겠다는 것을 명시하는 것입니다. 매번 변수의 값을 read/write 할 때마다 CPU cache에 저장된 값이 아닌 Main Memory에서 읽고/쓰겠다는 것입니다. 이는 메모리 가시성 문제를 해결합니다. 예를 들어, 한 스레드가 volatile 변수를 변경하면 다른 스레드는 항상 그 변경된 값을 볼 수 있습니다.

**가시성이란? : 한 스레드가 변수의 값을 변경했지만, 다른 스레드가 이 변경된 값을 보지 못하는 현상입니다.

출처_https://nesoy.github.io/articles/2018-06/Java-volatile

 

예시) counter 변수를 공유하는 두 개의 Thread가 있습니다.

1. Thread1는 counter 값을 더하고 읽는 연산을 합니다.(Read & Write)

2. Thread2는 counter 값을 읽기만 합니다.(Only Read)

3. volatile 변수를 사용하지 않는 멀티쓰레드 환경에서는 Task를 수행하는 동안 성능 향상을 위해 Main Memory에서 읽은 변수 값을 CPU Cache에 저장하게 됩니다. 즉, Thread1은 counter값을 증가시키고 있지만 CPU1 Cache에만 반영되어 있고 실제 Main Memory에는 반영이 되지 않았습니다. 그렇기 때문에 Thread2는 count값으로 0을 가져오는 문제가 발생합니다.

4. 이때 volatile 키워드를 추가하게 되면 Main Memory에 저장하고 읽어오기 때문에 변수 값 불일치 문제를 해결 할 수 있습니다.

// volatile 미적용, counter 값의 불일치 발생 가능
public class SharedObject {
       public int counter = 0;
}

// volatile 적용, 가시성 문제 해결
public class SharedObject {
       public volatile int counter = 0;
}

 

2. 재정렬 방지

: JVM 및 CPU는 성능 최적화를 위해 명령어를 재정렬할 수 있습니다. 그러나 volatile 변수는 읽기와 쓰기 시점을 정확하게 보장하기 때문에, volatile 변수에 대한 읽기/쓰기 연산이 발생할 때, 그 이전에 발생한 모든 명령어는 volatile 변수 접근 이전에 수행되도록 하고, 그 이후에 발생한 모든 명령어는 volatile 변수 접근 이후에 수행되도록 보장합니다.

**명령어 재정렬이란? : 컴파일러와 CPU는 프로그램의 성능을 최적화하기 위해 명령어의 실행 순서를 변경할 수 있습니다. 이러한 최적화는 프로그램의 결과에 영향을 미치지 않도록 설계되지만, 멀티스레드 환경에서는 문제가 될 수 있습니다.

 

한계

1. 원자성 불보장

: Volatile 키워드를 사용하면, 변수의 값을 메인 메모리에서 직접 읽고 쓰도록 하여 가시성은 보장되지만, 원자성은 보장되지 않습니다. 따라서 volatile은 단일 읽기 또는 쓰기 연산에 대해서만 일관성을 보장합니다. 즉, volatile 변수의 읽기 및 쓰기 연산이 다른 쓰레드와의 경쟁 상황에서 중간에 끼어들거나 중단될 수 있으며, 이로 인해 불완전한 연산이 수행될 수 있습니다.

**원자성이란? : 여러 작업이 합쳐져 하나의 작업처럼 보장되어야 하는 경우 일련의 작업이 끝까지 완전하게 수행되거나 전혀 수행되지 않아야 하는 성질을 의미합니다. 원자적인 작업은 중간 상태가 존재하지 않습니다. 원자적 연산이 보장되면, 여러 스레드가 동시에 같은 변수나 자원에 접근하더라도 불일치나 불안정한 상태가 발생하지 않아 일관성을 유지할 수 있습니다. 예를 들어, count++ 연산은 단순해 보이지만, 실제로는 다음 세 가지 단계로 이루어집니다:

1. count 값을 메모리에서 읽음

2. count 값을 1 증가시킴

3. 변경된 값을 다시 메모리에 저장함

이 과정이 원자적으로 실행되지 않으면, 두 개의 스레드가 동시에 접근했을 때 count 값이 두 번 증가하지 않고 한 번만 증가하는 문제가 발생할 수 있습니다.

// 예시 
volatile int counter = 0;
void increment() {
       counter++; // 이 코드는 원자적이지 않음
}

위 코드에서 count++은 원자적이지 않기 때문에 만약 여러 쓰레드가 동시에 increment() 메서드를 호출한다면, 일부 연산 결과가 덮어씌워질 수 있습니다. 이를 해결하려면 synchronized 블록을 사용하거나 AtomicInteger와 같은 원자성을 보장하는 클래스를 사용해야 합니다.

 

# CAS(Compare-And-Swap) 알고리즘

CAS는 멀티스레드 환경에서 락을 사용하지 않고도 동시성 문제를 해결할 수 있는 알고리즘입니다. CAS는 원자성 뿐만 아니라 가시성 문제도 해결해 주는 알고리즘입니다.

 

동작 원리:

1. 인자로 기존 값(Compared Value)과 변경할 값(Exchanged Value)을 전달합니다.

2. 기존 값(Compared Value)이 현재 메모리가 가지고 있는 값(Destination)과 같다면 변경할 값(Exchanged Value)을 반영하며 true를 반환합니다.

3. 반대로 기존 값(Compared Value)이 현재 메모리가 가지고 있는 값(Destination)과 다르다면 값을 반영하지 않고 false를 반환합니다.

출처_https://steady-coding.tistory.com/568

 

예시) count 변수를 공유하는 2개의 thread가 있습니다.

1. 각 스레드는 힙 내에 있는 count 변수를 읽어 CPU 캐시 메모리에 저장합니다.

2. 각 스레드는 번갈아가며 for문을 돌면서 count 값을 1씩 증가시킵니다.

3. Thread1 또는 Thread2는 변경한 count 값(1)을 힙에 반영하기 위해 변경하기 전의 count 값(0)과 힙에 저장된 count 값(0 또는 1)을 비교합니다. 여기서 두 가지 경우가 생길 수 있습니다.

    - 변경하기 전의 count 값(0)과 힙에 저장된 count 값(1)이 다를 경우 false를 반환하며, 힙에 저장된 값을 다시 읽어

       2번 과정으로 돌아갑니다.

    - 변경하기 전의 count 값(0)과 힙에 저장된 count 값(0)이 같을 경우 true를 반환하며, 힙에 변경한 count 값(1)을

      저장합니다.

4. 힙에 변경한 값을 저장한 Thread1 또는 Thread2는 1번 과정으로 돌아갑니다.(for문이 종료될 때까지)

 

장점

1. 락 프리 

: CAS는 락을 사용하지 않기 때문에, 스레드 경쟁이 적은 경우 더 높은 성능을 발휘할 수 있습니다.

2. 원자성 보장

: 하드웨어적으로 원자적 연산이 보장되므로, 스레드 간의 경쟁 상태를 효과적으로 관리할 수 있습니다.

 

CAS 알고리즘은 강력하지만, ABA 문제라는 한계를 가질 수 있습니다.

ABA 문제는 다음과 같이 발생할 수 있습니다.

1. 스레드 A가 변수 x의 값을 읽어 'A'라고 가정합니다.

2. 스레드 B가 변수 x의 값을 'A'에서 'B'로 변경한 다음, 다시 'A'로 복원합니다.

3. 스레드 A가 변수 x의 값을 다시 읽었을 때, 여전히 'A'로 되어 있으므로, 스레드는 변수의 값이 변경되지 않았다고 판단합니다.

4. 스레드 A는 x의 값을 변경하는 작업을 수행하지만, 실제로는 다른 스레드에서 값이 변경되었다가 다시 원래 값으로 돌아온 상황입니다.

이 문제는 스레드 A가 값의 변경을 감지하지 못하고, 잘못된 가정에 기반하여 작업을 수행하게 됩니다.

예를 들어, 홈페이지의 비밀번호 변경을 5회까지만 가능하도록 설정했다고 가정해봅시다.

만약 스레드 A에서 비밀번호를 한 번 바꾸고 두 번 째 바꿀 때 이전에 사용했던 번호로 바꾼다면 사용자는 총 두 번을 변경했지만 변경 횟수를 읽는 스레드 B에서는 0이라고 인식하여 잘못된 기록을 남길 수 있습니다.

 

CAS 알고리즘의 한계

1. 낮은 스레드 경쟁에서는 비효율적

: 낮은 스레드 경쟁은 멀티스레드 환경에서 자원 접근 충돌이 적거나 거의 없는 상황을 의미합니다. 스레드 경쟁이 적다면, 동시에 CAS를 시도하는 스레드가 적고, 대부분의 경우 CAS 연산이 성공합니다. 하지만 CAS 연산이 실패할 때마다 재시도하게 되며, 이로 인해 반복적인 CAS 연산이 오히려 성능을 저하시킬 수 있습니다.

또한 CAS 연산이 자주 성공하므로, CAS의 재시도 오버헤드가 성능에 큰 영향을 미칠 수 있습니다.

2. ABA 문제

: 위에서 언급한 ABA 문제가 발생할 수 있습니다.

 

동시성 문제를 해결하는 방법을 공부하면서, 멀티스레드 환경의 복잡성과 다양한 문제를 이해하게 되었습니다.

각 문제에 맞는 적절한 해결책을 선택하고, 설계 단계에서부터 동시성 문제를 고려하는 것이 중요할 것 같습니다.