[Review] Clean Code 내용정리 - 4

Cover image

Clean Code 내용 정리 - 4

11장. 시스템

도시가 잘 돌아가는 이유

  • 다양한 분야를 관리하는 팀원
  • 적절한 추상화와 모듈화

시스템 제작과 시스템 사용을 분리

제작과 사용은 다르다.

소프트웨어 시스템은 준비 과정(애플리케이션 객체를 제작하고 의존성을 서로 '연결'하는)과 런타임 로직(준비 과정 이후의 단계)을 분리해야 한다.

관심사 분리.

  • Ex) 초기화 지연(Lazy Initialization), 계산 지연(Lazy Evaluation)
  • 장점

    1. 애플리케이션을 시작하는 시간이 그만큼 빨라진다.
    2. 어떤 경우에도 null을 반환하지 않는다.
  • 단점

    1. 의존성을 해결해야 한다.
    2. 테스트에서 문제가 생긴다. 즉, 일시적으로라도 단일 책임 원칙(SRP, Single Responsibility Principle)을 깨야한다.

Main 분리.

시스템 생성과 사용을 분리하는 방법.

main 함수에서 시스템에 필요한 객체를 생성한 후 이를 애플리케이션에 넘기며, 애플리케이션은 그저 객체를 사용한다.

팩토리

Factory 패턴은 부모 클래스에 알려지지 않은 구체 클래스를 생성하는 패턴이며.
자식 클래스가 어떤 객체를 생성할지를 결정하도록 하는 패턴이다.

객체가 생성하는 시점을 애플리케이션이 결정할 필요가 있는 경우에는 Abstact factory 패턴을 사용한다.

의존성 주입

의존성 주입(DI, Dependency Injection)은 하나의 객체가 다른 객체의 의존성을 제공하는 테크닉이다. "의존성"은 예를 들어 서비스로 사용할 수 있는 객체이다. 클라이언트가 어떤 서비스를 사용할 것인지 지정하는 대신, 클라이언트에게 무슨 서비스를 사용할 것인지를 말해주는 것

사용과 제작을 분리하는 강력한 메커니즘. 객체는 의존성 자체를 인스턴스로 만드는 책임을 지지 않는 대신에 다른 메커니즘에 넘겨야 한다.

  • 해당 방법으로 'main'루틴이나 특수 컨테이너를 사용한다.
  • 스프링 프레임워크는 잘 알려진 자바 DI 컨테이너를 제공한다.

확장

깨끗한 코드는 코드 수준에서는 시스템을 조정하고 확장하기 쉽게 만들어진다.

그러나 시스템 수준에서는 그렇지 않다. 단순한 아키텍처를 복잡한 아키텍처로 조금씩 키울 수는 없다.

  • 따라서 소프트웨어 시스템은 관심사를 적절한 게 분리해 관리해야 한다.

횡단(Cross-cutting) 관심사

횡단 관심사는 다른 관심사에 영향을 미치는 프로그램의 측면이다. 이 관심사들은 디자인과 구현 면에서 시스템의 나머지 부분으로부터 깨끗이 분해되지 못하는 경우가 있을 수 있으며 분산되거나 얽히는 일이 일어날 수 있다.

이를 해결하기 대처하기 위해 나온 방법론으로 관점 지향 프로그래밍(AOP, Aspet-Oriented Programming)이 있다.

  • AOP에서의 관점 : 특정 관심사를 지원하려면 시스템에서 특정 지점들이 동작하는 방식을 일관성 있게 바꿔야 한다.

자바 프록시

  • 단순한 상황에 적합하다. Ex) 개별 객체나 클래스에서 메서드 호출을 감싸는 경우.
  • JDK가 지원하는 동적 프록시는 인터페이스만 지원하며, 클래스 프록시를 사용하려면 CGLIB, ASM. Javassist 같은 바이트 코드 처리 라이브러리가 필요하다.

순수 자바 AOP 프레임워크

POJO는 순수하게 도메인에 초점을 맞추며, 다른 도메인에 의존하지 않는다. 따라서 테스트가 개념적으로 더 쉽고 간단하며, 단순하여 구현에 쉬우며 이후 코드를 보수하고 개선하기 편하다.

POJO(Plain Old Java Object) : 오래된 방식의 간단한 자바 오브젝트라는 말로서 Java EE 등의 중량 프레임워크들을 사용하게 되면서 해당 프레임워크에 종속된 "무거운" 객체를 만들게 된 것에 반발해서 사용되게 된 용어

AspectJ 관점

