독서 - 리팩터링(마틴파울러)

ZeroJun·2022년 6월 29일
0

독서

목록 보기
1/2

5쇄 오류
303쪽 중간 문단
❶ 사실 이렇게 단순한 상황에서는 renderPerson()의 마지막 줄을 잘라내어 두 호출 코드 아래에 붙여 넣으면 끝이다.
→ ❶ 사실 이렇게 단순한 상황에서는 피호출 함수인 emitPhotoData()의 마지막 줄을 잘라내어 두 호출 코드 아래에 붙여 넣으면 끝이다.

365쪽 마지막 줄
return (anInstrument.income / anInstrument.duration anInstrument.adjustmentFactor;
→ return (anInstrument.income / anInstrument.duration)
anInstrument.adjustmentFactor;


22/06/29 (~p.77)

프로그램이 새로운 기능을 추가하기에 편한 구조가 아니라면, 먼저 기능을 추가하기 쉬운 형태로 리팩터링 하고 나서 원하는 기능을 추가한다.

리팩터링 코드정리 링크

CHAPTER 1. 리팩터링의 단계

1. 테스트 코드 작성

리팩터링할 코드 영역을 꼼꼼하게 검사해줄 테스트 코드를 마련해야한다. 리팩터링 기법들이 버그 발생 여지를 최소화 하도록 구성됐다고는 하나 실제 작업은 사람이 수행하기 때문에 언제든 실수할 수 있다. 테스트는 테스트 프레임워크를 이용하며 테스트는 수시로 하는 것이 좋다.

2. 함수 쪼개기

  • 긴 함수를 리팩터링 할 때는 먼저 전체 동작을 각각의 부분으로 나눌 수 있는 지점을 찾는다.
    나눌 수 있는 코드 조각을 찾으면 별도 함수로 추출한다. 기존 함수에서 값이 바뀌지 않는 변수는 매개변수로 전달하고, 값이 변화하는 변수는 새로 추출한 함수에서 반환한다.

  • 항상 한 가지를 수정할 때마다 테스트를 진행하는 것이 좋다. (조금씩 수정하여 피드백 주기를 짧게 가져가는 습관을 들이자)
    하나의 리팩터링을 문제 없이 끝낼 때마다 커밋해야지 중간에 문제가 새기더라도 이전의 정상 상태로 쉽게 돌아갈 수 있다.

  • 매개변수의 역할이 뚜렷하지 않을 때는 부정관사(a/an)을 붙인다.

  • 임시 변수(지역 변수)는 최대한 제거한다. (임시변수를 질의함수로 바꾼다, 그 후 변수를 인라인한다.)

// 기존 형태
function tempFunction1 (arg) {
  const tempArr = [1,2,3,4,5];

  let result = arg;

  let 임시변수 = 0;
  for (const el of tempArr) {
    임시변수 += subFunc1(el);
  }
  result += `결과:${임시변수}`
  return result;
}
const subFunc1 = (a) => a * 2;

// 값 계산 로직을 함수로 추출
function tempFunction2 (arg) {
  const tempArr = [1,2,3,4,5];

  let result = arg;
  result += `결과:${totalFunc(tempArr)}`
  return result;
}
function totalFunc (arr) {
  let 임시변수 = 0;
  for(const el of arr) {
    임시변수 += subFunc2(el);
  }
  return 임시변수;
}
const subFunc2 = (a) => a * 2;
  • 긴 함수를 작게 쪼개는 리팩터링은 이름을 잘 지어야만 효과가 있다.

  • 함수 내에서 특정 로직과 관련된 코드를 한데 모아두면 임시 변수를 질의함수로 바꾸기 용이해진다.

위에서 서술한 가정들은 아래와 같이 요약된다.
1. 반복문 쪼개기로 변수 값을 누적시키는 부분을 분리한다.
2. 문장 슬라이드하기로 변수 초기화 문장을 변수 값 누적 코드 바로 앞으로 옮긴다.
3. 함수 추출하기로 복잡한 계산 로직을 별도 함수로 추출한다.
4. 변수 인라인하기로 임시 변수를 제거한다.

3. 다형성을 활용해 계산 코드 재구성 하기

// 리팩토링 전
function plumages(birds) {
    return new Map(birds.map(b => [b.name, plumage(b)]));
}

function speeds(birds) {
    return new Map(birds.map(b => [b.name, airSpeedVelocity(b)]));
}

// 깃털 상태
function plumage(bird) {
    switch(bird.type) {
        case '유럽 제비': 
            return "보통이다.";
        case '아프리카 제비': 
            return (bird.numberOfCoconuts > 2) ? "지쳤다." : "보통이다.";
        case '노르웨이 파랑 앵무': 
            return (bird.voltage > 100) ? "그을렸다." : "예쁘다.";
        default:
            return "알 수 없다.";
    }
}

// 비행 속도
function airSpeedVelocity(bird) {
    switch (bird.type) {
        case '유럽 제비': 
            return 35;
        case '아프리카 제비': 
            return 40 -2 * bird.numberOfCoconuts;
        case '노르웨이 파랑 앵무': 
            return (bird.isNailed) ? 0 : 10 + bird.voltage / 10;
        default:
            return null;
    }
}

// 리팩토링 후
function plumages(birds) {
    return new Map(birds
                    .map(b => createBird(b))
                    .map(bird => [bird.name, bird.plumage(b)]));
                 
}

function speeds(birds) {
    return new Map(birds
                    .map(b => createBird(b))
                    .map(bird => [bird.name, bird.airSpeedVelocity]));
}

function createBird(bird) {
    switch (bird.type) {
        case '유럽 제비': 
            return new EuropeanSwallow(bird);
        case '아프리카 제비': 
            return new AfricanSwallow(bird);
        case '노르웨이 파랑 앵무': 
            return new NorweianBlueParrot(bird)
        default:
            return new Bird(bird);
    }
}

class Bird {
    constructor(birdObject) {
        Object.assign(this, birdObject);
    }

    get plumage() {
        return "알 수 없다."
    }
    get airSpeedVelocity() {
        return null;
    }
}

class EuropeanSwallow extends Bird {
    get plumage() {
        return "보통이다."
    }
    get airSpeedVelocity() {
        return 35;
    }
}

class AfricanSwallow extends Bird {
    get plumage() {
        return (this.numberOfCoconuts > 2) ? "지쳤다." : "보통이다.";
    }
    get airSpeedVelocity() {
        return 40 -2 * this.numberOfCoconuts;
    }
}

class NorweianBlueParrot extends Bird {
    get plumage() {
        return (this.voltage > 100) ? "그을렸다." : "예쁘다.";
    }
    get airSpeedVelocity() {
        return (this.isNailed) ? 0 : 10 + this.voltage / 10;
    }
}

리팩터링은 대부분 코드가 하는 일을 파악하는 데서 시작한다. 그래서 코드를 읽고 개선점을 찾고, 리팩터링 작업을 통해 개선점을 코드에 반영하는 식으로 진행한다. 그 결과 코드가 명확해지고 이해하기 더 쉬워진다. 좋은 코드를 가늠하는 확실한 방법은 '얼마나 수정하기 쉬운가'다.


22/06/30 (p.80 ~ p.114)

CHAPTER 2. 리팩터링의 원칙

리팩터링의 정의

[명사] 소프트웨어의 겉보기 동작은 그대로 유지한 채, 코드를 이해하고 수정하기 쉽도록 내부 구조를 변경하는 기법.

함수 추출하기와 조건부 로직을 다형성으로 바꾸기 등은 이 정의에 해당한다.

[동사] 소프트웨어의 겉보기 동작은 그대로 유지한 채, 여러가지 리팩터링 기법을 적용해서 소프트웨어를 재구성하다.

리팩터링하는 동안에는 코드가 항상 정상작동하기 때문에 전체 작업이 끝나지 않았더라도 언제든 멈출 수 있다.
리팩터링 하다가 코드가 깨지는 경우는 십중팔구 리팩터링 한 것이 아니다.

마틴파울러는 코드베이스를 정리하거나 구조를 바꾸는 모든 작업을 재구성(restructuring)이라는 포괄적인 용어로 표현하고, 리팩터링은 재구성 중 특수한 한 형태로 본다.

리팩터링 과정에서 발견된 버그는 리팩터링 후에도 남아있어야 한다.

리팩터링의 목적은 코드를 이해하고 수정하기 쉽게 만드는 것이고, 이에 따라 프로그램 성능은 좋아질 수도 나빠질 수도 있다. 반면 성능 최적화는 오로지 속도 개선에만 신경 쓴다. 그래서 목표 성능에 반드시 도달해야 한다면 코드는 다루기에 더 어렵게 바뀔 수도 있다.

두 개의 모자

기능 추가 모자 : 기능 추가시에는 기존 코드는 절대 건드리지 않고 새 기능을 추가하기만 한다. 진척도는 테스트를 추가해서 통과하는지 확인하는 방식으로 측정한다.

리팩터링 모자 : 기능 추가는 절대 하지 않기로 다짐한 뒤 오로지 코드 재구성에만 전념한다. 테스트도 새로 만들지 않는다. (부득이하게 인터페이스를 변경해야 할 때만 기존 테스트를 수정한다.)

리팩터링을 하는 이유

  1. 소프트웨어 설계가 좋아진다.
    => 리팩터링 하지 않으면 소프트웨어 내부 설계가 썩기 쉽다.

  2. 소프트웨어를 이해하기 쉬워진다.

  3. 버그를 쉽게 찾을 수 있다.

  4. 프로그래밍 속도를 높일 수 있다.
    => 리팩터링하는 데 시간이 드니 전체 개발 속도는 떨어질 것이라 착각하는 경우가 대부분이지만 리팩터링 없이 한 시스템을 오래 개발하는 경우 초기에는 진척이 빨라도 시간이 지날수록 기능을 하나 추가하는 데 훨씬 오래 걸리는 경우가 대부분이다.

리팩터링의 본질은 코드베이스를 예쁘게 꾸미는 데에 있지 않다. 오로지 경제적인 이유로 하는 것이다. 개발 기간을 단축하고, 기능 추가 시간을 줄이고, 버그 수정 시간을 줄여준다.

언제 리팩터링해야 할까?

  1. 처음에는 그냥 한다.
  2. 비슷한 일을 두 번째로 하게 되면 일단 계속 진행한다.
  3. 비슷한 일을 세 번째 하게 되면 리팩터링 한다.

준비를 위한 리팩터링: 기능을 쉽게 추가하게 만들기
이해를 위한 리팩터링: 코드를 이해하기 쉽게 만들기
쓰레기 줍기 리팩터링 => 로직이 쓸데없이 복잡하거나, 매개변수화한 함수 하나면 될 일을 거의 똑같은 함수 여러 개로 작성해놓은 경우 이런 것은 쓰레기고, 방치하는 것은 좋지 않다.

이런 리팩토링은 기회가 될 때만 진행하고, 따로 일정을 잡지 않는다. 프로그래밍 과정에 자연스럽게 녹이는 것이 좋다. 리팩터링은 프로그래밍과 구분되는 별개의 활동이 아니다.

무언가 수정하려 할 때는 먼저 수정하기 쉽게 정돈하고, 그런 다음 쉽게 수정하자 - 켄트 벡

리팩터링하지 말아야 할 때

  • 외부 API 다루듯 호출해서 쓰는 코드라면 지저분해도 그냥 둔다. 내부 동작을 이해해야 할 시점에 리팩터링해야 효과를 제대로 볼 수 있다.

  • 처음부터 새로 작성하는 게 쉬울 때 리팩터링 하지 않는다.

22/07/01 (p.113~p.131)

CHAPTER 3.코드에서 나는 악취

리팩터링을 언제 해야하는 가는 인간의 직관에 의해 코드에서 풍기는 악취가 진동하는 시점이다. 여기서의 '악취'는 무엇을 의미하는가?

  • 기이한 이름

  • 중복 코드

  • 긴 함수

  • 긴 매개변수 목록

  • 전역 데이터

  • 가변 데이터

  • 뒤엉킹 변경 : 버그 수정을 위해 어느 부분을 고쳤는데, 다른 부분까지 영향이 가는 상황 (단일 책임 원칙 미준수, 모듈간 관계가 깊은 상태). 이럴 땐 모듈을 맥락별로 분리해야한다.

  • 산탄총 수술 : 뒤엉킨 변경과 비슷하면서도 정반대다. 코드를 변경할 때마다 자잘하게 수정해야 하는 클래스가 많은 경우다. 이 경우는 변경되는 대상들을 맥락별로 묶어줘야 한다.

  • 기능편애 : 어떤 함수가 자기가 속한 모듈의 함수나 데이터보다 다른 모듈의 함수나 데이터와 상호작용 할 일이 더 많을 때 풍기는 냄새다.

  • 데이터 뭉치 : 데이터들에서 값 하나를 삭제했을 때, 나머지 데이터만으로는 의미가 없다면 객체로 환생하기를 갈망하는 데이터 뭉치다.

  • 기본형 집착 : 자신에게 주어진 문제에 딱 맞는 기초타입(화폐, 좌표, 구간)등을 직접 정의하기를 몹시 꺼려하는 경우.

  • 반복되는 switch문 : 반복되는 switch문은 조건절을 하나 추가할 때마다 다른 switch문들도 모두 찾아서 함께 수정해야 해서 문제가 된다.

  • 반복문 : map이나 filter같은 파이프라인 연산을 사용하는 것이 좋다.

  • 성의 없는 요소 : 매서드가 하나밖에 없는 클래스 등 이런 요소는 함수 인라인하기나 클래스 인라인하기로 처리한다. 상속을 사용했다면 계층 합치기를 적용한다.

  • 추측성 일반화 : 나중에 필요할 것이라 추측하여 작성한 코드의 냄새다.

  • 임시 필드

  • 메시지 체인 : 클라이언트가 한 객체를 통해 다른 객체를 얻은 뒤 방금 얻은 객체에 또 다른 객체를 요청하는 식으로, 다른 객체를 요청하는 작업이 연쇄적으로 이어지는 코드.

  • 중개자 : 객체의 대표적 기능 하나로 외부로부터 세부사항을 숨겨주는 캡슐화가 있다. 이것이 지나치면 문제가 된다. 가령 클래스가 제공하는 메서드 중 절반이 다른 클래스에 구현을 위임하고 있는 경우가 있다. 위임 메서드를 제거한 후 남는 일이 거의 없다면 호출하는 쪽으로 인라인하는게 좋다.

  • 내부자 거래 : 모듈 사이의 데이터 거래가 많은경우다. 그 양을 최소로 줄이고 모두 투명하게 처리해야한다.

  • 거대한 클래스

  • 서로 다른 인터페이스의 대안 클래스들 : 클래스의 장점은 필요할 때 언제든 다른 클래스로 교체할 수 있다는 것이다. 단, 교체하려면 인터페이스가 같아야한다.

  • 데이터 클래스 : 데이터 클래스란 데이터 필드와 게터/세터 메서드로만 구성된 클래스를 말한다. 데이터 저장 용도로만 쓰이다 보니 다른 클래스가 너무 깊이까지 함부로 다룰 때가 많다. 한편, 데이터 클래스는 필요한 동작이 엉뚱한 곳에 정의되어 있다는 신호일 수 있다. 이런 경우라면 클라이언트 코드를 데이터 클래스로 옮기기만 해도 대폭 개선된다.

  • 상속 포기 : 서브 클래스가 부모의 동작은 필요로하지만 인터페이스는 따르고 싶지 않을 때 냄새가 심하게 난다.

  • 주석 : 주석을 남겨야겠다는 생각이 들면, 가장 먼저 주석이 필요없는 코드로 리팩터링해본다.

22/07/01 (p.133~p.152)

CHAPTER 4. 테스트 구축하기

테스트 코드 작성 예시

UI화면
지역 : asia
수요 : 30, 가격 : 20
생산자수 : 3
홍길동 - 비용:10, 생산량:9, 수익 90
김철수 - 비용:12, 생산량:10, 수익 120
김민수 - 비용:10, 생산량:6, 수익:60
부족분 : 5 / 기대 총수익 : 230 <25 * 20 - (90 + 120 + 60) = 230>

여기서 비즈니스 로직 코드는 클래스 두 개로 구성된다. 하나는 생산자를 표현하는 Producer이고, 다른 하나는 지역 전체를 표현하는 province다. Province생성자는 JSON문서로부터 만들어진 자바스크립트 객체를 인수로 받는다.

JSON데이터로부터 지역 정보를 읽어오는 코드는 다음과 같다

class Province {
  constructor(doc) {
    this._name = doc.name;
    this._producers = [];
    this._totalProduction = 0;
    this._demand = doc.demand;
    this._price = dic.price;
    doc.producers.forEach(d => this.addProducer(new Producer(this, d)));
  }
  addProducer(arg) {
    this._producer.push(arg);
    this._totalProduction += arg.production;
  }
  
  get name() {return this._name;}
  get producer() {return this._producer.slice();}
  get totalProduction() {return this._totalProduction;}
  set totalProduction(arg) {return this._totalProduction = arg;}
  get demand() {return this._demand;}
  set demand(arg) {return this._demand = parseInt(arg);} // 숫자로 파싱해서 저장
  get price() {return this._price;}
  set price() {return this._price = parseInt(arg);} // 숫자로 파싱해서 저장
}

get shortfall() { // 생산 부족분 계산
  return this._demand - this.totalProduction;
}

// 수익 계산
get profit() {
  return this.demandValue - this.demandCost;
}

get demandValue() {
  return this.satisfiedDemand * this.price;
}
get satisfiedDemand() {
  return Math.min(this._demand, this.totalProduction);
}

get demandCost() {
  let remainingDemand = this.demand;
  let result = 0;
  
  this.producers
  	.sort((a,b)=> a.cost - b.cost)
  	.forEach(p => {
    	const contribution = Math.min(remainingDemand, p.production);
    		remainingDemand -= contribution;
    		result += contribution * p.cost;
  	});
  return result;
}

Province클래스에는 다양한 데이터에 대한 접근자들이 담겨 있다. 세터는 UI에서 입력한 숫자를 인수로 받는데, 이 값은 문자열로 전달된다. 그래서 계산에 활용하기 위해 숫자로 파싱한다.

다음의 sampleProvinceData() 함수는 앞 생성자의 인수로 쓸 JSON데이터를 생성한다. 이 함수를 테스트하려면 이 함수가 반환한 값을 인수로 넘겨서 Province 객체를 생성해보면 된다.

fucntion sampleProvinceData() {
  return {
    name: "Asia",
    producer: [
      {name:"홍길동", cost: 10, production:9},
      {name:"김철수", cost: 12, production:10}, 
      {name:"김민수", cost: 10, production:6}
    ],
    demand:30,
    price:20
  };
}

Producer클래스는 주로 단순한 데이터 저장소로 쓰인다.

constructor (aProvince, data) {
  this._province = aProvince;
  this._cost = data.cost;
  this._name = data.name;
  this._production = data.production || 0;
}

get name() {return this._name}
get cost() {return this._cost;}
set cost(arg) {this._cost = parseInt(arg);}

get production() {return this._production;}
set production(amountStr) {
  const amount = parseInt(amountStr);
  const newProduction = Number.isNaN(amount) ? 0 : amont;
  this._province.totalProduction += newProduction - this._production;
  this._production = newProduction;
}

set production()이 계산결과를 지역 데이터(province)에 갱신하는 코드가 좀 지저분하다. 이런 코드를 목격하면 리팩터링 해서 제거하고 싶어 지지만, 그러려면 먼저 테스트를 작성해야 한다.

다음은 Mocha를 활용한 테스트 코드다

테스트는 클래스가 하는 일을 모두 살펴보고, 각각의 기능에서 오류가 생길 수 있는 조건을 하나씩 테스트 하는 것이 좋다. 일부 프로그래머는 public메서드를 빠짐없이 테스트 하는데, 테스트는 위험 요인을 중심으로 작성해야 한다. 테스트의 목적은 어디까지나 현재 혹은 향후에 발생하는 버그를 찾는 데 있다. 따라서 단순히 필드를 읽고 쓰기만 하는 접근자는 테스트할 필요가 없다. 이런 코드는 너무 단순해서 버그가 숨어들 가능성도 별로 없다.

완벽하게 만드느라 테스트를 수행하지 못하느니, 불완전한 테스트라도 작성해 실행하는 게 낫다.


describe('province', function() {
 		 // 생산 부족분 계산 확인
	it('shortfall', function() {
      const asia = new Province(sampleProvinceData());
      assert.equal(asia.shortfall, 5); // 검증
      // 혹은
      // expect(asia.shortfall).equal(5);
    });
           
    it('profit', function() {
      const asia = new Province(sampleProvinceData());
      expect(asia.profit).equal(230);
    });
});

여기서 기대값 230은 먼저 임의의 값을 넣어 테스트 한 후, 프로그램이 제대로 동작할 것이라는 믿음 하에 먼저 프로그램이 내놓는 실제 값으로 대체 후 테스트한다. 그런 다음 테스트가 제대로 작동한다고 확인되면 로직에 *2를 붙여서 잘못된 값이 나오도록 수정한다. 일부러 주입한 이 오류를 테스트가 걸러내는 게 확인되면 원래 코드로 되돌린다. 이런 패턴은 흔히 사용하는 패턴이다.

위 테스트 코드에서 중복되는 부분이 있다. 그럴 때는 아래와 같이 처리할 수 있다.

describe('province', function() {
    //const asia = new Province(sampleProvinceData());
    // bad : 이렇게 하면 나중에 asia의 값을 수정하면
    // 이 픽스처를 사용하는 또 다른 테스트가 실패할 수 있다.
    // 즉, 테스트를 실행하는 순서에 따라 결과가 달라질 수 있다.
  	
  	beforeEach(function() {
      asia = new Province(sampleProvinceData());
    });
  	// good : beforeEach구문은 각각의 테스트 바로 전에 실행되어
    // asia를 초기화하기 때문에 모든 테스트가 자신만의 새로운
    // asia를 사용하게 된다.
    // 이처럼 개별 테스트를 실행할 때마다 픽스쳐를 새로 만들면
    // 모든 테스트를 독립적으로 구성할 수 있어서, 
    // 결과를 예측할 수 없어 골치를 썩는 사태를 예방할 수 있다.
    // 각 테스트에 픽스쳐를 넣지 않고, 이런 방식으로 하는 것은
    // 표준 픽스쳐를 사용한다는 사실을 알려준다.
    // 이는 코드를 읽는 이들로 하여금 해당 describe블록 안의
    // 모든 테스트가 똑같은 기준 데이터로부터 시작한다는 사실을 쉽게 알 수 있다.
  	
    // 생산 부족분 계산 확인
	it('shortfall', function() {
      assert.equal(asia.shortfall, 5); // 검증
      // 혹은
      // expect(asia.shortfall).equal(5);
    });
           
    it('profit', function() {
      expect(asia.profit).equal(230);
    });
  
  	// 복잡한 로직의 세터 검증(보통 세터는 잘 테스트 하지 않는다.)
  	it('change production', function() {
      asia.producer[0].production = 20;
      expect(asia.shortfall).equal(-6);
      expect(asia.profit).equal(292);
    });
  	// 이 테스트는 it구문 하나에서 두 가지 속성을 검증하고 있다.
  	// 일반적으로 it구문 하나당 검증도 하나씩 하는 게 좋다.
  	// 속성들이 밀접할 때만 묶어서 검증한다.
});

실패해야 할 상황에서는 반드시 실패하게 만들자.

수 많은 테스트를 실행했음에도 실패하는게 없다면 테스트가 내 의도와는 다른 방식으로 코드를 다루는 건 아닌지 불안해진다. 그래서 각각의 테스트가 실패하는 모습을 최소한 한 번씩은 직접 확인해본다. 이를 위해 흔히 쓰는 방법은 일시적으로 코드에 오류를 주입한 후 테스트를 실행하는 것이다.

자주 테스트하라, 작성 중인 코드는 최소한 몇 분 간격으로 테스트하고, 적어도 하루에 한 번은 전체 테스트를 돌려보자.

테스트코드가 통과하기 전 까지는 리팩터링하지 않는다.


경계조건 검사하기

지금까지의 테스트는 의도대로 사용하는 일명 "꽃길"상황이다. 이 범위를 벗어나는 경계지점에서 문제가 생기면 어떤 일이 벌어지는지 확인하는 테스트도 함께 작성하면 좋다.

다음 예시는 producers와 같은 컬렉션과 마주하면 그 컬렉션이 비었을 때 어떤 일이 일어나는지 확인하는 것이다.


describe('province', function() {
  	let noProducers;
  	beforeEach(function() {
      const data = {
       name: "No Producers",
       producers: [];
       demand:30,
       price:20
      };
      noProducers = new Province(data);
      asia = new Province(sampleProvinceData());
    });

  	
	it('shortfall', function() {
      expect(noProducers.shortfall).equal(30);
    });
           
    it('profit', function() {
      expect(noProducers.profit).equal(0);
    });
  
     // 숫자형이라면 0일 때를 검사해본다.
  	it('zero demand', function() { // 수요가 없는 상황
      asia.demand = 0;
      expect(asia.shortfall).equal(-25);
      expect(asia.profit).equal(0);
    });
  
  	// 음수도 넣어보면 좋다.
  	it('zero demand', function() { // 수요가 마이너스
      asia.demand = -1;
      expect(asia.shortfall).equal(-26);
      expect(asia.profit).equal(-10);
    });
  
  	// 수요 입력란이 비어 있는 상황
    it('empty string demand', function() { 
      asia.demand = "";
      expect(asia.shortfall).NaN;
      expect(asia.profit).NaN;
    });

수요가 음수일 때 수익이 음수가 나온다는 것은 상식적으로 말이 안된다. 그렇다면 수요 세터에 전달된 인수가 음수라면 에러를 던지거나 무조건 0으로 설정하는 식으로 정상적으로 다르게 처리해야한다는 생각을 해볼 수 있다. 이처럼 경계를 확인하는 테스트를 작성해보면 프로그램에서 이런 특이 상황을 어떻게 처리하는 게 좋을지 생각해볼 수 있다.

문제가 생길 가능성이 있는 경계 조건을 생각해보고 그 부분을 집중적으로 테스트하자.

어차피 모든 버그를 잡아낼 수 없다고 생각하여 테스트를 작성하지 않는다면 대다수의 버그를 잡을 수 있는 기회를 날리는 셈이다.

테스트는 프로그래밍 속도를 높여준다. 하지만 테스트를 너무 많이 작성하다 보면 오히려 의욕이 떨어져 나중에는 하나도 작성하지 않을수도 있으니 위험한 부분에 집중하는 것이 좋다.
코드 처리 과정이 복잡한 부분, 함수에서 오류가 생길만한 부분을 찾아서 테스트하면 된다.

버그 리포트를 받으면 가장 먼저 그 버그를 드러내는 단위 테스트부터 작성하자.

22/07/02 (p.153~p.155)

CHAPTER 5. 리팩터링 카탈로그 보는법

  • 리팩터링 이름
  • 리팩터링 개요(개념도 + 코드예시)
  • 리팩터링 배경 : 해당 리팩터링이 왜 필요한지? 적용하면 안되는 상황
  • 리팩터링 절차
  • 리팩터링 실제 적용 예시와 효과

22/07/03 (p.155~p.178)

CHAPTER 6. 기본적인 리팩터링

함수 추출하기

코드를 언제 독립된 함수로 묶어야 하는가?
=> 목적과 구현을 분리하는 방식

코드를 보고 무슨 일을 하는지 파악하는 데 한참이 걸린다면 그 부분을 함수로 추출한 뒤 '무슨 일'에 걸맞는 이름을 짓는다. 이렇게 해두면 나중에 코드를 다시 읽을 때 함수의 목적이 눈에 확 들어오고, 본문 코드에 대해서는 더 이상 신경 쓸일이 거의 없다.

이를 위해선 이름 짓기가 가장 중요하다.

절차

  1. 함수를 새로 만들고 목적을 잘 드러내는 이름을 붙인다. (어떻게가 아닌 무엇을 하는지가 드러나야 한다.)

  2. 추출한 코드를 원본 함수에서 복사하여 새 함수에 붙여넣는다.

  3. 추출한 코드 중 원본 함수의 지역 변수를 참조하거나 추출한 함수의 유효범위를 벗어나는 변수는 없는지 검사한다. 있다면 매개변수로 전달한다.

  4. 변수를 다 처리했다면 컴파일한다.

  5. 원본 함수에서 추출한 코드 부분을 새로 만든 함수를 호출하는 문장으로 바꾼다.

  6. 테스트한다.

  7. 다른 코드에 방금 추출한 것과 똑같거나 비슷한 코드가 없는지 살핀다. 있다면 방금 추출한 새 함수를 호출하도록 바꿀지 검토한다.(인라인 코드를 함수 호출로 바꾸기)

함수 인라인하기

때로 함수 본문이 이름만큼 명확한 경우도 있다. 또는 함수 본문 코드를 이름만큼 깔끔하게 리팩터링 할 때도 이싿. 이럴때는 그 함수를 제거한다.

리팩터링 과정에서 잘못 추출된 함수들도 다시 인라인한다.

간접호출을 너무 과하게 쓰는 코드도 흔한 인라인 대상이다.

절차

  1. 다형 메서드인지 확인한다.
    -> 서브클래스에서 오버라이드하는 메서드는 인라인하면 안된다.

  2. 인라인할 함수를 호출하는 곳을 모두 찾는다.

  3. 각 호출문을 함수 본문으로 교체한다.

  4. 하나씩 교체할 때마다 테스트한다.
    -> 인라인 작업을 한 번에 처리할 필요는 없다. 이나인하기가 까다로운 부분이 있다면 일단 남겨두고 여유가 생길 때마다 틈틈이 처리한다.

  5. 함수 정의(원래함수)를 삭제한다.

변수 추출하기

표현식이 너무 복잡해서 이해하기 어려우 때가 있다. 이럴 때 지역 변수를 활용하면 표현식을 쪼개 관리하기 더 쉽게 만들 수 있다. 그러면 복잡한 로직을 구성하는 단계마다 이름을 붙일 수 있어서 코드의 목적을 훨씬 명확하게 드러낼 수 있다.

절차

  1. 추출하려는 표현식에 부작용은 없는지 확인한다.
  2. 불변 변수를 하나 선언하고 이름을 붙일 표현식의복제본을 대입한다.
  3. 원본 표현식을 새로 만든 변수로 교체한다.
  4. 테스트한다.

변수 인라인하기

변수는 함수 안에서 표현식을 가리키는 이름으로 쓰이며, 대체로 긍정적인 효과를 준다. 하지만 그 이름이 원래 표현식과 다를 바 없을 때도 있다. 또 변수가 주변 코드를 리팩터링 하는 데 방해가 되기도 한다. 이럴 때는 그 변수를 인라인 하는 것이 좋다.

절차

  1. 대입문의 우변(표현식)에서 부작용이 생기지는 않는지 확인한다.

  2. 변수가 불변으로 선언되지 않았다면 불변으로 만든 후 테스트한다.
    -> 이렇게 하면 변수에 값이 단 한 번만 대입되는지 확인할 수 있다.

  3. 이 변수를 가장 처음 사용하는 코드를 찾아서 대입문의 우변의 코드로 바꾼다.

  4. 테스트한다.

  5. 변수를 사용하는 부분을 모두 교체할 때까지 이 과정을 반복한다.

  6. 변수 선언문과 대입문을 지운다.

  7. 테스트한다.

22/07/05 (p.178~p.202)

함수 선언 바꾸기

함수의 좋은 이름을 떠올리는 데 효과적인 방법이 하나 있다. 바로 주석을 이용해 함수의 목적을 설명해보는 것이다. 그러다 보면 주석이 멋진 이름으로 바뀌어 되돌아올 때가 있다.

간단한 절차

  1. 매개변수를 제거하려거든 먼저 함수 본문에서 제거 대상 매개변수를 참조하는 곳은 없는지 확인한다.

  2. 메서드 선언을 원하는 형태로 바꾼다.

  3. 기존 메서드 선언을 참조하는 부분을 모두 찾아서 바뀐 형태로 수정한다.

  4. 테스트한다.

function circum(radius) {
  return 2 * Math.PI * radius;
}

function circumference(radius) {
  return 2 * Math.PI * radius;
}

// 3. circum()부분을 모두 circumference()로 바꾼다.

변경할 게 둘 이상이면 나눠서 처리하는 편이 나을 때가 많다. 따라서 이름 변경과 매개변수 추가를 모두 하고 싶다면 각각 독립적으로 처리하자 (그러다 문제가 생기면 작업을 되돌리고 '마이그레이션 절차'를 따른다.


마이그레이션 절차

  1. 이어지는 추출 단계를 수월하게 만들어야 한다면 함수의 본문을 적절히 리펙터링한다.

  2. 함수 본문을 새로운 함수로 추출한다.
    -> 새로 만들 함수 이름이 기존 함수와 같다면 일단 검색하기 쉬운 이름을 임시로 붙여둔다.

  3. 추출한 함수에 매개변수를 추가해야 한다면 '간단한 절차'를 따라 추가한다.

  4. 테스트한다.

  5. 기존 함수를 인라인한다.

  6. 이름을 임시로 붙여뒀다면 함수 선언 바꾸기를 한 번 더 적용하여 원래 이름으로 되돌린다.

  7. 테스트한다.

function circum(radius) {
  return 2 * Math.PI * radius;
}

function circum(radius) {
  // 2. 함수 본문 전체를 새로운 함수로 추출
  return circumference(radius);
}

function circumference(radius) {
  return 2 * Math.PI * radius;
}

// 4, 5, 7

cicumFerence()함수를 만들고 나서 잠시 리팩터링 작업을 멈춘다. 가능하다면 curcum()이 폐기 예정임을 표시한다. 그런 다음 circum()의 클라이언트들 모두가 cicumFerence()를 사용하게 바뀔 때 까지 기다린다. 모든 클라이언트가 새 함수로 갈아탔다면 circum()을 삭제한다.

변수 캡슐화하기

데이터 캡슐화는 리팩터링 작업을 간소화 시켜준다. 또한 데이터를 변경하고 사용하는 코드를 감시할 수 있는 확실한 통로가 되어주기 때문에 데이터 변경 전 검증이나 변경 후 추가 로직을 쉽게 끼워 넣을 수 있다. 데이터의 유효범위가 넓을수록 캡슐화 해야한다. 그래야 자주 사용하는 데이터에 대한 결합도가 높아지는 일을 막을 수 있다.

불변 데이터는 가변 데이터보다 캡슐화할 이유가 적다. 게다가 불변 데이터는 옮길 필요없이 그냥 복제하면 된다.

절차

  1. 변수로의 접근과 갱신을 전담하는 캡슐화 함수들을 만든다.

  2. 정적 검사를 수행한다.

  3. 변수를 직접 참조하던 부분을 모두 적절한 캡슐화 함수 호출로 바꾼다. 하나씩 바꿀 때마다 테스트한다.

  4. 변수의 접근 범위를 제한한다.
    -> 변수로의 직접 접근을 막을 수 없을 때도 있다. 그럴 때는 변수 이름을 바꿔서 테스트해보면 해당 변수를 참조하는 곳을 쉽게 찾아낼 수 있다.

  5. 테스트한다.

  6. 변수 값이 레코드라면 레코드 캡슐화하기를 적용할지 고려해본다.

변수 이름바꾸기

절차

  1. 폭넓게 쓰이는 변수라면 변수 캡슐화하기를 고려한다.

  2. 이름을 바꿀 변수를 참조하는 곳을 모두 찾아서 하나씩 변경한다.
    -> 다른 코드베이스에서 참조하는 변수는 외부에 공개된 변수이므로 이 리팩터링을 적용할 수 없다.
    -> 변수 값이 변하지 않는다면 다른 이름으로 복제본을 만들어서 하나씩 점진적으로 변경한다. 하나씩 바꿀때마다 테스트한다.

  3. 테스트한다.

매개변수 객체 만들기

절차

  1. 적당한 데이터 구조가 아직 마련되어 있지 않다면 새로 만든다.

  2. 테스트한다.

  3. 함수선언바꾸기로 새 데이터 구조를 매개변수로 추가한다.

  4. 테스트한다.

  5. 함수 호출 시 새로운 데이터 구조 인스턴스를 넘기도록 수정한다. 하나씩 수정할 때마다 태스트한다.

  6. 기존 매개변수를 사용하던 코드를 새 데이터 구조의 원소를 사용하도록 바꾼다.

  7. 다 바꿨다면 기존 매개변수를 제거하고 테스트한다.

// 온도 측정값 배열에서 정상 작동 범위를 벗어난 것이 있는지 검사하는 코드

// 온도 측정값 표현 데이터
const station = {
  name: "ZB1",
  readings: [
    { temp: 47, time: "2016-11-10 09:10" },
    { temp: 53, time: "2016-11-10 09:20" },
    { temp: 58, time: "2016-11-10 09:30" },
    { temp: 53, time: "2016-11-10 09:40" },
    { temp: 51, time: "2016-11-10 09:50" }
  ]
};

// 정상 범위를 벗어난 측정값을 찾는 함수
function readingsOutsideRange(station, min, max) {
  return station.readings.filter((r) => r.temp < min || r.temp > max);
}

const operatingPlan = {
  temperatureFloor: 50,
  temperatureCeiling: 55
};
// 이 함수는 다음과 같이 호출될 수 있다.
let alerts = readingsOutsideRange(
  station,
  operatingPlan.temperatureFloor, // 최저온도
  operatingPlan.temperatureCeiling // 최고온도
);

// 위에서 최저 최고와 같이 범위 개념은 개게 하나로 묶어 표현하는게 나은 대표적인 예다.

// 1. 먼저 묶은 데이터를 표현하는 클래스부터 선언하자.
class NumberRange {
  constructor(min, max) {
    this._data = { min: min, max: max };
  }
  get min() {
    return this._data.min;
  }
  get max() {
    return this._data.max;
  }
}
// 새로 새성한 객체로 동작까지 옮기는 더 큰 작업의 첫 단계로 수행될 떄가 많아서 클래스로 선언.

// 매개변수를 객체로 만든 후 호출한다.
function readingsOutsideRange1(station, range) {
  return station.readings.filter(
    (r) => r.temp < range.min || r.temp > range.max
  );
}

const range = NumberRange(
  operatingPlan.temperatureFloor,
  operatingPlan.temperatureCeiling
);
alerts = readingsOutsideRange1(station, range);

// 이렇게 만들어 두면 온도가 허용 범위 안에 있는지 검사하는 메서드를 클래스에 추가할 수 있다.

class NumberRange1 {
  constructor(min, max) {
    this._data = { min: min, max: max };
  }
  get min() {
    return this._data.min;
  }
  get max() {
    return this._data.max;
  }
  contains(arg) {
    return arg >= this.min || arg <= this.max;
  }
}

function readingsOutsideRange2(station, range) {
  return station.readings.filter((r) => !range.contains(r.temp));
}

0개의 댓글