Programming/Refactoring

기본적인 리팩터링 (3)

Seung-o 2025. 4. 16. 23:14

매개변수 객체 만들기

배경

코드를 보다 보면, 여러 개의 데이터 항목이 항상 같이 다니는 경우가 많다. 한 함수에서 다른 함수로 이동할 때 꼭 함께 전달되는 값들 말이다. 마틴 파울러는 이런 상황을 발견하면, 그 데이터들을 하나의 구조로 묶어주는 리팩터링을 권장한다. 바로 "매개변수 객체 만들기"다.

이 작업의 핵심은 단순히 코드를 깔끔하게 정리하는 걸 넘어서, 도메인을 더 명확하게 표현하는 추상화를 도입하는 데 있다. 별 생각 없이 전달하던 값들이 실제로는 하나의 개념으로 묶일 수 있다는 걸 코드 구조로 드러내는 것이다.

이 리팩터링이 주는 이점은 다음과 같다.

  • 관계 명확화: 따로 놀던 값들이 하나의 구조로 묶이면서, 이 값들 사이의 관계가 더 뚜렷해진다.
  • 매개변수 정리: 함수를 호출할 때 전달해야 할 인자가 줄어든다.
  • 일관성 향상: 값을 꺼낼 때 공통된 이름을 쓰기 때문에 코드의 통일성이 높아진다.
  • 추상화 격상: 단순히 값을 묶는 걸 넘어서, 하나의 새로운 도메인 개념으로 격상시킬 수 있다. 이건 진짜 강력한 효과다.

절차

  1. 데이터 구조 정의: 적절한 데이터 구조(예: 클래스를 하나 만든다)가 없다면 먼저 만든다.
  2. 테스트: 항상 그렇듯, 리팩터링 전에는 테스트부터 돌려서 현재 상태를 확인한다.
  3. 함수 선언 수정: 함수에 새로 만든 구조를 매개변수로 추가한다.
  4. 다시 테스트
  5. 함수 호출부 수정: 함수를 호출하는 곳에서 새 객체를 만들어 넘기도록 수정한다. 하나씩 바꾸면서 그때마다 테스트를 돌린다.
  6. 내부 코드 수정: 함수 내부에서 기존 개별 매개변수 대신 객체의 필드를 참조하도록 바꾼다.
  7. 기존 매개변수 제거: 다 바꿨다면, 이제 더는 필요 없는 기존 매개변수를 지운다. 그리고 마지막 테스트.
매개 변수 그룹을 객체로 교체하는 일은 진짜 값진 작업의 준비 단계이다. 클래스로 만들어 두면 관련 동작들을 이 클래스로 옮길 수 있다는 이점이 생긴다.

 

예시

💥 초기 버전

function readingsOutsideRange(station, min, max) {
  return station.readings.filter((r) => r.temp < min || r.temp > max);
}

const alerts = readingsOutsideRange(
  station,
  operatingPlan.temperatureFloor,
  operatingPlan.temperatureCeiling
);

 

🔧 데이터 구조 정의

class NumberRange {
  constructor(min, max) {
    this._data = { min: min, max: max };
  }

  get min() {
    return this._data.min;
  }

  get max() {
    return this._data.max;
  }
}

 

🔧 함수 선언 수정

function readingsOutsideRange(station, min, max, range) {
  return station.readings.filter((r) => r.temp < min || r.temp > max);
}

 

🔧 함수 호출부 수정

const range = new NumberRange(
  operatingPlan.temperatureFloor,
  operatingPlan.temperatureCeiling
);

const alerts = readingsOutsideRange(
  station,
  operatingPlan.temperatureFloor,
  operatingPlan.temperatureCeiling,
  range
);

 

🔧 내부 코드 수정

 

min 과 max 를 사용하는 부분을 하나씩 지워나가면서 테스트를 진행한다. 최종적인 형태는 다음과 같다.

function readingsOutsideRange(station, range) {
  return station.readings.filter((r) => !range.contains(r.temp));
}

 

 기존 매개변수 제거

const range = new NumberRange(
  operatingPlan.temperatureFloor,
  operatingPlan.temperatureCeiling
);

const alerts = readingsOutsideRange(
  station,
  range
);

 

여러 함수를 클래스로 묶기

배경

여러 함수가 동일한 데이터를 공유하고 있다면, 이 함수들을 하나의 클래스로 묶는 걸 고려할 수 있다. 클래스는 객체 지향 언어의 핵심이기도 하지만, 사실 패러다임에 관계없이 꽤 유용한 구조다.

