개발자는 기록이 답이다

요즘 동시성 제어가 트랜드인가요? 그럼 교착상태나, 기아상태는 어떻게 해결하세요? 본문

카테고리 없음

요즘 동시성 제어가 트랜드인가요? 그럼 교착상태나, 기아상태는 어떻게 해결하세요?

slow-walker 2024. 6. 8. 22:08


멀티프로그래밍에서 주의해야 할 점으로 3가지가 있습니다.

  • 동시성이슈(경합상태)
  • 데드락
  • 기아상태

이 3가지에 대한 고찰한 내용을 담았습니다.

 

1. 동시성 정의

 

동시성이란 두 개 이상의 스레드나 프로세스가 동시에 실행 될때 발생합니다. 구체적으로 말하자면, "여러 주체가 하나의 공유 자원에  동시에 접근하여 2가지 이상의 Action"을 수행하면서 자원의 일관성이 깨지는 경쟁 상태(race condition)가 발생할 수 있습니다.

 

예를 들어, 한 스레드가 데이터를 변경하려고 할 때 다른 스레드가 이미 그 데이터를 사용하고 있다면 어떻게 될까요?

개발자가 정의한 정상적인 흐름을 따르지 못하고 자원의 일관성이 깨지며 예기치 않은 결과를 초래하게 됩니다.

 

"예기치 않은 결과"라는 건 아래 표와 같이 두 개의 스레드가 각각 counter값을 1씩 증가시키면 최종적으로 2가 나올 것이라 예상하지만, 1이 나오는 경우를 의미합니다.

스레드 1 스레드 2  공유자원
스레드 1이 counter 호출 - counter = 0
counter 값을 읽음 ( tmp = 0 ) - counter = 0
- 스레드 2가 counter 호출 counter = 0
- counter 값을 읽음 ( tmp = 0 ) counter = 0
tmp 값을 1 증가 - counter = 0
- tmp 값을 1 증가 counter = 0
counter 에 tmp 값 저장 - counter = 1
- counter 에 tmp 값 저장 counter = 1

 

 

이 때 이 "공유 자원"의 위치가 동시성 제어 방식에 큰 영향을 주게 되는데, 대표적으로 3가지 예시가 있습니다.

 

동시성을 제어하기 위해  실행 순서 제어와 상호배제 기법 2가지가 존재하지만, 상호배제 기법 위주로 설명하겠습니다.

 혼자 공부하는 컴퓨터 구조+운영체제 p.341

 

1-1. 소스 코드 내부에서의 동기화(스레드 동기화)

 

공유 자원이 하나의 프로세스이기 때문에 공유되는 Heap 메모리 영역만 제어하면 됩니다.

왜냐하면 일반적으로 멤버 변수에 대한 동기화를 해결하는데 중점을 두기 때문입니다.

스프링 입문을 위한 자바 객체 지향의 원리와 이해 p.69

 

멀티 스레드 환경에서 메모리는 stack 영역을 스레드 개수만큼 분할해서 다른 스레드에서 접근할 수 없지만, 나머지 static과 heap 영역은 공유해서 사용하기 때문에 서로 참조할 수 있습니다.

 

예를 들어, 아래와 같이 멤버 변수로 counter를 갖는 SynchronizedExample이라는 클래스의 예시를 살펴보겠습니다.

public class SynchronizedExample {
    private int counter;

    public synchronized void increment() { // 임계 영역 (critical section)
        counter++; // counter = count + 1;
    }

    public synchronized int getCounter() { // 임계 영역 (critical section)
        return counter;
    }

    public static void main(String[] args) {
        SynchronizedExample example = new SynchronizedExample();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();
            }
        });

        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Final counter value: " + example.getCounter());
    }
}

 

해당 객체가 생성되면 필드의 값을 heap 영역에 할당하게 됩니다. T 메모리 모델로 표현하면 아래와 같습니다.

여기서 오해할 수 있는 게 있는데,  Primitive Type이라고 무조건 stack 영역에 저장되는게 아니라, 변수의 목적에 따라 어디든 저장될 수 있습니다. 

 스프링 입문을 위한 자바 객체 지향의 원리와 이해 p.55

 

