나머지 매개변수와 전개: JavaScript & TypeScript에서 ...의 두 얼굴

최근 코드베이스의 라우터를 리팩토링하다가 이 작은 헬퍼를 마주쳤습니다:

export function docsPath(...segments: string[]): string {
  const path = segments.filter(Boolean).join("/");
  return path ? `${DOCS_BASE}/${path}` : DOCS_BASE;
}

제 눈을 멈추게 한 부분은 ...segments였습니다. 객체를 복사하거나({ ...props }) 배열을 복사할 때([...items]) ... 연산자를 수백 번 봐왔지만, 정작 함수의 매개변수 목록 안에서 이게 무슨 일을 하는지는 한 번도 제대로 생각해본 적이 없었습니다. 알고 보니 ...는 놓이는 위치에 따라 두 가지 다른 역할을 합니다 — 그리고 이 차이를 이해하면 일상적으로 마주치는 많은 코드가 한순간에 이해됩니다.

나머지 매개변수가 해결하는 문제

URL 세그먼트들을 하나의 경로로 합치는 함수를 만든다고 해봅시다. 호출하는 쪽에서 세그먼트를 몇 개나 넘길까요? 알 수 없습니다 — 어떤 때는 하나, 어떤 때는 셋, 어떤 때는 아예 없습니다:

docsPath("guides");                 // "/docs/guides"
docsPath("guides", "my-slug");      // "/docs/guides/my-slug"
docsPath("customers", "acme", "x"); // "/docs/customers/acme/x"
docsPath();                         // "/docs"

나머지 매개변수가 등장하기 전에는, 다루기 번거롭고 진짜 배열도 아닌 마법 같은 arguments 객체에 의존해야 했습니다:

function docsPath() {
  // `arguments`는 배열과 비슷하지만, .filter나 .join을 직접 호출할 수 없습니다
  const segments = Array.prototype.slice.call(arguments);
  // ...
}

나머지 매개변수는 이 모든 것을 깔끔한 문법 하나로 대체합니다.

...segments가 실제로 의미하는 것

...함수의 매개변수 목록에 나타나면, 그것은 나머지 매개변수(rest parameter) 입니다. 이렇게 말하는 셈입니다:

"호출자가 넘기는 인자가 몇 개든 전부 segments라는 하나의 진짜 배열로 모아라."

function docsPath(...segments: string[]) {
  // segments는 진짜 Array<string>입니다
}

docsPath("a", "b", "c"); // segments === ["a", "b", "c"]
docsPath();              // segments === []

segments가 진짜 배열이기 때문에, 모든 배열 메서드를 공짜로 쓸 수 있습니다 — 헬퍼가 쓰는 게 바로 그것이죠:

const path = segments.filter(Boolean).join("/");

기억해둘 만한 몇 가지 규칙:

  • 함수는 나머지 매개변수를 단 하나만 가질 수 있습니다.

  • 매개변수 목록의 맨 마지막에 와야 합니다 ("나머지"를 쓸어 담으니까요).

  • 일반 매개변수와 조합할 수 있습니다:

function logEvent(level: string, ...messages: string[]) {
  console.log(`[${level}]`, ...messages);
}

logEvent("info", "user", "logged", "in");
// level === "info"
// messages === ["user", "logged", "in"]

헬퍼를 단계별로 따라가기

DOCS_BASE = "/docs"라고 가정하고 docsPath를 추적해봅시다:

docsPath("guides", "my-slug");
// 1. segments = ["guides", "my-slug"]   ← 나머지 매개변수가 인자를 모음
// 2. .filter(Boolean)  → ["guides", "my-slug"]   ("", undefined, null 제거)
// 3. .join("/")        → "guides/my-slug"
// 4. path가 truthy     → "/docs/guides/my-slug"

docsPath();
// 1. segments = []
// 2. .filter(Boolean) → []
// 3. .join("/")       → ""
// 4. path가 falsy     → "/docs"   (base만 반환)

