모듈은 API로 연결된다. 잘 나뉜 모듈이라도 그 사이 API가 나쁘면 좋은 내부 구현이 소용없다. 이 장은 함수의 이름, 시그니처, 그리고 “무엇을 넘기고 무엇을 돌려주는가”를 다듬어 연결부를 이해하기 쉽고 쓰기 쉽게 만드는 기법을 모았다. 여러 기법이 서로 짝(정방향/역방향)으로 존재하는데, 정답이 정해져 있는 게 아니라 상황에 따라 어느 방향이 맞는지 판단하라는 뜻이다.

질의 함수와 변경 함수 분리하기 (CQS)

값을 돌려주면서 상태도 바꾸는 함수는 호출하기 무섭다. 결과가 필요해서 부르는 순간 부수효과가 딸려 오기 때문이다. 겉보기 부수효과 없이 값만 반환하는 함수는 몇 번을 불러도, 순서를 바꿔도, 다른 곳으로 옮겨도 안전하다. 이것이 명령-질의 분리(Command-Query Separation)다.

한 함수가 겸하던 두 역할을 두 함수로 가르는 그림은 이렇다.

flowchart LR
    M["겸직 함수<br/>값도 반환하고 상태도 바꿈"] --> Q["질의 함수<br/>부수효과 없이 값만 반환"]
    M --> C["변경 함수<br/>값 반환 없이 상태만 변경"]
    Q --> S["언제 호출해도 안전"]
    C --> S

질의 쪽이 순수해지면 호출 순서와 횟수에 대한 걱정이 사라진다.

// before — 최고가를 찾으면서 경보도 울린다
function alertForMiscreant(people) {
  for (const p of people) {
    if (p === "Don") {
      setOffAlarms()
      return "Don"
    }
    if (p === "John") {
      setOffAlarms()
      return "John"
    }
  }
  return ""
}
// after — 질의(누구인가)와 변경(경보)을 분리
function findMiscreant(people) {
  // 질의: 부수효과 없음
  for (const p of people) {
    if (p === "Don") return "Don"
    if (p === "John") return "John"
  }
  return ""
}
function alertForMiscreant(people) {
  // 변경: 값을 반환하지 않음
  if (findMiscreant(people) !== "") setOffAlarms()
}

함정: 성능 때문에 조회와 캐시 갱신을 합쳐 두는 경우가 있는데, 그건 관측 가능한 부수효과가 아니므로(caching) CQS 위반이 아니다. 문제는 호출자가 신경 써야 하는 상태 변화다.

함수 매개변수화하기 / 플래그 인수 제거하기

값만 다른 비슷한 함수들이 여럿 있으면, 그 다른 값을 매개변수로 뽑아 하나로 통합한다.

// before
function tenPercentRaise(person) {
  person.salary = person.salary * 1.1
}
function fivePercentRaise(person) {
  person.salary = person.salary * 1.05
}
// after
function raise(person, factor) {
  person.salary = person.salary * (1 + factor)
}

반대로, boolean 플래그 인수로 함수의 동작 자체를 갈라치는 건 나쁜 통합이다. 호출부만 봐서는 무슨 일이 일어나는지 알 수 없다.

// before — true가 뭘 의미하는지 호출부에 안 보인다
function deliveryDate(anOrder, isRush) {
  if (isRush) return anOrder.placedOn.plusDays(1)
  return anOrder.placedOn.plusDays(2)
}
deliveryDate(order, true)
// after — 동작마다 명시적 함수
function rushDeliveryDate(anOrder) {
  return anOrder.placedOn.plusDays(1)
}
function regularDeliveryDate(anOrder) {
  return anOrder.placedOn.plusDays(2)
}
rushDeliveryDate(order)

deliveryDate(order, true)rushDeliveryDate(order) 중 후자가 압도적으로 읽기 쉽다. 함정: 플래그가 여러 개거나 값이 리터럴이 아닌 변수라면 명시적 함수로 다 풀기 어렵다. 그럴 땐 플래그가 진짜 필요한 축인지부터 의심한다.

객체 통째로 넘기기 vs 매개변수를 질의 함수로 / 질의 함수를 매개변수로

레코드에서 값 몇 개를 뽑아 인자로 넘기고 있다면, 레코드를 통째로 넘기는 편이 낫다. 시그니처가 짧아지고, 나중에 그 객체의 다른 필드가 필요해져도 시그니처를 안 바꿔도 된다.

// before
const low = aRoom.daysTempRange.low
const high = aRoom.daysTempRange.high
if (aPlan.withinRange(low, high)) {
  /* ... */
}
// after
if (aPlan.withinRange(aRoom.daysTempRange)) {
  /* ... */
}

이 결정의 핵심은 의존성을 어느 쪽에 둘 것인가다. 통째로 넘기면 withinRangedaysTempRange 타입에 의존하게 된다. 그 결합이 싫다면 반대 방향을 쓴다. 값을 이미 아는 다른 함수에서 뽑아 넣을 수 있으면 매개변수를 질의 함수로 바꾸기로 인자를 아예 없애고, 반대로 함수 내부의 의존을 밖으로 빼고 싶으면 질의 함수를 매개변수로 바꾸기로 값을 인자로 승격시킨다. 정답은 없고, “이 모듈이 무엇에 의존하길 원하는가”로 정한다.

세터 제거하기

객체 생성 후에는 바뀌면 안 되는 필드인데 세터가 열려 있다면, 그 세터를 없앤다. 세터가 없다는 사실 자체가 “이 값은 불변”이라는 API 문서 역할을 한다.

