Eric Elliott님의 게시글 Master the JavaScript Interview: What is Functional Programming?(CC BY 2.0) 전문을 번역했습니다.


함수형 프로그래밍은 자바스크립트 세계에서 핫한 주제가 되었습니다. 불과 몇 년 전만해도 자바스크립트 프로그래머는 함수형 프로그래밍이 무엇인지 알지 못했지만, 지난 3년 동안 살펴본 모든 대형 애플리케이션 코드베이스에 함수형 프로그래밍 아이디어가 많이 사용되었습니다.

함수형 프로그래밍(종종 줄여서 FP라고 부름)은 순수 함수(pure function) 를 조합하고 공유 상태(shared state), 변경 가능한 데이터(mutable data)부작용(side-effects) 을 피하여 소프트웨어를 만드는 프로세스입니다. 함수형 프로그래밍은 명령형(imperative) 이 아닌 선언형(declarative) 이며 애플리케이션의 상태는 순수 함수를 통해 전달됩니다. 애플리케이션의 상태가 일반적으로 공유되고 객체의 메서드와 함께 배치되는 객체 지향 프로그래밍과는 대조됩니다.

함수형 프로그래밍은 위에서 정의된 기본 원칙들을 기반으로 소프트웨어를 구성하는 프로그래밍 패러다임 입니다. 프로그래밍 패러다임의 다른 예로는 객체 지향 프로그래밍과 절차 프로그래밍이 있습니다.

함수형 코드는 명령형이나 객체 지향 코드보다 더 간결하고 예측 가능하며 테스트하기 쉽습니다. 하지만 이에 익숙치 않다면 함수형 코드는 훨씬 더 복잡해 보일 수 있고, 문맥과 관련해서는 입문자가 이해하기 어려울 수 있습니다.

함수형 프로그래밍 용어들을 구글링해보면, 초급자에게 어렵게 느껴질 수 있는 용어들을 좀 더 빠르게 배울 수 있습니다. 학습곡선이 있다고 말하는 것은 심각한 과소평가입니다. 그러나 조금이라도 자바스크립트로 프로그래밍을 해왔다면, 당신이 개발한 소프트웨어에 함수형 프로그래밍 개념과 방법을 사용했을 가능성이 큽니다.

새로운 단어를 보고 겁먹지 마세요. 보기보다 쉽습니다.

가장 힘든 것은 익숙치 않은 단어입니다. 위의 순수해 보이는 정의에는 함수형 프로그래밍의 의미를 파악하기 전에 이해해야 할 것들이 있다는 의미가 있습니다.

  • 순수 함수 Pure functions
  • 합성 함수 Function composition
  • 공유 상태를 피하라 Avoid shared state
  • 상태 변화를 피하라 Avoid mutating state
  • 부작용을 피하라 Avoid side effects

즉, 실제로 함수형 프로그래밍이 의미하는 바를 알고 싶다면, 이러한 핵심 개념들을 먼저 이해해야 합니다.

순수 함수 는 다음과 같은 함수입니다.

  • 같은 입력이 주어지면, 항상 같은 출력을 반환하고,
  • 부작용이 없다.

순수 함수는 참조 투명성(referential transparency) (프로그램의 의미를 변경하지 않고 결과 값으로 함수 호출을 대체할 수 있음)을 포함하여 함수형 프로그래밍에서 중요한 몇가지 특성을 가지고 있습니다. 자세한 내용은 “What is a Pure Function?”“를 읽으십시오.

합성 함수 는 새로움 함수를 만들거나 계산하기 위해 둘 이상의 함수를 조합하는 과정입니다. 예를 들어, 합성 f . g(점은 “합성됨”을 의미)은 JavaScript에서 f(g(x))와 동일합니다. 합성 함수에 대한 이해는 함수형 프로그래밍을 이용하여 소프트웨어를 구성하는 방법을 이해하는 중요한 단계입니다. 자세한 내용은 “What is Function Composition?”“을 읽어 보세요.

공유 상태(Shared State)

공유 상태 는 공유 범위(shared scope) 내에 있는 변수, 객체 또는 메모리 공간이거나 범위 간에 전달되는 객체의 속성입니다. 공유 범위에는 전역 범위 또는 클로저가 포함될 수 있습니다. 종종 객체 지향 프로그래밍에서 객체는 다른 객체에 속성을 추가하여 해당 범위에서 공유됩니다.

