들어가기 전에

자바스크립트 개발자라면 알아야 할 33가지 개념 #20 자바스크립트 : 순수함수

순수 함수는 여러가지 목적을 위해서 필수적입니다. 함수형 프로그래밍, 믿을 수 있는 동시성 그리고 리액트 + 리덕스 앱. 하지만 "순수 함수"가 진짜 의미하는 것이 뭘까요?

우리는 다음 무료 강의 “Learn JavaScript with Eric Elliott”를 듣고 이 질문에 답을 해볼 것입니다.

순수 함수가 무엇인지 자세히 알아보기 전에, 함수를 더 자세히 알아보는 편이 나을 겁니다. 함수를 바라보는 다른 시각에 대해 설명할 건데 그 시각은 함수형 프로그래밍을 이해하는데 더욱 도움을 줄 겁니다.

함수란 무엇인가요?

함수인자(arguments) 라 불리는 것을 입력 값으로 받고 반환 값(return value) 이라 불리는 결과물을 생성해냅니다. 함수는 다음 목적을 위해 사용될 수 있습니다.

  • 맵핑(Mapping) : 주어진 입력 값을 기반으로 어떤 출력 값을 생성해냅니다. 말 그대로 어떠한 입력 값을 출력 값으로 맵핑 해줍니다.
  • 프로시져(Procedures) : 함수는 어떠한 일련의 과정을 수행하기 위해서 호출될 수 있습니다. 이 과정이 일반적으로 프로시져라고 알려져 있습니다. 그리고 이러한 스타일로 작성하는 프로그래밍은 프로시져형 프로그래밍(procedural programming) 으로 알려져 있습니다.
  • I/O : 시스템의 다른 부분과 통신하기 위해서 존재하는 함수들입니다. 이를테면 화면, 저장소, 시스템 로그, 네트워크 등이 있습니다.

맵핑

순수 함수는 맵핑에 관한 것입니다. 함수는 입력 인자 값을 반환 값으로 맵핑합니다. 이 말은 어떠한 입력 값이 있다면 출력 값이 존재한다는 것입니다. 한 함수는 입력 값을 받고 그에 상응하는 출력 값을 내보냅니다.

'Math.max()' 함수는 인자 값으로 숫자들을 받고 가장 큰 숫자를 반환합니다.

Math.max(2, 8, 5); // 8

이 예제에서는 2, 8, 5 가 입력 값으로 들어왔습니다. 이 값들이 함수로 넘어가는 값입니다.

'Math.max()' 는 함수입니다. 그리고 어떤 숫자들을 입력 값으로 받아서 가장 큰 값을 반환해줍니다. 위 경우에는 입력 값중 가장 큰 숫자는 8 이었습니다 그리고 8이 반환됐습니다.

함수는 컴퓨터 분야에서나 수학 분야에서나 정말 중요합니다. 함수는 우리가 데이터를 편리하게 처리할 수 있도록 도와줍니다. 좋은 프로그래머는 함수의 이름을 명확히 기재합니다. 그래서 우리가 코드를 봤을 때, 우린 함수의 이름을 보고 함수가 무슨 일을 하는지 이해할 수 있습니다.

수학도 함수가 있습니다. 그리고 수학의 함수도 자바스크립트의 함수랑 비슷하게 동작합니다. 아마 대수학에서 함수를 본 적이 있을 겁니다. 그 함수는 이렇게 생겼습니다.

f(x) = 2x

이 의미는 우리는 f라고 불리는 함수를 선언했고 x라는 인자를 받고 2로 곱한다는 뜻입니다.

이 함수를 사용하기 위해, 우리는 간단하게 x에 대한 값을 제공합니다.

f(2)

대수학에서, 이 의미는 쓰여진 것과 정확히 동일합니다.

4

그래서 어디든 f(2)4대신 쓸 수 있습니다.

이제 이 함수를 자바스크립트 버전의 함수로 변환해봅시다.

const double = x => x * 2;

console.log를 이용해 함수의 결과를 검사할 수 있습니다.

console.log( double(5) ); // 10

수학의 함수에서 f(2)4로 대신 쓸 수 있다고 했던 것을 기억하시나요? 이 경우에도 자바스크립트 엔진이 double(5)10이라는 숫자로 대체할 수 있습니다.

그래서, console.log(double(5));console.log(10);과 같습니다.

이건 사실입니다. 왜냐하면 double()은 순수 함수이기 때문입니다. 하지만 만일, double() 함수가 디스크에 값을 저장하거나 콘솔에 로깅하거나하는 side-effect를 갖고 있다면, 의미를 변경하지 않고 단순히 double(5)10으로 대체할 수 없습니다.