.filter(Boolean) 단계는 멋진 방어 장치입니다. 호출자가 조건부이거나 비어 있을 수 있는 세그먼트를 넘겨도, 보기 흉한 이중 슬래시를 만들지 않게 해줍니다:

const slug = maybeUndefined();        // undefined나 ""일 수 있음
docsPath("guides", slug);             // 여전히 "/docs/guides", 절대 "/docs/guides/"가 아님

(Boolean을 콜백으로 쓰면 모든 falsy 값을 제거합니다: "", undefined, null, 0, NaN, false.)

또 다른 얼굴: 전개

여기서 반전이 있습니다. 똑같은 ... 토큰이 함수 호출이나 배열/객체 리터럴에 나타나면 정반대 일을 합니다. 거기서는 전개 연산자(spread operator) 가 됩니다 — 기존의 이터러블을 받아 개별 요소들로 펼칩니다.

const parts = ["customers", "acme"];

docsPath(...parts);
// docsPath("customers", "acme")와 동일

그러니까:

  • Rest (매개변수 목록에서): 여러 값을 배열 안으로 모읍니다.

  • Spread (호출 / 리터럴에서): 배열을 여러 값으로 흩뿌립니다.

둘은 거울상입니다. 가장 분명하게 보는 방법은 둘을 함께 쓰는 것입니다:

function docsPath(...segments: string[]) {   // rest: 모으기
  return "/docs/" + segments.join("/");
}

const parts = ["a", "b", "c"];
docsPath(...parts);                           // spread: 흩뿌리기

전개는 다른 여러 곳에서도 등장합니다:

// 배열 복사 / 병합
const merged = [...arr1, ...arr2];

// 객체 복사 / 병합
const updated = { ...user, name: "New Name" };

// 개별 인자가 기대되는 곳에 배열 넘기기
Math.max(...[3, 1, 4, 1, 5]); // 5

빠른 멘탈 모델

딱 하나만 기억해야 한다면, ...어디에 있는지를 기억하세요:

위치이름하는 일함수 매개변수 목록Rest인자들을 배열 안으로 모음함수 호출Spread배열을 인자들 펼침배열 리터럴 [...]Spread요소들을 새 배열에 펼쳐 넣음객체 리터럴 {...}Spread속성들을 새 객체에 펼쳐 넣음

같은 문법, 반대 방향 — 모으기 vs 흩뿌리기.

TypeScript 노트

TypeScript에서는 나머지 매개변수를 배열로 타입 지정하며, 모인 각 인자는 요소 타입과 일치해야 합니다:

function docsPath(...segments: string[]): string { /* ... */ }

docsPath("a", "b");   // ✅
docsPath("a", 42);    // ❌ 'number' 타입의 인자는 'string' 타입에 할당할 수 없습니다

심지어 튜플 타입으로 이질적인 나머지 매개변수를 타입 지정할 수도 있는데, 이것이 타입이 지정된 이벤트 이미터나 console.log 스타일 시그니처가 만들어지는 방식입니다:

function emit(event: string, ...args: [id: number, ok: boolean]) {}

emit("done", 1, true);  // ✅
emit("done", 1);        // ❌ boolean이 빠짐

정리

  • 매개변수 목록 안의 ...이름나머지 매개변수입니다 — "임의 개수의 인자"를 filter, map, join을 쓸 수 있는 진짜 배열로 바꿔줍니다.

  • 호출이나 리터럴 안의 ...값전개 연산자입니다 — 배열/객체를 개별 조각들로 펼칩니다.

  • 둘은 서로 역(逆)입니다: rest는 모으고, spread는 흩뿌립니다.

  • 나머지 매개변수는 옛 arguments 객체를 대체하는 현대적이고 타입 안전한 방법이며, docsPath 같은 작은 유틸리티를 깔끔하고 유연하게 만들어줍니다.

아주 작은 문법이지만, ...의 두 얼굴을 한번 보고 나면 관용적인 JavaScript와 TypeScript 코드의 놀랄 만큼 많은 부분이 더 이상 마법처럼 보이지 않게 됩니다.