클린 코드 13장 - 동시성

French Marigold·2024년 2월 5일
0

클린코드

목록 보기
13/13
post-thumbnail

동시성이 필요한 이유? (226p)

  1. 애플리케이션 구조와 효율을 더 낫게 하기 위해서 (구조적 개선)
  2. 응답 시간과 작업 처리량을 개선하기 위해서
    • 1명이 100명을 맡아서 일하는 것보다, 100명이 1명씩 맡아서 일을 하는 것이 훨씬 빠르고 효율적
  • 동시성은 결합을 없애는 전략, 즉, 무엇과 언제를 분리하는 전략이다.
  • 스레드가 하나인 프로그램은 무엇과 언제가 서로 밀접하다.

동시성의 일반적인 미신과 오해 (228p)

  1. 동시성은 항상 성능을 높여준다.

    ⇒ 여러 프로세서가 동시에 처리할 계산이 충분히 많은 경우에만 성능이 높여준다.

  2. 동시성을 구현해도 설계는 변하지 않는다.

    ⇒ 단일 스레드와 다중 스레드는 설계가 판이하게 다르다.
    ⇒ 일반적으로 무엇과 언제를 분리하면 시스템 구조가 크게 달라진다.

  1. 웹 또는 EJB 컨테이너를 사용하면 동시성을 이해할 필요가 없다. (Swift 같은 경우, Alamofire나 Moya 같은 라이브러리를 이용하면 굳이 동시성 자체를 이해할 필요가 없다는 뜻으로 이해하면 될 듯)

    ⇒ 실제로 동시성이 어떻게 동작하는지, 어떻게 동시 수정, 데드락 등과 같은 문제를 피할 수 있는지를 알아야만 한다.

동시성과 관련된 타당한 생각들 (228p)

  1. 동시성은 다소 부하를 유발한다.

    ⇒ 성능 측면에서 부하가 걸리며, 코드도 더 짜야 한다.

  1. 동시성은 복잡하다.

    ⇒ 간단한 문제여도 동시성은 복잡하다.

  1. 일반적으로 동시성 버그는 재현하기 어렵다.

    ⇒ 그렇기에 진짜 결함으로 간주되지 않고 일회성 문제로 여겨 무시하기 쉬움

  2. 동시성을 구현하려면 흔히 근본적인 설계 전략을 재고해야 한다. ⭐️⭐️

    ⇒ 스레드를 한 개만 더 늘린 후 코드 한 줄을 거쳐가는 경로를 셌을 때, 바이트 코드만 고려해도 잠재적인 경로는 최대 12,870개에 달함.

    ⇒ 대다수 경로는 올바른 결과를 내놓으나 잘못된 결과를 내놓는 일부 경로가 존재하므로 동시성을 구현하려면 설계 전략을 재고해보아야 한다.

동시성 방어 원칙 (230p) ⇒ 동시성 코드가 일으키는 문제로부터 시스템을 방어하는 방법

  1. 단일 책임 원칙 (SRP)

    • 동시성 관련 코드는 다른 코드와 분리해야 한다.
    • 동시성 코드와 다른 코드를 합쳐놓지 말아라.
  2. 자료 범위를 제한하라

    • 객체 하나를 공유한 후 동일 객체를 수정하던 두 스레드가 서로 간섭하므로 예상치 못한 결과를 내놓는 경우가 있음.
    • 여러 스레드가 한 객체를 공유하는 상황을 최대한으로 줄여야 함.
class Counter {
    var value = 0
}

let counter = Counter()
let queue = DispatchQueue(label: "", attributes: .concurrent)

for _ in 0..<1000 {
		// 스레드 1000개를 사용하여 counter.value의 값을 하나 올림
    queue.async {
        counter.value += 1
    }
}

queue.sync(flags: .barrier) {
		// 여러 스레드가 동시에 counter의 value를 올리다보니
		// 정말 드물지만 간혹가다가 1000이 아닌 다른 값이 나오기도 한다. 
    print(counter.value)
}
  1. 자료 사본을 사용하라.

    • 처음부터 공유 자료를 많이 사용하지 않는 것이 좋음
  2. 스레드는 가능한 독립적으로 구현하라.

    • 다른 스레드와 자료를 공유하지 않도록 하자.
    • 각 스레드는 클라이언트 요청 하나만 처리한다.