리팩터링을 하다 보면, 어떤 함수들이 특정 데이터에 강하게 의존하고 있다는 걸 발견하게 된다. 이럴 때 이 함수들을 함께 묶어서 클래스를 만들면, 코드의 구조가 더 명확해지고, 관련 있는 기능들을 한곳에 모아둘 수 있다. 이 클래스는 다른 모듈에서 사용할 수 있도록 참조만 넘기면 되고, 내부 구현은 감출 수 있다.

단순히 코드를 정리하는 데 그치지 않는다. 클래스를 만들고 나면, 이전에는 놓치고 있었던 도메인 연산이 자연스럽게 드러나기도 한다. 그렇게 되면 그 연산도 새로운 클래스의 메서드로 옮겨주면 된다.

절차

  1. 공통 데이터 캡슐화: 여러 함수가 공유하는 데이터 구조(레코드 등)가 있다면, 이를 하나의 클래스로 감싼다.
  2. 함수 이동: 이 데이터를 사용하는 함수들을 하나씩 새 클래스로 옮긴다. 데이터에 직접 접근하는 로직이 있다면, 해당 클래스를 통해 접근하도록 수정한다.
  3. 로직 추출: 공통 데이터를 다루는 로직이 흩어져 있다면, 이걸 함수로 추출해서 새 클래스로 옮긴다. 이 클래스가 데이터와 기능을 모두 책임지도록 만드는 것이 핵심이다.

예시

💥 초기 버전

const reading = {
  customer: "ivan",
  quantity: 10,
  month: 5,
  year: 2017
};
// Client 1
const aReading = acquireReading();
const baseCharge =
  baseRate(aReading.month, aReading.year) * aReading.quantity;
// Client 2
const aReading = acquireReading();
const base =
  baseRate(aReading.month, aReading.year) * aReading.quantity;
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));
// Client 3
const aReading = acquireReading();
const basicChargeAmount = calculateBaseCharge(aReading);

function calculateBaseCharge(aReading) {
  return baseRate(aReading.month, aReading.year) * aReading.quantity;
}

 

🔧 공통 데이터 캡슐화

class Reading {
  constructor(data) {
    this._customer = data.customer;
    this._quantity = data.quantity;
    this._month = data.month;
    this._year = data.year;
  }

  get customer() {
    return this._customer;
  }

  get quantity() {
    return this._quantity;
  }

  get month() {
    return this._month;
  }

  get year() {
    return this._year;
  }
}

 

🔧 함수 이동: calculateBaseCharge() 옮기기

// Reading 클래스..

get baseCharge() {
  return baseRate(this.month, this.year) * this.quantity;
}
// Client 3
const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const basicChargeAmount = aReading.baseCharge;
// Client 1

const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const baseCharge = aReading.baseCharge;
// Client 2
const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const taxableCharge = Math.max(
  0,
  aReading.baseCharge - taxThreshold(aReading.year)
);

 

로직 추출: 세금을 부과할 소비량을 계산하는 코드를 함수로 추출

// Reading 클래스...

get taxableCharge() {
  return Math.max(0, this.baseCharge - taxThreshold(this.year));
}
// Client 3

const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const taxableCharge = aReading.taxableCharge;

 

여러 함수를 변환 함수로 묶기

배경

데이터를 받아서 여러 정보를 계산해내는 코드는 정말 자주 마주친다. 문제는 이 계산 로직이 중복돼 있다는 점이다. 어떤 값은 A에서 계산하고, 또 어떤 곳에서는 똑같은 계산이 반복되고 있다. 이럴 땐 계산 로직을 하나로 묶어서 재사용할 수 있게 만들어야 한다.

이럴 때 유용한 리팩터링이 바로 변환 함수 만들기다. 변환 함수는 원본 데이터를 받아서 필요한 정보를 한 번에 계산하고, 그걸 새 레코드에 담아 반환한다. 이렇게 해두면 계산 과정을 검토할 일도, 수정할 일도 이 함수 하나만 보면 된다.

물론 비슷한 상황에서 "여러 함수를 클래스로 묶기"를 선택해도 된다. 둘 중 어떤 걸 쓰든 중요한 건 일관성과 유지보수성이다. 일반적으로 원본 데이터를 수정하지 않고 새로운 정보를 도출할 때는 변환 함수가 잘 어울린다. 반면, 원본 데이터를 갱신해야 한다면 클래스로 묶는 쪽이 더 안정적이다. 변환 함수는 불변성 기반 접근에 가깝기 때문이다.