항목 스레드 동기화
스레드 수 2개 이상
공유 자원 힙 메모리에서 공유되는 객체
Action  increment()
1. 변수의 값을 읽는다
2. 읽은 변수에 1을 더해 저장한다

 

 

1-2. 프로세스 간의 동기화(프로세스 동기화)

프로세스 끼리는 자신만의 메모리 공간을 가지며, 기본적으로 다른 프로세스의 메모리 공간에 접근할 수 없습니다. 따라서 OS레벨에서 공유 자원(파일, 소켓)에 대한 제어를 진행합니다.

 

예를 들어, 파일을 삭제하거나, 파일 명을 변경하려고 할때 어딘가에서 해당 파일을 사용하고 있어서 삭제할 수 없다는 경고 문구를 본적이 있을 것입니다. 

 

 

파일에 접근하거나 변경하려면, 프로세스는 운영 체제의 커널에 시스템 콜을 사용하여 요청 하고, 커널은 이러한 요청을 처리하고 적절한 파일 시스템 자원에 접근을 허용합니다. 운영 체제는 파일 잠금(File Locking)과 같은 상호 배제 기법을 사용하여 데이터의 일관성을 유지하고 충돌을 방지합니다. 예를 들어, 한 프로세스가 파일을 쓰고 있는 동안 다른 프로세스는 그 파일에 접근하지 못하도록 합니다.

항목 프로세스 동기화
프로세스 수 2개 이상
공유 자원 파일 시스템
Action  1. 파일을 읽는다.
2. 파일을 삭제한다.

 

1-3. 머신 간의 동기화

"머신"은 일반적으로 컴퓨터 시스템이나 서버를 가리키는 용어입니다. 하나의 머신은 하나의 물리적인 컴퓨터 시스템을 나타냅니다. 따라서 "여러 머신"이란 여러 대의 컴퓨터 시스템을 의미합니다. 

 

위에서 봤던 프로세스 동기화는 단일 머신 내에서 발생하는 동기화를 다룹니다. 즉, 동일한 머신에서 실행되는 여러 프로세스 간의 상호 작용에 대한 것입니다.

 

반면 "머신 간의 동기화"는 여러 대의 컴퓨터 간에 발생하는 동기화를 다루고, 네트워크를 통해 연결된 여러 머신 간의 상호 작용에 대한 것입니다.

 

 

예를 들어, 여러 머신이 사용하는 공유 자원(DB 등)에 대한 동기화는 공유자원의 사용을 임의로 제한하기 어렵기 때문에 공유 자원 자체에서 제공하는 잠금을 사용하거나, 비즈니스 로직을 통해 해결해야 합니다. 즉, 데이터베이스 자체에서 제공하는 락킹 매커니즘이나 트랜잭션을 통해 제어합니다.

 

예시를 위해 이전에 프로젝트에서 진행했던 "선착순 쿠폰 발행" 예제를 들고 오겠습니다. 동시성 제어를 위해 Lock을 활용했습니다.

데이터 중심 어플리케이션 설계 p. 발췌

항목 머신 동기화
트랜잭션 수 2개 이상
공유 자원 issued_quantity 칼럼과 해당 식별자 행에 해당하는 cell
Action  1. select
2. update

 

1-4. 쿠폰 발행에 경쟁상태 이슈

현재 만들어진 쿠폰 발행 로직은 쿠폰 1개에 대해 접근하는 경우 발생할 수 있는 동시성 예방을 위해 락을 거는 행위였기 때문에, 경쟁상태만 발생했습니다. 예를 들어, 이해하기 쉽게 UI로 표현하자면 쿠폰이 여러개 있음에도 한개를 클릭하고 바로 요청이 되는 상황을 가정했습니다.

 

 

따라서 Controller에서 Query String으로 받는 쿠폰의 식별자도 1개만 가능했습니다.

 

 

Service Layer로 가기전에 Lock을 획득하는 코드를 살펴보면 쿠폰 식별자 기준으로 1개의 Lock을 걸어주었습니다.

