본문으로 건너뛰기

3장 자료구조는 적게, 일은 더 많이

이 장의 내용
  • 프로그램 제어와 흐름
  • 코드와 데이터를 효과적으로 헤아림
  • map, reduce, filter의 진면목
  • 로대시JS 라이브러리와 함수 체인
  • 재귀적 사고방식
[컴퓨터 프로그램의 구조와 해석](인사이트, 2016) 1장에서
"계산 프로세스는 컴퓨터에 내재하는 추상적인 존재다. 이들이 점점 진화하면서 프로세스는 데이터라는 또 다른 추상적인 존재에 영향을 끼친다."

자료구조를 순차적으로 탐색/변환하는 데 쓰이는 실용적인 연산 및 가지(map, reduce, filter)를 소개합니다. 또 함수형 프로그래밍에서 재귀가 차지하는 막대한 비중에 대해 알아보고 재귀적인 사고방식이 어떤 점에서 좋은지 설명합니다.

3.1 애플리케이션의 제어 흐름

프로그램이 정답에 이르기까지 거치는 경로를 제어 흐름이라고 합니다. 명령형 프로그램은 작업 수행에 필요한 전 단계를 노출하여 흐름이나 경로를 아주 자세히 서술합니다. 보통 작업을 수행하는 단계는 루프와 분기문, 구문마다 값이 바뀌는 변수들로 빼곡히 들어찹니다.

img

반면, 선언적 프로그램, 특히 함수형 프로그램은 독자적인 블랙박스 연산들이 단순하게, 즉 최소한의 제어 구조를 통해 연결되어 추상화 수준이 높습니다. 이렇게 연결한 연산들은 각자 다음 연산으로 상태를 이동시키는 고계함수에 불과합니다. 실제로 함수형 프로그램은 데이터와 제어 흘므 자체를 고수준 컴포넌트 사이의 단순한 연결로 취급합니다.

img

덕분에 다음과 같이 코드가 짧아집니다.

optA().optB().optC().optD();

연산을 체이닝하면 간결하면서 물 흐르는 듯한, 표현적인 형태로 프로그램을 작성할 수 있어 제어 흐름과 계산 로직을 분리할 수 있고 코드와 데이터를 더욱 효과적으로 헤아릴 수 있습니다.

3.2 메서드 체이닝

메서드 체이닝은 여러 메서드를 단일 구문으로 호출하는 OOP 패턴입니다. 메서드가 모두 동일한 객체에 속해 있으면 메서드 흘리기라고도 합니다. 대부분 객체지향 프로그램에서 불변 객체에 많이 적용하는 패턴이지만 함수형 프로그래밍에도 잘 맞습니다.

'Functional Programming'.substring(0, 10).toLowerCase() + ' is fun';

함수형으로 리팩터링한 코드는 다음과 같습니다.

concat(toLowerCase(substring('Functional Programming', 1, 10)), 'is fun');

매개변수는 모두 함수 선언부에 명시해서 부수효과를 없애고 원본 객체를 바꾸지 않아야 한다는 함수형 교리를 충실히 반영한 코드입니다. 그러나 이렇게 함수 코드를 안쪽에서 바깥쪽으로 작성하면 메서드 체이닝 방식만큼 매끄럽지 못합니다. 로직을 파악하려면 가장 안쪽에 감싼 함수부터 한 꺼풀씩 벗겨내야 하고 가독성도 현저히 떨어지지요.

변이를 일으키지 않는 한 함수형 프로그래밍에서도 단일 객체 인스턴스에 속한 메서드를 체이닝하는 건 나름대로 쓸모가 있습니다.

3.3 함수 체이닝

객체지향 프로그램은 주로 상속을 통해 코드를 재사용합니다. 순수 객체지향 언어에서, 특히 언어 자체의 자료구조를 구현한 코드를 보면 이런 패턴이 자주 눈에 띕니다. 가령 자바세는 List 인터페이스를 용도에 맞게 달리 구현한 ArrayList, LinkedList, DoublyLinkedList, CopyOnWriteArrayList 등이 있습니다. 이들은 모두 한 부모에서 출발하여 나름대로 특수한 기능을 덧붙인 클래스입니다.

