프로필 사진
Sojin Park
Frontend Dev

순수 함수와 부수 효과
부수 효과는 무엇이고, 왜 순수 함수를 써야 할까?
2019. 1. 31.

순수 함수Pure function와 부수 효과Side effect는 함수형 프로그래밍Functional programming에서 가장 중요한 개념 중 하나이다. 함수형 프로그래밍을 한 문장으로 요약하자면 프로그램을 순수 함수만으로 짜는 것이라고 할 수 있을 정도이다. 그리고 여기에서 순수하다는 것은 부수 효과를 일으키지 않는 것을 의미한다. 그렇다면 부수 효과는 어떤 것을 의미하는 것일까?

부수 효과

함수가 결괏값을 반환하는 것 이외에 다른 일을 할 때 그 함수는 부수 효과를 가진다고 한다. 예를 들어

  1. 함수가 함수 외부의 변수 값을 변경할 때, 또는 외부 변수의 자료 구조Data structure를 바꿀 때
let externalVariable = 1;

function func() {
  externalVariable = 2;
}
  1. 가변 객체Mutable object의 프로퍼티Property 또는 필드Field 값을 바꿀 때
const sojin = {
  name: 'Sojin Park',
  age: 21,
};

function func(person) {
  person.age = 22;
}

func(sojin); // sojin.age가 22로 변경됨
  1. 함수가 예외를 던지거나 에러와 함께 프로그램을 종료시킬 때
function func() {
  throw new Error('에러!!');
}
  1. 함수가 명령창Console으로 값을 출력하거나 그곳에서 값을 읽어들일 때, 디스크의 파일을 읽고 쓸 때, 화면에 UI 요소를 그릴 때 등
function func() {
  console.log('Hello, world!');
}

이렇게 부수 효과가 유용한 프로그램을 만드는 데에 보통 필수적인 역할을 하는 부분이다 보니 C언어나 Java 등 절차지향적 프로그래밍Imperative programming 또는 객체지향 프로그래밍Object-oriented Programming을 먼저 접한 사람들에게는 부수 효과의 개념이 무척 낯설 수 있다. 심지어 아무런 부수 효과 없이 어떻게 프로그래밍을 한다는 것인지 당황스러움을 선사하기도 한다.

그러나 함수형 프로그래밍은 프로그래밍을 하는 방법론에 제약을 가하는 것이지, 짤 수 있는 프로그램의 종류를 제한하는 것은 아니다. 지금부터 우선 순수 함수의 정의에 대해 알아본 뒤, 간단한 예제로 어떻게 순수한 함수를 기반으로 프로그래밍을 해 가는지 알아보자.

순수 함수

정의역DomainAA, 공역CodomainBB로 하는 함수 ff가 주어졌다고 하자. 함수 ff가 순수Pure하다는 것은

  1. 정의역 AA의 모든 원소 aa에 대해 정해진 값 b=f(a)Bb = f(a) \in B가 있고
  2. b=f(a)b = f(a)를 결정하기 위해서 외부 값에 조금의 영향도 받지 않고 인자Argument로 주어진 값 aa만 참조하고 있음

을 의미한다. 예를 들어 현재 시간을 가져오기 위해 외부 시간 정보에 접근하는 JavaScript 내장 함수 Date.now는 순수하지 않다. 유사하게 0부터 1 사이의 임의의 값을 가져오는 Math.random도 순수 함수가 아니다. 외부의 임의 값 발생기를 참조하기 때문이다.

그러나 23을 더하는 함수 add 또는 연산자 +는 결괏값 5를 만들기 위해 입력값만 참조하기 때문에 순수하다. 마찬가지로 인자로 주어진 여러 숫자 중 큰 숫자를 반환하는 함수 Math.max는 순수 함수이다. 그러나 이렇게 생각한다면 어떤 함수가 순수 함수인지 아닌지 빠르게 판단할 때 헷갈리기 시작한다. 어떤 함수가 순수한지 판단하기 위해 사용하는 방법론에 대해 알아보자.

메모하는 함수

메모하기Memoization는 프로그래밍에서 함수가 이전에 계산한 값들을 저장함으로써 비싸고 반복적인 계산을 줄임으로써 프로그램의 실행 속도를 빠르게 하는 방법이다. 큰 연관이 없어 보이지만 의외로 이것으로 어떤 함수가 순수 함수인지 아닌지 간단하게 판단할 수 있다.

예를 들어 Math.max를 다음과 같이 메모하는 함수가 있다고 생각해 보자. 이 함수는 똑같은 인자로 함수를 여러 번 호출했을 때 미리 저장한 값을 반환한다.