이렇게 되면 Service - Repository 부분을 다 끝내기 전까지 다른 요청이 접근을 하지 못하기 때문에 동시성 이슈가 해결 됩니다.

 

이렇게 동시성 제어는 가능하지만, Lock을 획득하고자 하는 요청이 계속 획득을 하지 못할 경우 Lock timeout이 발생합니다.

실제로 배포한 서버를 대상으로 pinpoint를 사용해서 모니터링툴을 사용했을때 획득하지 못한 요청의 경우 3,000ms를 기다리다가 FAILD이 되는 경우를 확인했습니다.

 

Lock을 획득하려는 요청이 계속 획득하지 못할 경우, Lock timeout이 발생하여 "선착순"의 의미가 없어질 수 있습니다. A 요청과 B 요청이 동시에 쿠폰 발급을 요청했을 때, A 요청이 Lock을 획득하고 B 요청이 기다리다가 다른 요청들에 의해 계속 밀려 Lock을 획득하지 못하면, 비록 B 요청이 선착순으로 요청했더라도 서버에서는 이를 처리하지 못하게 됩니다. 따라서, 이러한 상황에서는 선착순의 의미가 퇴색될 수 있습니다.

 

2. 교착 상태 정의

동시성을 제어하기 위해 Lock을 사용하다보면 교착상태가 발생할 수도 있습니다.

 

교착상태(deadLock)이란 어떤 상황에서 발생하게 될까요?

 

결론부터 얘기하자면, 교착상태는 "2가지 이상의 스레드가 각자 서로의 자원을 점유하려고 시도할때" 발생하게 됩니다.

 

교착상태가 무엇이고, 어떻게 표현하는지 그리고 교착 상태가 어떤 상황에서 발생하는지 알아보겠습니다.

운영 체제 관련 서적을 살펴보면 교착상태가 발생하기 위한 4가지 필수 조건이 존재합니다.

 

  • 상호 배제(mutual exclusion)
    • 자원은 한 번에 하나의 스레드만 사용할 수 있습니다.
  • 점유와 대기(hold and wait)
    • 스레드가 최소한 하나의 자원을 보유한 상태에서 다른 자원을 기다리고 있습니다.
  • 비선점(nonpreemptive)
    • 자원을 할당받은 A스레드가 자원을 스스로 반납하기 전까지 B스레드는 해당 자원을 강제로 뺏을 수 없습니다.
  • 원형 대기(circular wait)
    • 각 스레드는 순환적으로 다음 스레드가 요구하는 자우너을 가지고 있어 사이클이 형성됩니다.

 

2-1. 쿠폰발행의 데드락 이슈

쿠폰 발행 시스템에서 사용자가 여러 종류의 쿠폰을 동시에 가져갈 수 있는 상황을 고려해보겠습니다. 이때 쿠폰을 요청하는 순서가 다르게 들어오면 데드락(교착 상태)이 발생할 수 있습니다. 데드락 상황을 구체적으로 설명하고 이를 재현할 수 있는 코드 예제를 통해 이해해보겠습니다. 아래 그림처럼, 쿠폰의 종류를 여러개 선택하면 query string으로 순서가 다르게 가는 상황을 가정했습니다.

 

 

 

 

데드락은 두 개 이상의 프로세스가 서로의 자원을 기다리며 무한히 대기하는 상황을 의미합니다. 쿠폰 발행 시스템에서 데드락이 발생할 수 있는 상황은 다음과 같습니다.

<1번 요청>

쿠폰 1 점유 쿠폰 1 발급
쿠폰 2 점유 쿠폰 2 발급

<2번 요청>

