[리펙터링 2판] 8장 - 기능이동
도서

[리펙터링 2판] 8장 - 기능이동

반응형

  요소를 다른 컨텍스트(클래스나 모듈 등)으로 옮기는 일 역시 리팩터링의 중요한 축이다. 그리고 옮기기는 문장 단위에서도 이뤄진다.

반복문 관련하여 자주 사용되는 리팩터링도 있는데, 반복문이 단 하나의 일만 수행하거나 반목문을 완전히 없애버리는 방법이 있다. 마지막으로 대부분 프로그래머가 사용하는 죽은 코드 제거하는 리팩터링이 있다.

 

 

 

8.1 함수 옮기기

내용

  • 함수를 기존에 위치한 곳에서 다른 곳으로 이동

적용

  • 어떤 함수가 자신이 속한 모듈 A의 요소들보다 다른 모듈 B의 요소들을 더 많이 참조한다면 B로 옮겨줘야 한다.
  • 함수의 현재 컨텍스트와 후보 컨텍스트를 둘러보면서 호출하는 함수, 데이터를 파악하여 연관성을 살펴 해결한다.
  • 여러 함수를 묶을 컨텍스트가 필요해지면 클래스묶기(6.9), 클래스 추출하기(7.5)로 해결 가능하다.

결과

  • 함수를 옮김으로써 캡슐화가 좋아진다.
  • 소프트웨어의 나머지 부분(모듈 A)은 모듈 B의 세부사항에 덜 의존하게 된다. 

예시

Before

class Account {
  get overdraftCharge() {...}

After

class AccountType {
    get overdraftCharge() {...}

 

좋은 소프트웨어 설계의 핵심은 모듈화가 얼마나 잘 되어 있느냐를 뜻하는 모듈성 이다.

모듈성이란 프로그램의 어딘가를 수정하려 할 때 해당 기능과 깊이 관련된 작은 일부만 이해해도 가능하게 해주는 능력이다.

모듈성을 높이려면 서로 연관된 요소들을 함께 묶고, 요소 사이의 연결 관계를 쉽게 찾고 이해할 수 있도록 해야한다.

(소프트웨어의 이해도에 따라 구체적인 방법이 달라지고 이해도가 높아질수록 요소들을 잘 묶는 새로운 방법을 깨우치게 된다.)

그러므로 높아진 이해를 반영하기 위해서 요소들을 이리저리 옮겨야 할 수 있다.

 

다른 함수 안에서 도우미 역할로 정의된 함수 중 독립적으로 고유한 가치가 있는 것은 접근하기 더 쉬운 장소로 옮기는게 낫다. 또한 다른 클래스로 옮겨두면 사용하기 더 편한 메서드도 있다.

 

함수를 옮길지 말지 정하기란 쉽지 않고, 얼마나 적합한지는 깨달아 감으로써 잘 맞지 않다고 판단되면 위치는 언제든 옮길 수 있다.

 

 

 

 

8.2 필드 옮기기

내용

  • 데이터 구조의 변경 (필드를 한 곳에서 다른 곳으로 이동)
  • 레코드 대신 클래스나 객체가 와도 동일하다.

적용

  • 함수에 레코드를 넘길 때마다 또 다른 레코드의 필드도 함께 넘길 경우
  • 한 레코드를 변경 하는데 다른 레코드의 필드까지 변경해야 할 때

결과

  • 데이터 구조가 적절치 않으면 훗날 작성하게 될 코드를 더욱 복잡하게 만듦

예시

Before

class Customer {
  get plan() {return this._plan;}
  get discountRate() {return this._discountRate;}

After

class Customer {
  get plan() {return this._plan;}
  get discountRate() {return this.plan.discountRate;}

 

프로그램의 상당 부분이 동작을 구현하는 코드로 이뤄지지만 프로그램의 진짜 힘은 데이터 구조에서 나온다.

데이터 구조를 잘못 선택하면 아귀가 맞지 않는 데이터를 다루기 위한 코드 범벅이 된다. 이해하기 어려운 코드가 만들어지는 데서 끝내지 않고, 데이터 구조 자체도 어떤 일을 하는지 파악하기 어려워 진다.

 

 

 

8.3 문장을 함수로 옮기기

내용

  • 반복되는 (중복)코드를 생략하고 함수에 합친다.

적용

  • 특정 함수를 호출하는 코드가 반복 될 때 피호출 함수로 합친다.
  • 문장들이 피호출 함수의 일부라는 확신이 있어야 한다.

결과