const memoizedMax = memoize(Math.max);

memoizedMax(1, 2); // -> 2
memoizedMax(2, 5, 1); // -> 5

memoizedMax(1, 2); // -> 다시 계산하지 않고, 이전에 계산한 값 2를 반환함

이처럼 Math.max를 메모해서 memoizedMax를 만들어도 언제나 기대했던 값을 얻을 수 있다. 반대로 위에서 순수하지 않았던 함수 Math.random을 메모하는 경우를 생각해 보자.

const memoizedRandom = memoize(Math.random);

memoizedRandom(); // -> 예시) 0.3209802 ...

memoizedRandom(); // -> 0.3209802 ...
memoizedRandom(); // -> 0.3209802 ...

0부터 1까지 임의의 값을 가져오는 함수를 기대했지만 Math.random을 메모하면 늘 같은 값이 반환된다. 이는 memoizedRandom에 주어지는 인자가 언제나 비어 있는 것으로 같기 때문에 첫 번째 임의 값이 메모된 채로 계속 반환되기 때문이다.

유사하게 Date.nowfs.readDirSync, document.write 등 부수 효과를 나타내는 다양한 함수도 메모하면 마찬가지로 원하는 결과를 얻을 수 없다. 즉, 메모한 함수가 원래 함수와 같은 역할을 하지 않거나, 그 함수는 부수 효과를 가진다고 판단할 수 있다.

이는 순수 함수 f::ABf :: A \rightarrow B는 주어진 인자 aAa \in A에 대해 반드시 하나의 값 bBb \in B를 가지는 것과 연관되어 있다. Date.nowMath.random 등은 빈 인자라고 하는 하나의 입력에 대해 여러 개의 출력을 가질 수 있기 때문에 순수 함수가 아니라고 할 수 있다.

참조 투명성

이제 순수 함수를 조금 더 엄밀하게 정의해 보자. 이를 위해서는 위에서 언급한 메모하기와 연결되어 있는 참조 투명성Referential transparency이라고 하는 개념이 필요하다.

위에서 다룬 Math.random()2 + 3은 모두 JavaScript의 식Expression이다. 그러나 그 성질은 조금 다르다. 예를 들어 순수하지 않은 함수 Math.random을 다루는 다음과 같은 코드를 생각하자.

const a = Math.random();
const b = a;

console.log(a + b);

여기에서 const b = a;에 주목하자. 여기에서 이 코드를 다음과 같이 쓴다면,

const a = Math.random();
const b = Math.random();

console.log(a + b);

이 프로그램의 의미는 완전히 달라진다. 1개의 임의의 값을 복사해서 더하는 것이 아니라, 2개의 임의의 값을 뽑아서 서로 더하는 것이 되기 때문이다. 더 나아가

const a = Math.random();
const b = Math.random();

console.log(Math.random() + Math.random());

와 같이 써도 완전히 다른 의미의 프로그램이 됨에 주의하자.

한편 다음과 같이 순수한 + 연산자(또는 다른 Scala, Haskell과 같은 언어에서는 함수)를 다루는 프로그램의 경우

const a = 2 + 3;
const b = a;

console.log(a + b);

의 프로그램과

const a = 2 + 3;
const b = 2 + 3;

console.log(a + b);

의 프로그램, 즉 a 대신 a의 내용물을 덮어씌운 프로그램은 하는 동작이 완전히 같다.

이처럼 순수한 함수는 그 값을 변수에 저장하든, 어디에 두든, 위치를 바꿔치기Substitution해도 같은 의미를 가짐이 보장된다. 그러나 순수하지 않은 함수는 위치를 바꿔치기했을 경우 맥락에 영향을 받기 때문에 다른 의미를 가지게 된다.

여기에서 위치를 바꾸어도 함수 실행의 의미가 변하지 않는 것을 참조 투명성Referential transparency라고 한다. 더 엄밀히 말하자면

Expression ee가 주어졌을 때, 어떤 프로그램 pp에 대해서도 pp 안의 모든 eeee를 평가Evaluate한 값으로 바꾸어도 프로그램 pp의 의미가 바뀌지 않을 때 ee를 참조적으로 투명하다고 한다.

또한 이로써 순수 함수의 정의를 엄밀히 할 수 있는데

참조적으로 투명한 모든 식 xx에 대해 식 f(x)f(x)가 참조적으로 투명하면 함수 ff를 순수Pure하다고 한다.