예를 들어, 컴퓨터 게임에 캐릭터와 게임 아이템이 해당 객체가 소유한 속성으로 저장된 마스터 게임 객체가 있을 것입니다. 함수형 프로그래밍은 공유된 상태를 피합니다. 대신 불변의 데이터 구조(immutable data structures)와 순수한 계산을 통해 기존 데이터로부터 새로운 데이터를 만들어 냅니다. 함수형 소프트웨어가 애플리케이션 상태를 다루는 방법에 대한 자세한 내용은 “10 Tips for Better Redux Architecture”을 참조하세요.

공유 상태의 문제점은 해당 함수가 사용하거나 영향을 미치는 모든 공유 변수의 히스토리를 알아야 한다는 것입니다.

저장해야 하는 유저 객체가 있다고 가정해 보십시오. saveUser() 함수는 서버의 API에 요청합니다. 이러한 상황이 발생하는 동안 사용자는 updateAvatar()를 사용하여 프로필 사진을 변경하고, 다른 saveUser() 요청을 트리거합니다. 저장할 때 서버는 다른 API 호출에 대한 응답이나 서버에서 발생하는 변경 사항을 동기화하기 위해 메모리에 있는 내용을 대체해야 하는 표준 유저 객체를 다시 보냅니다.

불행하게도 첫 번째 응답 전에 두 번째 응답이 수신되므로 첫 번째 응답(현재로서 과거의 것)이 반환되면, 새 프로필이 메모리에서 지워지고 이전의 것으로 대체됩니다. 이는 공유 상태와 관련된 일반적인 버그인 경쟁 조건(race condition)의 예입니다.

공유 상태와 관련된 또 다른 문제는 함수 호출 순서를 변경하면 공유 상태에서 작동하는 함수가 타이밍에 종속적이기 때문에 함수 호출이 실패할 수 있다는 것입니다.

// 공유 상태에서는 함수 호출 순서에 따라 함수 호출의 결과가 변경됩니다.
const x = {
  val: 2
};

const x1 = () => x.val += 1;
const x2 = () => x.val *= 2;

x1();
x2();

console.log(x.val); // 6


// 이 예제는 위와 동일합니다. 단지,
const y = {
  val: 2
};

const y1 = () => y.val += 1;
const y2 = () => y.val *= 2;

// 함수 호출 순서가 뒤집어졌고,
y2();
y1();

// 결과값이 변했습니다.
console.log(y.val); // 5

공유 상태를 피하게 되면, 함수 호출의 타이밍과 순서는 함수 호출의 결과를 변경하지 않습니다. 순수 함수를 사용하면 동일한 입력에 대해 항상 동일한 출력을 얻을 수 있습니다. 이렇게 하면 해당 함수를 다른 함수 호출과 완전히 독립적으로 호출할 수 있으므로 변경과 리팩토링을 근본적으로 단순화할 수 있습니다. 하나의 함수에 변경이 있거나, 호출 타이밍이 변경되어도 프로그램의 다른 부분을 깨뜨리지 않습니다.

const x = {
  val: 2
};

const x1 = x => Object.assign({}, x, { val: x.val + 1 });
const x2 = x => Object.assign({}, x, { val: x.val * 2 });

console.log(x1(x2(x)).val); // 5


const y = {
  val: 2
};

// 외부 변수에 대한 의존성이 없으므로
// 다른 변수에서 작동하는 데 다른 함수가 필요하지 않습니다.

// 이 부분은 의도적으로 비워뒀습니다.


// 함수가 변경되지 않기 때문에,
// 다른 함수 호출의 결과를 변경하지 않고,
// 함수를 원하는 만큼, 원하는 순서대로 호출 할 수 있습니다.
x2(y);
x1(y);

console.log(x1(x2(y)).val); //5

위의 예제에서 우리는 Object.assign()을 사용하여 빈 객체를 첫 번째 매개 변수로서 전달하여 x의 속성을 그대로 변경하지 않고 복사합니다. 이는 Object.assign()을 사용하지 않고 처음부터 새로운 객체를 생성하는 것과 동일한 방법입니다. 하지만 첫 번째 예제에서 보여준 것처럼 변경하는 대신에 기존 상태를 복사하여 새로 만드는 것이 자바스크립트에서 일반적인 패턴입니다.

이 예제에서 console.log() 문을 자세히 살펴보면, 제가 언급한 합성 함수(function composition)에 주목해야 합니다. 이전에 얘기했던 것을 다시 말하자면, 합성 함수는 다음과 같습니다: f(g(x)). 이 경우, f()g()x1()x2()로 바꿉니다: x1 . x2.