만일 관계적인 투명성을 원한다면, 순수 함수를 사용할 필요가 있습니다.

순수 함수

순수 함수는 다음의 특성을 지닌 함수입니다.

  • 같은 입력을 받았을 때, 같은 출력을 반환한다.
  • side-effect를 갖지 않는다.

함수가 순수하지 않다는 것의 명백한 증거는 그 함수가 반환 값을 사용하지 않으면서도 호출했을 때 올바른 동작이 가능한지에 달렸습니다. 순수 함수는 반환 값을 사용하지 않으면서는 올바른 동작을 할 수 없습니다.

실제로 순수 함수를 이용하여 구현할 수 있는 프로그램 요구사항이라면, 당신이 다른 옵션을 선택하기보단 순수 함수를 사용하는 것을 권장합니다. 순수 함수는 어떤 입력 값을 받고 그 입력 값을 기반으로 출력 값을 반환합니다. 순수함수는 프로그램 안에서 가장 간단한 재사용 가능한 빌딩 블록 입니다. 아마, 컴퓨터 사이언스에서 가장 중요한 디자인 원칙은 KISS (Keep It Simple, Stupid)입니다. 저는 KISS 원칙을 선호합니다. 순수 함수는 존재하는 방식 중 가장 간단하고 단순합니다.

순수함수는 이익(beneficial)이 되는 속성(properties)들을 많이 갖고 있습니다. 그리고 함수형 프로그래밍의 기반을 형성합니다. 순수 함수는 바깥 상태로부터 완벽히 독립적입니다. 그리고 공유된 변화하는 상태들에 관련된 클래스의 버그로부터 완전히 면역(immune)입니다. 순수함수의 독립적인 특성은 또한 순수함수를 많은 CPU들 사이, 그리고 과학적이고 리소스가 많이 드는 컴퓨팅 테스크를 위해 필수적인 분배된 컴퓨팅 클러스터들 사이에서 병렬 프로세싱을 수행하기 위한 좋은 후보지로 만들어줍니다.

순수 함수는 굉장히 독립성이 있습니다 움직이기 쉽고 리팩토링하기 쉽고 다시 재구성하기도 쉽습니다. 여러분의 프로그램을 더 유연하고 미래의 변화에 대해 어디든 적용 가능하게 해줍니다.

공유되는 상태의 문제점

몇년 전 저는 사용자가 데이터베이스에서 가수를 찾고 그 가수의 노래 목록을 웹 플레이어로 불러올 수 있도록 하는 앱을 작업한 적이 있습니다. 이 작업을 Google Instant가 정착했을 때 했습니다. Google Instant는 여러분이 검색 쿼리를 타이핑하면 즉시 검색 결과를 나타내주는 것입니다. AJAX로 동작(powered)하는 자동완성은 갑자기 엄청나게 유행했었죠.

유일한 문제는 사용자가 종종 API의 자동완성 검색 결과가 나오는 것보다 빠르게 타이핑을 끝마쳐버린다는 것이었죠.이러한 문제는 꽤 이상한 버그를 만들어냈습니다. 새로운 검색 제안(suggestion)을 오래된 검색 제안(suggestion)이 대체해버리는 경쟁 상태(race condition)을 유발했습니다.

왜 이러한 일이 발생했을까요? 왜냐하면 각각의 AJAX success 핸들러는 사용자에게 표기되는 검색 제안을 즉시 업데이트 할 접근 권한을 받았기 때문입니다. 가장 느린 AJAX 요청이 항상 빠르게 끝난 요청들과 대체되며 사용자의 관심을 끌었을 것입니다. 심지어 대체되기 전 결과가 더욱 새로운 검색결과였을 때도요.

이 문제를 해결하기 위해, 저는 제안 관리자를 만들었습니다. 쿼리의 상태를 관리하기 위한 싱글 소스입니다. 이 제안 매니저는 현재 불러오고 있는 AJAX 요청에 대해 알고 있고 유저가 새로운 무언가를 타이핑했을 때, 새로운 요청을 불러오기 전에 요청 중이던 AJAX 리퀘스트는 취소합니다. 그래서 단일 답변 핸들러만 UI 상태 업데이트를 변경할 수 있게 합니다.

어떤 종류의 비동기 연산 또는 동시작업이든 비슷한 종류의 레이스 컨디션(race condition)을 발생시킬 수 있습니다. 레이스 컨디션은 출력 값이 통제할 수 없는 이벤트(이를테면 네트워크, 디바이스 지연율, 유저 입력, 랜덤성...)의 순서에 의존할 때 발생합니다. 사실, 여러분이 공유된 상태를 사용 중이고 그 상태가 결정되지 않은 요인들에 의존하여 순서가 멋대로 분기될 수 있다면, 출력 값은 예측할 수 없습니다. 그리고 이 말은 즉, 올바르게 테스트될 수 없고 완전히 이해할 수도 없다는 것입니다. Martin Odersky(스칼라의 창시자)는 다음과 같이 말했습니다.

