abonglog logoabonglog

Chapter 03: Pure Happiness with Pure Functions [번역] 의 썸네일

Chapter 03: Pure Happiness with Pure Functions [번역]

mostly-adequate-guide
프로필 이미지
yonghyeun

해당 게시글은 mostly-adequate-guide 의 시리즈들을 번역했습니다.

해당게시글의 저작권은 mostly-adequate-guide 의 저작권인 Attribution-ShareAlike 4.0 International 을 준수합니다.

🤖 AI가 요약한 글이예요 !
이 글은 함수형 프로그래밍의 핵심 개념인 순수 함수(pure function) 에 대해 설명합니다.
순수 함수는 동일한 입력에 대해 항상 동일한 출력을 반환하고, 관찰 가능한 부수 효과(side effect) 가 없는 함수입니다.
slicesplice 예시를 통해 순수 함수와 비순수 함수의 차이를 보여주고, 상태 의존성이 코드 복잡성을 증가시키는 이유를 설명합니다.
부수 효과의 다양한 예시를 들며, 함수형 프로그래밍에서는 이를 완전히 배제하는 것이 아니라 통제된 방식으로 다루려고 함을 강조합니다.
순수 함수의 장점(캐싱 가능, 이식성/자가 문서화, 테스트 용이성, 추론 용이성, 병렬 처리 용이성)을 설명하며, 참조 투명성(referential transparency)등식 추론(equational reasoning) 의 개념을 소개합니다.

챕터 03: 순수 함수로 순수한 행복을

다시 순수해지기 위해

우리가 확실히 해야 할 한 가지는 순수 함수(pure function) 라는 개념입니다.

순수 함수는 동일한 입력이 주어지면 항상 동일한 출력을 반환하고 관찰 가능한 부수 효과(side effect) 가 없는 함수입니다.

slicesplice를 예로 들어 봅시다. 이 두 함수는 정확히 같은 일을 합니다 - 물론 매우 다른 방식으로 하지만, 그럼에도 불구하고 같은 일입니다. 우리는 slice순수하다고 말합니다. 왜냐하면 매번 입력당 동일한 출력을 반환하는 것이 보장되기 때문입니다. 반면에 splice는 배열을 씹어 먹고 영원히 변형된 상태로 뱉어냅니다. 이것은 관찰 가능한 효과입니다.

slice와 splice의 순수성 비교
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처럼 뒤죽박죽 흔적을 남기는 함수는 안 됩니다.

다른 예시를 살펴봅시다.

상태 의존성에 따른 순수성 비교
// 비순수 함수 (impure)
let minimum = 21;
const checkAge = (age) => age >= minimum;
 
// 순수 함수 (pure)
const checkAgePure = (age) => {
  const minimum = 21;
  return age >= minimum;
};