  • 반복되는 부분에서는 여러 곳을 수정시켜줘야 하지만 합칠 경우에는 한 곳만 수정시켜주면 된다.

예시

Before

result.push(`<p>title: ${person.photo.title}</p>`);
result.concat(photoData(person.photo));

function photoData(aPhoto) {
  return [
    `<p>location: ${aPhoto.location}</p>`,
    `<p>date: ${aPhoto.date.toDateString()}</p>`,
  ];
}

After

result.concat(photoData(person.photo));

function photoData(aPhoto) {
  return [
    `<p>title: ${aPhoto.title}</p>`,
    `<p>location: ${aPhoto.location}</p>`,
    `<p>date: ${aPhoto.date.toDateString()}</p>`,
  ];
}

 

중복 제거는 코드를 건강하게 관리하는 가장 효과적인 방법 중 하나다. 코드의 동작을 여러 변형들로 나눠야 하는 순간이 오면 반대 리펙터링인 문장을 호출하 곳으로 옮기기(8.4)를 적용하여 다시 쉽게 뽑아 낼 수 있다.

 

 

 

8.4 문장을 호출한 곳으로 옮기기

내용

  • 기능 범위가 달라져 특정 동작을 다시 호출자로 옮기는 경우
  • 반대 리팩터링 : 8.3 문장을 함수로 옮기기

적용