비결정론 = 병렬 처리 + 변할 수 있는 상태이다.

프로그램 결정론은 컴퓨팅에서 바람직한 속성입니다. 자바스크립트가 싱글스레드에서 돌기 때문에 괜찮고 병렬 처리 문제에 관해 생각할 필요가 없다고 생각할 수도 있지만 AJAX 예제가 보여주었듯, 자바스크립트 엔진이 싱글스레드라고 동시처리가 없다고 생각하시면 안됩니다. 반대로, 동시 처리의 많은 소스들이 자바스크립트 내부에 있습니다. API I/O, 이벤트 리스너, 웹 워커, Iframes, 그리고 timeouts는 여러분의 프로그램을 비결정론의 세계로 인도할 것입니다. 이러한 것에 공유된 상태를 합쳐보세요. 버그를 위한 레시피가 필요할 것입니다.

순수 함수는 이러한 버그를 피할 수 있도록 도와줍니다.

같은 입력값이 들어왔을 때, 항상 같은 출력이 나온다.

double() 메소드를 썼던 것과 같이, 우리는 결과 값에 결과 대신에 함수를 넣어줄 수 있습니다. 그래도 프로그램은 여전히 같은 의미를 갖게 될 것입니다. double(5)는 프로그램에서 언제나 10과 같은 의미를 갖습니다. 컨텍스트와 상관 없이, 얼마나 많이 호출하든지 상관없이요.

하지만 모든 함수에서 동일하게 적용된다고 할 순 없습니다. 몇몇 함수는 인자로 들어오는 값보다 어떤 특정한 정보에 의존합니다.

다음 예제를 생각해봅시다.

Math.random(); // => 0.4011148700956255
Math.random(); // => 0.8533405303023756
Math.random(); // => 0.3550692005082965

함수를 호출할 때 우리가 아무런 인자도 넘기지 않았음에도 불구하고, 이 함수들은 각각 다른 출력 값을 만들어냅니다. 이 사실이 의미하는 바는 Math.random() 함수는 순수하지 않다는 것입니다.

Math.random() 함수는 0과 1사이의 새로운 랜덤 넘버를 생성해냅니다. 정확히 말하자면, 0.4011148700956255라는 정확한 값을 Math.random() 이라는 함수로 대체할 수 없다는 이야기입니다.

위 함수는 매번 같은 결과를 만들어낼 것입니다. 우리가 컴퓨터에게 랜덤 넘버를 요청했을 때, 이것이 의미하는 바는 우리가 지난번에 얻었던 값과 다른 값을 원한다는 것입니다. 모든 면에 같은 숫자가 적혀진 한쌍의 주사위가 무슨 의미가 있을까요?

때때로 우리는 컴퓨터에게 현재 시간을 요청합니다. 우리는 time 함수가 어떻게 동작하는지에 대해 상세히 알아보진 않을 것입니다. 지금은, 다음 코드를 그냥 복사하세요.

const time = () => new Date().toLocaleTimeString();
time(); // => "5:15:45 PM"

만일 여러분이 time() 함수 호출 부분을 현재 시간으로 바꾸면 어떤 일이 일어날까요?

그렇다면 언제나 같은 시간이 나올 것입니다. 함수가 대체된 시점의 시간이 계속 나올 것입니다. 그렇게 되면, 만일, 함수가 대체된 시점에만 프로그램을 실행시키면 하루에 한 번은 맞는 출력 값을 생성할 수는 있습니다.

정확히 말하자면 time() 함수는 double() 함수와는 다릅니다.

함수가 같은 입력 값을 받고 같은 출력 값을 반환한다면 함수는 순수(pure)합니다. 여러분은 아마 대수학 수업에서 배웠던 이 규칙을 기억하실 것입니다 : 같은 입력 값은 항상 같은 출력 값으로 맵핑될 것입니다. 하지만, 많은 입력 값은 같은 출력 값으로 맵핑될 수도 있는겁니다. 예를 들면, 다음 함수는 순수 합니다.

const highpass = (cutoff, value) => value >= cutoff;

같은 입력 값은 항상 같은 출력 값으로 매핑될 것입니다.

highpass(5, 5); // => true
highpass(5, 5); // => true
highpass(5, 5); // => true

