8장, 기능 이동

2021.10.25

1. Move Function, 함수 옮기기

  • 좋은 소프트웨어 설계의 핵심은 모듈화가 얼마나 잘 되어 있느냐를 뜻하는 모듈성(modularity)이다.
  • 모듈성이란 프로그램의 어딘가를 수정하려 할 때 해당 기능과 깊이 관련된 작은 일부만 이해해도 가능하게 해주는 능력이다.
  • 어떤 함수가 자신이 속한 모듈 A의 요소보다 다른 모듈 B의 요소를 더 많이 참조한다면 함수를 모듈 B로 옮겨주는게 좋다.
  • 함수들을 하나의 컨텍스트에 두고 작업하는 하는 것도 괜찮다. ( 얼마나 적합한지는 차차 깨달아갈 것이고, 위치는 언제든지 옮길 수 있다 )

절차

  1. 선택한 함수가 현재 컨텍스트에서 사용 중인 모든 프로그램 요소를 살펴본다. 이 요소들 중에도 함께 옮겨야 할 게 있는지 고민해본다.
  2. 선택한 함수가 다형 메서드인지 확인한다.
  3. 선택한 함수를 타겟 컨텍스트로 복사한다.
  4. 정적 분석을 수행한다.
  5. 소스 컨텍스트에서 타겟 함수를 참조할 방법을 찾아 반영한다.
  6. 소스 함수를 타겟 함수의 위임 함수가 되도록 수정한다.
  7. 테스트.
  8. 소스 함수를 인라인할지 고민해본다. ( 소스 함수를 위임함수로 내버려두어도 되지만, 사용하는 곳에서 직접 타겟 함수를 호출하는게 무리가 없다면 제거하는게 좋다 )

예시

before

function trackSummary(points) {
  const totalTime = calculateTime()
  const totalDistance = calculateDistance()
  const pace = totalTime / 60 / totalDistance
  return {
    time: totalTime,
    distance: totalDistance,
    pace: pace,
  }

  function calculateDistance() {  // 총 거리 계산
    let result = 0
    for (let i = 0; i < points.length; i++) {
      result += distance(points[i-1], points[i])
    }
    return result
  }

  function distance(p1, p2) {
    const EARTH_RADIUS = 3959
    const dLat = radians(p2.lat) - radians(p1.lat)
    const dLon = radians(p2.lon) - radians(p1.lon)
    const a = Math.pow(Math.sin(dLat/ 2), 2)
      + Math.cos(radians(p2.lat))
      + Math.cos(radians(p1.lat))
      + Math.pow(Math.sin(dLon / 2), 2)
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
    return EARTH_RADIUS * c
  } // 두 지점의 거리 계산
  function radians(degrees) {
    return degrees * Math.PI / 180
  } // 라디안 값으로 변환
  function calculateTime()  { ... } // 총 시간 계산
}

after

function trackSummary(points) {
  const totalTime = calculateTime()
  const pace = totalTime / 60 / totalDistance(points)
  return {
    time: totalTime,
    distance: totalDistance(points),
    pace: pace,
  }
}

function totalDistance(points) {
  let result = 0
  for (let i = 0; i < points.length; i++) {
    result += distance(points[i-1], points[i])
  }
  return result
}

function distance(p1, p2) { ... } // 두 지점의 거리 계산
function radians(degrees) { ... } // 라디안 값으로 변환
function calculateTime()  { ... } // 총 시간 계산

2. Move Field, 필드 옮기기

  • 프로그램의 상당 부분이 동작을 구현하는 코드로 이뤄지지만 프로그램의 진짜 힘은 데이터 구조에서 나온다.
  • 함수에 항상 함께 건네지는 데이터 조각들은 상호 관계가 명확하게 드러나도록 한 레코드에 담는게 좋다.
  • 클래스의 데이터들은 접근자 메서드들 뒤에 캡슐화되어 있으므로 데이터를 이리저리 옮기는 작업을 쉽게 해준다.

절차

  1. 소스 필드가 캡슐화되어 있지 않다면 캡슐화한다.
  2. 테스트한다.
  3. 타겟 객체에 필드(와 접근자 메서드들)를 생성한다.
  4. 정적 검사를 수행한다.
  5. 소스 객체에서 타겟 객체를 참조 할 수 있는지 확인한다.
  6. 접근자들이 타겟 필드를 사용하도록 수정한다.
  7. 테스트.
  8. 소스 필드를 제거한다.
  9. 테스트.

예시

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;
  }
}

3. Move Statements Into Function, 문장을 함수로 옮기기

  • 특정 함수를 호출하는 코드가 나올때 마다 그 앞이나 뒤에서 똑같은 코드가 추가로 실행되면 이 반복되는 부분을 피호출 함수로 합치는 방법을 생각하자.
  • 앞 혹은 뒤에 있는 문장들을 함수로 옮기려면 피호출 함수의 일부라는 확신이 있어야 한다.

절차

  1. 반복 코드가 함수 호출 부분과 멀리 떨어져 있따면 문장 슬라이드를 적용해 근처로 옮긴다.
  2. 타겟 함수를 호출하는 곳이 한 곳뿐이면, 단순히 소스 위치에서 해당 코드를 잘라내어 피호출 함수로 복사하고 테스트.
  3. 호출자가 둘 이상이면 호출자 중 하나에서 타겟 함수 호출 부분과 그 함수로 옮기려는 문장들을 함께 다른 함수로 추출한다. ( 추출한 함수는 임시로 이름을 지어준다 )
  4. 다른 호출자 모두가 방금 추출한 함수를 사용하도록 수정한다. ( 하나씩 수정할 때마다 테스트. )
  5. 모든 호출자가 새로운 함수를 사용하게 되면 원래 함수를 새로운 함수로 인라인한 후 원래 함수를 제거한다.
  6. 새로운 함수의 이름을 원래 함수의 이름으로 바꿔준다.