이 예제에서는 크게 보이지 않을 수 있지만, 상태에 대한 이러한 의존성은 시스템 복잡성의 가장 큰 원인 중 하나입니다 (http://curtclifton.net/papers/MoseleyMarks06a.pdf). 이 checkAge 함수는 입력 외적인 요인에 따라 다른 결과를 반환할 수 있으며, 이는 순수 함수 자격을 박탈할 뿐만 아니라 소프트웨어를 추론할 때마다 우리 마음을 괴롭힙니다.

반면에 순수한 형태는 완전히 자급자족적입니다. 또한 minimum불변(immutable) 으로 만들 수 있으며, 이는 상태가 절대 변하지 않으므로 순수성을 유지합니다. 이를 위해서는 객체를 생성하여 동결해야 합니다.

Object.freeze를 사용한 불변 상태
const immutableState = Object.freeze({ minimum: 21 });

부수 효과는 다음을 포함할 수 있습니다...

이러한 "부수 효과"에 대해 좀 더 자세히 살펴보고 직관력을 향상시켜 봅시다. 그렇다면 순수 함수 정의에서 언급된 이 의심할 여지 없이 사악한 부수 효과(side effect) 란 무엇일까요? 우리는 결과 계산 외에 계산 과정에서 발생하는 모든 것을 효과(effect) 라고 부를 것입니다.

효과 자체에는 본질적으로 나쁜 것이 없으며, 앞으로 나올 챕터들에서 우리는 효과를 곳곳에서 사용할 것입니다. 부정적인 함의를 지닌 것은 바로 부수(side) 라는 부분입니다. 물 자체는 본질적인 유충 부화기가 아닙니다. 고여있는 부분이 벌레 떼를 만들어내는 것이며, 장담컨대, 부수 효과는 여러분 자신의 프로그램에서 유사한 번식지입니다.

부수 효과는 결과 계산 중에 발생하는 시스템 상태의 변경 또는 외부 세계와의 관찰 가능한 상호작용입니다.

부수 효과는 다음을 포함하지만 이에 국한되지는 않습니다:

  • 파일 시스템 변경
  • 데이터베이스에 레코드 삽입
  • HTTP 호출 수행
  • 데이터 변경 (Mutations)
  • 화면 출력 / 로깅
  • 사용자 입력 받기
  • DOM 쿼리
  • 시스템 상태 접근

목록은 계속 이어집니다. 함수 외부 세계와의 모든 상호작용은 부수 효과이며, 이는 여러분이 부수 효과 없이 프로그래밍하는 것의 실용성에 대해 의심하게 만들 수 있는 사실입니다. 함수형 프로그래밍 철학은 부수 효과가 잘못된 행동의 주요 원인이라고 가정합니다.

우리가 그것들을 사용하는 것이 금지된 것은 아닙니다. 오히려 우리는 그것들을 포함(contain) 하고 통제된 방식(controlled way) 으로 실행하기를 원합니다. 우리는 나중에 펑터(functor)와 모나드(monad)를 다룰 때 이것을 어떻게 하는지 배울 것이지만, 지금은 이러한 교활한 함수들을 우리의 순수한 함수들과 분리하도록 노력합시다.

펑터(Functor): 값을 감싸고, 그 값에 함수를 적용할 수 있는 컨테이너 타입입니다. map 연산을 통해 컨테이너 내부의 값에 함수를 적용하고, 결과를 다시 동일한 타입의 컨테이너에 담아 반환합니다.
모나드(Monad): 펑터의 일종으로, 값을 감싸는 것 외에도 값에 함수를 적용하고 그 결과를 다시 모나드 컨텍스트로 감싸는 flatMap (또는 bind, chain) 연산을 제공합니다. 부수 효과나 비동기 작업 등을 순차적으로 연결하고 관리하는 데 유용합니다.

부수 효과는 함수가 순수 함수가 되는 것을 막습니다. 그리고 이것은 말이 됩니다: 순수 함수는 정의상 동일한 입력이 주어지면 항상 동일한 출력을 반환해야 하는데, 이는 우리 지역 함수 외부의 문제를 다룰 때는 보장할 수 없습니다.

왜 우리가 입력당 동일한 출력을 고집하는지 좀 더 자세히 살펴봅시다. 옷깃을 세우세요, 우리는 중학교 2학년 수학을 좀 볼 것입니다.

중학교 2학년 수학

mathisfun.com 에서:

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

다시 말해, 함수는 입력과 출력이라는 두 값 사이의 관계일 뿐입니다. 각 입력은 정확히 하나의 출력을 갖지만, 그 출력이 반드시 입력마다 고유할 필요는 없습니다. 아래는 x에서 y로 가는 완벽하게 유효한 함수의 다이어그램입니다:

1:1 사상이 가능한 다이어그램1:1 사상이 가능한 다이어그램

대조적으로, 다음 다이어그램은 입력 값 5가 여러 출력 값을 가리키기 때문에 함수가 아닌 관계를 보여줍니다:

1:1 사상을 만족하지 않는 다이어그램1:1 사상을 만족하지 않는 다이어그램

함수는 위치 (입력, 출력) 쌍의 집합으로 설명될 수 있습니다: [(1,2), (3,6), (5,10)] (이 함수는 입력을 두 배로 만드는 것 같습니다).

또는 테이블로 나타낼 수도 있습니다:

입력출력
12
24
36

또는 x를 입력으로, y를 출력으로 하는 그래프로도 나타낼 수 있습니다:

Function GraphFunction Graph

입력이 출력을 결정한다면 구현 세부 사항은 필요하지 않습니다. 함수는 단순히 입력에서 출력으로의 매핑이므로, 객체 리터럴을 적어두고 () 대신 []로 실행할 수도 있습니다.

객체 리터럴을 사용한 함수 표현
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) 에 대해 배울 때, 함수의 수학적 정의를 직접 모델링하는 방법을 보게 될 것입니다.)