많은 입력 값은 같은 출력 값으로 맵핑될 수도 있습니다.

highpass(5, 123); // => true
highpass(5, 6); // => true
highpass(5, 18); // => true

highpass(5, 1); // => false
highpass(5, 3); // => false
highpass(5, 4); // => false

순수 함수는 절대 외부의 변환 가능한 값에 의존해선 안됩니다. 왜냐하면 그렇게 되면 더이상 결정론적이지 않거나 참조적으로 투명하지도 않기 때문입니다.

순수 함수는 어떠한 Side-effect도 만들지 않습니다.

순수 함수는 어떠한 Side-effect도 만들지 않습니다. 즉, 순수함수는 어떤 외부 상태도 변환하지 않는 다는 것을 의미합니다.

불변성

자바스크립트의 오브젝트는 레퍼런스들입니다. 이 의미는 만일 함수가 오브젝트나 배열 파라미터에 변화를 가하면, 함수 밖의 접근 가능한 상태를 변화시킨다는 이야기입니다. 순수 함수는 외부 상태를 변화시켜선 안됩니다.

다음 소스는 순수하지 않은 addToCart() 함수입니다.

// 비순수 addToCart 함수는 존재하는 cart를 변환시킨다.
const addToCart = (cart, item, quantity) => {
  cart.items.push({
    item,
    quantity
  });
  return cart;
};


test('addToCart()', assert => {
  const msg = 'addToCart() should add a new item to the cart.';
  const originalCart =     {
    items: []
  };
  const cart = addToCart(
    originalCart,
    {
      name: "Digital SLR Camera",
      price: '1495'
    },
    1
  );

  const expected = 1; // num items in cart
  const actual = cart.items.length;

  assert.equal(actual, expected, msg);

  assert.deepEqual(originalCart, cart, 'mutates original cart.');
  assert.end();
});

위의 코드는 카트와 카트에 추가할 아이템 그리고 아이템 개수(quantity)를 넘김으로써 작동합니다. 함수는 아이템이 추가된 같은 카트를 반환합니다.

위 소스코드의 문제는 우리가 어떤 공유된 상태를 변화시켰다는 것입니다. 다른 함수들이 함수가 수행되기 전 상태의 cart 오브젝트에 의존하고 있을 수도 있습니다. 우리가 어떤 함수가 호출되는지 순서를 바꾼다면, 이러한 변화가 프로그램 로직에 어떤 영향을 줄지에 대해서 걱정해보아야 합니다. 코드를 리팩토링하는 것은 버그를 만들어낼 수 있고 순서를 꼬아버릴 수 있습니다. 이러한 일이 일어났을 때 물론 고객은 행복하지 못하겠죠.

다음 순수함수 버전을 생각해봅시다.

// 순수한 addToCart() 함수는 새로운 카트를 반환합니다.
// 기존의 오브젝트를 변환하지 않습니다.
const addToCart = (cart, item, quantity) => {
  const newCart = lodash.cloneDeep(cart);

  newCart.items.push({
    item,
    quantity
  });
  return newCart;

};


test('addToCart()', assert => {
  const msg = 'addToCart() should add a new item to the cart.';
  const originalCart = {
    items: []
  };

  // deep-freeze on npm
  // throws an error if original is mutated
  deepFreeze(originalCart);

  const cart = addToCart(
    originalCart,
    {
      name: "Digital SLR Camera",
      price: '1495'
    },
    1
  );


  const expected = 1; // num items in cart
  const actual = cart.items.length;

  assert.equal(actual, expected, msg);

  assert.notDeepEqual(originalCart, cart,
    'should not mutate original cart.');
  assert.end();
});

이 예제에서, 우리는 오브젝트에 중첩된 배열을 갖고 있습니다. 그래서 deep clone을 이용하여 복사하였습니다. 이러한 상황은 일반적으로 여러분이 다룰 상황보다 더욱 복잡한 상태입니다. 여러분은 이걸 더욱 작은 조각으로 떼어낼 수 있습니다.

예를 들면, 리덕스는 각각 리듀서 안에 들어있는 전체 앱 상태를 다루기보다 리듀서를 구성합니다. 결과는 매번 작은 부분을 업데이트하고 싶을 때마다 여러분이 전체 앱의 deep clone을 만들 필요가 없다는 것입니다.대신, 앱 상태의 작은 부분을 업데이트 하기 위해 파괴적이지 않은 배열 메소드나 Object.assign()을 사용할 수 있습니다.

당신의 차례입니다. 코드 pen을 fork하고 순수하지 않은 함수를 순수함수로 만들어보세요. unit tests들을 테스트의 수정 없이 통과하게 만들어보세요.