Skip to content

3장: 순수 함수가 가져다주는 행복

순수함의 회귀

우리가 명확히 이해해야 할 핵심 개념은 순수 함수(pure function)의 개념입니다.

순수 함수는 동일한 입력이 주어졌을 때 항상 동일한 출력을 반환하며 관찰 가능한 부수효과(side effect)가 없는 함수를 의미합니다.

slicesplice를 비교해보겠습니다. 이 두 함수는 본질적으로 동일한 작업을 수행하지만 완전히 다른 방식으로 작동합니다. slice순수한 함수로 분류됩니다. 매번 동일한 입력에 동일한 출력을 보장하기 때문입니다. 반면 splice는 배열을 직접 변경하고 영구적으로 변형된 상태를 반환하므로 관찰 가능한 부수 효과를 유발합니다.

js
const xs = [1,2,3,4,5];

// pure
xs.slice(0,3); // [1,2,3]

xs.slice(0,3); // [1,2,3]

xs.slice(0,3); // [1,2,3]


// impure
xs.splice(0,3); // [1,2,3]

xs.splice(0,3); // [4,5]

xs.splice(0,3); // []

함수형 프로그래밍에서는 데이터를 *변경(mutate)*하는 splice와 같은 불안정한 함수를 지양합니다. 우리는 splice처럼 뒤처리를 어지럽히지 않는 함수가 아닌, 매번 신뢰성 있는 결과를 반환하는 함수를 추구합니다.

다른 예시를 살펴보겠습니다.

js
// impure
let minimum = 21;
const checkAge = age => age >= minimum;

// pure
const checkAge = (age) => {
  const minimum = 21;
  return age >= minimum;
};

비순수한 함수 부분에서 checkAge는 변경 가능한 변수 minimum에 의존하여 결과를 결정합니다. 이는 시스템 상태에 종속됨을 의미하며, 외부 환경을 도입함으로써 인지 부하를 증가시키는 문제가 있습니다.

