읽을거리/리팩터링

06 기본적인 리팩터링 - 함수 선언 바꾸기

씨씨상 2024. 11. 6. 18:31

 

 

함수 선언 바꾸기

 

 

 

 

function circum(radius) {
  ...
}

 

 

function circumference(radius) {
  ...
}

 

 

배경

함수는 프로그램을 작은 부분으로 나누는 주된 수단이다. 함수 선언은 각 부분이 서로 맞물리는 방식을 표현하며, 실질적으로 소프트웨어 시스템의 구성 요소를 조립하는 연결부 역할을 한다. 건축과 마찬가지로 소프트웨어도 이러한 연결부에 상당히 의존한다. 연결부를 잘 정의하면 시스템에 새로운 부분을 추가하기가 쉬워지는 반면, 잘못 정의하면 지속적인 방해 요인으로 작용하여 소프트웨어 동작을 파악하기 어려워지고 요구사항이 바뀔 때 적절히 수정하기 어렵게 한다. 다행히 소프트웨어는 소프트하기 때문에 연결부를 수정할 수 있다. 단 주의해서 해야 한다.

 

이러한 연결부에서 가장 중요한 요소는 함수의 이름이다. 이름이 좋으면 함수의 구현 코드를 살펴볼 필요 없이 호출문만 보고도 무슨 일을 하는지 파악할 수 있다. 하지만 좋은 이름을 떠올리기란 쉽지 않다. 나도 적합한 이름을 단번에 지은 적이 거의 없다. 코드를 읽다가 의미가 와닿지 않는 이름을 발견해도 그대로 놔두고 싶은 유혹에 빠진다. 고작 이름일 뿐이지 않은가? 하지만 이는 '혼란'이라는 악마의 유혹이다. 프로그램의 영혼을 위해서라도 이러한 달콤한 속삭임에 절대 넘어가면 안 된다. 그래서 나는 이름이 잘못된 함수를 발견하면 더 나은 이름이 떠오르는 즉시 바꾸라는 명령으로 받아들인다. 그래야 나중에 그 코드를 다시 볼 때 무슨 일을 하는지 '또' 고민하지 않게 된다.

 

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

 

함수의 매개변수도 마찬가지다. 매개변수는 함수가 외부 세계와 어우러지는 방식을 정의한다. 매개변수는 함수를 사용하는 문맥을 설정한다. 예컨대 전화번호 포매팅 함수가 매개변수로 사람을 받는다고 해보자. 그러면 회사 전화번호 포매팅에는 사용할 수 없게 된다. 사람 대신 전화번호 자체를 받도록 정의하면 이 함수의 활용 범위를 넓힐 수 있다.

 

이렇게 하면 활용 범위가 넓어질 뿐만 아니라, 다른 모듈과의 결합coupling을 제거할 수도 있다. 예컨대 전화번호 포매팅 로직을 사람 관련 정보를 전혀 모르는 모듈에 둘 수 있다. 동작에 필요한 모듈 수가 줄어들수록 무언가를 수정할 때 머리에 담아둬야 하는 내용도 적어진다. 그리고 내 머리도 예전만 못하다(머리 크기는 그대로지만).

 

매개변수를 올바르게 선택하기란 단순히 규칙 몇 개로 표현할 수 없다. 예컨대 대여한 지 30일이 지났는지를 기준으로 지불 기한이 넘었는지 판단하는 간단한 함수가 있다고 해보자. 이 함수의 매개변수는 지불 객체가 적절할까, 아니면 마감일로 해야 할까? 지불 객체로 정하면 이 함수는 지불 객체의 인터페이스와 결합돼버린다. 대신 지불이 제공하는 여러 속성에 쉽게 접근할 수 있어서 내부 로직이 복잡해지더라도 이 함수를 호출하는 코드를 일일이 찾아서 변경할 필요가 없다. 실질적으로 함수의 캡슐화 수준이 높아지는 것이다.

 

이 문제의 정답은 바로 정답이 없다는 것이다. 특히 시간이 흐를수록 더욱더 그렇다. 따라서 어떻게 연결하는 것이 더 나은지 더 잘 이해하게 될 때마다 그에 맞게 코드를 개선할 수 있도록 함수 선언 바꾸기 리팩터링과 친숙해져야만 한다.

 

나는 다른 리팩터링을 지칭할 때 대체로 대표 명칭만 사용한다. 하지만 함수 선언 바꾸기에서 '이름 바꾸기'가 차지하는 비중이 상당히 높기 때문에, 단순히 이름만 바꿀 때는 '함수 이름 바꾸기'라고 표현해서 확실히 구분할 것이다. 이름을 바꿀 때든 매개변수를 변경할 때든 절차는 똑같다.

 

 

절차

