로버트 C.마틴의 클린 코드를 읽고 정리한 포스트입니다.
동시성이 필요한 이유?
- 구조적 개선
- 결합(coupling)을 없애기 위해서 사용
- 무엇과 언제를 분리하는 전략임
무엇
과언제
를 분리하면 애플리케이션 구조와 효율이 극적으로 나아진다. 구조적인 관점에서 프로그램은 거대한 루프 하나가 아니라 작은 협력 프로그램 여럿으로 보인다. 따라서 시스템을 이해하기가 쉽게 문제를 분리하기도 쉽다.
- 작업 처리량(throughput) 개선
- 대량의 정보를 병렬로 처리
- 미신과 오해
- 동시성은 항상 성능을 높여준다
- 여러 프로세서가 동시에 처리할 독릭적인 계산이 충분히 많은 경우에만 성능이 높아진다. 어느 쪽도 일상적으로 발생하는 상황은 아니다
- 동시성을 구현해도 설계는 변하지 않는다
- 단일 스레드 시스템과 다중 스레드 시스템은 설계가 판이하게 다르다. 일반적으로
무엇
과언제
를 분리하면 시스템 구조가 크게 달라진다
- 단일 스레드 시스템과 다중 스레드 시스템은 설계가 판이하게 다르다. 일반적으로
- 동시성은 항상 성능을 높여준다
- 타당한 생각
- 동시성은 다소 부하를 유발한다
- 성능 측면에서 부하가 걸리며, 코드도 더 짜야 한다
- 동시성은 복잡하다
- 버그를 재현하기 어렵다
- 진짜 결함으로 간주되지 않고 일회성 문제로 여겨 무시하기 쉽다
- 동시성을 구현하려면 흔히 근본적인 설계 전략을 재고해야 한다
- 동시성은 다소 부하를 유발한다
동시성 방어 원칙
- 단일 책임 원칙(Single Responsibility Principle, SRP)
- SRP이란? 주어진 메서드/클래스/컴포넌트를 변경할 이유가 하나여야 한다는 원칙
- 동시성은 복잡성 하나만으로도 따로 분리할 이유가 충분함
- 동시성 관련 코드는 다른 코드와 분리해야 함
- 동시성 코드는 독자적 개발, 변경, 조율 주기를 가져야 함
- 동시성 코드에는 독자전인 난관이 있다. 다른 코드에서 겪는 난관과 다르며 훨씬 어렵다
- 잘못 구현한 동시성 코드는 별의별 방식으로 실패한다. 주변에 있는 다른 코드가 발목을 잡지 않더라도 동시성 하나만으로도 충분히 어렵다
- 동시성 코드는 다른 코드와 분리하라
- 따름 정리(corollary): 자료 범위를 제한하라
- 객체 하나를 공유한 후 동일 필드를 수정하던 두 스레드가 서로 간섭할 경우 예상치 못한 결과를 낼 수 있음
- 공유 객체를 사용하는 코드 내 ‘임계 영역’을 synchronized 키워드로 보호해야 함
- 하지만 이런 임계영역의 수가 많아서 공유 자료를 수정하는 위치가 많을수록 다음 가능성이 커짐
- 보호할 임계영역을 빼먹어 공유 자료를 수정하는 모든 코드를 망가뜨림
- 모든 임계영역을 올바로 보호했는지(Don’t Repeat Yourself, DRY 위반) 확인하느라 똑같은 노력과 수고를 반복
- 그렇지 않아도 찾아내기 어려운 버그가 더욱 찾기 어려워짐
- 자료를 캡슐화하라. 공유 자료를 최대한 줄여라
- 따름 정리: 자료 사본을 사용하라
- 공유 자료를 줄이려면 처음부터 공유하지 않는 방법이 제일 좋음
- 객체를 복사해 읽기 전용으로 사용할 수 있음
- 각 스레드가 객체를 복사해 사용한 후 한 스레드가 해당 사본에서 결과를 가져오게 할 수 있음
- 객체를 복사하는 시간과 부하 비용 vs 공유 객체가 코드에 문제를 일으키는 비용
- 둘 중 어떤 비용이 진짜 문제일지 실측해보자
- 사본으로 동기화를 피할 수 있다면 내부 잠금을 없애 절약한 수행 시간이 사본 생성과 가비지 컬렉션에 드는 부하를 상쇄할 가능성이 큼
- 따름 정리: 스레드는 가능한 독립적으로 구현하라
- 자신만의 세상에 존재하는 스레드를 구현하라
- 다른 스레드와 자료를 공유하지 마라
- 각 스레드는 클라이언트 요청 하나만 처리하도록 하라
- 모든 정보는 비공개 출처에서 가져오며 로컬 변수에 저장하라
- 독자적인 스레드로, 가능하면 다른 프로세서에서 돌려도 괜찮도록 자료를 독립적인 단위로 분할하라
실행 모델을 이해하라
다중 스레드 환경에서 자주 사용하는 기본 개념
개념 | 설명 |
---|---|
한정된 자원(Bound Resource) | 다중 스레드 환경에서 사용하는 자원으로, 크기나 숫자가 제한적이다. 데이터베이스 연결, 길이가 일정한 읽기/쓰기 버퍼 등이 예다. |
상호 배제(Mutual Exclusion) | 한 번에 한 스레드만 공유 자료나 공유 자원을 사용할 수 있는 경우를 가리킨다. |
기아(Starvation) | 한 스레드나 여러 스레드가 굉장히 오랫동안 혹은 영원히 자원을 기다린다. 예를 들어, 항상 짧은 스레드에게 우선 순위를 준다면, 짧은 스레드가 지속적으로 이어질 경우, 긴 스레드가 기아 상태에 빠진다. |
데드락(Deadlock) | 여러 스레드가 서로가 끝나기를 기다린다. 모든 스레드가 각기 필요한 자원을 다른 스레드가 점유하는 바람에 어느 쪽도 더 이상 진행하지 못한다. |
라이브락(Livelock) | 락을 거는 단계에서 각 스레드가 서로를 방해한다. 스레드는 계속해서 진행하려 하지만, 공명(resonance)으로 인해, 굉장히 오랫동안 혹은 영원히 진행하지 못한다. |
프로그래밍에서 사용하는 실행 모델들
모델 | 설명 |
---|---|
생산자-소비자(Producer-Consumer) | * 생산자 스레드 : 대기열에 빈 공간이 있어야 정보를 채움. 빈 공간이 생길 때까지 기다림. 대기열에 정보를 채운 다음 “대기열에 정보가 있다”는 시그널을 보냄 |
* 소비자 스레드 : 대기열에 정보가 있어야 가져옴. 정보가 채워질 때까지 기다림. 대기열에서 정보를 읽어들인 후 “대기열에 빈 공간이 있다”는 시그널을 보냄 | |
* 생산자&소비자 스레드가 둘 다 진행 가능함에도 불구하고 동시에 서로에게서 시그널을 기다릴 가능성이 존재함 | |
읽기-쓰기(Readers-Writers) | * 읽기 스레드 VS 쓰기 스레드 둘 중 하나가 작업을 하는 동안 다른 한쪽에서 버퍼를 갱신하지 않으면 버퍼를 기다리느라 처리율(throughput)이 떨어진다. 처리율을 강조하면 기아(starvation)현상이 생기거나 오래된 정보가 쌓인다 |
* 읽기와 쓰기 스레드의 요구를 적절히 만족시켜 처리율도 높이고 기아도 방지하는 해법이 필요하다 | |
* 양쪽 균형을 잡으면서 동시 갱시 문제를 피하는 해법이 필요하다 | |
식사하는 철학자들(Dining Philosophers) | * 철학자= 스레드, 포크=자원으로 가정해보기 |
* 철학자들은 배가 고플 때만 먹는다. 철학자들은 양 손에 포크를 쥐지 않으면 먹지 못한다. 왼쪽이나 오른쪽에 앉은 철학자가 포크를 사용하는 중이라면 기다려야 한다. 먹고 나면 포크를 내려놓고 배가 고플 떄까지 다시 생각에 잠긴다 | |
* 이것은 다중 스레드 문제의 예이다 | |
* 여러 프로세스가 자원을 얻으려 경쟁한다 | |
* 주의해서 설계하지 않으면 데드락, 라이브락, 처리율 저하, 효율성 저하 등을 겪는다 |
동기화하는 메서드 사이에 존재하는 의존성을 이해하라
권장사항: 공유 객체 하나에는 메서드 하나만 사용하라.
공유 객체 하나에 여러 메서드가 필요한 상황도 생긴다. 그럴 때는 다음 세가지 방법을 고려한다.
방법 | 설명 |
---|---|
클라이언트에서 잠금 | 클라이언트에서 첫 번째 메서드를 호출하기 전에 서버를 잠근다. 마지막 메서드를 호출할 때까지 잠금을 유지한다 |
서버에서 잠금 | 서버에다 “서버를 잠그고 모든 메서드를 호출한 후 잠금을 해제하는” 메서드를 구현한다. 클라이언트는 이 메서드를 호출한다 |
연결(Adapted) 서버 | 잠금을 수행하는 중간 단계를 생성한다. ‘서버에서 잠금’ 방식과 유사하지만 원래 서버는 변경하지 않는다 |
동기화하는 부분을 작게 만들어라
권장사항: 동기화하는 부분을 최대한 작게 만들어라.
락으로 감싼 코드 영역은 한 번에 한 스레드만 실행이 가능하지만, 스레드를 지연시키고 부하를 가중시킨다. 남발하지 않도록 주의하자. 반면, 임계영역(critical section)은 반드시 보호해야 한다. 따라서, 임계영역 수를 최대한 줄여야 한다.
하지만, 필요 이상으로 임계영역 크기를 키우면 스레드 간에 경쟁이 늘어나고 프로그램 성능이 떨어진다.
올바를 종료 코드는 구현하기 어렵다
권장사항: 종료 코드를 개발 초기부터 고민하고 동작하게 초기부터 구현하라. 생각보다 오래 걸린다. 생각보다 어려우므로 이미 나온 알고리즘을 검토하라.
가장 흔히 발생하는 문제: 데드락
- 스레드가 절대 오지 않을 시그널을 기다림
- 부모 스레드가 자식 스레드를 여러 개 만든 후 모두가 끝나기를 기다렸다 자원을 해제하고 종료하는 시스템이 있다고 가정
- 자식 스레드 중 하나가 데드락에 걸렸다면 부모 스레드는 영원히 기다림
- 시스템은 영원히 종료하지 못함
- 유사한 시스템이 사용자에게서 종료하라는 지시를 받았다고 가정
- 부모 스레드는 모든 자식 스레드에게 작업을 멈추고 종료하라는 시그널을 전달할 경우 자식 스레드 중 두 개가 생산자/소비자 관계라면 아래와 같은 현상이 발생할 수 있음
- 생산자 스레드는 재빨리 종료했는데 소비자 스레드가 생산자 스레드에서 오는 메세지를 기다림
- 생산자에서 메세지를 기다리는 소비자 스레드는 차단(blocked)상태에 영원히 기다리고 부모 스레드는 자식 스레드를 영원히 기다림
- 부모 스레드는 모든 자식 스레드에게 작업을 멈추고 종료하라는 시그널을 전달할 경우 자식 스레드 중 두 개가 생산자/소비자 관계라면 아래와 같은 현상이 발생할 수 있음
스레드 코드 테스트하기
권장 사항: 문제를 노출하는 테스트 케이스를 작성하라. 프로그램 설정과 시스템 설정과 부하를 바꿔가며 자주 돌려라. 테스트가 실패하면 원인을 추적하라. 다시 돌렸더니 통과하더라는 이유로 그냥 넘어가면 절대로 안 된다.
지침
- 말이 안 되는 실패는 잠정적인 스레드 문제로 취급하라
- 권장사항 : 시스템 실패를 ‘일회성’이라 치부하지 마라
- 다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들자
- 권장사항 : 스레드 환경 밖에서 생기는 버그와 스레드 환경에서 생기는 버그를 동시에 디버깅하지 마라. 먼저 스레드 환경 밖에서 코드를 올바로 돌려라
- 다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 수 있도록 스레드 코드를 구현하라
- 권장사항 : 다양한 설정에서 실행한 목적으로 다른 환경에 쉽게 끼워 넣을 수 있게 코드를 구현하라
- 다중 스레드를 쓰는 코드 부분을 상황에 맞춰 조정할 수 있게 작성하라
- 권장사항 : 스레드 개수를 조율하기 쉽게 프로그램 처리율과 효율에 따라 스스로 스레드 개수를 조율하는 코드도 고민한다
- 스와핑(swapping)을 일으키기 위해서 프로세서 수보다 많은 스레드를 돌려보라
- 권장사항 : 스와핑이 잦을수록 임계영역을 빼먹은 코드나 데드락을 일으키는 코드를 찾기 쉬워진다
- 다른 플랫폼에서 돌려보라
- 권장사항 : 처음부터 그리고 자주 모든 목표 플랫폼에서 코드를 돌려라
- 코드에 보조 코드(instrument)를 넣어 돌려라. 강제로 실패를 일으키게 해보라
- 권장사항 : 흔들기(jiggling) 기법을 사용해 오류를 찾아내라
결론
다중 스레드 코드는 올바로 구현하기 어렵다. 따라서, 아래와 같이 주의하지 않으면 희귀하고 오묘한 오류에 직면하게 된다.
- SRP(Single Responsibility Principle)를 준수
- POJO를 사용해 스레드를 아는 코드와 모르는 코드로 분리
- 스레드 코드를 테스트할 때는 전적으로 스레드만 테스트하기
- 스레드 코드를 최대한 집약되고 작게 구현
- 동시성 오류를 일으키는 잠정적인 원인을 철저히 이해하기
- 사용하는 라이브러리와 기본 알고리즘을 이해하기
- 보호할 코드 영역을 찾아내는 방법과 특정 코드 영역을 잠그는 방법을 이해한다
- TDD(Test Driven Development) 3대 규칙을 따르며 테스트 용이성을 얻자
Previous
‘[클린 코드 리뷰] 창발성 12장’ -> Previous
Next
‘[클린 코드 리뷰] 점진적인 개선 14장’ -> Next