지금까지가 요소를 만들고 없애고 이름 짓는 기법이었다면, 이 장은 요소를 옮기는 기법이다. 좋은 설계란 결국 “무엇이 어디에 있어야 하는가”의 문제다. 프로그램 요소를 처음부터 완벽한 자리에 놓을 수는 없다. 이해가 깊어질수록 “이건 저쪽에 있어야 했다”가 보이고, 이 장의 기법들은 그 이동을 안전하게 하는 절차를 준다. 이동의 신호는 대체로 “참조 방향”이다. 어떤 요소가 자기 집보다 남의 집 데이터를 더 자주 만지면, 그건 이사 신호다.

함수 옮기기

함수가 자기 모듈보다 다른 모듈의 요소를 더 많이 참조하면(기능 편애, feature envy) 그 데이터가 있는 쪽으로 옮긴다.

// before — Account 안에 있지만 AccountType 데이터에 더 의존
class Account {
  get overdraftCharge() {
    if (this.type.isPremium) {
      const baseCharge = 10
      if (this.daysOverdrawn <= 7) return baseCharge
      return baseCharge + (this.daysOverdrawn - 7) * 0.85
    }
    return this.daysOverdrawn * 1.75
  }
}
 
// after — 규칙이 type 에 의존하므로 AccountType 으로 이동
class AccountType {
  overdraftCharge(daysOverdrawn) {
    if (this.isPremium) {
      const baseCharge = 10
      if (daysOverdrawn <= 7) return baseCharge
      return baseCharge + (daysOverdrawn - 7) * 0.85
    }
    return daysOverdrawn * 1.75
  }
}
class Account {
  get overdraftCharge() {
    return this.type.overdraftCharge(this.daysOverdrawn)
  }
}

절차의 요점: 대상 맥락에 함수를 먼저 복사하고, 원본이 새 함수를 위임 호출하게 둔 뒤, 호출자를 점진적으로 옮기고, 마지막에 원본을 제거한다. 흔한 함정: 옮기려는 함수가 원본 모듈의 여러 요소와도 얽혀 있으면, 그 얽힘을 먼저 풀거나(그 요소들도 같이 옮길지 판단) 인자로 넘겨야 한다.

필드 옮기기

데이터 구조 변경은 함수 옮기기보다 파급이 크다. 그래서 변수 캡슐화가 선행 조건이다. 접근이 함수 뒤에 숨어 있으면 필드의 실제 위치는 한 곳만 고치면 된다. 파울러의 판단 기준이 명료하다. “한 레코드를 수정할 때 다른 레코드의 필드도 항상 같이 수정하게 되면, 그 필드는 잘못된 곳에 있다.”

// before — 할인율이 Customer 에 있는데 늘 계약과 함께 움직임
class Customer {
  constructor(name, discountRate) {
    this._discountRate = discountRate
    this._contract = new CustomerContract(dateToday())
  }
  get discountRate() {
    return this._discountRate
  }
}
 
// after — discountRate 를 CustomerContract 로 이동
class Customer {
  get discountRate() {
    return this._contract.discountRate
  }
  becomePreferred() {
    this._contract.discountRate += 0.03
  }
}

절차: 필드를 캡슐화(get/set)하고, 대상 클래스에 필드와 접근자를 만든 뒤, 원본 접근자가 대상 쪽을 바라보게 바꾸고, 원본 필드를 제거한다. 흔한 함정: 캡슐화 없이 필드를 옮기면 직접 접근하던 코드가 전부 깨진다. 반드시 접근자부터 세운다.

문장을 함수로 옮기기 / 호출한 곳으로 옮기기

함수를 호출하는 곳마다 똑같은 코드가 앞뒤에 붙어 있으면, 그 문장을 함수 안으로 들여 중복을 없앤다(문장을 함수로 옮기기). 반대로 그 앞뒤 문장이 호출자마다 달라져야 한다면, 함수 밖 호출한 곳으로 도로 꺼낸다(문장을 호출한 곳으로 옮기기). 역시 쌍이다.

// before — 모든 호출부가 photoData 출력 앞에 제목을 찍음
function renderPerson(outStream, person) {
  outStream.push(`<p>${person.name}</p>`)
  outStream.push(emitPhotoData(person.photo))
}
function emitPhotoData(photo) {
  return `<p>title: ${photo.title}</p>` + `<p>${photo.date}</p>`
}

“공통 부분이면 안으로, 갈라지는 부분이면 밖으로”가 판단 기준이다. 함정: 지금은 공통이라 안으로 넣었는데 나중에 호출부마다 달라지면 다시 꺼내야 한다. 그래서 이 두 기법을 한 쌍으로 익혀 두는 게 실용적이다.

인라인 코드를 함수 호출로 바꾸기

이미 존재하는 함수와 똑같은 일을 하는 인라인 코드를 발견하면, 그 코드를 함수 호출로 대체한다. 그러면 중복이 사라지고, 함수 이름이 코드의 의도를 대신 말해 준다.