쿠폰 2 점유 쿠폰 2 발급
쿠폰 1 점유 쿠폰 1 발급
  1. 사용자가 여러 종류의 쿠폰을 선택하여 요청을 보냅니다.
  2. 요청이 들어오는 순서에 따라 자원을 점유하는 순서가 달라집니다.
  3. 한 요청이 쿠폰 1을 점유한 후 쿠폰 2를 점유하려고 시도하는 동시에, 다른 요청이 쿠폰 2를 점유한 후 쿠폰 1을 점유하려고 시도하면 데드락이 발생합니다.
  1. 첫 번째 요청: 쿠폰 1을 점유하고 나서 쿠폰 2를 점유하려고 시도합니다.
  2. 두 번째 요청: 쿠폰 2를 점유하고 나서 쿠폰 1을 점유하려고 시도합니다.

이 경우, 첫 번째 요청은 쿠폰 2를 점유하려고 대기하고, 두 번째 요청은 쿠폰 1을 점유하려고 대기하면서 데드락이 발생합니다.

 

 

관련해서 기존 코드를 변경하여 데드락이 발생할 수 있도록 해보겠습니다.

먼저, 쿠폰 식별자를 단일 long 타입에서 List<Long> 타입으로 변경하여 여러 쿠폰을 동시에 요청할 수 있도록 수정합니다.

 

 

위에서 언급했던 쿠폰 식별자 별로 바라보는 데드락의 경우는 "공유자원"이 쿠폰 식별자가 있는 row 일 경우라서, 레코드락을 잡을때만 해당합니다. 현재 제가 만든 프로젝트에서는 4가지의 Locking 기법 중 Redisson을 활용하는걸로 설정해두었기 때문에 "공유자원"이 무엇일지 다시 고민해야했습니다.

 

기존 코드에서는 쿠폰 식별자 데드락이 발생할 수 있는 상황을 고려하려 했습니다. 그러나, 위에서 언급했던 상황은 레코드락을 사용할 때만 해당되는 사항입니다. 현재 프로젝트에서는 Redisson을 사용하여 락을 관리하고 있기 때문에, Redisson의 RLock을 "공유자원"으로 간주하여 데드락을 고려해야 합니다.

 

Redisson의 경우 Service Layer에 접근하기 전에 Lock을 걸어서 아예 두 번째 요청이 접근되지 않습니다. 그래서 쿠폰 식별자를 기준으로 한 레코드가 아닌 Redisson의 RLock을 "공유자원"으로 바라보았습니다. 왜냐하면 락도 공유자원이 될 수 있기 때문입니다.

Lock을 획득하는것 자체도 아래처럼 2가지 Action을 하면서 동시성 이슈가 발생할 수 있습니다.

 

1) 락을 획득 할 수 있는지 확인
2) 락 획득

 

2-2-1. Redisson의 Lock 원리


Redisson 자체 내부적으로 Lua 스크립트 사용해서 1,2번을 원자적으로 하고 있기 때문에 Lock자체에 대한 동시성 이슈가 발생하지 않습니다.

  1. if (redis.call('exists', KEYS[1]) == 0) then: 만약 키가 존재하지 않으면 (락이 걸려 있지 않으면),
    • redis.call('hincrby', KEYS[1], ARGV[2], 1);: 해시 필드 값 증가 (락 소유자 수 증가).
    • redis.call('pexpire', KEYS[1], ARGV[1]);: 락 키의 만료 시간을 설정합니다.
    • return nil;: nil을 반환하여 락을 성공적으로 획득했음을 나타냅니다.
  2. if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then: 만약 락이 현재 스레드에 의해 이미 소유되고 있다면,
    • redis.call('hincrby', KEYS[1], ARGV[2], 1); : 락 소유자 수를 증가시킵니다.
    • redis.call('pexpire', KEYS[1], ARGV[1]);: 락 키의 만료 시간을 갱신합니다.
    • return nil;: nil을 반환하여 락을 성공적으로 재획득했음을 나타냅니다.
  3. return redis.call('pttl', KEYS[1]);:
    • 그렇지 않으면 (락이 다른 스레드에 의해 소유되고 있으면), 현재 락의 남은 TTL(Time To Live)을 반환합니다.

쿠폰 1 Lock 점유    쿠폰 2 Lock 대기
   ↑                   ↓
쿠폰 2 Lock 점유    쿠폰 1 Lock 대기

 

 

수정중..