// before — id는 생성 시 정해지는데 세터가 열려 있다
class Person {
  get id() {
    return this._id
  }
  set id(v) {
    this._id = v
  }
}
const p = new Person()
p.name = "홍길동"
p.id = 1234
// after — id는 생성자로만, 세터는 제거
class Person {
  constructor(id) {
    this._id = id
  }
  get id() {
    return this._id
  } // 세터 없음 = 불변 계약
}
const p = new Person(1234)
p.name = "홍길동"

생성자를 팩토리 함수로 바꾸기

생성자는 제약이 많다. 클래스와 같은 이름이어야 하고(의도를 담은 이름을 못 붙인다), 자기 클래스 인스턴스만 반환할 수 있으며(서브클래스를 골라 줄 수 없다), new라는 특별한 문법을 요구한다. 팩토리 함수는 이 셋에서 자유롭다.

// before
const eng = new Employee(name, "engineer")
// after — 이름으로 의도를 드러내고, 타입별 서브클래스도 고를 수 있다
function createEngineer(name) {
  return new Employee(name, "engineer")
}
const eng = createEngineer(name)

함수를 명령으로 바꾸기 / 명령을 함수로 바꾸기

복잡한 함수는 그 자체를 객체(명령 객체, command)로 만들면 이득이 있다. 중간 결과를 필드로 나눠 담아 큰 함수를 여러 메서드로 쪼갤 수 있고, 실행 취소(undo) 같은 부가 동작도 붙일 수 있다.

// before
function score(candidate, medicalExam, scoringGuide) {
  let result = 0
  let healthLevel = 0
  // 길고 얽힌 로직 ...
  return result
}
// after — 명령 객체
class Scorer {
  constructor(candidate, medicalExam, scoringGuide) {
    this._candidate = candidate
    this._medicalExam = medicalExam
    this._scoringGuide = scoringGuide
  }
  execute() {
    this._result = 0
    this._healthLevel = 0
    this.scoreSmoking() // 공유 상태를 필드로 두니 메서드 분해가 쉬워진다
    return this._result
  }
  scoreSmoking() {
    /* this._healthLevel 등을 다룬다 */
  }
}

반대로, 명령 객체가 하는 일이 단순하다면 굳이 클래스를 유지할 이유가 없다. 명령을 함수로 바꾸기로 다시 평범한 함수로 되돌린다. 큰 로직엔 명령, 작아지면 함수 — 방향은 복잡도가 정한다.

수정된 값 반환하기

함수가 인자로 받은 변수를 몰래 갱신하면 데이터 흐름을 추적하기 어렵다. 갱신한 값을 명시적으로 반환하게 바꾸면, 호출부에서 “이 변수가 여기서 바뀐다”가 눈에 보인다.

// before — totalAscent를 안에서 바꾼다
let totalAscent = 0
calculateAscent(points, totalAscent) // 바뀐 걸 호출부가 모른다
// after — 반환값으로 흐름을 드러낸다
const totalAscent = calculateAscent(points)
 
function calculateAscent(points) {
  let result = 0
  for (let i = 1; i < points.length; i++) {
    const verticalChange = points[i].elevation - points[i - 1].elevation
    result += Math.max(0, verticalChange)
  }
  return result
}

함정: 이 기법은 값 하나를 계산해 돌려줄 때 적합하다. 여러 값을 갱신한다면 그건 함수가 너무 많은 일을 한다는 신호다.

오류 코드를 예외로 바꾸기 / 예외를 사전확인으로 바꾸기

오류 코드를 반환해 호출 사슬을 따라 릴레이하면, 중간 단계마다 검사·전달 코드가 붙어 지저분해진다. 예외를 쓰면 오류 처리 경로를 정상 경로에서 분리할 수 있다.

// before — 오류 코드 릴레이
function localShippingRules(country) {
  const data = countryData.shippingRules[country]
  if (data) return new ShippingRules(data)
  return -23 // 매직 코드가 위로 전파된다
}
// after — 예외로 정상 경로와 분리
function localShippingRules(country) {
  const data = countryData.shippingRules[country]
  if (data) return new ShippingRules(data)
  throw new OrderProcessingError(-23)
}

하지만 예외를 남용하면 안 된다. 호출자가 미리 검사해서 합리적으로 피할 수 있는 조건이라면 예외가 아니다. 그럴 땐 예외를 사전확인으로 바꾸기로 되돌린다.

// before — 있는지 없는지를 예외로 판단(예외의 오용)
function getValue(resourceId) {
  let result
  try {
    result = getResource(resourceId)
  } catch (e) {
    if (e instanceof ResourceUnavailableError) return null
    throw e
  }
  return result
}
// after — 먼저 확인해서 예외를 안 만든다
function getValue(resourceId) {
  if (!isResourceAvailable(resourceId)) return null
  return getResource(resourceId)
}

판단 기준은 하나다. “호출자가 미리 알 수 있었나?” 미리 검사할 수 있으면 사전확인, 정말 예상 밖이면 예외. 예외는 “정말 예외적인” 상황 전용이다.

정리

  • API 설계의 절반은 이름과 시그니처다. 플래그 인수와 겸직 함수(질의+변경)가 가장 흔한 부채다.
  • 명령-질의 분리는 pharos CLI 설계에도 그대로 쓰는 기준 — 읽기 커맨드와 변경 커맨드를 섞지 않으면 언제 호출해도 안전하다.
  • 여러 기법이 정방향/역방향 짝으로 온다. 정답은 “이 모듈이 무엇에 의존하길 원하는가”로 정한다.
  • 예외 vs 사전확인: 호출자가 합리적으로 피할 수 있으면 그건 예외가 아니다.
  • 생성자·세터의 제약이 걸리면 팩토리 함수와 불변 필드로 갈아탄다 — 시그니처가 곧 문서다.