이 책에서 다른 리팩터링들은 절차를 한 가지만 소개했다. 방법이 하나뿐이라서가 아니라 대부분 상황에서 대체로 효과적인 방법이라서다. 하지만 함수 선언 바꾸기는 사정이 다르다. '간단한 절차'만으로 충분할 때도 많지만, 더 세분화된 '마이그레이션 절차'가 훨씬 적합한 경우도 많기 때문이다. 따라서 이 리팩터링을 할 때는 먼저 변경 사항을 살펴보고 함수 선언과 호출문들을 단번에 고칠 수 있을지 가늠해본다. 가능할 것 같다면 간단한 절차를 따른다. 마이그레이션 절차를 적용하면 호출문들을 점진적으로 수정할 수 있다. 호출하는 곳이 많거나, 호출 과정이 복잡하거나, 호출 대상이 다형 메서드거나, 선언을 복잡하게 변경할 때는 이렇게 해야 한다.

 

 

간단한 절차

  1. 매개변수를 제거하려거든 먼저 함수 본문에서 제거 대상 매개변수를 참조하는 곳은 없는지 확인한다.
  2. 메서드 선언을 원하는 형태로 바꾼다.
  3. 기존 메서드 선언을 참조하는 부분을 모두 찾아서 바뀐 형태로 수정한다.
  4. 테스트한다.

 

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

 

 

마이그레이션 절차

  1. 이어지는 추출 단계를 수월하게 만들어야 한다면 함수의 본문을 적절히 리팩터링한다.
  2. 함수 본문을 새로운 함수로 추출한다.
    • → 새로 만들 함수 이름이 기존 함수와 같다면 일단 검색하기 쉬운 이름을 임시로 붙여둔다.
  3. 추출한 함수에 매개변수를 추가해야 한다면 '간단한 절차'를 따라 추가한다.
  4. 테스트한다.
  5. 기존 함수를 인라인한다.
  6. 이름을 임시로 붙여뒀다면 함수 선언 바꾸기를 한 번 더 적용해서 원래 이름으로 되돌린다.
  7. 테스트한다.

 

다형성을 구현한 클래스, 즉 상속 구조 속에 있는 클래스의 메서드를 변경할 때는 다형 관계인 다른 클래스들에도 변경이 반영되어야 한다. 이때, 상황이 복잡하기 때문에 간접 호출 방식으로 우회(혹은 중간 단계로 활용)하는 방법도 쓰인다. 먼저 원하는 형태의 메서드를 새로 만들어서 원래 함수를 호출하는 전달forward 메서드로 활용하는 것이다. 단일 상속 구조라면 전달 메서드를 슈퍼클래스에 정의하면 해결된다. (덕 타이핑duck typing처럼) 슈퍼클래스와의 연결을 제공하지 않는 언어라면 전달 메서드를 모든 구현 클래스 각각에 추가해야 한다.

 

공개된 API를 리팩터링할 때는 새 함수를 추가한 다음 리팩터링을 잠시 멈출 수 있다. 이 상태에서 예전 함수를 폐기 대상deprecated으로 지정하고 모든 클라이언트가 새 함수로 이전할 때까지 기다린다. 클라이언트들이 모두 이전했다는 확신이 들면 예전 함수를 지운다.

 

 

예시: 함수 이름 바꾸기(간단한 절차)

함수 이름을 너무 축약한 예를 준비했다.

 

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

 

이 함수의 이름을 이해하기 더 쉽게 바꾸려 한다. 먼저 함수 선언부터 수정하자.

 

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

 

다음으로 circum()을 호출한 곳을 모두 찾아서 circumference()로 바꾼다(참고로 'circumference'는 원의 둘레를 뜻한다).

 

기존 함수를 참조하는 곳을 얼마나 쉽게 찾을 수 있는가는 개발 언어에 영향을 받는다. 정적 타입 언어와 뛰어난 IDE의 조합이라면 함수 이름 바꾸기를 자동으로 처리할 수 있고, 그 과정에서 오류가 날 가능성도 거의 없다. 정적 타입 언어가 아니라면 검색 기능이 뛰어난 도구라도 잘 못 찾는 경우가 꽤 있어서 일거리가 늘어난다.

 

매개변수 추가나 제거도 똑같이 처리한다. 함수를 호출하는 부분을 모두 찾은 뒤, 선언문을 바꾸고, 호출문도 그에 맞게 고친다. 이 각각의 단계를 순서대로 처리하는 편이 대체로 좋다. 함수 이름 바꾸기와 매개변수 추가를 모두 할 때는 이름부터 바꾸고, 테스트하고, 매개변수 추가하고, 또 테스트하는 식으로 진행한다.

 