AspectJ는 관심사를 관점으로 분리하는 가장 강력한 도구이다.

  • AspectJ '애너테이션 폼'은 새로운 도구와 새로운 언어의 부담을 제거한다.
  • 애너테이션이란 주석처럼 프로그래밍에 영향을 미치지 않으며, 유용한 정보를 제공

테스트 주도 시스템 아키텍처 구축

  • 애플리케이션 도메인 논리를 POJO로 작성할 수 있다면 (코드 수준에서 아키텍처 관심사를 분리할 수 있다면), 테스트 주도 아키텍처 구축이 가능하다.
  • 좋은 웹 사이트 들은 고도의 자료 캐싱, 보안, 가상화 등을 이용해 아주 높은 가용성과 성능을 효율적이고 유연하게 달성한다.
  • 이를 정리하면, 최선의 시스템 구조는 각기 POJO 객체로 구현되는 모듈화 된 관심사 영역(도메인)으로 구성된다. 이러한 서로 다른 영역은 해당 영역 코드에 최소한의 영향을 미치는 관점이나 유사한 도구를 사용해 통합하며, 이러한 구조는 테스트 주도 기법을 사용할 수 있다.

의사 결정을 최적화

  • 모듈을 나누고 관심사를 분리하면 지엽적인 관리와 결정이 가능하다.
  • 가능한 마지막 순간까지 결정을 미루는 방법이 좋은 경우가 있는데, 이러한 경우에서 옳게 쓰일 수 있다.

    • 즉, 관심사를 모듈로 분리한 POJO 시스템은 기민함을 제공하고, 이러한 기민함은 최신 정보에 기반에 최선의 시점에 최적의 결정을 내리는데 도움을 준다. 더불어 결정의 복잡성도 감소한다.

명백한 가치가 있을 때 표준을 현명하게 사용

표준을 사용하면 아이디어와 컴포넌트를 재사용하기 쉽고, 적절한 경험을 가진 사람을 구하기 쉬우며, 좋은 아이디어를 캡슐화하기 쉬우며 컴포넌트를 엮기 쉽다.

단점으로는 표준을 만드는 시간이 너무 오래 걸리게 된다면, 다른 업계가 기다리지 못한다. 더불어 표준이 목적을 잃어버리는 경우도 발생한다.

시스템은 도메인 특화 언어가 필요

도메인 특화 언어(DSL, Domain-Specific Language)이란. 간단한 스크립트 언어나 표준 언어로 구현한 API

  • 좋은 DSL은 도메인 개념과 그 개념을 구현한 코드 사이에 존재하는 '의사소통 간극'을 줄여주며, 도메인을 잘못 구현할 가능성이 줄어든다.
  • 추상화 수준을 코드 관용구나 디자인 패턴 이상의 효과를 만들어 낼 수 있다.
  • DSL을 사용하면 고차원 정책에서 저 차원 세부사항에 이르기까지 모든 추상화 수준과 모든 도메인을 POJO로 표현 가능하다.

결론

  • 깨끗한 아키텍처는 도메인 논리를 높여주며, 기민성을 높이고, 제품 품질이 떨어지며, TDD가 제공하는 단점이 사라진다.
  • POJO를 작성하고 관점 등을 통해 관심사를 분리해서 추상화 단계에서의 의도를 명확히 표현해야 한다.
  • 핵심 중 하나는 실제로 돌아가는 가장 단순한 수단을 사용해야 한다.

12장. 창발성

창발적 설계로 깔끔한 코드를 구현

우수한 설계가 나오는 간단한 규칙 4가지

  1. 모든 테스트를 실행한다
  2. 중복을 없앤다.
  3. 프로그래머 의도를 표현한다.
  4. 클래스와 메서드 수를 최소로 줄인다.

단 위가 밑보다 중요하다.

단순한 설계 규칙 1: 모든 테스트를 실행

  • 설계는 의도한 대로 돌아가야 한다.
  • 철저한 테스트가 가능한 시스템은 더 나은 설계를 만든다.
  • 결합도가 높을수록 테스트 케이스를 작성하기 어렵다.
  • 테스트 케이스를 만들고 돌리는 것 -> 낮은 결합도와 높은 응집력

단순한 설계 규칙 2~4: 리팩터링

  • 테스트 케이스를 모두 작성했다면, 코드와 클래스를 점진적으로 정리하면 된다.
  • 코드를 정리하면서 테스트 케이스를 통해 깨지지 않도록 한다.
  • 리팩터링 단계에서는 소프트웨어 설계 품질을 높이는 기법을 사용하는 것이 좋다.
  • 이 단계 동안 중복을 없애고, 프로그래머 의도를 표현하고 클래스와 메서드 수를 줄이는 단계이다.

