막간: 자바스크립트 파이프 연산자에 대해 다시 생각해보기


원문 : https://www.wix.engineering/post/interlude-rethinking-the-javascript-pipeline-operator

원문은 Wix Engineering의 Dan Shappir가 작성했다.

(Unsplash에서 Martin Adams의 사진)

최근 필자가 쓴 2개의 블로그 글에서 간단한 자바스크립트 이터레이션(iteration) 라이브러리를 구현했었다. 첫 번째 글에서는 이 라이브러리가 모든 이터러블 컬렉션(iterable collection)에서 동작할 방법에 대해 설명했었고, 두 번째 글에서는 비동기 순서의 이터레이팅을 지원하는 방법을 소개하며 다음 단계로 넘어갔었다. 제안 상태의 파이프 연산자(proposed pipeline operator) 사용법을 제외하고는 두 게시물 모두 아주 좋은 반응을 얻었다.

제안 상태의 파이프 연산자가 익숙하지 않은 사람들을 위해 설명을 하자면, 현재 파이프 연산자는 TC39(ESCMScript 표준을 담당하는 위원회)에서 1단계: 제안("stage 1" proposal) 상태에 있다. 이는 파이프 연산자가 활발히 논의되고는 있지만, 표준에 포함될지 알 수 없는 단계에 있다는 것을 의미한다. 다시 말해, 표준에 파이프 연산자는 없다.

파이프 연산자가 ECMAScript 표준도 아니고 영원히 사용되지 않을 수도 있는데, 필자가 글에서 사용하기로 했던 이유는 무엇이었을까? 쉬운 사용 방법과 가독성 때문이다. 필자가 제시한 라이브러리는 간단한 이터레이션 함수들로 구성되어 있는데, 정교한 데이터 흐름과 알고리즘을 구현할 수 있게 되어 있다. 자바스크립트에서 함수를 구성하는 방식 때문에, 이 라이브러리는 코드를 작성하고 쓰기 어렵게 만들 수 있었다. 예를 들어, 컬렉션에서 값을 필터링하고 맵핑하고 추출하는 코드를 라이브러리를 사용해 다음과 같이 구현할 수 있는데,

const result = Array.from(
  slice(
    map(
      filter(numbers, (v) => v % 2 === 0),
      (v) => v + 1
    ),
    0,
    3
  )
);

보다시피 함수들이 사용하려는 순서와 반대로 작성되어 있다. 또한 어떤 화살표 함수가 이터레이션 함수와 연결되어 있는지 파악하기가 어렵다.

이런 문제들을 해결하기 위해 파이프 연산자가 특별히 설계되었다.

