오늘 하루에 집중하자
  • [BOOK - 클린코드] Chapter 13. 동시성(Concurrency)
    2025년 03월 17일 22시 10분 44초에 업로드 된 글입니다.
    작성자: nickhealthy

    이번 장에서는 소프트웨어 개발에서 가장 복잡하고 까다로운 영역 중 하나인 '동시성(Concurrency)'을 다룹니다.
    동시성 프로그래밍의 원칙과 패턴, 그리고 주의사항에 대해 설명합니다.


    동시성의 기본 개념

    동시성은 여러 작업이 동시에 진행되는 것처럼 보이는 프로그래밍 패러다임입니다. 이는 병렬성(Parallelism)과는 다른 개념입니다. 동시성은 단일 프로세서에서 여러 작업을 번갈아가며 수행함으로써 동시에 진행되는 것처럼 보이게 하는 반면, 병렬성은 실제로 여러 프로세서에서 작업을 동시에 수행합니다.


    동시성이 필요한 이유

    동시성이 필요한 주요 이유:

    1. 응답성 향상: 사용자 인터페이스의 반응성을 높일 수 있습니다. 예를 들어, 긴 계산을 수행하는 동안에도 UI는 계속 반응할 수 있습니다.
    2. 성능 향상: 현대 컴퓨터는 여러 코어를 가지고 있으므로, 병렬 처리를 통해 성능을 향상시킬 수 있습니다.
    3. 처리량(throughput) 증가: 여러 작업을 동시에 처리함으로써 전체 처리량을 증가시킬 수 있습니다.

    동시성의 어려움

    그러나 동시성은 많은 어려움을 수반:

    1. 동시성 버그: 경쟁 상태(Race Conditions), 교착 상태(Deadlocks), 라이브락(Livelocks) 등의 문제는 디버깅하기 매우 어렵습니다.
    2. 비결정성: 동시성 문제는 특정 타이밍에만 발생하기 때문에 테스트 하거나 재현하기 어렵습니다.
    3. 추가 복잡성: 동시성 코드는 단일 스레드 코드보다 이해와 설계하기가 어렵습니다.

    동시성 원칙

    동시성 프로그래밍을 위한 몇 가지 중요한 원칙:

    1. 단일 책임 원칙(SRP): 동시성 관련 코드는 다른 코드와 분리해야 합니다. 즉, 동시성 관련 코드는 자체적인 개발, 변경, 조율 주기가 있으므로 분리해야 합니다.
    2. 한정된 자원: 공유 자원(데이터베이스 연결, 읽기/쓰기 버퍼 등)에 대한 스레드 수를 제한하고, 데드락을 피하기 위해 자원 할당 순서를 고려해야 합니다.
    3. 스레드 분리: 데이터를 공유하지 않는 독립적인 스레드를 사용하면 동기화 문제를 피할 수 있습니다.
    4. 스레드 안전성: 공유 자원에 대한 접근을 동기화하고, 서로 다른 스레드 간의 의존성(임계영역)을 최소화해야 합니다.
    5. 비동기 코드 테스트: 충분한 스트레스 테스트를 통해 동시성 문제를 발견해야 합니다.

    동시성 프로그래밍의 주요 문제와 해결 방안

    한정된 자원 (Bounded Resources)

    한정된 자원은 동시성 프로그래밍에서 중요한 개념입니다. 이는 시스템에서 제한된 수량으로 존재하는 자원을 말합니다.

    특징:

    • 데이터베이스 연결, 메모리 버퍼, 파일 핸들 등이 대표적인 한정된 자원입니다.
    • 이러한 자원은 생성과 소멸에 비용이 많이 듭니다.
    • 자원의 사용이 끝나면 적절히 반환해야 합니다.

    관리 방법:

    • 자원 풀(Resource Pool)을 사용하여 한정된 자원을 효율적으로 관리합니다.
    • 자원 수를 제한하고 필요할 때만 할당합니다.
    • 스레드의 개수를 제한하여 자원 고갈을 방지합니다.

     

    상호 배제 (Mutual Exclusion)

    상호 배제는 여러 스레드가 동시에 공유 자원에 접근하는 것을 방지하는 메커니즘입니다.

    특징:

    • 한 번에 하나의 스레드만 공유 자원에 접근할 수 있도록 합니다.
    • 임계 구역(Critical Section)을 보호하는 데 사용됩니다.
    • 경쟁 상태(Race Condition)를 방지합니다.

    구현 방법:

    • 뮤텍스(Mutex): 가장 기본적인 상호 배제 메커니즘입니다.
    • 세마포어(Semaphore): 여러 스레드에 대한 접근을 제어합니다.
    • 모니터(Monitor): 객체 지향 언어에서 사용되는 상호 배제 메커니즘입니다.
    // 상호 배제 예시
    synchronized void transferMoney(Account from, Account to, int amount) {
        synchronized(from) {
            synchronized(to) {
                from.withdraw(amount);
                to.deposit(amount);
            }
        }
    }

     

    기아 상태 (Starvation)

    기아 상태는 스레드가 필요한 자원을 계속해서 얻지 못하는 상황을 말합니다.

    원인:

    • 우선순위가 높은 스레드가 계속해서 자원을 선점합니다.
    • 부적절한 스케줄링 알고리즘이 사용됩니다.
    • 자원 할당이 공정하지 않습니다.

    해결 방법:

    • 에이징(Aging): 기다리는 시간에 따라 스레드의 우선순위를 점진적으로 높입니다.
    • 공정한 락(Fair Lock): 자원 요청 순서대로 스레드에게 자원을 할당합니다.
    • 타임아웃(Timeout): 일정 시간이 지나면 자원 요청을 재시도합니다.
    // 공정한 락 사용 예시
    private final ReentrantLock lock = new ReentrantLock(true); // 공정한 락
    
    public void performTask() {
        lock.lock();
        try {
            // 작업 수행
        } finally {
            lock.unlock();
        }
    }

     

    데드락 (Deadlock)

    데드락은 두 개 이상의 스레드가 서로가 가진 자원을 기다리며 무한히 차단된 상태를 말합니다.

    데드락 발생 조건 (필요충분조건):

    1. 상호 배제(Mutual Exclusion): 한 번에 하나의 스레드만 자원을 사용할 수 있습니다.
    2. 점유와 대기(Hold and Wait): 스레드가 자원을 가진 상태에서 다른 자원을 기다립니다.
    3. 비선점(No Preemption): 스레드가 자원을 강제로 빼앗기지 않습니다.
    4. 순환 대기(Circular Wait): 스레드 간에 자원 요청이 원형으로 발생합니다.

    방지 방법:

    • 락 순서 정하기: 항상 같은 순서로 락을 획득합니다.
    • 타임아웃 사용: 일정 시간 내에 락을 획득하지 못하면 포기합니다.
    • 데드락 감지 및 회복: 주기적으로 데드락을 감지하고 회복합니다.
    // 데드락을 피하기 위한 락 순서 정하기 예시
    public void transferMoney(Account fromAccount, Account toAccount, int amount) {
        // 계좌 ID로 락 순서 정하기
        Account firstLock = fromAccount.getId() < toAccount.getId() ? fromAccount : toAccount;
        Account secondLock = fromAccount.getId() < toAccount.getId() ? toAccount : fromAccount;
    
        synchronized(firstLock) {
            synchronized(secondLock) {
                // 돈 이체 로직
            }
        }
    }

     

    라이브락 (Livelock)

    라이브락은 스레드가 계속 동작하지만 실제로는 진행이 이루어지지 않는 상태를 말합니다.

    특징:

    • 데드락과 달리 스레드는 차단되지 않고 계속 실행됩니다.
    • 스레드가 서로에게 반응하여 동일한 행동을 반복합니다.
    • 마치 복도에서 서로 양보하다가 계속 같은 방향으로 이동하는 두 사람과 유사합니다.

    해결 방법:

    • 랜덤 지연 추가: 충돌 시 랜덤한 시간 동안 대기합니다.
    • 백오프 전략(Backoff Strategy): 충돌이 발생할 때마다 대기 시간을 증가시킵니다.
    • 우선순위 설정: 충돌 상황에서 하나의 스레드에 우선권을 부여합니다.
    // 라이브락을 피하기 위한 랜덤 지연 예시
    public void transferMoney(Account fromAccount, Account toAccount, int amount) {
        while (true) {
            if (fromAccount.tryLock()) {
                try {
                    if (toAccount.tryLock()) {
                        try {
                            // 돈 이체 로직
                            return;
                        } finally {
                            toAccount.unlock();
                        }
                    }
                } finally {
                    fromAccount.unlock();
                }
            }
            // 랜덤 지연 추가
            try {
                Thread.sleep((long)(Math.random() * 100));
            } catch (InterruptedException e) {
                // 예외 처리
            }
        }
    }

     

    정리

    동시성 프로그래밍에서 한정된 자원, 상호 배제, 기아, 데드락, 라이브락 등의 문제는 피할 수 없지만, 올바른 설계와 패턴을 적용하면 이러한 문제를 최소화할 수 있습니다. 특히 중요한 것은:

    1. 자원을 효율적으로 관리하고 공정하게 할당합니다.
    2. 락 획득 순서를 일관되게 유지합니다.
    3. 타임아웃과 재시도 메커니즘을 구현합니다.
    4. 동시성 코드를 철저히 테스트합니다.

    이러한 원칙과 패턴을 적용하면 견고하고 효율적인 동시성 시스템을 구축할 수 있습니다.


    동시성 패턴

    다중 스레드 프로그래밍에서 여러 동시성 패턴이 존재:

    1. 생산자-소비자(Producer-Consumer): 생산자 스레드가 작업을 생성하고 대기열에 넣으면, 소비자 스레드가 대기열에서 작업을 가져와 처리합니다.
    2. 읽기-쓰기 락(Read-Write Lock): 여러 스레드가 동시에 읽을 수 있지만, 쓰기는 한 번에 하나의 스레드만 가능합니다.
    3. 식사하는 철학자들(Dining Philosophers): 자원 할당 문제를 해결하기 위한 고전적인 동시성 문제와 해결책입니다.

    동시성 코드 테스트

    동시성 코드를 테스트하는 것은 매우 어렵습니다. 다음과 같은 접근 방법을 제안합니다:

    1. 스레드 코드를 작성하기 전에 철저히 테스트하기: 기본 기능이 올바르게 작동하는지 확인한 후에 동시성을 추가합니다.
    2. 다양한 설정에서 실행하기: 다른 스레드 수, 프로세서 수, OS 등에서 테스트합니다.
    3. 실패한 테스트를 자동화하기: 동시성 버그를 발견하면 해당 버그를 재현하는 테스트를 작성합니다.
      • 특히, 코드 실행 흐름을 바꿔주는 보조 코드를 추가해 테스트한다.
      • wait(), sleep(), yield(), priority()
    4. 충분한 스트레스 테스트: 부하가 높은 상황에서도 안정적으로 작동하는지 확인합니다.

    결론

    동시성 프로그래밍은 응답성과 성능이라는 이점을 제공하지만, 복잡한 문제들을 수반합니다. 데드락, 라이브락, 기아 상태 같은 문제는 예측하기 어렵고 디버깅이 까다롭습니다. 하지만 적절한 설계 원칙을 따르면 이러한 문제를 최소화할 수 있습니다.

    성공적인 동시성 구현을 위한 핵심 원칙은 다음과 같습니다:

    1. 동시성 코드를 분리하여 단일 책임 원칙을 준수합니다.
    2. 공유 자원에 대한 접근을 최소화하고 적절히 동기화합니다.
    3. 일관된 락 획득 순서를 유지하고 타임아웃을 활용합니다.
    4. 철저한 테스트로 동시성 버그를 조기에 발견합니다.

    복잡하더라도 올바른 동시성 패턴을 적용하면 안정적이고 효율적인 멀티스레드 시스템을 구축할 수 있습니다.

     

    댓글