커링(Currying): 여러 인수를 받는 함수를 단일 인수를 받는 함수들의 연속으로 변환하는 기법입니다. 예를 들어, f(a, b, c)f(a)(b)(c) 형태로 바꾸는 것입니다.

이제 극적인 폭로가 나옵니다: 순수 함수는 수학적 함수이며, 이것이 함수형 프로그래밍의 전부입니다. 이 작은 천사들과 함께 프로그래밍하는 것은 큰 이점을 제공할 수 있습니다. 순수성을 유지하기 위해 우리가 기꺼이 많은 노력을 기울이는 몇 가지 이유를 살펴봅시다.

순수성을 위한 논거

캐싱 가능 (Cacheable)

우선, 순수 함수는 항상 입력별로 캐시될 수 있습니다. 이것은 일반적으로 메모이제이션(memoization) 이라는 기법을 사용하여 수행됩니다:

메모이제이션을 이용한 함수 캐싱
const squareNumber = memoize((x) => x * x);
 
squareNumber(4); // 16
 
squareNumber(4); // 16, 입력 4에 대한 캐시 반환
 
squareNumber(5); // 25
 
squareNumber(5); // 25, 입력 5에 대한 캐시 반환

여기 단순화된 구현이 있지만, 사용 가능한 훨씬 더 강력한 버전들이 많이 있습니다.

간단한 메모이제이션 구현
const memoize = (f) => {
  const cache = {};
 
  return (...args) => {
    const argStr = JSON.stringify(args);
    cache[argStr] = cache[argStr] || f(...args);
    return cache[argStr];
  };
};

주목할 점은 일부 비순수 함수를 평가를 지연시켜 순수 함수로 변환할 수 있다는 것입니다:

평가 지연을 통한 순수 함수 변환
const pureHttpCall = memoize((url, params) => () => $.getJSON(url, params));

여기서 흥미로운 점은 실제로 HTTP 호출을 하지 않는다는 것입니다 - 대신 호출될 때 그렇게 할 함수를 반환합니다. 이 함수는 동일한 입력이 주어지면 항상 동일한 출력을 반환하기 때문에 순수합니다: 주어진 urlparams로 특정 HTTP 호출을 수행할 함수 말입니다.

우리의 memoize 함수는 잘 작동하지만, HTTP 호출 결과를 캐시하는 것이 아니라 생성된 함수를 캐시합니다.

이것은 아직 그다지 유용하지 않지만, 곧 유용하게 만들 몇 가지 트릭을 배울 것입니다. 핵심은 파괴적으로 보이는 모든 함수를 캐시할 수 있다는 것입니다.

이식성 / 자가 문서화 (Portable / Self-documenting)

순수 함수는 완전히 자가 포함적(self contained) 입니다. 함수에 필요한 모든 것이 은쟁반에 담겨 제공됩니다. 잠시 이것에 대해 생각해 보세요... 이것이 어떻게 유익할 수 있을까요? 우선, 함수의 의존성이 명시적이므로 보고 이해하기가 더 쉽습니다 - 내부에서 이상한 일이 벌어지지 않습니다.

