[리팩터링] 12장 최적화 및 일반화 회피

공효은·2023년 6월 14일
0

refactoring

목록 보기
12/12
post-thumbnail

이번 장에서 다룰 내용

  • 일반성을 최소화해서 커플링 최소화하기
  • 불변속성 측면에서 최적화 바라보기
  • 최적화를 통해 취약성 관리하기

성능 최적화와 일반화는 프로그래머가 하는 두 가지 게임으로, 도움이 되기보다 큰 상처를 입히는 경우가 많다.
최적화라고 하면 코드 처리량을 늘리거나 처리 시간을 줄이는 성능 최적화를 의미한다.
일반화는 코드가 더 일반적인 매개변수를 통해 더 많은 기능을 포함하도록 하는 것을 의미한다.

12.1 단순성 추구

  • 인간의 인지 능력은 제한적이기 때문에 단순함이 필수이다.
  • 코드를 다룰 때 우리의 인지능력을 빠르게 채우는 것은 두 가지 이다. 한 번에 두 컴포넌트를 머릿속에 유지해야 하는 서로 결합된 컴포넌트, 기능을 이해하기 위해 추적해야하는 불변속성
  • 더 일반적인 것을 만들면 그로 인해 용도가 증가하고 많은 것이 결합된다. 일반화된 코드로 작업할 때 그것이 호출될 수 있는 더 많은 가능한 방법을 고려해야한다. (문제점을 말하는듯)

일반성에대한 문제 예제 코드.
예제 함수를 볼 때 모든 타일 값을 입력으로 사용해서 호출되는지, 아니면 일부의 값만으로 사용되는지 확인할 수 없다. 이것을 모른 채 기능을 단순화하는것은 불가능하다.

// 불필요한 함수 일반화
function remove(tile:Tile){
  for(let y = 0; y < map.length; y++){
    for(let x = 0; x < map[y].length; x++){
      if(map[y][x] === tile){
        map[y][x] = new Air();
      }
    }
  }
}

다른 습관은 불변속성을 활용하는 최적화이다.

최적화가 어떻게 불변을 도입하는가에 대한 예시

//최적화 되지 않은 CountingSet
class CountingSet {
  private data: StringMap<number> = { };

  randomElement():string {
    let index = randomInt(this.size())
    for(let key in this.data.keys()){
      index -= this.data[key];
      if(index <=0 )
        return key;
    }
    throw new Impossible();
  }
  add(element:string){
    let c = this.data.get(element);
    if(c === undefined)
      c = 0;
    this.data.put(element, c + 1);
  }
  size() {
    let total = 0;
    for (let key in this.data.keys()) {
      total += this.datapkey];
    }
    return total;
  }
 }
}

// 최적화된 CountingSet
class CountingSet {
  private data: StringMap<number> = { };
  privage total = 0;
  randomElement():string {
    let index = randomInt(this.size())
    for(let key in this.data.keys()){
      index -= this.data[key];
      if(index <=0 )
        return key;
    }
    throw new Impossible();
  }
  add(element:string){
    let c = this.data.get(element);
    if(c === undefined)
      c = 0;
    this.data.put(element, c + 1);
    this.total++  // 재 계산을 피하기 위한 필드
  }
  size() {
   return this.total; // 재 계산을 피하기 위한 필드
 }
}

요소의 총 개소를 계산하는 것은 간단하지만 이것을 매번 하는 것은 낭비다.
총 요소의 수를 추적하는 total 필드를 도입해서 이 낭비를 줄일 수 있다. 이 필드에는 요소를 추가하거나 제거할 때 항상 업데이트 해야하는 불변 속성이 존재한다.
그렇지 않으면 randomElement 메서드가 손상될 위험이 있다.
반면 요소의 총 개수를 계산하는 최적화를 하지 않은 버전에서는 새로운 메서드를 추가해서 기존 메서드에 손상을 가하는 것이 불가능하다.

일반화나 최적화가 필요한지에 대한 확실한 증거를 항상 갖고 있어야한다.
그리고 단순함을 희생할 떄는 부작용을 최소화하기 위해 예방 조치를 해야한다.

12.2 일반화의 시기와 방법

12.2.1 구현의 최소화로 일반화 지양하기

하지 않는 일의 양을 최대화 하라 - 켄트 백

보수적으로 요구된 대로만 구축하는 또다른 이유는 소프트웨어가 발전함에 따라 요구사항이 변경되는 경향이 있으므로 불필요한 일반화를 구현하고 유지하는 데 드는 모든 노력이 쉽게 무효화 된다. 따라서 우리는 상상할 수 있는 문제가 아니라 이미 당면한 문제만 해결해야한다.

12.2.2 안정성이 유사한 것 통합하기

새로운 것과 오래된 것을 바로 통합하지 않는 것이 가장 좋다. 대신, 통합 대상이 비슷한 안정성에 도달할 때 까지 기다리자.

12.2.3 불필요한 일반화 제거

정기적으로 모니터링해서 발견 시 제거하자.
불필요한 일반화를 찾는 효과적인 방법은 함수에 전달된 런타임 인자들을 모니터링하는 것이다.
각 메서드의 최신 N개의 호출을 검사하고 일부 매개변수가 항상 동일한 값으로 호출되는지 확인할 수 있다. 이 경우 이 매개변수에 따라 메서드 전문화를 적용할 수 있다. 각 함수에 대한 특수한 복사본을 만드는 것이 더 가치 있을 수 있다.

12.3 최적화 시기화 방법

