🌈 Chapter 7: 함수형 최적화
📚 함수 실행의 내부 작동 원리
- 자바스크립트에서는 함수를 호출할 때마다 함수 콘텍스트 스택에 레코드(프레임) 가 생성된다.
- 콘텍스트 스택은 함수 실행 및 함수가 에워싼(클로저 같은 것) 변수를 관리하는 자바스크립트 프로그래밍 모델이다.
- 스택은 언제나 전역 데이터가 담긴, 전역 실행 콘텍스트 프레임에서 시작한다.
- 전역 콘텍스트 프레임은 항상 스택 맨 밑에 위치한다. 지역 변수가 하나도 없는 빈 프레임은 48비트 정도 되고, 숫자, 불리언 같은 지역 변수/매개변수는 8바이트를 차지한다.
executionContextData = {
scopeChain, // 이 함수의 variableObject, 그리고 부모 실행 콘텍스트의 variableObject에 접근하는 연결 고리이다.
variableObject, // 함수의 인수, 내부 변수, 함수 선언부를 포함한다.
this // 함수 객체를 가리키는 레퍼런스
}
variableObject
는 지역 변수와 함수는 물론, 함수의 인수, 유사배열 객체arguments
를 가리키는 속성이므로, 사실상 스택 프레임의 크기는 이 속성으로 결정된다.- 스코프 체인은 이 함수의 콘텍스트를 그 부모 실행 콘텍스트와 연결하거나 참조한다.
- 모든 함수는 스코프 체인이 결국 직/간접적으로 전역 콘텍스트와 연결된다.
- 📌 스택의 주요 작동 규칙
- 자바크립트는 단일 스레드로 작동한다. 즉, 동기 실행 방식이다.
- 전역 콘텍스트는 단 하나만 존재한다. (모든 함수 콘텍스트는 전역 콘텍스트를 공유한다.)
- 함수 콘텍스트 개수는 제한은 없다.
- 함수가 호출할 때마다 실행 콘텍스트가 새로 생성되며, 자기 자신을 재귀 호출할 때도 마찬가지이다.
- 함수형 프로그래밍은 함수를 최대한 사용하려고 하므로, 유연성과 재사용을 늘리고자 당면한 문제를 가능한 한 많은 함수로 분해하고 커리하는 건 얼마든지 좋지만, 커리된 함수를 지나치게 사용하면 콘텍스트 스택에 어떤 식으로든 영향을 끼친다.
🎈 커링과 함수 콘텍스트 스택
- 추상화를 한 꺼풀 더 입히면 일반적인 함수 평가보다 콘텍스트 오버해드가 더 많이 발생할 수 있다.
const logger = function(appender, layout, layout, name, level, message);
// ...
const logger =
function (appender) {
return function (layout) {
return function (name) {
return function (level) {
return function (message) {
// ...
}
}
}
}
}
- 위와 같은 중첩 구조는 한 번에 호출하는 것 보다 함수 스택을 더 많이 쓴다.
logger
함수를 커링 없이 실행하면 자바스크립트는 동기 실행되기 때문에 우선 전역 콘텍스트 실행을 잠시 멈추고 새 활성 콘텍스트를 만든 다음, 변수 해석에 사용할 전역 콘텍스트 레퍼런스를 생성한다.logger
함수는 그 안에서 다른Log4js
연산을 호출하므로 새 함수 콘텍스트가 생성되어 스택에 쌓인다. 자바스크립트 클로저 떄문에 내부 함수 호출로 비롯된 함수 콘텍스트는 다른 콘텍스트 위에 차곡차곡 쌓이며, 각 콘텍스트는 일정 메모리를 차지한 채scopeChain
레퍼런스를 통해 연결된다.- 중첩 함수를 실행하면 이런 식으로 함수 콘텍스트가 증가하고, 함수마다 스택 프레임이 새로 생기므로 함수가 중첩된 정보만큼 스택이 커진다. 커링과 재귀는 함수 호출을 중첩하여 작동한다.
- 다시 실행이 순서대로 실행이 완료되면 런타임은 다시 처음 상태로 돌아가고 전역 콘텍스트 단 하나만 실행 상태로 남는다. 이것이 자바스크립트 클로저라는 것이다.
- 모든 함수를 커리하면 항상 좋을 것 같지만, 과용하면 엄청난 메모리가 소모되면서 프로그램 실행 속도가 현저히 떨어질 수 있다.
const add = function (a, b) {
return a + b;
};
const c_add = curry2(add);
const input = _.range(80000);
addAll(input, add); // -> 511993600000000
addAll(input, c_add); // -> 브라우저가 뻗음
function addAll(arr, fn) {
let result = 0;
for(let i = 0; i < arr.length; i++) {
for(let j = 0; j < arr.length; j++) {
result += fn(arr[i], arr[j]);
}
}
return result;
}
🎈 재귀 코드의 문제점
- 함수가 자신을 호출할 때에도 새 함수 콘텍스트가 만들어진다.
- 엄청 큰 용량의 데이터를 재귀로 처리할 때에는 배열 크기만큼 스택이 커질 수 있다.
- 리스트, 특히 원소가 아주 많은 리스트는
map
,filter
,reduce
등의 고계함수를 이용해서 탐색하는 방법이 좋다. 이런 함수를 쓰면 함수 호출을 중첩하지 않고 반복할 때마다 스택을 계속 재활용할 수 있다.
📚 느긋한 평가로 실행을 늦춤
- 불필요한 함수 호출을 삼가고 꼭 필요한 입력만 넣고 실행하면 여러모로 성능 향상을 기대할 수 있다.
- 하스켈 같은 함수형 언어는 기본적으로 모든 함수 평가식을 느긋하게 평가(lazy function evaluation) 하도록 지원한다.
- 느긋한 평가는 여러 가지 전략이 있지만, 가능한 한 오래, 의존하는 표현식이 호출될 때까지 미룬다는 근본 사상은 같다.
- 자바스크립트는 기본적으로 함수 결괏값이 필요한지 따져볼 새도 없이 변수에 바인딩되자마자 표현식 평가를 마친다. 그래서 탐욕스런 평가라고 한다.
- 다음 Maybe 모나드 예제이다.
// Maybe 모나드
Maybe.of(student).getOrElse(createNewStudent());
// 아래와 같이 실행될 거 같지만 자바스크립트 엔진은 그렇지 않다.
if(!student) {
return createNewStudent();
}
else {
return student;
}
- 자바스크립트 엔진은 조급하게 평가하므로 학생 객체가
null
이든 아니든createNewStudent
함수를 무조건 실행한다. - 느긋하게 평가하면, 표현식은 위 코드처럼 작동하겠지만 학생 객체가 정상이 아닐 경우
createNewStudent
함수는 호출하지 않는다. - 느긋한 평가는 어떻게 활용할 수 있을까?
- 불필요한 계산을 피한다.
- 함수형 라이브러리에서 단축 융합(shortcut fusion)을 사용한다.