위에서 수행한 바꿔쳐서 함수의 순수함을 검증하는 방법을 바꿔치기 모델Substitution model이라고 한다. 이는 가장 빠르게 함수의 순수성을 검증할 수 있는 방법 중 하나이다.

순수 함수는 왜 좋은가

순수 함수는 인자에만 의존하여 반환값을 결정하는 성질 상 함수를 둘러싸는 맥락Context과 완전히 분리되어 있다. 따라서 순수 함수는 완전히 모듈화되었다Modularized고 할 수 있다. 이로써 함수를 테스트하거나, 재사용하거나, 병렬 처리를 하거나, 일반화할 때 더욱 효율적으로 작업을 수행할 수 있다.

뿐만 아니라 함수의 입력과 출력이 확실하게 결정되면서 함수의 요구 사항이 명료해지는 한편, 함수의 동작에 대해 더 쉽게, 그리고 깊이 생각할 수 있다. 예기치 못한 버그의 발생 장소를 줄일 수 있음은 덤이다.

순수 함수만으로 프로그래밍할 수 있는가

순수 함수만으로 프로그래밍을 할 수 있음을 보여주기 위해 간단히 콘솔 창에서 입력을 받아 로깅을 하는 프로그램을 만든다고 생각하자. 우선 가상의 콘솔 창을 나타내는 Console 인터페이스를 생각하자.

interface Console<T> {
  input: T;
  output: string;
}

여기에서 input은 콘솔 창의 입력, output은 출력으로 생각하자. 그러면 다음 initialConsole은 콘솔 창에 값 8이 들어왔고 출력창은 비어 있는 상태를 나타낸다.

const initialConsole: Console<number> = {
  input: 8,
  output: '',
};

이제 입력된 값을 바탕으로 작업을 수행한 뒤, output에 출력하는 함수를 만드는 createConsoleWriter를 만들어 보자.

function createConsoleWriter<Input, Output>(
  action: (input: Input) => Output,
  loggingMessage: string
) {
  return function(value: Input): Console<Output> {
    return {
      value: action(value),
      output: loggingMessage,
    };
  };
}

이것으로 반으로 나누고 제곱을 하는 등 작업을 수행하면서 로깅을 수행하는 부분들을 만들 수 있다.

const halfer = createConsoleWriter(
  (val: number) => val / 2,
  '반으로 나눴어요! '
);
const squarer = createConsoleWriter((val: number) => val * val, '제곱했어요! ');

이제 이 부분들을 묶는 compose 함수를 만들자.

type ConsoleWriter<Input, Output> = ReturnType<typeof createConsoleWriter<Input, Output>>;

function compose<Input, Output>(writers: Array<ConsoleWriter<Input, Output>>, initialValue: Console<Input>) {
  return writers.reduceRight((prevConsole, writer) => {
    const newConsole = writer(prevConsole);

    return {
      value: newConsole.value,
      output: prevConsole.output + newConsole.output,
    }
  }, initialValue);
}

이것으로 간단히 콘솔에 출력하는 프로그램을 만들 수 있다.

compose(
  [squarer, halfer],
  initialConsole
); // Console { value: 16, output:'반으로 나눴어요! 제곱했어요! ' }

여기에서 우리가 만든 함수들은 주어진 인자가 같으면 반드시 같은 값을 출력한다는 점에서 모두 순수하다. 그러면서 계산 및 로그 출력을 하는 프로그램을 제작할 수 있었다. 여기에서 Console은 하스켈HaskellWriter 모나드를 간단히 TypeScript로 옮긴 것인데, 이렇게 함수형 프로그래밍에서는 순수 함수와 모나드들을 이용하여 우아하게 부수 효과를 처리한다. 다른 부수 효과도 마찬가지 방법으로 순수하게 처리할 수 있다. 이런 순수한 부수 효과 처리 방법은 React 등 많은 JavaScript의 라이브러리들이 부분적으로 도입하고 있다.

정리

  1. 순수 함수Pure function은 외부 맥락은 참조하지 않고 주어진 입력값에만 의존하여 반환값을 내는 함수를 말한다.
  2. 어떤 함수가 순수한지 검증하기 위해 메모하기 기법이나 바꿔치기 모델Substitution model을 사용할 수 있다.
  3. 순수 함수는 모듈화되어 있어 재사용, 테스트, 병렬 처리, 일반화에서 강점을 보인다. 또한 입력과 출력이 명확하여 버그 발생을 줄이고 함수의 작동에 대해 더 쉽게 깊이 생각할 수 있다.