[ Programming > Refactoring ]
[리팩터링] API 리팩터링
API 리팩터링
모듈과 함수는 소프트웨어를 구성하는 빌딩 블록이다. API는 이 블록들을 끼워 맞추는 연결부다. 그래서 API는 이해하고 사용하기 쉽게 만드는 일이 중요하다. 물론 어렵지만 API를 개선 한느 방법을 새로 깨달을 때마다 그에 맞게 리팩터링 해야한다.
이번 장에서는 API를 리팩터링 하는 방법들을 소개하려고 한다.
1. API 리팩터링 방법들
API 리팩터링 방법 예시들을 소개하려고 한다.
1.1 질의 함수와 변경 함수 분리하기
좋은 API는 데이터 갱신과 조회를 명확히 구분해야한다. 그래야 겉보기 효과가 부수효과 없이 이해와 일치하기 때문이다. 예를 들면 조회 함수(get)을 사용하는데 부수효과로 데이터를 바꾸면(set) 테스트도 힘들도 신경 쓸 거리가 많아진다. 이를 명령-질의 분리라고도 하는데, 항상 100% 따르기는 힘들 수도 있지만 이 원칙을 왠만하면 지키는 것이 좋다. 이제 예시를 보겠다.
// 찾고 알람 울리는 함수.function alertForMiscreant(people) { for (const p of people) { if(p === "조커") { setOffAlarms(); return "조커"; } if(p === "사루만") { setOffAlarms(); return "사루만"; } } return "";}// 사이드 이펙트 제거function findMiscreant (people) { for (const p of people) { if (p === "Don") { setOffAlarms(); return "Don"; } if (p === "John") { setOffAlarms(); return "John"; } } return "";}// 클라이언트const found = alertForMiscreant(people);alertForMiscreant(people);1.2 함수 매개변수화하기
함수 매개변수화는 비슷한 로직을 가진 여러 함수를 매개변수를 추가하여 공통화 하게 만드는 방법이다. 예시를 보며 무슨 이야기 인지 알아보면 될 것 같다.
// 적용 전 비슷한 2개의 함수function tenPercentRaise(aPerson) { aPerson.salary = aPerson.salary.multiply(1.1);}function fivePercentRaise(aPerson) { aPerson.salary = aPerson.salary.multiply(1.05);}// factor를 매개변수로 조정할 수 있게 변경function raise(aPerson, factor) { aPerson.salary = aPerson.salary.multiply(1 + factor);}// 사용raise(aPeson, 0.1);그런데 이렇게 간단한 예시만 있지는 않을 것이다. 전기 요금 계산을 예시로 만들어 보자. 전기 요금은 사용량에 따라 누진세가 붙어 금액이 영역대 별로 달라진다.
// 리팩터링 전 예시function baseCharge(usage) { if (usage < 0) return usd(0); const amount = bottomBand(usage) * 0.03 // 0 ~ 100 kwh 계산 + middleBand(usage) * 0.05 // 100 ~ 200 kwh 계산 + topBand(usage) * 0.07; // 200 이상 전기 요금 배율 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;}// 리팩터링 적용 후function withinBand(usage, bottom, top) { return usage > bottom ? Math.min(usage, top) - bottom : 0;}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; return usd(amount);}1.3 플래그 인수 제거하기
플래그 인수는 호출함수에서 제어문의 인수로 사용하여 제어 흐름을 결정하는데 사용되는데 사용되는 값이다. 그런데 플래그 인수는 코드를 읽는 이에게 뜻을 온전히 전달하기 힘들다. 그래서 이를 인수로 사용하기 보다는 명시적인 함수로 제공하는 편이 더 깔끔하다. 이제 예시 코드를 보며 알아보자.
// 리팩터링 전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 { 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); }}// 사용하는 클라이언트 입장.deliveryDate(anOrder, true); // true가 왜들어가는지 파악하기 힘들다.// 플래그 인수 제거하며 함수 분리.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);}// 상황에 맞게 호출rushDeliveryDate(anOrder);// 만약 인수가 너무 복잡하게 사용한다면 단순 Wrapping하여 사용하게 만드는 것도 좋다.function rushDeliveryDate(anOrder) {return deliveryDate(anOrder,true)}1.4 객체 통째로 넘기기
하나의 레코드에서 값 두어 개를 인수로 넘기는 코드가 종종 있다. 예를 들면 user.name과 user.age를 함수의 인자로 넘겨주는 것이다. 이럴 때는 레코드를 통째로 넘겨주어 호출문에서 사용하는 것으로 수정하면 좋다.
통째로 레코드를 넘기면 변화에 대응하기 쉽기 때문이다. 하지만 함수가 레코드에 의존하지 않는 경우도 있다. 레코드와 함수가 서로 다른 모듈에 속한 상황이면 더욱 그렇다. 그렇기 때문에 레코드와 함수의 의존성이 생기는 것이 싫다면 이 리팩터링을 수행해서는 안된다.
// 리팩터링 전const low = aRoom.daysTempRange.low;const high = aRoom.daysTempRange.high;if (!aPlan.withinRange(low, high)) // HeatingPlan.withinRangealerts.push("방 온도가 범위를 벗어났습니다.");class HeatingPlan { withinRange(bottom, top) { return (bottom >= this._temperatureRange.low) && (top <= this._temperatureRang }}// 리팩터링 후class HeatingPlan { withinRange(aNumberRange) { return (aNumberRange.low >= this._temperatureRange.low) && (aNumberRange.high <= this._temperatureRange.high); }}if (!aPlan.withinRange(aRoom.daysTempRange)) alerts.push("방 온도가 범위를 벗어났습니다.");1.5 매개변수를 질의 함수로 바꾸기
매개변수 목록은 함수의 변동 요인을 모아놓은 곳이다. 즉, 함수의 동작에 변화를 줄 수 있는 일차적인 수단이다. 그래서 매개변수 목록은 중복은 피하고, 짧을수록 이해하기 쉽다.
매개변수가 있으면 결정 주체가 호출자가 되고, 매개변수가 없으면 피호출 함수가 주체가 된다.(User 통째로 넘기면, 피호출 함수가 User.name으로 꺼내 사용할 것이다.) 즉 매개 변수 목록을 수정하는 것은 책임 소재를 옮기는 것과 같다.
그래서 매개변수를 질의 함수로 바꾸지 말아야 할 상황이 있다. 매개변수를 제거하면 피호출 함수에 의존성이 생기는 경우이다. (순수함수에서 직접 참조해버려 순수함수가 아니게 되는 것과 같은 상황)
// 리팩터링 전class Order { 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; } }}class Order { get finalPrice() { const basePrice = this.quantity * this.itemPrice; return this.discountedPrice(basePrice); } get discountLevel() { return (this.quantity > 100) ? 2 : 1; } discountedPrice(basePrice) { // discountedPrice는 질의함수를 통해 discountLevel에 의존한다. switch (this.discountLevel) { case 1: return basePrice * 0.95; case 2: return basePrice * 0.9; } }}1.6 질의 함수를 매개변수로 바꾸기
코드를 읽다 보면 전역 변수를 참조한다거나, 제거하길 원하는 원소를 참조하는 경우가 있다. 즉, 그냥 두기엔 거북할 수 있는 참조를 발견하는 경우가 있다. 이런 경우에는 해당 참조를 매개변수로 바꿔 해결할 수 있다. 참조를 풀어내는 책임을 피호출자에서 호출자로 변경하는 것이다.
참조에 대한 책임을 피호출자에서 호출자로 변경하는 것은 참조 투명성(순수 함수)을 강화하는 것이고, 피호출자로 옮기는 것은 해당 참조에 연관성을 가져서 참조 투명성을 잃는 방향이다. 반드시 무엇이 좋다고는 할 수 없고 적절한 균형을 찾아야한다. 하지만 대체로 순수 함수로 만들면 부작용이 적기 때문에 이점이 크며, 보통 순수 함수 로직을 따로 구분하고 Wrapper로 감싸는 패턴으로 순수 함수를 사용하는 방향으로 많이 사용한다.
이 리팩터링에는 단점이 있다. 호출자가 더 복잡해지게된다. 그래서 항상 그 균형을 잘 찾아야한다.
// 리팩터링 전, 피호출자(targetTemperature)가 selectedTemperature을 직접 참조하여 사용하고 있다.// thermostat은 전역 변수다class HeatingPlan { 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();// 매개변수로 selectedTemperature을 받는다.난방계획이 thermostat에 대한 의존성이 사라졌다.// HeatingPlan이 생정자에서 모든 필드가 결정되며 불변으로 바뀌어 완전히 thermostat 객체에서 자유로워졌다.class HeatingPlan { targetTemperature(selectedTemperature) { if (selectedTemperature > this._max) return this._max; else if (selectedTemperature < this._min) return this._min; else return selectedTemperature; }}// 클라이언트if (thePlan.targetTemperature(thermostat.selectedTemperature) > thermostat.currentTemperature) setToHeat();else if (thePlan.targetTemperature(thermostat.selectedTemperature) <thermostat.currentTemperature) setToCool();else setOff();1.7 세터 제거하기
세터 메서드가 존재하는 것은 인스턴스의 필드가 수정될 수 있다는 뜻이다. 객체 생성 후에는 수정되지 않길 원하는 필드는 세터를 제공하지 말아야한다. 그러면 언제 세터를 제거할까??
생성자 안에서도 무조건 접근자 메서드를 통해서만 필드를 다루려고 하는 경우이다. 왜냐하면 오직 생성자에서만 사용하는 세터가 생기기 때문이다. 그러면 객체 생성 후에는 수정되지 않길 원한다는 메시지를 전달할 수 없다.
생성 스크립트를 사용해 객체를 생성할 때다. 생성 스크립트는 객체 생성 후, 세터 함수로 객체를 완성하는 형태의 코드다. 물론 어떤 필드가 추후 바껴야 한다면 괜찮지만, 바뀌면 안되는 경우에도 세터 함수로 객체를 완성하면 기대하는 메시지를 전달할 수 없게 된다.
// 리팩터링 전class Person { get id() {...} set id(string) {...}}const person = new Person();person.id = "123";// 리팩터링 후class Person { get id() {...}}const person = new Person("123");핵심은 추후 변경될 필드인지 여부를 확실히 구분할 수 있게 만드는 것이다.
1.8 생성자를 팩터리 함수로 바꾸기
new 연산자를 통한 객체 초기화는 보통 특별한 제약이 따라붙는다. 자바의 경우 반드시 그 생성자를 정의한 클래스의 인스턴스를 반환한다. 하지만 팩터리 함수에는 이런 제약이 없다.
// 리팩터링 전class Employee { constructor (name, typeCode) { this._name = name; this._typeCode = typeCode; } get name() {return this._name;} get type() { return Employee.legalTypeCodes[this._typeCode]; } static get legalTypeCodes() { return {"E": "Engineer", "M": "Manager", "S": "Salesman"}; }}const leadEngineer = new Employee(document.leadEngineer, 'E');// 리팩터링 후Employee는 그대로 둔다.// 객체 생성 함수function createEngineer(name) { return new Employee(name, 'E');}// 클라이언트 호출const leadEngineer = createEngineer(document.leadEngineer);1.9 함수를 명령으로 바꾸기
함수는 프로그래밍의 기본적인 빌딩 블록 중 하나다. 그런데 함수를 그 함수만을 위한 객체 안으로 캡슐화하면 더 유용해지는 상황이 있다. 이런 객체를 가리켜 '명령 객체' 혹은 '명령(command)'라고 한다. 명령 객체는 대부분 메서드 하나로 구성되며, 이 메서드를 요청해 실행하는 것이 이 객체의 목적이다. (커맨드 패턴의 커맨드를 의미한다. 명령-질의 분리 원칙의 명령을 의미하지 않는다.)
나는 독자로서 왜 Command 패턴의 command를 굳이 가져왔는지는 모르겠다. 물론 그 안에서 함수를 쪼갤 수 있다는 것은 알겠는데... 히스토리 관리 같은 기능을 넣을 것이 아니면 이렇게 까지 만들어야 할까?? 라는 의문은 든다. 물론 완전히 책임을 분리한다는 점에서는 좋은 것 같다. 분명 부가적인 기능을 많이 달고 유연해지지만 좀더 생각해봐야 겠다. 내 배움이 짧은 것 같다.
// 리팩터링 전function score(candidate, medicalExam, scoringGuide) { let result = 0; let healthLevel = 0; let highMedicalRiskFlag = false; if (medicalExam.isSmoker) { healthLevel += 10; highMedicalRiskFlag = true; } let certificationGrade = "regular"; if (scoringGuide.stateWithLowCertification(candidate.originState)) { certificationGrade = "low"; result = 5; } // lots more code like this result = Math.max(healthLevel 5, 0); return result;}// 함수를 명령으로 바꾼 후 (커맨드 패턴)function score(candidate, medicalExam, scoringGuide) { return new Scorer(candidate, medicalExam, scoringGuide).execute();}class Scorer { constructor(candidate, medicalExam, scoringGuide){ this._candidate = candidate; this._medicalExam = medicalExam; this._scoringGuide = scoringGuide; } execute () { let result = 0; let healthLevel = 0; let highMedicalRiskFlag = false; if (this._medicalExam.isSmoker) { healthLevel += 10; highMedicalRiskFlag = true; } this.scoreSmoking(); let certificationGrade = "regular"; if (this._scoringGuide.stateWithLowCertification(this._candidate.originState))certificationGrade = "low"; result = 5; } // 비슷한 코드 이어짐. result = Math.max(healthLevel 5, 0); return result; } scoreSmoking() { if (this._medicalExam.isSmoker) { this._healthLevel += 10; this._highMedicalRiskFlag = true; } }}1.10 명령을 함수로 바꾸기
명령 객체는 복잡한 연산을 다룰 수 있는 강력한 메커니즘을 제공한다. 구체적으로는, 큰 연산 하나를 여러 개의 작은 메서드로 쪼개고 필드를 이용해 쪼개진 메서드들끼리 정보를 공유할 수 있다. 또한 어떤 메서드를 호출하냐에 따라 다른 효과를 줄 수 있고 각 단계를 거치며 데이터를 조금씩 완성해갈 수도 있다.
그러나 명령의 이런 능력은 공짜가 아니다. 명령은 그저 함수를 하나 호출해 정해진 일을 수행하는 요도로 주로 쓰인다. 그래서 크게 복잡하지 않다면 명령 객체는 장점보다 단점이 더 많다.
// 적용 전class ChargeCalculator { constructor (customer, usage, provider){ this._customer = customer; this._usage = usage; this._provider = provider; } get baseCharge() { return this._customer.baseRate * this._usage; } get charge() { return this.baseCharge + this._provider.connectionCharge; }}monthCharge = new ChargeCalculator(customer, usage, provider).charge;function charge(customer, usage, provider) { const baseCharge = customer.baseRate * usage; return baseCharge + provider.connectionCharge;}monthCharge = charge(customer, usage, provider);1.11 수정된 값 반환하기
데이터가 어떻게 수정되는지를 추적하는 일은 코드에서 이해하기 가장 어려운 부분 중 하나다. 특히 같은 데이터 블록을 읽고 수정하는 코드가 여러 곳이라면 데이터 수정 흐름과 코드의 흐름을 일치시키기 힘들다.
데이터가 수정됨을 알려주는 좋은 방법이 있다. 변수를 갱신하는 함수라면 수정된 값을 반환하는 것이다. 단, 이 방식은 해당 변수의 값을 단 한 번만 정하면 될때 유용하다. 값 여러 개를 갱신하는 함수에는 효과적이지 않다.
// 리팩터링 전let totalAscent = 0;let totalTime = 0;let totalDistance = 0;calculateAscent(); // totalAscent를 직접 참조하여 바꿈calcualteTime(); // Time을 바꿈...function calculateAscent() { for (let i = 1; i < points.length; i++) { const verticalCharge = points[i].elevation - points[i - 1].elevation; totalAscent += verticalChange > 0 ? verticalCharge : 0; }}// 리팩터링 후const totalAscent = calculateAscent(); // 별도 스코프를 가져 계산값 반환const totalTime = calculateTime(); // 계산값 반환function calculateAscent() { let result = 0; for (let i = 1; i < points.length; i++) { const verticalCharge = points[i].elevation - points[i - 1].elevation; result += verticalChange > 0 ? verticalCharge : 0; } return result;}1.12 예외를 사전확인으로 바꾸기
예외는 "뜻밖의 오류"다. 말 그대로 예외적인 동작을 할 때만 쓰여야 한다. 예를 들면 함수 수행 시 문제가 될 수 있는 조건을 함수 호출 전에 검사할 수 있다면 예외를 던지는 대신 호출하는 곳에서 조건을 검사하도록 해야 한다.
// 리팩터링 전public class ResourcePool { private Deque<Resource> available; private List<Resource> allocated; public Resource get() { Resource result; try { result = available.pop(); allocated.add(result); } catch (NoSuchElementException e) { result = Resource.create(); allocated.add(result); } return result; }}// 리팩터링 후public class ResourcePool { private Deque<Resource> available; private List<Resource> allocated; public Resource get() { Resource result = available.isEmpty()?Resource.create():available.pop(); allocated.add(result); return result; }}2. 마무리
이번 편에서는 API 리팩터링을 했다. 여기서 API는 HTTP API가 아니라 함수, 클래스 인터페이스라고 보는 것이 맞는 것 같다. 다 사소한 내용들로 보인다. 하지만 이 사소한 것들 하나하나 다 기억하기가 힘들 것이다. 그럼에도 불구하고 이걸 공부하며 이 하나하나 지식을 완전히 체화한다면 훨씬 좋은 코드가 나올 것이다.

