[함수형 자바스크립트]7.함수형 최적화


최적화는 항상 맨 나중에 한다. 함수형 코드를 작성하는 것과 테스트하는것까지 알아본 지금이 최적화에 대해 이야기 할 적기인 것 같다. 모든 프로그래밍 패러다임은 완벽할 수 없다. 그저 일장일단이 있을뿐.

성능추상화도 마찬가지다. 함수형은 래핑하는 경우가 많기때문에 성능에 대한 궁금증이 생길 수 밖에 없다. 명령형과 비교하여 어떨까? 결론적으로 말하자면 또이또이하다. 하드웨어의 성능이 충분히 좋은 요즘같은경우에 몇 밀리초 단축시키거나 느린건 큰 문제가 되지 않는다.

패러다임으로써는 문제가 없다는걸 알았고, 사실 코드에서 대부분의 냄새는 패러다임보다는 로직의 구성방법이나 로직에서 나온다.

함수 실행의 내무 작동 원리

자바스크립트에서는 함수를 호출할 때마다 함수 컨텍스트 스택에 레코드(프레임)가 생성된다.

전역 컨텍스트 프레임은 항상 스택 맨 밑에 위치한다. 함수 컨텍스트 프레임은 각각 내부 지역 변수의 개수만큼 메모리를 점유한다. 지역변수가 하나도 없는 빈 프레임은 48바이트 정도 되고, 숫자, 불리언 같은 지역 변수/매개변수는 8바이트를 차지한다.

executionContextData = {
    scopeChain,
    variableObject,
    this
}

variableObject는 지역변수와 함수는 물론이고 함수의 인수, 유사배열 객체 arguments를 가리키는 속성이므로, 사실상 스택프레임의 크기는 이 속성으로 결정된다. 스코프체인은 부모 실행컨텍스트와 연결하거나 참조한다. 모든 함수는 결국 전역 프레임과 직/간접적으로 연결되게 된다.

스택의 주요 작동 규칙은 아래와 같다.

  • 단일 스레드로작동한다. 즉 동기실행이다.
  • 전역컨텍스트는 단 하나만 존재한다.
  • 함수컨텍스트 개수에 제한은 없다.
  • 함수를 호출할 때마다 실행 콘텍스트가 새로 생성되며, 자기 자신을 재귀호출해도 마찬가지다.

이런 연유로 커리된 함수를 지나치게 사용하면 컨텍스트 스택을 신경써야 할 때가 올 수도 있다.

커링과 함수 컨텍스트 스택

추상화를 한 커풀 더 입히면 일반적인 함수 평가보다 컨텍스트에 오버헤드가 더 많이 발생할 수 있다.

어떤 함수를 커리하면 한 번에 인수를 전부 넣고 평가하는 것이 아니라 한 번에 하나씩 인수를 받는다고 이야기했었다. 즉 인수 수만큰 함수를 생성하는 것과 마찬가지이다. 그럼 함수 컨텍스트 프레임이 쌓이게된다.

모든 함수를 커리하면 좋을 것 같지만, 과용하면 메모리 소모가 커져 곤란해질 수 있다.

재귀 코드의 문제점

재귀 호출도 함수 컨텍스트가 생성된다. 출구 케이스에 도달하지 못하는 잘못된 재귀 코드를 호출하면 스택 넘침이 일어날 수 있다.

원소가 많은 리스트는 map, filter, reduce등의 고계함수를 이용하는것이 좋다.

느긋한 평가로 실행을 늦춘다

불필요한 함수 호출을 삼가하고 꼭 필요한 입력만 넣고 실행하면 여러모로 성능 향상을 기대할 수 있다. 하스켈 같은 함수형 언어는 기본적으로 모든 함수 표현식을 느긋하게 평가한다.

자바스크립트는 기본적으로 함수를 조급하게 평가한다. 함수 결괏값이 필요한지 따져볼 새도 없이 변수에 바인딩되자마자 표현식 평가를 마친다.

어떻게 느긋하게 평가 할 수 있을까?

대체 함수형 조합으로 계산을 회피

느긋한 평가를 제대로 흉내 내면 순수 함수형 언어만의 혜택을 누릴 수 있다. 가장 단순한 사용법은 함수를 레퍼런스로 전달하고 조건에 따라 한쪽만 호출하여 쓸데없는 계산을 건너뛰는 것이다.

alt조합기를 생각하면 된다. 혹은 실행 전에 전체 프로그램을 미리 정의해서 함수형 라이브러리로 하여금 단축 융합이라는 최적화를 수행하게 하는 방법도 있다.

단축 융합

chain 이라는 함수를 배웠었다. 맨 마지막에 value()를 호출하면 전체 함수를 몽땅 실행하게 만드는 함수. 이렇게 하면 프로그램 서술부와 실행부를 나눌 수 있을 뿐아니라 함수 실행 중 차지하는 공간을 효율적으로 통합하여 최적화하도록 할 수 있다.

단축 융합은 몇 개 함수의 실행을 하나로 병합하고 중간 결과를 계산할 때 사용하는 내부 자료구조의 개수를 줄이는 함수 수준의 최적화이다. 자료구조가 줄면 대량 데이터를 처리할 때 필요한 과도한 메모리 사용을 낮출 수 있다.

이게 가능한 이유는 함수형프로그래밍이 참조투명성을 가지고 있어서 수학적인 정합 관계가 성립하기 때문이다. 예를 들면 compose(map(f), map(g))map(compose(f, g))와 같다.

필요할 때 부른다.

반복적인 계산을 피하는것도 실행 속도를 올리는 방법이다. 캐시나 프록시 기법을 생각하면 된다. 함수형에서는 메모이제이션이 있다.

계산량이 많은 함수를 메모이제이션

순수 함수형 언어는 자동으로 메모이제이션을 하지만, 자바스크립트나 파이썬같은 언어에서는 언제 할지 선택할 수 있다. 아무래도 캐시 계층을 잘 엮어놓으면 가장 큰 덕을 보는건 계산 집약적인 함수이다.

Function.prototype.memoized = function () {
    let key = JSON.stringify(arguments);
    
    this._cache = this._cache || {};

    this._cache[key] = this._cache[key] || this.apply(this, arguments);

    return this._cache[key];
}

Function.prototype.memoize = function () {
    let fn = this;
    if(fn.length === 0 || fn.length > 1) {
        return fn;
    }

    return function () {
        return fn.memoized.apply(fn, arguments);
    }
}

단항함수를 메모이제이션 하는 것을 구현하는건 크게 어렵지 않다. 단항함수를 할 수 있다면, 커링하면 된다.

커링과 메모이제이션

인수가 여러개인 함수는 순수함수라해도 캐시하기가 어렵다. 캐시과정에서 키값을 생성하는데 더 큰 오버헤드가 들기 때문이다.

재귀와 꼬리 호출 최적화

재귀는 보통 스택 소비량이 일반적인 상황보다 훨씬 많다. 메모이제이션은 재귀에서 엄청난 성능을 보여주지만, 만약 매번 입력값이 바뀌는 재귀라면 캐싱은 쓸모가 없어진다.

재귀를 일반 루프만큼 최적화 할 수 있을까? 컴파일러가 꼬리 호출 최적화를 수행하게끔 짜면 된다.


2023년 새해에는 성장하고 함께하고 싶다면?

Pre A 단계 이상의 스타트업 C 레벨들이 모여서 커뮤니티를 만들었습니다. 같이 스터디하고 친해질 일잘러를 찾습니다.




© 2017. by isme2n

Powered by aiden