함수형 프로그래밍은 접근 방법이 다릅니다. 자료구조를 새로 만들어 어떤 요건을 충족시키는게 아니라, 배열 등의 흔한 자료구조를 이용해 다수의 굵게 나뉜 고계 연산을 적용합니다. 이러한 고계 연산으로 다음과 같은 일을 합니다.

  • 작업을 수행하기 위해 무슨 일을 해야 하는지 기술된 함수를 인수로 받습니다.
  • 임시 변수의 값을 계속 바꾸면서 부수효과를 일으키는 기존 수동 루프를 대체합니다. 그 결과 관리할 코드가 줄고 에러가 날 만한 코드 역시 줄어듭니다.

3.3.1 람다 표현식

함수형 프로그래밍에서 탄생한 람다 표현식은 한 줄짜리 익명 함수를 일반 함수 선언보다 단축된 구문으로 나타냅니다.

람다 표현식은 항상 어떤 값을 반환하게 만들어 함수 정의부를 확실히 함수형으로 굳힙니다.

함수형 프로그래밍은 람다 표현식과 잘 어울리는 세 주요 고계함수 map, reduce, filter를 적극 사용할 것을 권장합니다. 사실 함수형 자바스크립트는 대부분 자료 리스트를 처리하는 코드입니다.

lodashJs는 개발자가 함수형 프로그램을 작성하도록 유도하는 중요한 장치를 제공하고, 여러 가지 공통적인 프로그래밍 작업을 처리하는 데 유용한 도우미 함수들을 풍성하게 제공합니다.

lodashJs 속 underscoreJs라는

lodashJs는 underscoreJs라는 유명 프로젝트에서 파생된 라이브러리이므로 underscoreJs의 관례를 따릅니다. 하지만 내부적으로는 함수 체인을 좀 더 우아하게 구축하는 방향으로 완전히 재작성되었고 성능 문제도 개선된 라이브러리입니다.

배열 축약

map, filter는 어떤 배열을 받아 새 배열을 내는 고계함수로, 하스켈, 클로저 등 대부분의 함수형 프로그래밍 언어에 기본 내장되어 있습니다. 이들을 조합하는 대신 배열 축약(또는 리스트 축약)이란 개념을 적용하는 방법도 있습니다. 배열 축약은 map, filter의 기능을 각각 for..of와 if 키워드를 이용하여 단축된 구문으로 캡슐화하는 함수형 장치입니다. 다음과 같은 형식입니다.

[for (x of 이터러블) if (조건) x]
예시
[for (p of people) if (p.birthYear === 1903) p.fullname].join(' and ');

3.4 코드 헤아리기

앞에서 고수준 연산을 서로 연결하여 프로그램을 구축하는 것이 중요하다고 강조했습니다. 함수형 흐름은 프로그램 로직을 파헤치지 않아도 뭘 하는 프로그램인지 윤곽을 잡기 쉽기 때문에, 개발자는 코드뿐만 아니라, 결과를 내기 위해 서로 다른 단계를 드나드는 데이터의 흐름까지 더 깊이 헤아릴 수 있습니다.

3.4.1 선언적 코드와 느긋한 함수 체인

함수형 프로그램은 단순 함수들로 구성한다고 했습니다. 개별 함수가 하는 일은 보잘것 없지만, 함께 뭉치면 복잡한 작업도 할 수 있죠. 함수들을 연결해서 프로그램을 구성하는 방법을 살펴보겠습니다.