간단한 절차의 단점은 호출문과 선언문을(다형성을 구현했다면 여러 선언문 모두를) 한 번에 수정해야 한다는 것이다. 수정할 부분이 몇 개 없거나 괜찮은 자동 리팩터링 도구를 사용한다면 그리 어렵지 않다. 하지만 수정할 부분이 많다면 일이 힘들어진다. 같은 이름이 여러 개일 때도 문제다. 예컨대 changeAddress()란 메서드가 사람 클래스와 계약 클래스 모두에 정의되어 있을 때, 사람 클래스의 메서드만 이름을 바꾸고 싶은 경우 난감해질 수 있다. 나는 변경 작업이 복잡할수록 한 번에 진행하기를 꺼린다. 그래서 이런 상황에 처하면 마이그레이션 절차를 따른다. 마찬가지로 간단한 절차를 따르다가 문제가 생겨도 코드를 가장 최근의 정상 상태로 돌리고 나서 마이그레이션 절차에 따라 다시 진행한다.

 

 

예시: 함수 이름 바꾸기(마이그레이션 절차)

이름을 너무 축약한 앞의 함수를 다시 살펴보자.

 

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

 

이번에는 마이그레이션 절차를 따라 진행하겠다. 먼저 함수 본문 전체를 새로운 함수로 추출한다.

 

function circum(radius) {
  return circumference(radius);
}

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

 

수정한 코드를 테스트한 뒤 예전 함수를 인라인한다. 그러면 예전 함수를 호출하는 부분이 모두 새 함수를 호출하도록 바뀐다. 하나를 변경할 때마다 테스트하면서 한 번에 하나씩 처리하자.  모두 바꿨다면 기존 함수를 삭제한다.

 

리팩터링 대상은 대부분 직접 수정할 수 있는 코드지만, 함수 선언 바꾸기만큼은 공개된 API, 다시 말해 직접 고칠 수 없는 외부 코드가 사용하는 부분을 리팩터링하기에 좋다. 가령 circumference()함수를 만들고 나서 잠시 리팩터링 작업을 멈춘다. 가능하다면 circum()이 폐기 예정deprecated임을 표시한다. 그런 다음 circum()의 클라이언트들 모두가 circumference()를 사용하게 바뀔 때까지 기다린다. 모든 클라이언트가 새 함수로 갈아탔다면 circum()을 삭제한다. circum()을 삭제하는 상쾌한 순간을 결코 맞이하지 못할 수도 있지만, 새로 작성되는 코드들은 더 나은 이름의 새로운 함수를 사용하게 될 것이다.

 

 

예시: 매개변수 추가하기

도서 관리 프로그램의 Book 클래스에 예약 기능이 구현되어 있다고 하자.

 

addReservation(customer) {
  this._reservations.push(customer);
}

 

그런데 예약 시 우선 순위 큐를 지원하라는 새로운 요구가 추가되었다. 그래서 addReservation()을 호출할 때 예약 정보를 일반 큐에 넣을지 우선순위 큐에 넣을지를 지정하는 매개변수를 추가하려 한다. addReservation()을 호출하는 곳을 모두 찾고 고치기가 쉽다면 곧바로 변경하면 된다. 그렇지 않다면 마이그레이션 절차대로 진행해야 한다. 여기서는 후자의 경우라고 가정해보자.

 

먼저 addReservation()의 본문을 새로운 함수로 추출한다. 새로 추출한 함수 이름도 addReservation()이어야 하지만, 기존 함수와 이름이 같은 상태로 둘 수는 없으니 우선은 나중에 찾기 쉬운 임시 이름을 붙인다.

 

addReservation(customer) {
  this.zz_addReservation(customer);
}

zz_addReservation(customer) {
  this.reservations.push(customer);
}

 

그런 다음 새 함수의 선언문과 호출문에 원하는 매개변수를 추가한다(이 작업은 간단한 절차로 진행한다).

 

addReservation(customer) {
  this.zz_addReservation(customer, false);
}

zz_addReservation(customer, isPriority) {
  this.reservations.push(customer);
}

 

나는 자바스크립트로 프로그래밍한다면, 호출문을 변경하기 전에 어서션을 추가하여 호출하는 곳에서 새로 추가한 매개변수를 실제로 사용하는지 확인한다.

 

zz_addReservation(customer, isPriority) {
  assert(isPriority === true || isPriority === false);
  this.reservations.push(customer);
}

 

이렇게 해두면 호출문을 수정하는 과정에서 실수로 새 매개변수를 빠뜨린 부분을 찾는 데 도움된다. 오랜 세월 나보다 실수를 많이 하는 프로그래머를 거의 못 봤기 때문이기도 하다.

 

이제 기존 함수를 인라인하여 호출 코드들이 새 함수를 이용하도록 고친다. 호출문은 한 번에 하나씩 변경한다.

 

다 고쳤다면 새 함수의 이름을 기존 함수의 이름으로 바꾼다.

 