최적화가 필요한 이유를 찾기 위해 항상 자동 성능 테스트를 설정하고 테스트가 실패할 때만 최적화 하는 것이 좋다.

  • 벤치마크 테스트
    "이 메서드는 14ms 안에 종료 되어야합니다." 제한된 기간이나 간격으로 답변을 제공해야하는 임베디드나 실시간 시스템에서 일반적이다.

  • 부하테스트
    "이 서비스는 초당 1000개의 요청을 처리할 수 있어야 한다" 처리량을 검증한다. 이는 웹이나 클라우드 기반 시스템에서 일반적이다.

  • 성능 승인 테스트
    "이 테스트를 실행하면 마지막으로 실행했을 떄 보다 10% 이상 느려지지 않아야한다." 이 테스트를 통해 성능이 갑자기 저하되지 않는지 확인한다.

    테스트에서 최적화가 필요하다는 것이 입증되면 이후 유지 보수 시 인지 부담을 최소화 하는 방법을 알아야한다.

12.3.1 최적화 전 리팩터링

첫 번째 단계는 코드가 적절하게 리팩터링되었는지 확인한다.
리팩터링의 목표 중 하나는 불변속성을 지역화해서 더 명확하게 만드는 것이다. 최적화는 불변속성에 의존하기 때문에 이는 잘 분해된 코드를 최적화 하는 것이 더 쉽다는 것을 의미한다.

3장에서 호출 또는 전달, 한가지만 할 것 규칙을 도입했을 때 규칙 위반을 피하기 위해 length 를 별도의 함수로 추출하면서 이 리팩터링을 보았다.
그 당시에는 과도하게 보였을 것이다. 그러나 이후의 단계가 클래스 메서드 캡슐화였고, 이런 메서드들이 새로운 데이터 구조를 위해 매우 훌륭하고 최소한의 공개 인터페이스를 정의한다는 것을 알 수 있다.
이 인터페이스를 사용하면 나중에 설명하는 최적화를 쉽게 구현할 수 있다.

// 변경 전
function average (arr:number[]) {
  return sum(arr) / arr.length
}

// 변경 후
function average(arr:number[]){
  return sum(arr) / size(arr);
}

내부에 캐시를 추가하는 것은 간단하다.

// 캡슐화
class NuberSequence {
  constructor(private arr:number[]){ }
  sum() {
    let result = 0;
    for(let i = 0; i < this.arr.length; i++)
      result += this.arr[i]'
    return result;
  }
  size() {return this.arr.length;}
  average() {
    return this.sum() / this.size();
  }
}

// total 캐시
class NuberSequence {
  private total = 0;
  constructor(private arr:number[]){ 
     for(let i = 0; i < this.arr.length; i++)
      this.total += this.arr[i];
  }
  sum() {
   return this.total;
  }
  size() {return this.arr.length;}
  average() {
    return this.sum() / this.size();
  }
}

컴파일러에게 맡기기

과시용 코드를 작성하지 말자! 코드가 일반적이지 않으면 컴파일러에서 최적화를 수행하지 못한다. 과시용 코드의 유일한 효과는 읽기가 더 어렵다는것이다.

//관용적인 표현
function isEven(n: number){
  return n % 2 === 0;
}
//과시적인 표현
function isEvent(n:number){
  return (n & 1) === 0;
}

12.3.2 제약 이론에 따른 최적화

제약이론. 지역적 비효율성을 줄이기 위한 노력이 어떻게 전역적 효율 성이 거의 영향을 미치지 않는지를 보여준다.

  • 병목 지점의 작업자를 최적화 해야만 시스템 성능에 영향을 미친다.
  • 병목 현상을 최적화하면 새로운 병목 현상에 맞닥뜨린다. 다운 스트림 작업자가 이전 병목 현상의 증가된 처리량을 따라가지 못하거나 업스트립 작업자가 이전 병목 현상때 만큼 출력을 빠르게 생성하지 못할 수 있다.

리소스 풀링 이라는 해결책이 있다.
리소스 풀링은 사용 가능한 모든 리소스를 필요할 때 사용할 수 있는 공용 풀에 배치하는 것을 의미한다. 따라서 가능한 최대 용량이 병목 지점에 제공됩니다.

부하 분산 장치를 통해 외부적인 서비스 수준에서 또는 스레드 풀링을 통해 애플리케이션 내에서 이 접근 방식을 구현할 수 있다.

12.3.3 측정 지표를 사용한 최적화

리소스 풀링으로 시스템을 최적화한 후에도 성능 요구사항을 충족하지 못하면 병목 지점 내에서 최적화를 해야한다.

가장 중요한 영향을 미칠 부분의 코드에 노력을 집중해야한다.
그렇게 하려면 코드에서 핫스팟을 식별해야한다.
핫스팟은 스레드가 대부분의 시간을 보내는 지점이다.
메서드가 핫스팟이 되는 데는 두가지 요인이 있다. 완료 까지 시간이 걸리는 메서드와 루프 내부에 있는 메서드 이다.

프로파일링이란 메서드에서 얼마나 많은 누적 시간을 소비 했는지 추적하는 것을 의미한다.
프로파일링을 지원하는 수많은 도구가 존재하지만 다른 방법으로 최상위 수준에서 시작해서 수동으로 소요 시간을 측정하는 코드를 추가한 후 전체 코드의 20%가 80% 의 시간을 차지하는 지점을 반복적으로 드릴다운하는 것이 간편하다.

12.3.4 좋은 알고리즘과 데이터 구조 선택하기

병목 컴포넌트의 핫스팟을 찾았으면 이를 최적화하는 방법을 생각해야한다. 가장 안전한 최적화 방법은 하나의 데이터 구조를 동등한 인터페이스를 가진 다른 데이터 구조로 바꾸는 것이다.

profile
잼나게 코딩하면서 살고 싶어요 ^O^/

0개의 댓글