실행 모델을 이해하라 - 컴퓨터 공학적 문제 (233p) ⭐️⭐️

  1. 생산자 - 소비자 문제
  • 생산자 스레드가 정보를 만들어낸 후, 대기열에 넣으면 소비자 스레드는 대기열에서 정보를 가져와 사용하는 모델로, 스레드 간의 공유하는 대기열에 동시에 접근하다 문제가 생기게 된다. ⭐️⭐️
    • 쿠키를 구워 바구니에 담는 사람을 생산자, 바구니에 담긴 쿠키를 먹는 사람을 소비자라고 가정해보자.
    • 소비자는 바구니에 담긴 쿠키를 끊임없이 먹으면서 바구니에 있는 쿠키 count를 하나 내린다.
      • 쿠키가 없다면 (count 0) 먹지 않고 쿠키를 만들어줄 때까지 기다린다.
    • 생산자는 쿠키를 끊임없이 바구니에 담아 바구니에 있는 쿠키 count를 하나 올린다.
      • 바구니에 쿠키가 가득차면 (count max) 쿠키를 넣지 않고 기다린다.
    • 이 규칙에 따르면 1) 쿠키가 없는데 소비자가 먹는 경우2) 바구니가 꽉 찼음에도 생산자가 쿠키를 만들어내는 경우없어야 한다.
    • 그런데 이 생산자소비자가 “동시에 일을 진행하면”, “프로그래밍적” 으로는 제대로 동작하지 않게 된다. 쿠키가 4개 남아야 하는 상황에서 2개 밖에 남지 않거나 하는 이상한 상황이 발생하기도 한다. 이렇게 공유 자원(쿠키) 에 대해 여러 스레드(생산자 - 소비자)가 동시에 접근을 시도할 때, 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태를 프로그래밍 용어로 “경쟁 상태 (Race Condition)” 라고 한다.
  • Semaphore를 사용하여 “경쟁 상태”를 해결할 수 있다. ⭐️⭐️⭐️
class CookieBox {
    private var cookies = [String]()
    
    func add(cookie: String) {
        cookies.append(cookie)
        print("기계가 \(cookie)를 바구니에 넣었어요!")
    }
    
    func take() -> String? {
        if !cookies.isEmpty {
            let takenCookie = cookies.removeFirst()
            print("아이가 \(takenCookie)를 상자에서 꺼냈어요!")
            return takenCookie
        } else {
            print("상자가 비었어요!")
            return nil
        }
    }
}

let cookieBox = CookieBox()

// 생산자와 소비자가 동시에 실행되면 생산자와 소비자간의 경쟁 상태가 펼쳐져
// 생산자가 쿠키를 이미 구웠는데도 소비자가 쿠키를 기다리고 있는 상황이 발생하거나
// 쿠키가 없는데 소비자가 쿠키를 빼가거나 하는 이상한 상황이 펼쳐진다. ⭐️⭐️

// 생산자 
DispatchQueue.global().async {
    for i in 1...5 {
        cookieBox.add(cookie: "쿠키 \(i)")
        sleep(1) // 쿠키를 만드는데 시간이 조금 걸려요.
    }
}

// 소비자
DispatchQueue.global().async {
    for _ in 1...5 {
        _ = cookieBox.take()
        sleep(1) // 쿠키를 꺼내는데 시간이 조금 걸려요.
    }
}

============================================================================

// 이런 생산자 소비자 문제를 해결하기 위해서 "Semaphore"을 사용한다. ⭐️⭐️⭐️

class CookieBox {
    private var cookies = [String]()

		// Semaphore를 사용하여 한 번에 하나씩만 쿠키가 들어갔다 나갔다 하도록 조절한다. 
    private let lock = DispatchSemaphore(value: 1) 

    func add(cookie: String) {
				lock.wait() // 상자에 넣기 전에 잠깐 기다려요.
        cookies.append(cookie)
        print("기계가 \(cookie)를 바구니에 넣었어요!")
				lock.signal() // 이제 다른 사람이 상자를 사용할 수 있어요.
    }
    