FP의 선언적 모델에 따르면, 프로그램이란 개별적인 순수함수들을 평가하는 과정이라고 볼 수 있습니다. 그래서 필요 시 코드의 흐름성과 표현성을 높이기 위한 추상화 수단을 지원하며, 이렇게 함으로써 여러분이 개발하려는 애플리케이션의 실체를 명확하게 표현하는 온톨로지 또는 어휘집을 만들 수 있습니다. map, reduce, filter라는 구성 요소르 ㄹ바탕으로 순수함수를 쌓아가면 자연스레 한눈에 봐도 흐름이 읽히는 코드가 완성됩니다.

함수형 프로그래밍은 자료구조보다 연산에 더 중점을 둡니다.

이름 리스트를 읽고 데이터를 정제 후, 중복은 제거하고 정렬하는 일련의 작업을 예로 들어봅시다. 명령형 버전으로 먼저 프로그램을 작성 후, 함수형으로 리팩터링하겠습니다.

var names = ['alonzo church', 'Haskell curry', 'stephen_kleene', 'John Von Neumann', 'stephen_kleene'];
[코드 3-6] 배열을 순차적으로 연산 (명령형)
var result = [];
for(let i = 0; i < names.length; i++) {
var n = names[i];
if (n !== undefined && n !== null) {
var ns = n.replace(/_/, ' ').split(' ');
for(let j = 0; < ns.length; j++) {
var p = ns[j];
p = p.charAt(0).toUpperCase() + p.slice(1);
ns[j] = p;
}
if (result.indexOf(ns.join(' ')) < 0) {
result.push(ns.join(' '));
}
}
}

result.sort();

명령형 코드의 단점은 특정 문제의 해결만을 목표한다는 점입니다. 함수형보다 훨씬 저수준에서 추상한 코드로서 한 가지로 용도로 고정됩니다. 추상화 수준이 낮을수록 코드를 재사용할 기회는 줄어들고 에러 가능성과 코드 복잡성은 증가합니다.

반면, 함수형 프로그램은 블랙박스 컴포넌트를 서로 연결만 해주고, 뒷일은 테스트까지 마친 검증된 API에게 모두 맡깁니다. 폭포수 떨어지듯 함수를 연달아 호출하는 모습이 눈에 더 잘 들어오지 않나요?

[코드 3-7] 배열을 순차적으로 연산 (함수형)
_.chain(names)
.filter(isValid)
.map(s => s.replace(/_/, ' '))
.uniq()
.map(_startCase)
.sort()
.value();

names 배열을 정확한 인덱스로 순회하는 등 버거운 일은 모두 _.filter와 _.map 함수가 대행하므로 여러분은 그저 나머지 단계에 대한 프로그램 로직을 구현하면 됩니다.

_.chain 함수는 주어진 입력을 원하는 출력으로 변환하는 연산들을 연결함으로써 입력 객체의 상태를 확장합니다. 이 함수는 임의의 함수를 명시적으로 체이닝 가능한 함수로 만듭니다. 프로그램이 조금 복잡해 보이긴 하지만, 변수를 만들거나 루프를 돌리는 일 따위는 할 필요가 없습니다.

3.4.2 유사 SQL 데이터: 데이터로서의 함수

개발자는 대부분 SQL에 익숙한 편이라 쿼리만 봐도 데이터에 무슨 작업을 하는지 압니다. 결국 쿼리 언어를 구사하듯 개발하는 것과 함수형 프로그래밍에서 배열에 연산을 적용하는 것은 일맥상통합니다.

SELECT p.firstname FROM Person p
WHERE p.birthYear > 1903 and p.country IS NOT 'US'
GROUP BY p.firstname

위 쿼리는 실행 결과가 어떤 데이터가 나올지 불 보듯 훤합니다.

[코드 3-9] 자바스크립트를 SQL 비슷하게 작성하기
_.from(persons)
.where(p => p.birthYear > 1900 && p.address.country !== 'US')
.sortBy(['firstname'])
.select(p => p.firstname)
.value();
자바스크립트 믹스인