const result =
  numbers |> filter(#, (v) => v % 2 === 0) |> map(#, (v) => v + 1) |> slice(#, 0, 3) |> Array.from;

이 코드는 함수가 사용되는 순서대로 나타나기 때문에 따라가면서 추론하기가 아주 쉽다. 또한 인자의 플레이스홀더로 #을 사용해 모든 인자가 함께 배치된다. 가상의 함수 스택을 상상하거나 여러 함수 표현식에서 괄호를 매칭시키려고 노력할 필요도 없다.

커뮤니티의 반대 반응

그렇다면 이 제안 상태의 기능을 사용하는 데 반대하는 반응이 있었던 이유는 무엇이었을까? 한 가지 이유는 일부 사람들이 ECMAScript 표준이나 TC39 프로세스 3단계에 아직 포함되지 않은 언어 기능을 사용하는걸 싫어하기 때문이다. 필자가 인정할 수 있었던 것은 개발자들이 TC39에 의해 롤백되고 수정될 수 있는 기능들을 사용할 수 있다는 점이었다. 이러한 예는 데코레이터 제안에서 일어났다. 데코레이터 언어 기능 구현을 위한 2개의 경쟁 제안(competing proposals)으로 인해, 파이프 연산자가 데코레이터 컨텍스트 안에서 사용되는 상황은 특히 심오했다.

하지만 필자가 받은 답변과 몇 가지 논의를 바탕으로 보았을 때 거부 반응의 주된 이유는, 많은 자바스크립트 개발자들이 파이프 연산자 전부를 그냥 싫어하는 것이었다. 상당히 그랬다. 예를 들어, 필자가 처음 올린 글에 영감을 받아 Costin Manda는 블로그 글과 라이브러리를 작성하기도 했고, 글에서 그는 다음과 같이 썼다.

이 글에서 이터레이터와 제너레이터 함수의 최신 자바스크립트 기능을 사용하고 표준 Array 함수를 대체하는 함수를 생성하는 방법을 제안했다. 이건 정말 최고였다!

그리고서 이 글이 갑자기 방향을 바꿔버렸다. 저자는 함수형 프로그래밍 언어가 그런 것처럼 자바스크립트에서도 파이프 연산자를 추가해, 해시 문자가 있는 정적 함수를 플레이스홀더로 사용하도록 제안하고... 으악! 이건 끔찍했어!

여기서 느낀 감정을 확실히 이해할 수 있었다. 필자 역시 기존 구문과 의미에서 벗어난 것처럼 보이는 언어 확장을 좋아하지는 않는다. 즉, Costin이 사용한 파이프 연산자의 대안(도트 연산자를 사용한 메서드 체이닝 방식)은 사실 체이닝보다 파이프의 이점을 강조한다. 사실 이것은 필자가 처음 올린 글의 제목에서 암시한 것이다. "파이프로 체인 끊기"

Kyle Simpson의 대안

언어를 확장할 필요 없이 파이프 연산자의 모든 이점을 제공할 수 있는 대안을 제시한 사람은 필자의 좋은 친구이자 자바스크립트 지도자인 Kyle Simpson (@getify)이었다. 아주 좋게도 이 대안은 트윗에 딱 맞았다.

Kyle Simpson 트위터 캡처

Kyle이 제안한 pipe() 함수는 임의의 함수 참조를 인자로 받는 고차 함수이면서 어떤 인자를 받던 순서대로 인자 함수들을 실행하는 함수를 반환한다.

사용 사례에 맞게 다음과 같이 간단한 pipe() 함수를 구현하기로 했다.

// 역자주: Kyle Simpson의 코드
// function pipe(...fns) {
//   return arg => fns.reduce((v,fn) => fn(v),arg);
// }

function pipe(arg, ...fns) {
  return fns.reduce((v, fn) => fn(v), arg);
}

함수를 인자로 받고 입력된 인자를 받는 다른 함수를 반환하는 대신, 함수와 인자를 함께 입력받게 하고 인자를 첫 번째로 받도록 구현했다. 이렇게 하면 Kyle의 코드에서 함수 표현식 끝이 아닌 그 앞에서 인자를 바로 넘길 수 있고 괄호도 제거할 수 있다(역자주: 체이닝 함수가 아닌 pipe 함수에서 바로 인자를 넘길 수 있다). 그 결과 파이프 연산자를 사용하는 구현 방법과 유사한 코드가 만들어진다.

// 역자주: Kyle Simpson의 코드
// var res = pipe(
//   filter(x => x % 2 == 0),
//   map(x => x + 1),
//   slice(0,3),
//   Array.from
// )
// (numbers);

const result = pipe(
  numbers,
  filter((v) => v % 2 == 0),
  map((v) => v + 1),
  slice(0, 3),
  Array.from
);

이 pipe() 함수 구현을 위해 원문에서 보여줬던 filter(), map(), slice() 함수를 수정해야 한다. 예를 들어 filter() 함수는 다음과 같이 구현되어 있었다.

function* filter(src, op) {
  for (const value of src) {
    if (op(value)) {
      yield value;
    }
  }
}

새로 구현한 코드는 이렇다.

function filter(op) {
  return function* (src) {
    for (const value of src) {
      if (op(value)) {
        yield value;
      }
    }
  };
}

파이프 연산자의 일부분으로 소개했던 # 인자 플레이스홀더 때문에 수정했고, 이 #은 더 이상 사용할 수 없다. 대신 함수는 연산자만 받고 호출되는 함수를 순서대로 반환한다. 이것은 언어를 확장하지 않도록 만드는 데 가장 적은 비용이 드는 방식이라고 생각한다. 필자가 보여줬던 다른 함수들을 수정하는 건 당신의 몫에 맡긴다.

비동기성 추가

두 번째 블로그 글에서는 브라우저 DOM 요소에 의해 생성된 이벤트와 같은 비동기 순서를 지원하도록 이터레이션 라이브러리를 확장할 방법에 대해 소개했었다. 그리고 파이프 연산자는 다음과 같은 순서로도 동작할 수 있었다.

const myButton = document.getElementById('myButton');

fromEvent(myButton, 'click')
  |> reduce(#, (acc) => acc + 1, 0)
  |> forEach(#, (count) => console.log(count));

마찬가지로 위에서 본 pipe() 함수도 비동기 순서로 동작할 수 있지만, 이터레이션 함수가 조금 수정되어야 한다. 예를 들어, forEach() 함수는 다음과 같이 구현되어 있었는데,

async function forEach(src, op) {
  let index = 0;
  for await (const value of src) {
    if (op(value, index++) === false) {
      break;
    }
  }
}

지금은 이렇게 수정되었다.

function forEach(op) {
  return async function (src) {
    let index = 0;
    for await (const value of src) {
      if (op(value, index++) === false) {
        break;
      }
    }
  };
}

우리는 이제 DOM 이벤트를 다음과 같이 pipe() 함수를 사용해 다시 작성할 수 있다.

const myButton = document.getElementById('myButton');

pipe(
  fromEvent(myButton, 'click'),
  reduce((acc) => acc + 1, 0),
  forEach((count) => console.log(count))
);

다른 이터레이션 함수뿐만 아니라 reduce() 함수를 수정하는 것도 늘 그렇듯 당신에게 맡기겠다. 아니면 CodePen에서 직접 구현한 함수들과 사용 예제를 확인할 수 있다.

요약

언어 명세가 관련된 컨텍스트 안에서 충분히 표현 가능하다면, 보통은 프로그래밍 언어 확장을 피하는 것이 좋다. 제안 상태의 확장이 프로그래밍 언어의 구문과 의미에서 벗어난 경우에는 특히 더 그렇다. 이 글에서 보았듯이, 필자는 이제 파이프 연산자가 이런 경우에 해당한다고 확신한다. 그래서 TC39가 파이프 연산자 제안을 승인하지 않기를 바란다. 어쨌든 파이프 연산자 사용을 제거하면 결과 코드가 최신 브라우저와 호환된다. 이 글과 관련된 CodePen을 확인하면, 바벨 사용이 비활성화된 것을 볼 수 있다. 이것은 라이브러리를 더 유용하고 유용하게 만든다.

류선임2020.11.18
Back to list