물론, 합성의 순서를 변경하면 출력이 변경됩니다. 실행 순서는 여전히 중요합니다. f(g(x))는 항상 g(f(x))와 동일하지 않습니다. 하지만, 더 이상 중요하지 않은 것은 바로 함수 밖의 변수에 일어나는 일들입니다. 순수하지 않은 함수를 사용하면, 함수가 사용하거나 영향을 미치는 모든 변수의 히스토리를 알지 않는 이상, 함수의 기능을 완전히 이해하는 것은 불가능합니다.

함수 호출 타이밍의 종속성을 제거하면, 전체 클래스의 잠재적 버그를 제거할 수 있습니다.

불변성(Immutability)

변경할 수 없는 객체란 객체를 생성한 후에 수정할 수 없는 객체입니다. 반대로, 변경 가능한 객체는 생성된 후에 수정할 수 있는 객체입니다.

불변성은 함수형 프로그래밍의 핵심 개념입니다. 불변성을 빼놓고 보면 프로그램의 데이터 흐름이 손실되기 때문입니다. 상태 히스토리는 없어지고 이상한 버그가 소프트웨어에 끼어들 수 있습니다. 불변의 중요성에 대한 자세한 내용은 “The Dao of Immutability.”를 참조하세요.

자바스크립트에서는 불변성과 const를 혼동하지 않는 것이 중요합니다. const는 생성 후 재할당 할 수 없는 변수 이름 바인딩을 만듭니다. const는 불변 객체를 만들지 않습니다. 바인딩이 참조하는 객체를 변경할 수는 없지만, 여전히 객체의 속성을 변경할 수 있습니다. 즉, const로 작성된 바인딩은 불변이 아니고, 변경할 수 있다는 말입니다.

불변 객체는 절대 변경될 수 없습니다. 객체를 완전히 동결시킴으로써 진정한 불변의 값으로 만들 수 있습니다. 자바스크립트에서는 객체를 한 단계 깊게 동결시키는 메서드가 있습니다.

const a = Object.freeze({
  foo: 'Hello',
  bar: 'world',
  baz: '!'
});

a.foo = 'Goodbye';  // 객체의 'foo' 속성은 읽기 전용이므로 변경할 수 없습니다.

하지만 동결된 객체는 표면적으로만 변경할 수 없습니다. 예를 들어, 다음 객체는 변경할 수 있습니다.

const a = Object.freeze({
  foo: { greeting: 'Hello' },
  bar: 'world',
  baz: '!'
});

a.foo.greeting = 'Goodbye';

console.log(`${a.foo.greeting}, ${a.bar}${a.baz}`); // Goodbye, world!

보시다시피, 동결된 객체의 최상위 원시 속성은 변경할 수 없지만, 객체(배열 등을 포함하여)일 수 있는 모든 속성은 여전히 변경될 수 있습니다. 따라서 동결된 객체의 전체 객체 tree로 방문하여 모든 객체 속성을 고정시키지 않는 한, 변경 불가능하게 만들 수 없습니다.

많은 함수형 프로그래밍 언어에는 트리 자료 구조(trie data structures)(“트리”라고 발음)라고 불리는 특수한 불변의 자료 구조가 있습니다. 이는 객체의 계층에서 속성의 수준에 관계없이 속성이 변경될 수 없음을 의미합니다.

Trie는 연산자에 의해 복사된 후 변경되지 않은 객체의 모든 부분에 대한 메모리 위치를 공유하기 위해 구조적인 공유(structural sharing) 를 사용합니다. 이는 메모리를 덜 사용하고, 일부 작업에 대해 상당한 성능 향상을 가능하게 합니다.

예를 들어, 비교를 위해 객체 tree의 루트에서 Id 비교를 사용할 수 있습니다. Id가 동일하면 차이점을 확인하기 위해 tree 전체를 방문할 필요가 없습니다.

자바스크립트에는 Immutable.jsMori를 포함한 trie의 장점을 가진 여러 라이브러리가 있습니다.

필자는 두 가지 모두를 실험해봤고, 불변 상태를 필요로 하는 대규모 프로젝트에서 Immutable.js를 사용하는 경향이 있습니다. 자세한 내용은 ““10 Tips for Better Redux Architecture”를 참조하십시오.

부작용(Side Effects)