중복을 없애라

  • 중복은 추가 작업, 추가 위협, 불필요한 복잡도를 의미한다.
  • TEMPLATE METHOD 패턴은 고차원 중복을 제거할 목적으로 자주사 사용하는 기법

Templaet Method Pattern. 동작 상의 알고리즘의 프로그램 뼈대를 정의하는 행위 디자인 패턴, 알고리즘의 구조를 변경하지 않고 알고리즘의 특정 단계들을 다시 정의할 수 있게 해 준다

표현하기

많은 소프트웨어 프로젝트 비용 중 대다수는 장기적인 유지보수에 들어간다.

이를 해결하는 방법은 다음과 같다.

  1. 좋은 이름 선택한다.
  2. 함수와 클래스 크기를 가능한 줄인다.
  3. 표준 명칭을 사용한다.
  4. 단위 테스트 케이스를 꼼꼼히 작성한다.

가장 큰 핵심은 나중에 볼 사람을 위해서 노력해야 한다.

클래스와 메서드 수를 최소로 줄이기

  • 너무 중복을 제거하고, 의도를 표현하고, SRP를 준수하는 경우에는 단점이 발생할 수 있다.
  • 목표는 함수와 클래스 크기를 줄이면서 시스템 크기를 작게 유지하는 것이다.
  • 단, 테스트 케이스를 만들고 중복을 제거하고 의도를 표현하는 작업이 더 중요하다.

13장. 동시성

동시성과 깔끔한 코드는 양립하기 아주 어렵다. 깨끗한 동시성은 사실 매우 중요하며, 어려운 문제이다.

동시성이 필요한 이유?

  • 동시성은 결함(coupling)을 없애는 전략. 즉, 무엇언제를 분리하는 전략이다.
  • 무엇과 언제를 분리하면 애플리케이션 구조와 효율이 극적으로 나아진다.
  • 시스템 응답 시간과 작업 처리량 개선으로 인해 동시성이 필요하다.

 미신과 오해

대표적인 오해.

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

    • 동시성은 때로 성능을 높여준다. 즉, 대기 시간이 아주 길어 여러 스레드가 프로세서를 공유할 수 있거나, 여러 프로세서가 동시에 처리할 독립적인 계산이 충분히 많은 경우에만 성능이 높아진다.
  • 동시성을 구현해도 설계는 변하지 않는다.

    • 단일 스레드 시스템과 다중 스레드 시스템은 설계가 판이하게 다르다. 일반적으로 무엇언제를 분리하면 시스템 구조가 달라진다.
  • 웹 또는 EJB 컨테이너를 사용하면 동시성을 이해할 필요가 없다.

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

타당한 생각.

  • 동시성은 사소 부하를 유발한다. 성능 측면에서 부하가 걸리며, 코드도 더 짜야한다.
  • 동시성은 복잡하다. 간단한 문제라도 동시성은 복잡하다.
  • 일반적으로 동시성 버그는 제한하기 어렵다. 그래서 진짜 결함으로 간주 하지 않고 일회성 문제로 여겨 무시하기 쉽다.
  • 동시성을 구현하려면 흔히 근본적인 설계 전략을 재고해야 한다.

난관

public class X {
  private int lastIdUsed;

  public int getNextId() {
    return ++lastIdUsed;
  }
}

다음과 같은 경우. 인스턴스 X를 생성하고 lastIdUsed를 필드. 42로 설정하는 경우로 보면.

  • 어떤 스레드는 43을 받고 다른 스레드는 44를 받는데 저장은 제각각이다. 이러한 스레드가 수많이 있다.

동시성 방어 원칙

다양한 방어 원칙과 기술이 있다.

단일 책임 원칙(SRP, Single Reponsibility Priniciple)

SRP는 주어진 메서드/클래스/컴포넌트를 변경할 이유가 하나여야 한다는 원칙

동시성은 복잡성 하므로 다른 코드와 분리해야 한다. 아래는 고려해야 할 사항이다.

  • 동시성 코드는 독자적인 개발, 변경, 조율 주기가 있다.
  • 동시성 코드에는 독자적인 난관이 있으며 이는 더 어렵다.
  • 잘못 구현된 동시성 코드는 온갖 에러가 발생한다.

따라서. 동시성 코드는 다른 코드와 분리한다.

따름 정리(corollary) : 자료 범위를 제한해라