의존성 주입을 통한 순수 함수
// 비순수 함수 (impure)
const signUp = (attrs) => {
  const user = saveUser(attrs);
  welcomeUser(user);
};
 
// 순수 함수 (pure)
const signUpPure = (Db, Email, attrs) => () => {
  const user = saveUser(Db, attrs);
  welcomeUser(Email, user);
};

여기 예제는 순수 함수가 자신의 의존성에 대해 정직해야 하며, 따라서 정확히 무엇을 하고 있는지 알려준다는 것을 보여줍니다. 단지 시그니처만으로도 Db, Email, attrs를 사용할 것이라는 것을 알 수 있으며, 이는 최소한 많은 것을 말해줍니다.

우리는 단순히 평가를 미루는 것 없이 이런 함수를 순수하게 만드는 방법을 배울 것이지만, 요점은 순수한 형태가 무엇을 하고 있는지 알 수 없는 교활한 비순수 상대보다 훨씬 더 유익하다는 것이 분명해야 합니다.

주목해야 할 또 다른 점은 우리가 의존성을 주입(inject) 하거나 인수로 전달해야 한다는 것입니다. 이는 데이터베이스나 메일 클라이언트 등을 매개변수화했기 때문에 앱을 훨씬 더 유연하게 만듭니다 (걱정 마세요, 이것이 들리는 것보다 덜 지루하게 만드는 방법을 보게 될 것입니다). 다른 Db를 사용하기로 선택하면 해당 Db로 함수를 호출하기만 하면 됩니다. 이 신뢰할 수 있는 함수를 재사용하고 싶은 새로운 애플리케이션을 작성하게 된다면, 그 시점에 가지고 있는 DbEmail을 이 함수에 제공하기만 하면 됩니다.

자바스크립트 환경에서 이식성은 함수를 직렬화하여 소켓을 통해 보내는 것을 의미할 수 있습니다. 모든 앱 코드를 웹 워커에서 실행하는 것을 의미할 수도 있습니다. 이식성은 강력한 특성입니다.

상태, 의존성, 사용 가능한 효과를 통해 환경에 깊이 뿌리내린 명령형 프로그래밍의 "전형적인" 메서드 및 프로시저와는 달리, 순수 함수는 우리 마음이 원하는 어디에서나 실행될 수 있습니다.

마지막으로 메서드를 새 앱에 복사한 것이 언제였나요? 제가 가장 좋아하는 인용구 중 하나는 얼랭(Erlang) 창시자인 조 암스트롱(Joe Armstrong)의 말입니다: "객체 지향 언어의 문제는 그들이 가지고 다니는 모든 암묵적인 환경입니다. 당신은 바나나를 원했지만 얻은 것은 바나나를 들고 있는 고릴라... 그리고 정글 전체였습니다".

테스트 용이성 (Testable)

다음으로, 우리는 순수 함수가 테스트를 훨씬 쉽게 만든다는 것을 깨닫게 됩니다. "실제" 결제 게이트웨이를 모의(mock)하거나 각 테스트 후 세상의 상태를 설정하고 단언(assert)할 필요가 없습니다. 단순히 함수에 입력을 제공하고 출력을 단언하면 됩니다.

실제로, 우리는 함수형 커뮤니티가 생성된 입력으로 함수를 폭격하고 출력에서 속성이 유지되는지 단언할 수 있는 새로운 테스트 도구를 개척하고 있음을 발견합니다. 이 책의 범위를 벗어나지만, 순수 함수형 환경에 맞춰진 테스트 도구인 Quickcheck를 검색하고 시도해 보시기를 강력히 권장합니다.

추론 용이성 (Reasonable)