부작용은 반환값 이외에 호출된 함수 밖에서 관찰할 수 있는 애플리케이션 상태 변화입니다.

  • 외부 변수 또는 객체 속성 수정(예: 전역 변수나 상위 함수 스코프 체인의 변수)
  • 콘솔에서 로깅
  • 화면에 쓰기 작업
  • 파일에 쓰기 작업
  • 네트워크에 쓰기 작업
  • 외부 프로세스를 트리거
  • 부작용을 동반한 다른 함수 호출

부작용은 함수형 프로그래밍에서 대부분 피하게 되는데, 이는 프로그램을 더 쉽게 이해하고 더 쉽게 테스트하는 효과를 가져다 줍니다.

Haskell과 다른 함수형 언어는 자주 monads를 사용하여 순수 함수로부터 부작용을 격리하고 캡슐화합니다. 모나드의 주제는 책을 쓸 수 있을 정도로 깊기 때문에, 나중을 기약하겠습니다.

지금 당장 알아야 할 것은 부작용을 소프트웨어에서 분리해야 한다는 것입니다. 부작용을 프로그램 로직과 분리하면 소프트웨어를 확장하고, 리팩토링, 디버그, 테스트 및 유지 보수가 훨씬 쉬워집니다.

이는 대부분의 프론트-엔드 프레임워크가 사용자가 느슨하게 결합된 모듈에서 상태 및 컴포넌트 렌더링을 관리하도록 유도하는 이유입니다.

고차함수를 통한 재사용성(Reusability Through Higher Order Functions)

함수형 프로그래밍은 데이터를 처리하기 위해 함수형 유틸리티를 재사용하는 경향이 있습니다. 객체 지향 프로그래밍은 하나의 객체 안에 메서드와 데이터를 배치합니다. 같은 곳에 배치된 메서드는 특정 타입의 데이터만 조작할 수 있도록 설계 되었고, 특정 객체 인스턴스에 포함된 데이터에 대해서만 조작할 수 있습니다.

함수형 프로그래밍에서 거의 모든 유형의 데이터는 공정한 게임입니다. 동일한 map() 유틸리티는 주어진 데이터 타입을 적절하게 처리하는 인수로서 함수를 사용하기 때문에 객체, 문자열, 숫자 또는 기타 데이터 타입을 맵핑할 수 있습니다. FP는 고차 함수 를 사용하여 일반 유틸리티 속임수를 사용합니다.

자바스크립트는 함수를 데이터로 취급할 수 있는 일급 함수(first class functions) 를 가지고 있습니다. 이는 변수에 할당하고, 다른 함수로 전달하고, 함수에서 반환될 수 있습니다.

고차 함수 는 함수를 인수로 취급하거나, 함수를 반환하거나 또는 둘 다인 함수입니다. 고차 함수는 종종 다음과 같은 목적으로 사용됩니다.

  • 콜백 함수, 프로미스, 모나드 등을 사용하여 액션, 효과 또는 비동기 흐름을 추상화하거나 분리시킵니다.
  • 다양한 데이터 타입에 대해 동작할 수 있는 유틸리티를 만듭니다.
  • 합성 함수나 재사용의 목적으로 커링 함수를 만들거나 인수를 함수에 부분적으로 적용합니다.
  • 함수 목록을 가져오고, 입력 함수의 합성을 반환합니다.

Containers, Functors, Lists, and Streams

Functor는 맵핑될 수 있는 것을 말합니다. 즉, 함수를 내부의 값의 값으로 적용하는데 사용할 수 있는 인터페이스가 있는 컨테이너입니다. Functor라는 단어를 볼 때 “mappable”이라고 생각하면 됩니다.

이전에 우리는 동일한 map() 함수로 다양한 데이터 타입을 처리할 수 있다고 배웠습니다. 이는 functor API를 사용하여 맵핑 연산을 가능하게 합니다. 중요한 흐름 제어 작업에 쓰이는 map()은 이 인터페이스를 사용합니다. Array.prototype.map()의 경우 컨테이너는 배열이지만, 맵핑 API를 제공하는한 다른 데이터 구조도 functor가 될 수 있습니다.

Array.prototype.map()을 사용하여 맵핑 도구에서 데이터 타입을 추상화하여 map()을 모든 데이터 타입에서 사용할 수 있도록 하는 방법을 살펴 보겠습니다. 다음과 같이 전달된 값에 2를 곱하는 간단한 double() 맵핑을 만듭니다.

const double = n => n * 2;
const doubleMap = numbers => numbers.map(double);
console.log(doubleMap([2, 3, 4]));  // [ 4, 6, 8 ]