    func take() -> String? {
        if !cookies.isEmpty {
						lock.wait() // 상자에서 꺼내기 전에 잠깐 기다려요.
            let takenCookie = cookies.removeFirst()
            print("아이가 \(takenCookie)를 상자에서 꺼냈어요!")
						lock.signal() // 이제 다른 사람이 상자를 사용할 수 있어요.
            return takenCookie
        } else {
            print("상자가 비었어요!")
						lock.signal() // 이제 다른 사람이 상자를 사용할 수 있어요.
            return nil
        }
    }
}

let cookieBox = CookieBox()

// Semaphore를 사용하면 "경쟁 상태" 가 벌어지지 않게 된다. ⭐️⭐️
// 생산자 
DispatchQueue.global().async {
    for i in 1...5 {
        cookieBox.add(cookie: "쿠키 \(i)")
        sleep(1) // 쿠키를 만드는데 시간이 조금 걸려요.
    }
}

// 소비자
DispatchQueue.global().async {
    for _ in 1...5 {
        _ = cookieBox.take()
        sleep(1) // 쿠키를 꺼내는데 시간이 조금 걸려요.
    }
}
  1. 읽기 - 쓰기 문제
  • 공통 데이터베이스에 접근하는 문제를 다룬다. ⭐️⭐️ 서버에 데이터베이스가 존재하는데, 접근하는 사용자에는 Reader와 Writer가 존재한다.
  • 앞의 내용에 따르면 우리는 Semaphore를 이용해 한 번에 한 개의 스레드만 접근이 가능하게 만들었다. 하지만 여기서는 그런 방식으로 스레드를 처리하면 매우 비효율적이라는 것을 알 수 있다.
  • 예를 들어, Reader1이 데이터베이스에 접근하여 데이터베이스에 존재하는 값을 읽고 있는데 Reader2가 데이터베이스에 접근하여 값을 읽는다고 문제가 될까? 전혀 문제가 되지 않는다. 하지만 Reader1이 값을 읽고 있는데 갑자기 Writer1이 데이터베이스에 접근하여 값을 수정한다면 문제가 발생할 것이다.
  • 그렇다면 Writer 같은 경우는 어떨까? Writer1이 데이터베이스에 접근하여 데이터베이스 값을 수정하고 있는데 Writer2가 데이터베이스에 접근하여 데이터베이스 값을 수정한다면 문제가 발생할 것이다. 또한 Writer1이 데이터베이스에 접근하여 값을 수정하고 있는데 Reader1이 데이터베이스에 접근하여 데이터베이스 값을 읽으려고 한다면 문제가 발생할 것이다.
  • 읽기 - 쓰기 문제를 해결하는 방법은 다음과 같이 요약할 수 있다.
    • 임계 영역이란 운영체제에서 여러 프로세스가 데이터를 공유하면서 수행될 때 각 프로세스에서 공유 자원에 접근하는 프로그램 코드 부분 을 의미한다. ⭐️⭐️
    • Reader가 데이터베이스(임계 영역)에 들어갈 경우, 다른 Reader가 들어간다고 해서 문제되진 않으니 접근할 수 있으나 Writer는 접근할 수 없다.
    • Writer가 데이터베이스(임계 영역)에 들어갈 경우, Reader와 Writer 모두 접근할 수 없다.
    • 위의 문제는 우선순위를 이용해 해결할 수 있다. Reader에게 먼저 우선권을 주어 Reader가 접근을 하면 Writer는 반드시 다음으로 실행이 되어야한다. ⭐️⭐️⭐️
  1. 식사하는 철학자들 문제
  • 데드락 상황을 설명하는 고전적인 예시이다.

  • 식사하는 철학자들은 다음과 같은 과정을 통해 식사를 한다고 가정하자.
    1. 일정 시간 동안 철학자가 생각을 한다.
    2. 왼쪽 포크가 사용 가능해질 때까지 대기한다. 만약 사용 가능하다면 집어든다.
    3. 오른쪽 포크가 사용 가능해질 때까지 대기한다. 만약 사용 가능하다면 집어든다.
    4. 양쪽의 포크를 잡으면 일정 시간만큼 식사를 한다.
    5. 오른쪽 포크를 내려놓는다.
    6. 왼쪽 포크를 내려놓는다.
    7. 다시 1번으로 돌아간다.
  • 이런 방법은 운영체제에서 큰 문제가 생기는데 문제가 생기는 과정은 다음과 같다.
    1. 첫 번째 철학자가 생각을 하다가 밥을 먹기 위해 왼쪽 포크를 먼저 든다.
    2. 오른쪽 포크를 들려고 하는 찰나에 옆에 있는 두 번째 철학자가 생각을 마치고 왼쪽 포크를 든다.
    3. 양쪽의 포크를 잡아야지만 식사를 할 수 있으므로, 첫 번째 철학자는 두 번째 철학자가 자신의 오른쪽 포크를 놓을 때까지 왼쪽 포크를 들고 기다린다.
    4. 두 번째 철학자도 오른쪽 포크를 들려고 하는 찰나에 옆에 있는 세 번째 철학자가 생각을 마치고 왼쪽 포크를 든다.
    5. 두 번째 철학자도 세 번째 철학자가 자신의 오른쪽 포크를 놓을 때까지 왼쪽 포크를 들고 기다린다.
    6. 이렇게 모든 철학자가 왼쪽 포크를 들고 기다리기만 하는 상황이 발생한다. 이를 교착 상태(DeadLock) 라고 한다.
  • 교착 상태(DeadLock)는 “프로세스가 자원을 얻지 못해 다음 처리를 하지 못하는 상황”을 의미함. ⭐️⭐️⭐️
  • 식사하는 철학자들 문제를 해결하는 방법 중 하나는 각 포크 (자원)에 번호를 매기고 “오름차순”으로 자원을 사용하도록 하는 것이다.
    • 오름차순으로 포크를 사용할 수 있다면 포크 1을 들고 포크 2를 들 수 있다.
    • 포크 2를 들고 포크 3을 들 수 있다.
    • 이런식으로 쭉 가다가 포크 5를 들 수는 있지만 포크 1을 드는 것은 오름차순으로 자원을 사용하는 것이 아니므로 포크 1을 들 수는 없다.
    • 이런 방식으로 "순환성 대기”의 문제를 깨뜨릴 수 있다.