공유 객체를 사용하는 코드 임계 영역(critical section)을 synchronized 키워드로 보호하는 것뿐만 아니라, 이 수를 줄여야 한다.

수가 많으면 다음과 같은 문제가 발생한다.

  • 보호할 임계 영역을 빼먹어서 해당 자료를 수정하는 모든 코드가 망가진다.
  • 모든 임계 영역을 올바로 보호했는지 확인하느라 똑같은 노력과 수고가 필요하다.
  • 찾기 어려운 버그를 더 찾기 힘들어진다.

따라서. 자료를 캡슐화해야 하며, 공유 자료를 최대한 줄여야 한다.

따름 정리 : 자료 사본을 사용하기

공유 자료를 줄이는 최고의 방법은 공유하지 않은 방법이다. 즉, 객체를 복사해서 읽는 방법도 존재한다. 객체를 복사하는 비용이 그렇게 크지않다면 나쁘지 않는 방법이다.

따름 정리 : 스레드는 가능한 독립적으로 구현하라

다른 스레드와 공유하지 않는 독립적인 스레드를 구성해라. (예를 들면 로컬 변수 등)

따라서, 독자적인 스레드로, 가능하다면 다른 프로세서에서, 돌려도 괜찮도록 자료를 독립적인 단위로 분할한다.

라이브러리를 이해하기.

자바 5에서 스레드를 구현한다면 다음을 고려해보기.

  • 스레드 환경에 안전한 컬렉션을 사용.
  • 서로 무관한 작업을 수행할 때는 executor 프레임워크 사용.
  • 가능하다면 스레드가 차단(blocking) 되지 않는 방법을 사용.
  • 일부 클래스 라이브러리는 스레드에 안전하지 못함.

스레드 환경에 안전한 컬렉션

다중 스레드에서 안전한 메소드로 여러 가지 존재한다.

이름 설명
ConcurrentHashMap HashMap보다 거의 모든 상황에서 빠르다.
ReentrantLock 한 메서드에서 잠그고 다른 메서드에서 푸는 락(lock)이다.
Semaphore 전형적인 세마포어, 개수(count)가 있는 락이다.
CountDownLatch 지정한 수만큼 이벤트가 발생하고 대기 중인 스레드를 모드 해제하는 락.
모든 스레드에게 동시에 공평하게 시작할 권리를 제공

실행 모델을 이해하기.

간단한 기본 용어는 다음과 같다.

이름 설명
한정된 자원 (Bound Resource) 다중 스레드 환경에서 사용하는 자원으로, 크기나 숫자가 제한적이다.
데이터 베이스 연결, 길이가 일정한 읽기/쓰기 버퍼 등이 예시이다.
상호 배제 (Mutual Exclusion) 한 번에 한 스레드만 공유 자료나 공유 자원을 사용할 수 있는 경우를 가리킨다.
기아 (Starvation) 한 스레드나 여러 스레드가 굉장히 오랫동안 호은 영원한 자원을 기다린다.
예를 들어, 항상 짧은 스레드에게 우선순위를 준다면, 짧은 스레드가 지속적으로 이루어지는 경우, 긴 스레드가 기아 상태에 빠진다.
데드락 (Deadlock) 여러 스레드가 서로가 끝나기를 기다린다.
모든 스레드가 각기 필요한 자원을 다른 스레드가 점유하는 바람에 어느쪽도 더이상 진행하지 못한다.
라이브락 (Livelock) 락을 거는 단계에서 각 스레드가 서로를 방해한다.
스레드는 계속해서 진행하려 하지만, 공명(response)으로 인해, 굉장히 오랫동안 혹은 영원히 진행하지 못한다.

다중 스레드 프로그래밍에서 실행하는 방법은 대부분 아래의 3가지 방법이다.

생산자-소비자(Producer-Consumer)

image

다음 그림처럼 한정적 자원을 생산자는 정보를 생성하고, 소비자는 정보를 사용한다.

생산자 스레드는 정보를 채우고 시그널을 보내고, 소비자는 대기열에 정보를 읽은 후 시그널을 보낸다. 단, 동시에 시그널을 기다릴 가능성 또한 존재한다.

읽기-쓰기(Readers-Writers)

image

읽기 스레드가 공유 자원을 사용하지만, 처리율이 부족한 경우 기아 현상이나 오래된 정보가 쌓인다.

이러한 처리율을 높이는 방법은 여러 가지가 있는데.

  • 간단한 전략 : 읽기 스레드가 없을 때까지 쓰기 스레드가 버퍼를 기다리는 방법, 하지만 쓰기 스레드가 기아 발생 가능.
  • 이러한 방법을 갱신하는 밥법은 "식사하는 철학자들" 방법이 있다.