// before
let appliesToMass = false
for (const s of states) {
  if (s === "MA") appliesToMass = true
}
 
// after — 라이브러리 함수 재사용
const appliesToMass = states.includes("MA")

함정: 정말 “같은 일”인지 확인해야 한다. 미묘하게 다른 동작(중복 처리, 대소문자 등)이면 함수 호출로 바꾸는 순간 버그가 된다.

문장 슬라이드하기

관련된 코드를 물리적으로 가까이 모으는 것. 추출의 준비 운동이다. 변수 선언은 처음 쓰는 곳 바로 앞으로 내리고, 같이 쓰이는 문장끼리 붙인다.

// 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

함정: 슬라이드는 동작을 바꾸면 안 된다. 옮기려는 문장 사이에 부작용이나 의존(같은 변수를 읽고 쓰는 관계)이 있으면 순서를 바꾸는 순간 결과가 달라진다. 슬라이드 전에 의존 관계를 확인한다.

반복문 쪼개기

한 반복문에서 두 가지를 계산하고 있으면 반복문을 복제해 하나씩 맡긴다. 아래 summary()는 한 루프가 총급여 합산과 최연소 찾기를 겸직하고 있다. 여기서 시작해 반복문 쪼개기 → 함수 추출 → 파이프라인까지 한 흐름으로 밀고 나가 보자. Next를 누를 때마다 직전 단계 대비 바뀐 줄이 강조되므로, 한 걸음이 코드를 어떻게 움직이는지가 눈에 들어온다.

Refactoring Step 원본 — 한 루프가 두 일을 겸직
1/4
function summary(people) {  let totalSalary = 0  let youngest = people[0] ? people[0].age : Infinity  for (const p of people) {    totalSalary += p.salary    if (p.age < youngest) youngest = p.age  }  return { totalSalary, youngest }}

두 번 도는 게 아까워 보이지만, 분리된 각 반복문은 이제 함수로 추출할 수 있고(totalSalary(), youngestAge()) 이름을 가질 수 있다. 성능 걱정에 대한 반박: 반복 한 번을 두 번으로 늘리는 비용은 대부분 무시할 수준이고, 애초에 성능은 추측이 아니라 측정으로 판단한다. 병목이 여기라고 프로파일러가 말하기 전까지는 명확성을 택한다. 게다가 쪼개서 구조가 깔끔해지면 훨씬 강력한 최적화를 나중에 적용하기도 쉬워진다.

반복문을 파이프라인으로 바꾸기

위 흐름의 마지막 단계가 바로 이것이다. 컬렉션을 도는 반복문은 종종 map/reduce/filter 같은 파이프라인 연산으로 바꿀 수 있다. 무엇을 뽑고 무엇으로 합치는지가 선언적으로 드러난다. 추출해 둔 totalSalary() 내부의 누적 루프를 보면, 데이터가 왼쪽에서 오른쪽으로 한 단계씩 변환되어 흐르는 모양이다.

flowchart LR
    A["people<br/>원본 배열"] --> M["map<br/>salary 만 추출"]
    M --> R["reduce<br/>합산해 totalSalary"]

각 단계가 순수 변환이므로 위에서 아래로, 왼쪽에서 오른쪽으로 그대로 읽힌다. 읽는 사람은 “급여만 뽑아 → 더해”를 그대로 읽는다. 누적 변수(total)와 그 조작이 사라진다. 함정: 파이프라인 중간에 부작용을 섞으면(forEach 안에서 외부 상태 변경) 파이프라인의 장점인 “각 단계가 순수 변환”이 깨진다. 파이프라인은 데이터 흐름을 표현할 때만 쓴다.

죽은 코드 제거하기

“나중에 쓸지도 몰라”는 주석 처리도, 보관도 필요 없다 — 버전 관리가 기억한다. 죽은 코드의 진짜 비용은 저장 공간이 아니라, 읽는 사람이 그 동작을 이해하려고 쓰는 시간과, 리팩터링할 때 죽은 코드까지 함께 끌고 다니는 부담이다. 발견 즉시 삭제한다. 주석 처리로 남기지 않는다 — git이 무덤이고, 무덤은 언제든 되찾을 수 있다.

정리

  • 옮기기의 신호는 참조 방향이다. 함수가 어느 쪽 데이터와 더 친한지 세어 보면 답이 나온다(기능 편애).
  • 필드 옮기기는 캡슐화가 선행 조건이다. “한 레코드를 고칠 때 늘 다른 레코드도 같이 고친다”면 필드가 잘못된 곳에 있는 것.
  • 반복문 쪼개기 + 파이프라인 전환은 pharos 같은 TS 코드베이스에서 매일 쓸 수 있는 조합이다. 성능은 측정 전까지 명확성에 양보한다.
  • 문장을 함수로/호출한 곳으로 옮기기는 쌍이다. 공통이면 안으로, 갈라지면 밖으로.
  • 죽은 코드는 발견 즉시 지운다. 버전 관리가 있으니 주석 처리는 낭비다.