동기화되는 메소드 사이에에 존재하는 의존성을 이해하라 (235p)

  • 가능하다면 공유 객체 하나에는 메소드 하나만 사용하도록 한다. ⭐️
  • 공유 객체 하나에 여러 메소드를 사용해야 할 수 밖에 없는 상황 (= 여러 스레드가 동시에 접근해 메소드를 사용하는 경우) 도 있는데 그럴 때에는 다음 세 가지 방법을 고려한다.
    1. 클라이언트에서 잠금 ⇒ 클라이언트에서 첫 번째 메소드를 호출하기 전에 서버를 잠근다.
      - 즉, 공유 자원을 사용하는 경우 해당 자원의 접근을 1) Semaphore나 2) NSLock등의 lock으로 통제하여 임계 영역 안에서 한 가지 스레드만 접근하도록 만듦. 이것을 프로그래밍 용어로 “상호 배제 (Mutual Exclusion)” 라고 한다. ⭐️⭐️

      import Foundation
      
      class MoneyCalculator {
          var money = 10000
          
          func deduct() {
              money = money - 1000
              
          }
      }
      
      let moneyCalculator = MoneyCalculator()
      
      DispatchQueue.global().async {
          moneyCalculator.deduct()
          print(moneyCalculator.money)
      }
      
      DispatchQueue.global().async {
          moneyCalculator.deduct()
          print(moneyCalculator.money)
      }
      
      // 8000
      // 8000
      
      // 두 가지 스레드가 하나의 메소드에 동시에 접근하므로, 결과가 같게 나오는 현상이 발생.
      
      =============================
      
      // **NSLock**등의 lock으로 임계 영역에 한 가지 스레드만 접근할 수 있게끔 막는다. ⭐️⭐️
      
      class MoneyCalculator {
          var money = 10000
          let locker = NSLock()
          
          func deduct() {
              money = money - 1000
          }
      }
      
      let moneyCalculator = MoneyCalculator()
      
      // 2번 스레드
      DispatchQueue.global().async {
      		// 2번 스레드가 실행되는 동안 다른 스레드가 접근하지 못하도록 잠근다.
          moneyCalculator.locker.lock() 
          moneyCalculator.deduct()
          print(moneyCalculator.money)
      		// 메소드 실행이 종료되었다면 잠금을 해제하여 다른 스레드가 접근하도록 허용한다.
          moneyCalculator.locker.unlock()
      }
      
      // 3번 스레드
      DispatchQueue.global().async {
      		// 3번 스레드가 실행되는 동안 다른 스레드가 접근하지 못하도록 잠근다.
          moneyCalculator.locker.lock()
          moneyCalculator.deduct()
          // 메소드 실행이 종료되었다면 잠금을 해제하여 다른 스레드가 접근하도록 허용한다.
          moneyCalculator.locker.unlock()
      }
      
      // 9000
      // 8000
    2. 서버에서 잠금 ⇒ 서버에다 “서버를 잠그고 모든 메소드를 호출한 후 잠금을 해제하는” 메소드를 구현한다. 클라이언트는 이 메소드를 호출한다.

      class MoneyCalculator {
          var money = 10000
          let locker = NSLock()
          
      		func openAndDeductAndClose() {
      				locker.lock() 
      				money = money - 1000
      				print(money)
      				locker.unlock()
      		}
      }
      
      let moneyCalculator = MoneyCalculator()
      
      // 2번 스레드
      DispatchQueue.global().async {
      		moneyCalculator.openAndDeductAndClose()
      }
      
      // 3번 스레드
      DispatchQueue.global().async {
      		moneyCalculator.openAndDeductAndClose()
      }
      
      // 9000
      // 8000
    3. 연결 서버 ⇒ 잠금을 수행하는 중간 단계를 생성한다. '서버에서 잠금' 방식과 유사하지만 원래 서버는 변경하지 않는다.

      // 실제 수행을 하는 객체를 생성
      class MoneyCalculator {
          func deduct() {
              money = money - 1000
          }
      }
      
      // 잠금을 수행하는 중간 단계를 생성
      class MoneyCalculatorManager {
          let moneyCalculator: MoneyCalculator
          let locker = NSLock()
          
          init(moneyCalculator: MoneyCalculator) {
              self.moneyCalculator = moneyCalculator
          }
          
          func openAndDeductAndClose() {
              locker.lock()
              moneyCalculator.deduct()
              locker.unlock()
          }
      }
      
      let moneyCalculator = MoneyCalculator()
      let moneyCalculatorManager = MoneyCalculatorManager(moneyCalculator: moneyCalculator)
      
      // 2번 스레드
      DispatchQueue.global().async {
          moneyCalculatorManager.openAndDeductAndClose()
      }
      
      // 3번 스레드
      DispatchQueue.global().async {
      		moneyCalculatorManager.openAndDeductAndClose()
      }
      
      // 9000
      // 8000