게임에서 타겟을 조작하여 점수를 두 배로 늘리려면 어떻게 해야 할까요? 우리가 해야 할 일은 map()에 넘겨줄 double() 함수를 약간 바꾸는 것이고, 다음과 같이 여전히 잘 동작합니다.

const double = n => n.points * 2;

const doubleMap = numbers => numbers.map(double);

console.log(doubleMap([
  { name: 'ball', points: 2 },
  { name: 'coin', points: 3 },
  { name: 'candy', points: 4 }
]));  // [4, 6, 8]

다양한 데이터 타입을 조작하는 일반적인 함수를 사용할 목적으로 펑터와 고차 함수처럼 추상화를 사용하는 개념은 함수형 프로그래밍에서 중요합니다. 다양한 방식이 적용된 이 곳에서 비슷한 개념을 볼 수 있습니다.

“시간에 따라 표현된 리스트는 스트림입니다.”

지금 이해해야 할 것은 배열과 functor만이 컨테이너 값과 컨테이너의 개념이 적용된 유일한 방법이 아니라는 것입니다. 예를 들어, 배열은 그저 어떤 것들의 리스트일 뿐입니다. 시간에 따라 표현된 리스트는 스트림입니다. 따라서 같은 종류의 방법을 적용하여 들어오는 이벤트 스트림을 처리할 수 있습니다. FP로 실제 소프트웨어를 빌드하기 시작할 때 많이 볼 수 있습니다.

선언형 vs 명령형(Declarative vs Imperative)

함수형 프로그래밍은 선언적 패러다임으로, 흐름 제어를 명시적으로 기술하지 않고 프로그램 로직이 표현된다는 것을 의미합니다.

명령형 프로그램은 원하는 결과를 얻기 위해 특정 단계를 설명하는 코드 라인을 사용합니다. - 흐름 제어: 어떻게(How) 하는지.

선언적 프로그램은 흐름 제어를 추상화하고, 대신에 데이터 흐름 을 설명하는 코드 라인을 사용합니다. - 데이터 흐름(data flow): 무엇을(What) 하는지.

예를 들어, 이 명령형 맵핑은 숫자 배열을 사용하고 각 숫자에 2를 곱한 새 배열을 반환합니다.

const doubleMap = numbers => {
  const doubled = [];
  for (let i = 0; i < numbers.length; i++) {
    doubled.push(numbers[i] * 2);
  }
  return doubled;
};

console.log(doubleMap([2, 3, 4]));  // [4, 6, 8]

선언적 맵핑은 동일한 작업을 수행하지만, 데이터의 흐름을 보다 명확하게 보여주는 함수형 Array.prototype.map() 도구를 사용해서 흐름 제어를 추상화합니다.

const doubleMap = numbers => numbers.map(n => n  * 2);

console.log(doubleMap([2, 3, 4]));  // [4, 6, 8]

명령형 코드는 구문를 자주 사용합니다. 구문(statement) 은 어떤 동작을 수행하는 코드 조각입니다. 일반적으로 사용되는 구문의 예로는 for, if, switch, throw 등이 있습니다.

선언적 코드는 표현에 더 의존합니다. 표현식(expression 은 어떤 값을 평가하는 코드 조각입니다. 표현식은 대개 함수 호출, 값 그리고 결과 값을 생성하기 위해 평가되는 연산자의 조합입니다.

여기에 표현식의 예가 있습니다.

2 * 2
doubleMap([2, 3, 4])
Math.max(4, 3, 2)

보통 코드에서는 식별자에 할당되거나 함수로 전달되는 표현식을 보게 됩니다. 할당, 반환 또는 전달되기 전에 표현식이 먼저 평가되고 결과 값이 사용됩니다.

결론

  • 공유 상태(Shared state)와 부작용(Side effects) 대신 순수 함수(Pure function) 사용하라
  • 변경 가능한 데이터보다는 불변성(Immutability)을 따르라
  • 명령형(Imperative) 흐름 제어보다는 합성 함수(Function composition) 사용하라
  • 같은 곳에 있는 데이터에서만 작동하는 메서드 대신에 많은 데이터 유형에 대해 작업할 수 있도록 고차 함수(Higher order functions)를 사용하여 일반적이고 재사용 가능한 도구를 만들어라
  • 명령적(Imperative)인 코드보다는 선언적으로(Declarative, 어떻게 하는지보다는 무엇을 해야하는지) 코드를 만들어라
  • 구문(statement)보다는 표현식(expression)을 사용하라
  • ad-hoc polymorphism(가장 단순한 형태의 다형성)보다는 컨테이너와 고차 함수를 사용하라