믹스인은 (예제의 SQL 명령어처럼) 특정 형식과 연관된 함수를 부분적으로 추상한 객체입니다. 그래서 그 자체로 쓰이기 보단 다른 객체의 로직을 확장하는 용도로 활용합니다. 타깃 객체는 믹스인의 모든 기능을 빌려 쓰게 됩니다.

믹스인은 OOP 세계에서 다중 상속을 지원하지 않는 언어에서 다중 상속을 모방하거나, 상속 등의 우회책을 쓰지 않아도 코드를 재사용할 수 있게 합니다.

자바스크립트 코드도 SQL처럼 데이터를 함수 형태로 모형화할 수 있는데 이를 데이터로서의 함수라는 개념으로 부르기도 합니다. 선언적으로 어떤 데이터가 출력되어야 할지 서술할 뿐 그 출력을 어떻게 얻는지는 논하지 않지요. 앞으로도 루프를 쓰지 않으려고 합니다. 고수준의 추상화로 루프를 대체할 수 있으니까요.

재귀 역시 루프를 대체할 때 많이 쓰는 기법입니다. 천성이 자기 반복적인 문제에 대해 반복 자체를 재귀로 추상하여 푸는 방법이지요. 이런 유형의 문제는 순차적 함수 체인만으로는 해결하기 어렵고 비효율적입니다. 하지만 재귀는 일반 루프로 수행하는 버거운 작업을 언어 자체의 런타임에 맡김으로써 독자적인 방식으로 데이터를 처리합니다.

3.5 재귀적 사고방식

3.5.1 재귀란?

재귀는 주어진 문제를 자기 반복적인 문제들로 잘게 분해한 다음, 이들을 다시 조합해 원래 문제의 정답을 찾는 기법입니다. 재귀 함수의 주된 구성 요소는 다음과 같습니다.

  • 기저 케이스 (종료 조건)
  • 재귀 케이스

기저 케이스는 재귀 함수가 구체적인 결괏값을 바로 계산할 수 있는 입력 집합입니다. 재귀 케이스는 함수가 자신을 호출할 때 전달한 입력 집합(최초 입력 집합보다 점점 작아집니다)을 처리합니다.

함수가 반복될수록 입력 집합은 무조건 작아지며, 제일 마지막에 기저 케이스로 빠지면 하나의 값으로 귀결됩니다.

3.5.2 재귀적으로 생각하기

재귀는 변이가 없으므로, 더 강력하고 우수하며 표현적인 방식으로 반복을 대체할 수 있습니다. 사실상 순수 함수형 언어는 모든 루프를 재귀로 수행하기 때문에 do, for, while 같은 기본 루프 체계조차 없으며, 재귀를 적용한 코드가 더 이해하기 쉽습니다. 점점 줄어드는 입력 집합에 똑같은 작업을 여러 번 반복한다는 전제하에 작동하기 때문입니다.

sum[1,2,3,4,5,6,7,8,9] = 1 + sum[2,3,4,5,6,7,8,9]
= 1 + 2 + sum[3,4,5,6,7,8,9]
= 1 + 2 + 3 + sum[4,5,6,7,8,9]
[코드 3-10] 재귀적 덧셈
function sum(arr) {
if(_isEmpty(arr)) {
return 0;
}
return _.first(arr) + sum(_.rest(arr));
}
sum([]);
sum([1,2,3,4,5,6,7,8,9]);

3.6 마치며

  • 고계함수 map, reduce, filter를 쓰면 코드를 확장할 수 있습니다.
  • 로대시JS는 데이터 흐름과 변환 과정이 명확히 구획된 제어 체인을 통해 데이터 처리 및 프로그램 작성을 도모합니다.
  • 함수형 프로그래밍의 선언적 스타일로 개발하면 코드를 헤아리기 쉽습니다.
  • 고수준의 추상화를 SQL 어휘로 매핑하면 더 심도있게 데이터를 이해할 수 있습니다.
  • 재귀는 자기 반복적 문제를 해결하는 데 쓰이며, 정의된 자료구조를 재귀적으로 파싱해야 합니다.