Skip to main content

11 장 API 리팩터링 上

written by
Woongjo-Yoo
Woongjo-Yoo 🏆Back End Engineer

들어가며#

모듈과 함수는 소프트웨어를 구성하는 빌딩 블록!
API는 이 블록들을 끼워 맞추는 연결부
따라서 API를 이해하기 쉽고 사용하기 쉽게 만들어야 함

살펴볼 것들#

  • 데이터의 갱신 / 조회 를 분리하자
  • 데이터 구조가 함수 사이를 건너다니면서 필요 이상으로 분해될 때는 객체 통째로 넘기기
  • 매개변수를 질의 함수로 바꾸기 / 질의 함수를 매개변수로 바꾸기
  • 최대한 클래스의 세터를 제거하기
  • 클래스의 생성자만으로 부족할 땐 팩토리 패턴을 사용하기
  • 함수를 명령으로 / 명령을 함수로 바꾸기

1. 질의 함수와 변경 함수 분리하기#

--- 변경 전 ---function getTotalOutstandingAndSendBill() {    const result = customer.invoices.reduce((total, each) => each.amount + total, 0);    sendBill();    return result;}
--- 변경 후 ---function totalOutstanding() {    return customer.invoices.reduce((total, each) => each.amount + total, 0);}function sendBill() {    emailGateway.send(formatBill(customer));}

외부에서 관찰할 수 있는 겉보기 부수효과(Observable Side Effect) 가 전혀 없이 값을 반환해주는 함수를 추구해야한다. 그러면 언제나 원하는 만큼 호출해도 아무런 문제가 없다. 테스트하기도 쉽다. 즉, 이용할 때 신경 쓸 거리가 매우 적다.

이를 위한 방법 중 하나가 질의 함수(읽기 함수)는 모두 부수효과가 없어야 한다 (Command-Query Seperation)는 규칙을 따르는 것!

절차#

  1. 대상 함수를 복제하고 질의 목적에 충실한 이름을 짓는다
  2. 새 질의 함수에서 부수효과를 모두 제거한다
  3. 정적 검사를 수행한다
  4. 원래 함수를 호출하는 곳을 모두 찾아낸다. 호출하는 곳에서 반환 값을 사용한다면 질의 함수를 호출하도록 바꾸고, 원래 함수를 호출하는 코드를 바로 아래 줄에 새로 추가한다. 하나 수정할 때마다 테스트한다.
  5. 원래 함수에서 질의 관련 코드를 제거한다.
  6. 테스트한다.

예시#

대상: 이름 목록을 훑어 악당을 찾는 함수
동작: 악당을 찾으면 그 사람의 이름을 반환하고 경고를 울린다.
비고: 가장 먼저 찾은 악당만 취급