식사하는 철학자들(Dining Philosopheres)

image

여기서 철학자는 스레드이고, 포크는 자원이다. 여기서, 자원을 얻으려면 몇몇의 스레드는 쉬어야 하는 것을 알 수 있다.

단 이와 같은 설계를 할 때는 데드락, 라이브락, 처리율 저하, 효율성 저하 등을 고려해서 사용해야 한다.

동기화하는 메서드 사이에 존재하는 의존성을 이해

  • 공유 객체 하나에는 메서드 하나만 사용하기.

공유 객체 하나에 여러 메서드가 필요한 경우는 다음과 같은 세 가지 방법을 고려한다.

  • 클라이언트에서 잠금 : 클라이언트에서 첫 번째 메서드를 호출하기 전에 서버를 잠금. 마지막 메서드를 호출할 때까지 잠금을 유지한다
  • 서버에서 잠금 : 서버에다 "서버를 잠그고 모든 메서드를 호출한 후 잠금을 해제하는" 메서드를 구현. 클라이언트는 이 메서드를 구현
  • 연결(Adapted) 서버 : 잠금을 수행하는 중간 단계를 생성. '서버에서 잠금' 방식과 유사하지만 원래 서버는 변경하지 않음

동기화하는 부분을 작게 만들기

필요 이상으로 임계 영역 크기를 키우면 스레드 간 경쟁이 늘어나고 프로그램 성능이 떨어진다.

따라서. 동기화하는 부분을 최대한 작게 만든다.

올바른 종료 코드는 구현하기 어렵다

데드락에 걸려서 종료 코드가 안 갈 수도 있다. 따라서 종료 코드를 개발 초기부터 고민하고 동작하게 초기부터 구현해야 한다. 생각보다 오래 걸리고 어려우므로 나온 알고리즘을 검토하는 것도 좋은 방법이다.

스레드 코드 테스트하기

하나의 스레드에서는 이전에 했던 모든 말 들이 통용되지만, 여러 스레드에서의 상황은 복잡하다.

따라서. 문제를 노출하는 테스트 케이스를 작성한다. 프로그램 설정과 시스템 설정과 부하를 바꿔가면서 자주 돌리고 테스트가 실패할 경우 원인을 추적해야 한다. 다시 돌렸더니 통과한다는 이유로 넘어가면 안 된다.

구체적인 지침은 다음과 같다.

  • 말이 안 되는 실패를 잠정적인 스레드 문제로 취급하기

    • 시스템 실패를 '일회성'이라고 취급하지 말기.
  • 다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들기

    • 스레드 환경 밖에서 생기는 버그와 스레드 환경에서 생기는 버그를 동시에 디버깅하면 안 된다. 먼저 스레드 환경 밖부터 해결해야 한다.
  • 다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 수 있도록 스레드 코드를 구현

    • 다양한 설정에서 실행할 목적으로 다른 환경에 쉽게 끼워 넣을 수 있게 코드를 구현
  • 다중 스레드를 쓰는 코드 부분을 상황에 맞춰 조정해주기

    • 프로그램 처리율과 효율에 따라 스레드 개수를 조율하는 코드도 고민해보기
  • 프로세서 수보다 많은 스레드 돌려보기

    • 스와핑이 잦을수록 임계 영역을 빼먹은 코드나 데드락을 일으키는 코드를 찾기 쉬워진다.
  • 다른 플랫폼에서 돌려보기

    • 처음부터 그리고 자주 모든 목표 플랫폼에서 코드를 돌려보는 것이 좋다.
  • 코드에 보조 코드(instruction)를 넣어 돌리기. 강제로 실패 만들기.

    • 방법 1. 직접 구현하기

      • 코드에다 wait(), sleep(), yield(), probity() 함수를 추가하기.
      • 생각했는 결과물과 맞는지 체크하기.
    • 방법 2. 자동화

      • AOP(Aspect-Oriented Framework), CGLIB, ASM 등의 도구를 사용
      • 흔들기 기법(jiggle) 등을 사용해 오류를 찾는 것도 좋다. 이를 사용하면 스레드를 매번 다른 순서로 실행한다.

결론.

핵심은 다음과 같다.

  • SRP를 준수하기
  • 동시성 오류를 일으키는 잠정적 원인을 정학하게 이해
  • 사용하는 라이브러리와 기본 알고리즘을 정확히 이해
  • 보호할 코드 영역을 찾아내는 방법과 특정 코드 영역을 잠그는 방법을 이해