이상의 작업은 대부분 간단한 절차만으로도 무리가 없지만, 필요하면 마이그레이션 절차를 따르기도 한다.

 

 

예시: 매개변수를 속성으로 바꾸기

지금까지는 이름을 바꾸거나 매개변수 하나만 추가하는 단순한 예만 살펴봤다. 하지만 마이그레이션 절차를 따른다면 훨씬 복잡한 상황도 꽤 깔끔하게 처리할 수 있다. 이번에는 좀 더 복잡한 예를 살펴보자.

 

고객이 뉴잉글랜드에 살고 있는지 확인하는 함수가 있다고 하자.

 

function inNewEngland(aCustomer) {
  return ['MA', 'CT', 'ME', 'VT', 'NH', 'RI'].includes(aCustomer.address.state);
}

 

다음은 이 함수를 호출하는 코드 중 하나다.

 

const newEnglanders = someCustomers.filter(c => isNesEngland(c));

 

inNewEngland()함수는 고객이 거주하는 주 이름을 보고 뉴잉글랜드에 사는지 판단한다. 나라면 이 함수가 주state 식별 코드를 매개변수로 받도록 리팩터링할 것이다. 그러면 고객에 대한 의존성이 제거되어 더 넓은 문맥에 활용할 수 있기 때문이다.

 

나는 함수 선언을 바꿀 때 함수 추출부터 하는 편이다. 하지만 이번 코드는 함수 본문을 살짝 리팩터링해두면 이후 작업이 더 수월해질 터라 우선 매개변수로 사용할 코드를 변수로 추출해둔다.

 

function inNewEngland(aCustomer) {
  const stateCode = aCustomer.address.state;
  return ['MA', 'CT', 'ME', 'VT', 'NH', 'RI'].includes(stateCode);
}

 

이제 함수 추출하기로 새 함수를 만든다.

 

function inNewEngland(aCustomer) {
  const stateCode = aCustomer.address.state;
  return xxNewinNewEngland(stateCode);
}
function xxNewinNewEngland(stateCode) {
  return ['MA', 'CT', 'ME', 'VT', 'NH', 'RI'].includes(stateCode);
}

 

새 함수의 이름을 나중에 기존 함수 이름으로 바꾸기 쉽도록 검색하기 좋은 이름을 붙여둔다(예시들을 보면 알겠지만 나는 임시 이름 짓기에 특별히 정해둔 규칙은 없다).

 

그런 다음 기존 함수 안에 변수로 추출해둔 입력 매개변수를 인라인한다(변수 인라인하기).

 

function inNewEngland(aCustomer) {
  return xxNewinNewEngland(aCustomer.address.state);
}

 

함수 인라인하기로 기존 함수의 본문을 호출문들에 직접 집어넣는다. 실질적으로 기존 함수 호출문을 새 함수 호출문으로 교체하는 셈이다. 이 작업은 한 번에 하나씩 처리한다.

 

const newEnglanders = someCustomers.filter((c) => xxNewinNewEngland(c.address.state));

 

기존 함수를 모든 호출문에 인라인했다면, 함수 선언 바꾸기를 다시 한번 적용하여 새 함수의 이름을 기존 함수의 이름으로 바꾼다.

 

const newEnglanders = someCustomers.filter((c) => isNewEngland(c.address.state));

 

function inNewEngland(stateCode) {
  return ['MA', 'CT', 'ME', 'VT', 'NH', 'RI'].includes(stateCode);
}

 

자동 리팩터링 도구는 마이그레이션 절차의 활용도를 떨어뜨리기도 하고 효과를 배가하기도 한다. 활용도를 떨어뜨리는 이유는 훨씬 복잡한 이름 바꾸기와 매개변수 수정도 자동 리팩터링이 안전하게 수행해줘서 이 절차를 사용할 일이 적어지기 때문이다. 하지만 마지막 예시처럼 모든 작업을 자동 리팩터링만으로 처리할 수 없을 때는 반대로 상당한 도움을 준다. 추출과 인라인 같은 핵심적인 변경을 훨씬 빠르고 안전하게 할 수 있기 때문이다.

 

 

 

 

 

[출처]

리팩터링 2판 - 마틴 파울러

 

[정리]

함수 선언 바꾸기에 관한 리팩터링을 살펴본다. 함수 선언 바꾸기에는 함수 이름을 더 명확하게 하는 것과, 매개변수를 삭제하거나 추가하는 등의 내용이 있다. 만약 추가해야 할 작업이 여러가지라면 마이그레이션하는 방법을 사용한다. 이름이 비슷한 새로운 함수를 만들고, 기존 함수의 내용을 새로운 함수로 옮긴 후 기존 함수를 인라인한다. 테스트가 완료되면 기존 함수를 지우고 새로운 함수의 이름을 기존 함수로 변경한다.