  • 여러 곳에서 사용하던 기능이 일부 호출자에게는 다르게 동작하도록 바뀌어야 할 때
  • 호출자와 호출 대상의 경계를 다시 그어야 할 때 (함수 인라인하기(6.2) 부터 적용한다음, 문장슬라이스하기(8.6)와 함수 추출하기 (6.1)로 더 적합한 경계를 설정해야 한다.

결과

  • 달라지는 동작을 호출자로 옮긴 뒤에는 필요할 때마다 독립적으로 수정 가능하다

예시

Before

emitPhotoData(outStream, person.photo);

function emitPhotoData(outStream, photo) {
  outStream.write(`<p>title: ${photo.title}</p>\n`);
  outStream.write(`<p>location: ${photo.location}</p>\n`);
}

After

emitPhotoData(outStream, person.photo);
outStream.write(`<p>location: ${person.photo.location}</p>\n`);

function emitPhotoData(outStream, photo) {
  outStream.write(`<p>title: ${photo.title}</p>\n`);
}

 

함수는 프로그래머가 쌓아 올리는 추상화의 기본 빌딩 블록이다. 그런데 추상화라는 것이 그 경계를 항상 올바르게 긋기가 만만치 않다. 그래서 코드베이스의 기능 범위가 달라지면 추상화의 경계도 움직이게 된다. 함수 관점에서 생각해 보면, 초기에는 응집도 높고 한 가지 일만 수행하던 함수가 어느새 둘 이상의 다른 일을 수행하게 바뀔 수 있다는 뜻이다.

 

일부 호출자에게는 다르게 동작 하도록 바뀌어져야 한다면 달라진 동작을 함수에서 꺼내 해당 호출자로 옮겨야 한다. 이 때 우선 문장슬라이스 하기(8.6)를 적용해 달라지는 동작을 함수의 시작 혹은 끝으로 옮긴 다음, 바로 이어서 문장을 호출한 곳으로 옮기기(8.4) 리팩터링을 적용하면 된다.

 

  결국 동작에 있어 예외적인 변경 상황이 생긴다면 문장을 호출한 곳으로 옮겨야 한다는 뜻이다.

 

 

 

8.5 인라인 코드를 함수 호출로 바꾸기

내용

  • 이미 존재하는 함수와 같은 일을 하는 인라인 코드를 발견하면 대체

적용

  • 이미 존재하는 함수와 똑같은 일을 하는 인라인 코드를 발견할 때
  • 예외적으로 목적이 다르지만, 우연히 비슷한 코드가 만들어진 경우에는 적용하면 안된다.

결과

  • 동작을 변경할 때도, 비슷해 보이는 코드들을 일일이 찾아 수정하는 대신 함수 하나만 수정하면 된다.

예시

Before

  let appliesToMass = false;
  for(const s of states) {
    if (s === "MA") appliesToMass = true;
  }

After

appliesToMass = states.includes("MA");

 

이미 존재하는 함수와 똑같은 일을 하는 인라인 코드를 발견하면 보통은 해당 코드를 함수 호출로 대체하길 원한다.

예외가 있다면 우연히 비슷한 코드가 만들어 졌을 때인데, 함수 이름을 힌트삼아 판단해야 한다. 그 함수의 목적이 인라인 코드의 목적과 다르기 때문에 함수호출로 대체하면 안된다.

이것이 함수 추출하기(6.1) 과 차이이다.

 

정리하자면 대체할 함수가 없다면 함수 추출하기(6.1)을 적용하고 이미 존재한다면 인라인 코드를 함수 호출로 바꾸기를 적용하면 된다.

 

 

 

8.6 문장 슬라이스 하기

내용

  • 하나의 데이터 구조를 이용하는 문장들은 한 곳에 모이게 한다.

적용

  • 함수 추출하기(6.1)의 준비 단계로 자주 행해진다.

결과

  • 함수를 이해하기 쉽게 함
  • 코드들이 모여 있어야만 함수 추출이 가능해진다

예시

Before

const pricingPlan = retrievePricingPlan();
const order = retreiveOrder();
let charge;
const chargePerUnit = pricingPlan.unit;

After

const pricingPlan = retrievePricingPlan();
const chargePerUnit = pricingPlan.unit;
const order = retreiveOrder();
let charge;

 

코드 조각을 슬라이스 할 때는 두가지를 확인해야 하는데 .1 무엇을 슬라이스 할지 2. 슬라이드 할 수 있는지 여부이다.

무엇을 슬라이드할지는 맥락과 관련이 깊다.

코드 조각을 슬라이드하기로 했다면, 그 일이 실제로 가능한지를 점검 해야한다. 슬라이드할 코드 자체와 그 코드가 건너뛰어야 할 코드를 모두 살펴서 순서가 바뀌면 프로그램의 겉보기 동작이 달라지는지 확인해야한다.

 

 

 

8.7 반복문 쪼개기

내용

  • 하나의 반복문 안에 하나의 기능만 수행한다.

적용

  • 반복문에서 두 가지 일을 수행하는 경우

결과

  • 두 가지의 일을 한꺼번에 처리할 수 있다는 이유로 한 반복문안에서 두가지 일을 수행하지만 이렇게 코드를 짤 경우 반복문을 수정할 때마다 두 가지 일 모두를 잘 이해하고 진행해야하는 문제가 있다.
  • 그러므로 반복문을 쪼개면 수정할 동작 하나만 이해하면 된다.

예시

Before

  let averageAge = 0;
  let totalSalary = 0;
  for (const p of people) {
    averageAge += p.age;
    totalSalary += p.salary;
  }
  averageAge = averageAge / people.length;

After

  let totalSalary = 0;
  for (const p of people) {
    totalSalary += p.salary;
  }

  let averageAge = 0;
  for (const p of people) {
    averageAge += p.age;
  }
  averageAge = averageAge / people.length;

 

반복문을 두 번 실행 해야하므로 최적화에 의문을 제기할 수 있다. 여기서 리팩토링가 최적화를 구분해야 한다.

최적화는 코드를 깔끔히 정리한 이후에 수행해야 한다. 반복문을 두 번 실행 시키는게 병목이라 밝혀지면 그 때 다시 합치는 것은 쉽다.

반복문 쪼개기가 다른 더 강력한 최적화를 적용할 수 있는 길을 열어주기도한다.

 

 

 

8.8 반복문을 파이프라인으로 바꾸기

내용

  • 반복문을 파이프라인으로 변경하여 논리의 흐름을 쉽게 파악할 수 있다.

적용

  • 파이프라인으로 교체 가능한 컬렉션 반복문이 있을 경우 (반복문 쪼개기가 선행되어야 한다)
  • 대표적인 연산은 map, filter가 있다.

결과

  • 논리를 파이프라인으로 표현하게 되면 이해하기 쉬워진다.
  • 객체가 파이프라인을 따라 흐르며 어떻게 처리되는지를 읽을 수 있다.

예시

Before

  const names = [];
  for (const i of input) {
    if (i.job === "programmer")
      names.push(i.name);
  }

After

  const names = input
    .filter(i => i.job === "programmer")
    .map(i => i.name)
  ;

 

 

객체 컬렉션을 순회할 때 반복문을 사용하라고 배웠다. 하지만 언어는 계쏙해서 더 나은 구조를 제공하는 쪽으로 발전해왔다.

 

 

 

8.9 죽은 코드 제거하기

내용

  • 사용하지 않는 코드는 제거한다.

적용

  • 코드를 더 이상 사용하지 않게 되면 코드를 지워야 한다. 
  • 버전 관리 시스템으로 다시 살리면 된다.
  • 어느 리비전에서 삭제했는지를 커밋 메세지로 남기면 된다. (하지만 지운 기억나지 않고 후회한 기억이 없다)

결과

  • 남겨 놓으면 복잡하기 때문에 지워서 버전 관리 시스템을 이용한다.

예시

Before

if(false) {
  doSomethingThatUsedToMatter();
}

After

'코드 삭제'

 

  코드 양에는 따로 비용을 매기지 않고, 쓰이지 않는 코드가 몇 줄 있다고 해서 시스템이 느려지는 것도 아니고 메모리를 많이 잡아먹지도 않는다. 최신 컴파일러들은 이런 코드들을 알아서 제거해준다.

 

하지만 소프트웨어 동작을 이해하는 데는 커다란 걸림돌이 될 수 있다. 코드들 스스로 '나는 절대 호출되지 않으니 무시해도 되는 함수다' 라는 신호를 주지 않기 때문이다. 그래서 운 나쁜 프로그래머는 이 코드의 동작을 이해하기 위해, 그리고 코드 수정을 했는데도 기대한 결과가 나오지 않는 이유를 파악하기 위해 시간을 허비하게 된다.

 

코드가 더 이상 사용되지 않게 되었다면 지워야한다. 다시 필요해질 날이 오지 않을까 걱정할 필요 없이 버전 관리 시스템을 사용하면 된다.

죽은 코드를 주석 처리하는 방법이 있었는데, 버전 관리 시스템이 보편화 되지 않았거나 쓰기 불편했던 시절엔 유용한 방법이다.

 

 

반응형