예시

before

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

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

after

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

function photoData(aPhoto) {
  return [`<p>제목: ${aPhoto.title}</p>`, `<p>위치: ${aPhoto.location}</p>`, `<p>위치: ${aPhoto.data}</p>`];
}

4. Move Statements to Caller, 문장을 호출한 곳으로 옮기기

  • 초기와 달리 하나의 함수에서 둘 이상의 일을 수행하게 될 경우, 하나의 기능을 함수에서 꺼내 호출자로 옮긴다.
  • 경우에 따라, 문장을 더 슬라이스하거나 함수로 추출할 수 있다.

절차

  1. 호출자가 한두 대뿐이고 피호출 함수도 간단하여 단순한 상황이면 피호출 함수의 처음줄을 잘라내어 호출자로 복사해 넣는다. 테스트만 통과하면 이번 리팩터링은 여기서 끝이다.
  2. 더 복잡한 상황이라면, 이동하지 않기를 원하는 문장들을 함수로 추출한 다음 임시 이름을 지어준다.
  3. 원래 함수를 인라인한다.
  4. 추출된 함수의 이름을 원래 함수의 이름으로 변경한다.

예시

before

emitPhotoData(outStream, person.photo);

function emitPhotoData(outStream, photo) {
  outStream.write(`<p>제목: ${photo.title}</p>`);
  outStream.write(`<p>위치: ${photo.location}</p>`);
}

after

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

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

5. Replace Inline Code with Function Call, 인라인 코드를 함수 호출로 바꾸기

  • 똑같은 코드를 반복하는 대신 함수를 호출하면 좋다.
  • 이미 존재하는 함수와 똑같은 일을 하는 인라인 코드를 발견하면 보통은 해당 코드를 함수 호출로 대체하길 원한다.
  • 이름을 잘 지었다면 인라인 코드 대신 함수 이름을 넣어도 말이 된다.
  • 특정 라이브러리가 제공하는 함수로 대체할 수 있다면 훨씬 좋다. 함수 본문을 작성할 필요조차 없어지기 때문이다.
    • 물론, 외부 라이브러리에 지나치게 의존하면 설계 유연성이 떨어지니 상황에 맞게 판단해야 한다.

절차

  1. 인라인 코드를 함수 호출로 대체한다.
  2. 테스트.

예시

before

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

after

appliesToMass = states.includes('MA');

6. Slide Statements, 문장 슬라이드하기

  • 관련된 코드들은 가까이 모여 있다면 이해하기가 더 쉽다.
  • 관련 있는 코드들을 명확히 구분되는 함수로 추출하는 것이 문장 슬라이드 하는 것보다 더 좋은 분리법이다.
  • 관련 코드를 모으는 것은 다른 리팩터링의 준비 단계이다.

절차

  1. 코드 조각을 이동할 목표 위치를 찾는다. 코드 조각의 원래 위치아ㅗ 목표 위치 사이의 코드들을 훑어보면서 조각을 모으고 나면 동작이 달라지는 코드가 있는지 살핀다. 간섭이 있다면 이 리팩터링을 포기한다.
  2. 코드 조각을 원래 위치에서 잘라내어 목표 위치에 붙여 넣는다.
  3. 테스트.

예시

before

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

after

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

7. Split Loop, 반복문 쪼개기

  • 종종 반복문 하나에서 두 가지 일을 수행하는 모습을 보게 된다. 두 일을 한꺼번에 처리할 수 있다는 이유로.
    • 반복문을 나중에 수정할 때는 두 가지를 모두 잘 이해하고 진행해야 하는 단점이 있다.
    • 이를 각각의 반복문으로 분리해두면 수정할 동작 하나만 이해하면 된다.
  • 반복문을 두 번 실행해야하지만 리팩터링과 최적화를 구분하여 생각하자.
    • 반복문을 두 번 실행하는게 병목이라 밝혀지면 그때 다시 하나로 합치면 된다.
    • 긴 리스트를 반복하더라도 병목으로 이어지는 경우는 매우 드물다.

절차

  1. 반복문을 복제해 두 개로 만든다.
  2. 반복문이 중복되어 생기는 부수효과를 파악해서 제거한다.
  3. 테스트.
  4. 완료됐으면 각 반복문을 함수로 추출할지 고민해본다.

예시

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. Replace Loop with Pipeline, 반복문을 파이프라인으로 바꾸기

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

절차

  1. 반복문에서 사용하는 컬렉션을 가리키는 변수를 하나 만든다.
  2. 반복문의 첫 줄부터 시작해서, 각각의 단위 행위를 적절한 컬렉션 파이프라인 연산으로 대체한다. 이때 컬렉션 파이프라인 연산은 1에서 만든 반복문 컬렉션 변수에서 시작하여, 이전 연산의 결과를 기초로 연쇄적으로 수행된다. 하나를 대체할 때마다 테스트.
  3. 반복문의 모든 동작을 대체했다면 반복문 자체를 지운다.

예시

before

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

after

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

9. Remove Dead Code, 죽은 코드 제거하기

  • 최신 컴파일러들이 죽은 코드들을 알아서 잘 제거해주지만, 개발자가 소프트웨어의 동작을 이해하는데 죽은 코드들은 걸림돌이 될 수 있다.
  • 코드를 삭제한다고 해도 버전 관리 시스템이 있기 때문에 걱정하지 말자.

절차

  1. 죽은 코드를 외부에서 참조할 수 있는 경우라면(예컨대 함수 하나가 통째로 죽었을 때) 혹시라도 호출하는 곳이 있는지 확인한다.
  2. 없다면 죽은 코드를 제거한다.
  3. 테스트.