절차

  1. 변환 함수 만들기: 변환할 레코드를 입력받아서 그대로 반환하는 기본 틀의 변환 함수를 만든다.
  2. 하나씩 옮기기: 묶을 함수 중 하나를 선택해서, 그 로직을 변환 함수 안으로 옮긴다. 처리된 값을 새 필드에 담아 반환한다.
  3. 클라이언트 코드 수정: 외부에서 직접 계산하던 코드를 수정해서, 변환 함수가 반환한 레코드의 필드를 참조하도록 고친다.
  4. 테스트: 바꾼 부분이 제대로 작동하는지 확인한다.
  5. 나머지 함수 반복 적용: 같은 방식으로 관련 함수들을 하나씩 변환 함수로 옮긴다.

 

예시

💥 초기 버전

const aReading = acquireReading();
const base = baseRate(aReading.month, aReading.year) * aReading.quantity;
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));

 

코드에 위와 같은 계산 코드가 여러 곳에 반복된다고 가정하자. 물론 중복 코드라면 함수 추출하기로 처리할 수도 있지만, 추출한 함수들이 프로그램 곳곳에 흩어져서 나중에 그런 함수가 있는지 조차 모르게 될 가능성이 있다.

 

🔧 변환 함수 만들기

function enrichReading(original) {
  const result = _.cloneDeep(original);
  return result;
}

 

🔧 하나씩 옮기기

const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const basicChargeAmount = calculateBaseCharge(aReading);
function enrichReading(original) {
  const result = _.cloneDeep(original);
  result.baseCharge = calculateBaseCharge(result);
  return result;
}

 

🔧 클라이언트 코드 수정

const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const basicChargeAmount = aReading.baseCharge;

 

✅ 나머지 함수도 반복 적용

const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const base = aReading.baseCharge;
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));

 

리팩터링: 단계 쪼개기

배경

서로 다른 두 대상을 한 번에 처리하는 복잡한 코드를 보면, 각 대상을 별도의 모듈로 나누는 방법을 고려해야 한다. 잘 분리된 모듈은 서로의 세부사항에 대해 알지 못해도 독립적으로 수정할 수 있게 만들어준다.

가장 간단한 방법 중 하나는 동작을 두 단계로 나누는 것이다. 컴파일러가 대표적인 예시인데, 컴파일러는 입력된 텍스트를 실행 가능한 코드로 변환하는 작업을 한다. 이 과정에서 여러 단계로 나누어 처리하는 방식이 효과적이라는 사실이 오래전부터 알려졌다. 텍스트를 먼저 토큰화하고, 그 토큰을 파싱하여 구문 트리를 만들고, 그 후 최적화 등을 거쳐 목적 코드를 생성하는 식이다. 각 단계는 독립적인 문제에 집중하기 때문에, 다른 단계에 대한 세부적인 이해가 없어도 충분히 작업을 진행할 수 있다.

이처럼 큰 덩치의 소프트웨어에서는 각 단계를 명확히 나누는 것이 유지보수와 확장성에 유리하다. 이 기법을 사용하면 소프트웨어의 복잡성을 줄이면서도 각 기능을 명확히 구분할 수 있다.

절차

  1. 두 번째 단계 추출: 두 번째 단계에 해당하는 코드를 독립적인 함수로 분리한다.
  2. 테스트: 함수가 제대로 동작하는지 테스트한다.
  3. 중간 데이터 구조 도입: 앞서 추출한 함수의 인수로 사용할 중간 데이터 구조를 만든다.
  4. 테스트: 중간 데이터 구조와 함께 코드가 올바르게 동작하는지 다시 테스트한다.
  5. 매개변수 검토: 두 번째 단계 함수의 매개변수를 하나씩 검토하여, 첫 번째 단계에서 사용되는 데이터는 중간 데이터 구조로 옮긴다. 각 매개변수를 옮길 때마다 테스트한다.
  6. 첫 번째 단계 함수로 추출: 첫 번째 단계 코드를 별도의 함수로 추출하면서, 중간 데이터 구조를 반환하도록 만든다.

'Programming > Refactoring' 카테고리의 다른 글

기본적인 리팩터링 (2)  (0) 2025.04.10
기본적인 리팩터링 (1)  (0) 2025.04.10
테스트 구축하기  (0) 2025.03.20
코드에서 나는 악취  (1) 2025.03.13
리팩터링: 첫 번째 예시  (0) 2025.02.26