많은 사람들은 순수 함수로 작업할 때 가장 큰 이점이 참조 투명성(referential transparency) 이라고 믿습니다. 코드의 한 부분이 프로그램의 동작을 변경하지 않고 평가된 값으로 대체될 수 있을 때 참조적으로 투명합니다.

참조 투명성(Referential Transparency): 어떤 표현식(expression)을 그 표현식의 값으로 바꾸어도 프로그램의 동작에 영향을 주지 않는 성질입니다. 순수 함수는 참조적으로 투명합니다.

순수 함수는 부수 효과가 없으므로 출력 값을 통해서만 프로그램의 동작에 영향을 미칠 수 있습니다. 더욱이, 출력 값은 입력 값만 사용하여 안정적으로 계산될 수 있으므로 순수 함수는 항상 참조 투명성을 유지합니다. 예시를 봅시다.

참조 투명성 예시
const { Map } = require("immutable");
 
// 별칭: 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 함수를 인라인(inline)합니다.

등식 추론 1단계: isSameTeam 인라인
const punch = (a, t) => (a.get("team") === t.get("team") ? t : decrementHP(t));

데이터가 불변이므로 팀을 실제 값으로 간단히 대체할 수 있습니다.

등식 추론 2단계: 값 대체
const punch = (a, t) => ("red" === "green" ? t : decrementHP(t));

이 경우 거짓임을 알 수 있으므로 전체 if 분기를 제거할 수 있습니다.

등식 추론 3단계: 조건부 분기 제거
const punch = (a, t) => decrementHP(t);

그리고 decrementHP를 인라인하면, 이 경우 punchhp를 1 감소시키는 호출이 된다는 것을 알 수 있습니다.

등식 추론 4단계: decrementHP 인라인
const punch = (a, t) => t.set("hp", t.get("hp") - 1);

코드에 대해 추론하는 이 능력은 리팩토링과 코드 이해 전반에 걸쳐 굉장합니다. 실제로, 우리는 이 기법을 사용하여 갈매기 무리 프로그램을 리팩토링했습니다. 우리는 덧셈과 곱셈의 속성을 활용하기 위해 등식 추론을 사용했습니다. 실제로, 우리는 이 책 전반에 걸쳐 이러한 기법을 사용할 것입니다.

병렬 코드 (Parallel Code)

마지막으로, 그리고 이것이 결정타입니다. 우리는 공유 메모리에 접근할 필요가 없고 정의상 부수 효과로 인한 경쟁 상태(race condition)를 가질 수 없으므로 모든 순수 함수를 병렬로 실행할 수 있습니다.

경쟁 상태(Race Condition): 둘 이상의 프로세스나 스레드가 공유 자원에 동시에 접근하려고 할 때, 접근 순서에 따라 실행 결과가 달라지는 상황을 말합니다.

이것은 스레드가 있는 서버 측 js 환경과 웹 워커가 있는 브라우저에서 매우 가능하지만, 현재 문화는 비순수 함수를 다룰 때의 복잡성 때문에 이를 피하는 경향이 있습니다.

요약하자면

우리는 순수 함수가 무엇인지, 그리고 함수형 프로그래머로서 왜 그것들이 고양이의 야회복(매우 멋지다는 뜻의 관용구)이라고 믿는지 보았습니다. 이 시점부터 우리는 모든 함수를 순수한 방식으로 작성하려고 노력할 것입니다. 우리는 그렇게 하는 데 도움이 되는 몇 가지 추가 도구가 필요하지만, 그 동안에는 비순수 함수를 나머지 순수 코드와 분리하려고 노력할 것입니다.

순수 함수로 프로그램을 작성하는 것은 우리 벨트에 몇 가지 추가 도구가 없으면 약간 힘듭니다. 우리는 인수를 여기저기 전달하여 데이터를 저글링해야 하고, 상태 사용이 금지되며, 효과는 말할 것도 없습니다. 어떻게 이런 고행적인 프로그램을 작성할 수 있을까요? 커리(curry)라는 새로운 도구를 습득해 봅시다.