이 예시에서는 크게 느껴지지 않을 수 있지만(http://curtclifton.net/papers/MoseleyMarks06a.pdf 참조), 상태 의존성은 시스템 복잡성의 주요 원인 중 하나입니다. checkAge는 입력 외부 요인에 따라 다른 결과를 반환할 수 있어 순수성 조건을 충족하지 못할 뿐만 아니라, 소프트웨어 추론 과정에서 상당한 인지 부담을 유발합니다.

반면 순수 함수 버전은 완전히 독립적입니다. minimum을 불변(immutable) 상태로 유지함으로써 상태 변경 없이 순수성을 보장할 수 있습니다. 이를 구현하기 위해 객체를 동결(freeze)해야 합니다.

js
const immutableState = Object.freeze({ minimum: 21 });

부수 효과의 파급력

"부수 효과(side effect)"의 본질을 더 깊이 이해해봅시다. 순수 함수 정의에서 언급된 이 해로운 개념은 정확히 무엇을 의미할까요? 여기서 *효과(effect)*란 결과를 계산하는 것 외에 발생하는 모든 작업을 포괄합니다.

효과 자체가 본질적으로 나쁜 것은 아니며, 이후 장에서 광범위하게 활용될 예정입니다. 문제는 부수적인(side) 부분에 있습니다. 물 자체가 유충 번식처가 아니듯, 정체된(stagnant) 부분이 문제를 야기하며, 프로그램에서도 부수 효과가 유사한 문제의 온상이 됩니다.

부수 효과는 계산 과정에서 발생하는 시스템 상태 변화 또는 외부 환경과의 관찰 가능한 상호작용을 의미합니다.

부수 효과의 예시는 다음과 같습니다(단순히 이에 국한되지 않음):

  • 파일 시스템 변경
  • 데이터베이스 레코드 삽입
  • HTTP 호출 실행
  • 데이터 변이(mutation)
  • 화면 출력/로깅
  • 사용자 입력 수신
  • DOM 조회
  • 시스템 상태 접근

이 목록은 끝없이 이어집니다. 함수 외부 세계와의 모든 상호작용은 부수 효과로 분류되며, 이는 부수 효과 없이 프로그래밍의 실용성을 의심케 하는 요인입니다. 함수형 프로그래밍 철학은 부수 효과가 비정상적 동작의 주범이라고 주장합니다.

절대적 사용 금지가 아니라 제어된 방식으로 관리해야 합니다. 추후 장에서 펑터와 모나드를 통해 이에 대한 해법을 배우게 되겠지만, 현재 단계에서는 교활한 비순수 함수들을 순수 함수와 분리하는 데 집중합시다.

부수 효과는 함수의 순수성을 무효화합니다. 정의상 순수 함수는 동일 입력에 항상 동일 출력을 보장해야 하는데, 외부 요인과 연동 시 이 조건을 충족시킬 수 없기 때문입니다.

왜 동일 입력-출력 조건을 고수하는지 자세히 살펴봅시다. 중학교 수학 시간으로 돌아가 볼까요?

중학교 수학 시간

mathisfun.com의 정의:

함수는 값들 간의 특별한 관계입니다: 각 입력값은 정확히 하나의 출력값을 반환합니다.

즉 입력과 출력 간의 관계를 의미합니다. 모든 입력이 정확히 하나의 출력을 가지지만, 출력이 반드시 고유할 필요는 없습니다. 다음 다이어그램은 x에서 y로의 완벽한 함수 관계를 보여줍니다:

function sets(https://www.mathsisfun.com/sets/function.html)

대조적으로, 다음 다이어그램은 입력값 5가 여러 출력을 가리키므로 함수로 인정되지 않는 관계를 보여줍니다:

relation not function(https://www.mathsisfun.com/sets/function.html)

함수는 (입력, 출력) 쌍의 집합으로 표현 가능합니다: [(1,2), (3,6), (5,10)] (입력값을 2배하는 함수로 판단됨)

표 형식으로 표현:

Input Output
1 2
2 4
3 6

또는 x를 입력, y를 출력으로 하는 그래프 형태도 가능:

function graph

입력이 출력을 결정한다면 구현 세부사항은 불필요합니다. 함수가 단순한 입력-출력 매핑이므로 객체 리터럴을 작성하고 () 대신 []로 실행할 수도 있습니다.

js
const toLowerCase = {
  A: 'a',
  B: 'b',
  C: 'c',
  D: 'd',
  E: 'e',
  F: 'f',
};
toLowerCase['C']; // 'c'

const isPrime = {
  1: false,
  2: true,
  3: true,
  4: false,
  5: true,
  6: false,
};
isPrime[3]; // true

물론 수작업 대신 계산을 선호할 수 있지만, 이는 함수 사고 방식의 차이를 보여줍니다. ("다중 인자 함수는 어떻게 처리하나요?"라는 의문이 들 수 있습니다. 수학적 관점에서는 다소 불편함이 있죠. 현재 단계에서는 배열로 묶거나 arguments 객체를 입력으로 간주할 수 있습니다. *커링(currying)*을 배우면 수학적 함수 정의를 직접 모델링하는 방법을 알게 될 것입니다.)

중요한 사실: 순수 함수는 수학적 함수와 동일하며, 이 것이 바로 함수형 프로그래밍의 핵심입니다. 이 작은 천사들과 프로그래밍하면 엄청난 이점을 얻을 수 있습니다. 순수성 유지를 위해 노력하는 이유를 살펴보겠습니다.

순수성의 필요성

캐싱 가능성

우선 순수 함수는 항상 입력 기반 캐싱이 가능합니다. 일반적으로 메모이제이션(Memoization) 기법을 사용합니다:

js
const squareNumber = memoize(x => x * x);

squareNumber(4); // 16

squareNumber(4); // 16, returns cache for input 4

squareNumber(5); // 25

squareNumber(5); // 25, returns cache for input 5

다음은 단순화된 구현 예시이며, 더 강력한 버전들이 존재합니다.

js
const memoize = (f) => {
  const cache = {};

  return (...args) => {
    const argStr = JSON.stringify(args);
    cache[argStr] = cache[argStr] || f(...args);
    return cache[argStr];
  };
};

평가 시점을 지연시킴으로써 일부 비순수 함수를 순수 함수로 변환할 수 있습니다:

js
const pureHttpCall = memoize((url, params) => () => $.getJSON(url, params));

여기서 핵심은 실제 HTTP 호출을 즉시 실행하지 않고, 호출 시점에 실행할 함수를 반환하는 것입니다. urlparams가 주어지면 특정 HTTP 호출을 수행할 함수를 반환하므로 이 함수는 순수합니다.

memoize 함수는 HTTP 호출 결과가 아닌 생성된 함수 자체를 캐싱하지만 정상 작동합니다.

현재는 유용성 측면에서 다소 부족하지만, 향후 활용 가능한 기법들을 배우게 될 것입니다. 중요한 점은 겉보기에 파괴적인 함수라도 캐싱이 가능하다는 사실입니다.

이식성/자기 문서화

순수 함수는 완전히 자급자족합니다. 함수에 필요한 모든 요소는 명시적으로 전달됩니다. 이점을 생각해보세요... 함수 종속성이 명확히 드러나 이해하기 쉽고, 내부에서 은밀한 작업이 발생하지 않습니다.

js
// impure
const signUp = (attrs) => {
  const user = saveUser(attrs);
  welcomeUser(user);
};

// pure
const signUp = (Db, Email, attrs) => () => {
  const user = saveUser(Db, attrs);
  welcomeUser(Email, user);
};

이 예시는 순수 함수가 종속성을 정직하게 선언함을 보여줍니다. 시그니처만으로 Db, Email, attrs 사용 여부를 즉시 파악할 수 있습니다.

평가 지연 없이 순수 함수를 만드는 방법은 추후 배우겠지만, 순수 형식이 교묘한 비순수 버전보다 정보 전달력이 훨씬 우수함을 이해하는 게 중요합니다.

주의할 점은 종속성을 "주입"하거나 인자로 전달해야 한다는 것입니다. 이는 데이터베이스나 메일 클라이언트 등을 매개변수화함으로써 애플리케이션 유연성을 크게 향상시킵니다. 다른 Db를 사용하려면 해당 함수를 호출하기만 하면 되며, 신뢰할 수 있는 함수를 새 애플리케이션에서 재사용할 때도 당시의 DbEmail을 제공하면 됩니다.

JavaScript 환경에서 이식성은 소켓을 통해 함수를 직렬화/전송하거나 웹 워커에서 전체 앱 코드를 실행하는 것을 의미할 수 있습니다. 이식성은 강력한 특성입니다.

명령형 프로그래밍의 '전형적인' 메서드들이 상태/종속성/효과를 통해 환경에 깊게 뿌리내린 것과 달리, 순수 함수는 어디서든 자유롭게 실행 가능합니다.

마지막으로 새 앱에 메소드를 복사한 적이 언제인가요? Erlang 창시자 Joe Armstrong의 명언을 인용합니다: "객체 지향 언어의 문제점은 암묵적인 환경을 함께 운반한다는 점입니다. 당신은 바나나를 원했지만 실제 얻은 것은 바나나를 쥔 고릴라와 정글 전체입니다".

테스트 용이성

순수 함수는 테스트 과정을 극도로 단순화합니다. "실제" 결제 게이트웨이를 모킹(mock)하거나 테스트 후 시스템 상태를 설정/검증할 필요 없이, 단순히 입력을 주고 출력을 확인하면 됩니다.

실제로 함수형 커뮤니티는 생성된 입력으로 함수를 테스트하고 출력 속성을 검증하는 새로운 테스트 도구를 선도하고 있습니다. 본서 범위를 벗어나지만 순수 함수형 환경에 맞춰진 테스트 도구 Quickcheck를 적극 권장합니다.

합리성

순수 함수의 최대 장점은 *참조 투명성(referential transparency)*이라는 의견이 많습니다. 참조 투명성이란 코드 조각을 평가된 값으로 대체해도 프로그램 동작이 변하지 않는 특성을 말합니다.

순수 함수는 부수 효과가 없으므로 출력값을 통해서만 프로그램 동작에 영향을 미칩니다. 또한 입력값만으로 출력을 신뢰성 있게 계산 가능하므로 참조 투명성을 항상 보존합니다. 예시를 살펴보겠습니다.

js
const { Map } = require('immutable');

// Aliases: p = player, a = attacker, t = target
const jobe = Map({ name: 'Jobe', hp: 20, team: 'red' });
const michael = Map({ name: 'Michael', hp: 20, team: 'green' });
const decrementHP = p => p.set('hp', p.get('hp') - 1);
const isSameTeam = (p1, p2) => p1.get('team') === p2.get('team');
const punch = (a, t) => (isSameTeam(a, t) ? t : decrementHP(t));

punch(jobe, michael); // Map({name:'Michael', hp:19, team: 'green'})

decrementHP, isSameTeam, punch는 모두 순수하며 참조 투명성을 가집니다. "동등성을 위한 동등성 치환"이 가능한 수식 추론(equational reasoning) 기법을 사용할 수 있습니다. 프로그램적 평가의 특성 없이 코드를 수동으로 평가하는 것과 유사합니다. 참조 투명성을 활용해 이 코드를 분석해보죠.

먼저 isSameTeam 함수를 인라인 처리합니다.

js
const punch = (a, t) => (a.get('team') === t.get('team') ? t : decrementHP(t));

데이터가 불변이므로 팀 값을 실제 값으로 치환

js
const punch = (a, t) => ('red' === 'green' ? t : decrementHP(t));

이 경우 false이므로 if 분기 전체 제거 가능

js
const punch = (a, t) => decrementHP(t);

decrementHP를 인라인하면 HP를 1만큼 감소시키는 호출로 변환됨

js
const punch = (a, t) => t.set('hp', t.get('hp') - 1);

이러한 코드 추론 능력은 리팩토링과 코드 이해에 탁월합니다. 실제로 우리는 갈매기 무리 프로그램 리팩토링 시 이 기법을 적용했습니다. 덧셈과 곱셈 속성을 활용하기 위해 수식 추론을 사용했으며, 본서 전체에서 이 기법들을 계속 활용할 예정입니다.

병렬 처리

결정적으로, 순수 함수는 공유 메모리 접근이 필요 없고 부수 효과로 인한 경쟁 조건(race condition)이 발생하지 않으므로 병렬 실행이 가능합니다.

서버 사이드 JS 환경의 스레드나 브라우저의 웹 워커에서 실행이 가능하지만, 현재는 비순수 함수 처리의 복잡성으로 인해 이러한 접근을 꺼리는 분위기입니다.

요약

순수 함수의 개념과 함수형 프로그래머들이 이를 중시하는 이유를 살펴보았습니다. 이제부터 모든 함수를 순수하게 작성하려 노력할 것입니다. 추가 도구들이 필요하겠지만, 그동안 비순수 함수들을 순수 코드와 분리하는 데 집중합시다.

순수 함수로 프로그램을 작성하는 것은 추가 도구 없이는 다소 번거롭습니다. 인자를 곳곳에 전달하며 데이터를 다뤄야 하고, 상태 사용은 금지되며 부수 효과는 엄격히 통제됩니다. 이러한 고된 프로그램을 어떻게 작성할 수 있을까요? 이제 커링(curry)이라는 새로운 도구를 배워봅시다.

4장: 커링