--- 코드 ---function alertForMiscreant(people) {    for (const p of people) {        if (p === "조커") {            setOffAlarms();            return "조커";        }        if (p === "사루만") {            setOffAlarms();            return "사루만";        }    }    return "";}
  1. 함수를 적절한 이름으로 바꿔준다.

    function findMiscreant(people) {  for (const p of people) {    if (p === "조커") {      setOffAlarms();      return "조커";    }    if (p === "사루만") {      setOffAlarms();      return "사루만";    }  }  return "";}
  2. 변경을 하던 코드를 삭제한다.

    function findMiscreant(people) {  for (const p of people) {    if (p === "조커") {      // setOffAlarms();      return "조커";    }    if (p === "사루만") {      // setOffAlarms();      return "사루만";    }  }  return "";}
  3. 함수를 호출하는 곳에서 새로 추가된 함수를 호출한다.

    --- 변경 전 ---const found = alertForMiscreant(people);
    --- 변경 후 ---const found = findMiscreant(people);alertForMiscreant(people);
  4. 변경만 하는 코드를 만든다.

    function alertForMiscreant(people) {    for (const p of people) {        if (p === "조커") {            setOffAlarms();            return;        }        if (p === "사루만") {            setOffAlarms();            return;        }    }    return;}--- 혹은 ---function alertForMiscreant(people) {    if (findMiscreant(people) !== "") setOffAlarms();}

2. 함수 매개변수화하기#

두 함수의 로직이 아주 비슷하고 단지 리터럴 값만 다르다면, 그 다른 값만 배개변수로 받아 처리하는 함수 하나로 합쳐서 중복을 없앨 수 있다. 그렇게 되면 매개변수 값만 바꿔서 해당 함수를 다른 곳에서 더 활용할 수 있다.

절차#

  1. 비슷한 함수 중 하나를 선택한다.
  2. 함수 선언 바꾸기로 리터럴들을 배개변수로 추가한다.
  3. 이 함수를 호출하는 곳 모두에 적절한 리터럴 값을 추가한다.
  4. 테스트한다.
  5. 매개변수로 받은 값을 사용하도록 함수 본문을 수정한다. 하나 수정할 때마다 테스트한다.
  6. 비슷한 다른 함수를 호출하는 코드를 찾아 매개변수화된 함수를 호출하도록 하나씩 수정한다. 하나 수정할 때마다 테스트한다.

예시#

간단한 예시

--- 변경 전 ---function tenPercentRaise(aPerson) {    aPerson.salary = aPerson.salary.multiply(1.1);}function fivePercentRaise(aPerson) {    aPerson.salary = aPerson.salary.multiply(1.05);}--- 변경 후 ---function raise(aPerson, factor) {    aPerson.salary = aPerson.salary.multiply(1 + factor);}

복잡한 경우

function baseCharge(usage) {  if (usage < 0) return usd(0);  const amount =    bottomBand(usage) * 0.03 + middleBand(usage) * 0.05 + topBand(usage) * 0.07;  return usd(amount);}function bottomBand(usage) {  return Math.min(usage, 100);}function middleBand(usage) {  return usage > 100 ? Math.min(usage, 200) - 100 : 0;}function topBand(usage) {  return usage > 200 ? usage - 200 : 0;}

대역(Band)을 다루는 조금 복잡한 함수다.

  1. 이런 식의 범위를 나타내는 함수들의 경우엔 중간 함수부터 리펙터링하는 것이 더 좋다.

    function withinBand(usage, bottom, top) {  return usage > 100 ? Math.min(usage, 200) - 100 : 0;}function baseCharge(usage) {  if (usage < 0) return usd(0);  const amount =    bottomBand(usage) * 0.03 +    withinBand(usage, 100, 200) * 0.05 +    topBand(usage) * 0.07;  return usd(amount);}
  2. 함수 내의 리터럴(100, 200)을 적절한 매개변수로 바꾼다.

    function withinBand(usage, bottom, top) {  return usage > bottom ? Math.min(usage, top) - bottom : 0;}
  3. 이제 다 바꾼다.

    function baseCharge(usage) {  if (usage < 0) return usd(0);  const amount =    withinBand(usage, 0, 100) * 0.03 +    withinBand(usage, 100, 200) * 0.05 +    withinBand(usage, 200, Infinity) * 0.07; // 상한 호출의 경우 Infinity 사용  return usd(amount);}

3. 플래그 인수 제거하기#

플래그 인수란 호출되는 함수가 실행할 로직을 호출하는 쪽에서 선택하기 위해 전달하는 인수

예:

function bookConcert(aCutomer, isPremium) {  if (isPremium) {    // do something  } else {    // do something else  }}

위의 코드 같이 플래그로 인해 동작 방식이 달라지는 경우, 어떻게 호출해야 하는지 이해하기가 어려워진다.
어떤 함수를 사용해야는지 명확하게 알 수 없을 수도 있고, 알아냈다고 하더라도 플래그로 어떤 값을 넘겨야하는지 또 알아내야 한다.

bookConcert(aCustomer, true); // 여기서 true 의 의미가 무엇인지?
// 차라리 명시적인 함수를 호출하는 것이 더 낫다.premiumBookConcert(aCustomer);

만약 하나의 함수에서 두개 이상의 플래그를 사용하고 있다면 어쩌면 플래그 인수를 써야하는 합당한 근거가 될 수도 있다.
왜냐하면 플래그의 수만큼 다른 함수가 존재해야 하기 때문.
그러나 반대로 해당 함수 하나가 너무 많은 일을 하고 있는 것일 수도 있다.

절차#

  1. 매개변수로 주어질 수 있는 값 각각에 대응하는 명시적 함수들을 생성한다.
  2. 원래 함수를 호출하는 코드들을 모두 찾아서 각 리터럴 값에 대응되는 명시적 함수를 호출하도록 수정한다.

예시#

aShipment.deliveryDate = deliveryDate(anOrder, true);aShipment.deliveryDate = deliveryDate(anOrder, false);

여기서의 boolean 값이 의미하는 바는 무엇인가?

function deliveryDate(anOrder, isRush) {  if (isRush) {    let deliveryTime;    if (["MA", "CT"].includes(anOrder.deliveryState)) deliveryTime = 1;    else if (["NY", "NH"].includes(anOrder.deliveryState)) deliveryTime = 2;    else deliveryTime = 3;    return anOrder.placedOn.plusDays(1 + deliveryTime);  } else {    if (["MA", "CT", "NY"].includes(anOrder.deliveryState)) deliveryTime = 2;    else if (["ME", "NH"].includes(anOrder.deliveryState)) deliveryTime = 3;    else deliveryTime = 4;    return anOrder.placedOn.plusDays(2 + deliveryTime);  }}
  1. 위는 전형적인 플래그 함수라고 볼 수 있는데 이런 경우, 조건문 분해하기를 적용할 수 있다.

    function deliveryDate(anOrder, isRush) {  if (isRush) return rushDeliveryDate(anOrder);  else return regularDeliveryDate(anOrder);}function rushDeliveryDate(anOrder) {  let deliveryTime;  if (["MA", "CT"].includes(anOrder.deliveryState)) deliveryTime = 1;  else if (["NY", "NH"].includes(anOrder.deliveryState)) deliveryTime = 2;  else deliveryTime = 3;  return anOrder.placedOn.plusDays(1 + deliveryTime);}function regularDeliveryDate(anOrder) {  let deliveryTime;  if (["MA", "CT", "NY"].includes(anOrder.deliveryState)) deliveryTime = 2;  else if (["ME", "NH"].includes(anOrder.deliveryState)) deliveryTime = 3;  else deliveryTime = 4;  return anOrder.placedOn.plusDays(2 + deliveryTime);}
  2. 호출하는 쪽을 바꿔준다.

    aShipment.deliveryDate = rushDeliveryDate(anOrder);aShipment.deliveryDate = regularDeliveryDate(anOrder);
  3. deliveryDate(anOrder, isRush) 함수를 제거한다.

만약, 매개변수가 훨씬 까다로운 형태로 존재한다면 해당 함수를 한번 감싸는 것만으로도 도움이 된다.

const rushDeliveryDate = (anOrder) => deliveryDate(anOrder, true);const regularDeliveryDate = (anOrder) => deliveryDate(anOrder, false);

4. 객체 통째로 넘기기#

--- 변경 전 ---const low = aRoom.daysTempRange.low;const high = aRoom.daysTempRange.high;if (aPlan.withinRange(low, high))
--- 변경 후 ---if (aPlan.withinRange(aRoom.daysTempRange))

레코드를 통째로 넘기게 되면 그 함수 안에 존재하는 더 다양한 데이터를 사용할 수 있게 되어 변화에 대응하기 쉽다.
또 매개변수 목록이 짧아져서 일반적으로는 함수 사용법을 이해하기 쉬워진다.
하지만 객체로부터 값을 얻은 뒤 그 값들로만 무언가를 하는 로직이 있다면 그 로직이 사실은 객체 안으로 들어가야 함을 알려주는 악취일 수도 있다!

절차#

  1. 매개변수들을 원하는 형태로 받는 빈 함수를 만든다.
  2. 새 함수의 본문에서는 원래 함수를 호출하도록 하며, 새 매개변수와 원래 함수의 매개변수를 매핑한다.
  3. 정적 검사를 수행한다.
  4. 모든 호출자가 새 함수를 사용하게 수정한다. 하나씩 수정하며 테스트하자.
  5. 호출자를 모두 수정했다면 원래 함수를 인라인한다.
  6. 새 함수의 이름을 적절히 수정하고 모든 호출자에 반영한다.

예시#

실내 온도 모니터링 시스템
일일 최저 | 최고 기온이 난방 계획에서 정한 범위를 벗어나는지 확인한다.

const low = aRoom.daysTempRange.low;const high = aRoom.daysTempRange.high;if (!aPlan.withinRange(low, high))  alerts.push("방 온도가 지정 범위를 벗어났습니다");
// HeatingPlan classwithinRange(bottom, top) {    return (bottom >= this._temperatureRange.low) && (top <= this._temperatureRange.high);}
  1. 가장 먼저 원하는 인터페이스를 갖춘 빈 메서드를 만든다.

    // HeatingPlan classxxNEWwithinRange(aNumberRange) {}
  2. 기존 메서드를 호출하는 코드로 채운다.

    // HeatingPlan classxxNEWwithinRange(aNumberRange) {    return this.withinRange(aNumberRange.low, aNumberRange.high);}
  3. 기존 함수를 호출하는 코드를 찾아서 새 함수를 호출하게 한다.

    const low = aRoom.daysTempRange.low;const high = aRoom.daysTempRange.high;if (!aPlan.xxNEWwithinRange(aRoom.daysTempRange))  alerts.push("방 온도가 지정 범위를 벗어났습니다");
  4. 필요 없는 부분을 삭제한다.

    // const low = aRoom.daysTempRange.low;// const high = aRoom.daysTempRange.high;if (!aPlan.xxNEWwithinRange(aRoom.daysTempRange))  alerts.push("방 온도가 지정 범위를 벗어났습니다");
  5. 원래 함수를 인라인한다.

    // HeatingPlan classxxNEWwithinRange(aNumberRange) {    return (aNumberRange.low >= this._temperatureRange.low) && (aNumberRange.high <= this._temperatureRange.high);}
  6. 함수 이름 바꾼다.

    // HeatingPlan classwithinRange(aNumberRange) {    return (aNumberRange.low >= this._temperatureRange.low) && (aNumberRange.high <= this._temperatureRange.high);}

5. 매개변수를 질의 함수로 바꾸기#

매개변수 목록은 함수의 변동 요인을 모아놓은 곳이다.
따라서 중복은 피하는게 좋으며, 짧을수록 이해하기 쉽다.

피호출 함수가 값을 스스로 결정할 수 있다면, 값을 결정하는 주체가 피호출 함수가 되도록 하자.

--- 변경 전 ---availableVacation(anEmployee, amEmployee.grade);
--- 변경 후 ---availableVacation(anEmployee);
function availableVacation(anEmployee) {    const grade = anEmployee.grade;    ...}

함수가 매개변수로부터 질의해서 얻을 수 있고, 참조투명하며, 원치 않는 의존성이 생기는 것이 아니라면 매개변수를 질의 함수로 바꾸자.

절차#

  1. 필요하다면 대상 매개변수의 값을 계산하는 코드를 별도 함수로 추출해놓는다.
  2. 함수 본문에서 대상 매개변수로의 참조를 모두 찾아서 그 매개변수의 값을 만들어주는 표현식을 참조하도록 바꾼다.
  3. 함수 선언 바꾸기로 대상 매개변수를 없앤다.

예시#

get finalPrice() {    const basePrice = this.quantity * this.itemPrice;    let discountLevel;    if (this.quantity > 100) discountLevel = 2;    else discountLevel = 1;    return this.discountedPrice(basePrice, discountLevel);}
discountedPrice(basePrice, discountLevel) {    switch (discountLevel) {        case 1: return basePrice * 0.95;        case 2: return basePrice * 0.9;    }}
  1. discountLevel 와 같은 임시 변수는 질의 함수로 바꿔준다.
get discountLevel() {    return (this.quantity > 100) ? 2 : 1;}
get finalPrice() {    const basePrice = this.quantity * this.itemPrice;    return this.discountedPrice(basePrice, this.discountLevel);}
  1. 그러면 this.discountedPrice() 에 discountLevel 을 매개변수로 넘길 이유가 사라진다. 원할 때 질의하도록 바꾸자.
discountedPrice(basePrice) {    switch (this.discountLevel) {        case 1: return basePrice * 0.95;        case 2: return basePrice * 0.9;    }}

6. 질의 함수를 매개변수로 바꾸기#

코드를 읽다보면 함수 안에 두기엔 거북한 참조를 발견할 때가 있다.
전역 변수를 참조한다거나 제거하길 원하는 원소를 참조하는 경우가 여기 속한다.
이럴 땐 해당 참조를 매개변수로 바꿔 해결할 수 있다.

똑같은 값을 건네면 매번 똑같은 결과를 내는 함수가 다루기가 쉽다.
이런 성질을 참조 투명성이라고 한다.
참조 투명하지 않은 원소에 접근하는 모든 함수는 참조 투명성을 잃게 된다.

이러한 리펙터링을 하게되면 매개변수로 어떤 값을 제공해야하는지 호출자가 알아내야 한다는 단점이 존재한다.

절차#

  1. 변수 추출하기로 질의 코드를 함수 본문의 나머지 코드와 분리한다.
  2. 함수 본문 중 해당 질의를 호출하지 않는 코드들을 별도 함수로 추출한다.
  3. 방금 만든 변수를 인라인하여 제거한다.
  4. 원래 함수도 인라인한다.
  5. 새 함수의 이름을 원래 함수의 이름으로 고쳐준다.

예시#

실내 온도 제어 시스템.
사용자는 온도 조절기로 온도를 설정할 수 있지만, 목표 온도는 난방 계획에서 정한 범위에서만 선택할 수 있음

--- HeatingPlan Class ---get targetTemperature() {    if (thermostat.selectedTemperature > this._max) return this._max;    else if (thermostat.selectedTemperature < this._min) return this._min;    else return thermostat.selectedTemperature;}
--- 호출부 ---if (thePlan.targetTemperature > thermostat.currentTemperature) setToHeat();else if (thePlan.targetTemperature < thermostat.currentTemperature) setToCool();else setOff();

targetTemperature() 메서드가 전역 객체인 thermostat에 의존한다는 것이 거슬린다. 매개변수로 옮겨서 의존성을 끊어보자.

  1. 변수 추출하기를 이용하여 메서드에서 사용할 매개변수를 준비한다.
get targetTemperature() {    const selectedTemperature = thermostat.selectedTemperature;    if (selectedTemperature > this._max) return this._max;    else if (selectedTemperature < this._min) return this._min;    else return selectedTemperature;}
  1. 매개변수의 값을 질의하는 부분 외의 나머지 코드를 메서드로 추출한다.
xxNEWtargetTemperature(selectedTemperature) {    if (selectedTemperature > this._max) return this._max;    else if (selectedTemperature < this._min) return this._min;    else return selectedTemperature;}
  1. 원래 메서드를 단순한 호출로 바꾼다.
get targetTemperature() {    const selectedTemperature = thermostat.selectedTemperature;    return this.xxNEWtargetTemperature(selectedTemperature)}
  1. 이어서 메서드를 인라인한다.
  2. 이름을 바꿔준다.
--- 호출부 ---if (thePlan.targetTemperature(thermostat.selectedTemperature) > thermostat.currentTemperature) setToHeat();else if (thePlan.targetTemperature(thermostat.selectedTemperature)) setToCool();else setOff();