동기화하는 부분을 작게 만들어라 (236p)

  • 하지만 1) Semaphore나 2) NSLock등의 lock은 스레드를 지연시키고 부하를 가중시킨다. 그러므로 lock을 남발하는 코드는 바람직하지 않다. ⭐️⭐️
  • 하지만 임계영역은 반드시 보호해야 한다. 따라서 코드를 짤 때는 임계 영역의 개수를 최대한 줄여야 한다.
  • 임계영역 개수를 줄인다고 거대한 임계영역 하나로 구현하는 경우가 있는데 필요 이상으로 임계영역 크기를 키우면 스레드 간에 경쟁이 늘어나고 프로그램 성능이 떨어진다.

스레드 코드 테스트하기 (237p)

  • 스레드가 하나만 동작하는 경우에는 테스트가 위험을 낮추지만, 스레드가 두 개 이상일 경우에는 테스트 케이스가 복잡해질 수 있다.
  • 따라서 문제를 노출하는 테스트 케이스를 작성하라. 다시 돌렸더니 통과한다는 이유로 그냥 넘어가면 절대 안 된다.
  • 다중 스레드를 테스트해야 하는 경우 고려해야 하는 구체적인 지침은 다음과 같다.
    1. 말이 안 되는 실패는 잠정적인 스레드 문제로 취급하라.

      • 다중 스레드에서 테스트 케이스를 돌렸는데 말이 안 되는 실패 결과가 나왔으면 잠정적으로 스레드 문제로 여겨야 한다.
      • 시스템 실패를 일회성이라 치부하지 마라.
    2. 다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들자.

      • 우선 스레드 환경 밖에서는 실패하지 않는지부터 제대로 확인하자.
      • 스레드 환경 밖에서 생기는 버그와 스레드 안에서 생기는 버그를 동시에 디버깅하지 말고 먼저 스레드 환경 밖의 테스트 케이스를 올바로 돌려라.
    3. 다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 수 있도록 스레드 코드를 구현하라.

      • 한 스레드로 실행하거나, 여러 스레드로 실행하거나, 실행 중 스레드 수를 바꿔본다.
      • 스레드 코드를 실제 환경이나 테스트 환경에서 돌려본다.
      • 테스트 코드를 빨리, 천천히, 다양한 속도로 돌려본다.
      • 반복 테스트가 가능하도록 테스트 케이스를 작성한다.
    4. 다중 스레드를 쓰는 코드 부분을 상황에 맞춰 조정할 수 있게 작성하라.

    5. 프로세서 수보다 많은 스레드를 돌려보라.

    6. 다른 플랫폼에서 돌려보라.

      • 다중 스레드 코드는 플랫폼에 따라 다르게 돌아간다.
    7. 코드에 보조 코드를 넣어돌려라. 강제로 실패를 일으키게 해보라.

      • 스레드 코드의 오류는 찾기 쉽지 않으므로 보조 코드로 실행 순서를 변형시켜 강제로 실패를 일으킬 수 있다.
      • 스레드 코드에 보조 코드를 추가하는 방법은 두 가지다. 1) 직접 구현 2) 자동화
      • 직접 구현하기
        • DispatchSemaphore.wait() ⇒ Semaphore가 signal을 받을 때까지 대기시킬 수 있다.
        • Sleep() ⇒ 스레드를 정해진 시간동안 잠재운다.
      • 자동화
        • 스레드에 흔들기 코드를 생성한다. (jiggle() 메소드를 호출하면 무작위로 아무 동작도 하지 않거나, 잠재우거나, 기다리는 코드를 만든다.)
      class ThreadJigglePoint {
      		
          static func jiggle() {
      				let semaphore = DispatchSemaphore(value: 1)
              let randomValue = Int.random(in: 0...2)
              
              switch randomValue {
              case 0:
                  let sleepInterval = UInt32.random(in: 1...3)
                  sleep(sleepInterval)
              case 1:
                  semaphore.wait()
      						// 마지막에 semaphore.signal() 메소드를 실행함으로 다른 스레드가 
      						// 들어갈 수 있도록 자리를 마련한다.
                  defer { semaphore.signal() } 
              case 2:
                  print("Do nothing")
              default:
                  fatalError("Unexpected random value")
              }
          }
      }
      
      =========================================================================
      
      // 2번 스레드
      DispatchQueue.global().async {
      		// 어떤 로직을 처리 
      
      		ThreadJigglePoint.jiggle() // 흔들기 코드로 오류 찾기 
      
      		// 어떤 로직을 처리 
      
      		ThreadJigglePoint.jiggle() // 흔들기 코드로 오류 찾기 
      
      		// 어떤 로직을 처리 
      
      		ThreadJigglePoint.jiggle() // 흔들기 코드로 오류 찾기 
      }
      
      // 3번 스레드
      DispatchQueue.global().async {
      		// 어떤 로직을 처리 
      
      		ThreadJigglePoint.jiggle() // 흔들기 코드로 오류 찾기 
      
      		// 어떤 로직을 처리 
      
      		ThreadJigglePoint.jiggle() // 흔들기 코드로 오류 찾기 
      
      		// 어떤 로직을 처리 
      
      		ThreadJigglePoint.jiggle() // 흔들기 코드로 오류 찾기 
      }
      
profile
꽃말 == 반드시 오고야 말 행복

0개의 댓글