본문으로 건너뛰기

4장 재사용 가능한, 모듈적인 코드

이 장의 내용
  • 함수 체인과 함수 파이프라인 비교
  • 함수형 라이브러리 람다JS 소개
  • 커링, 부분 적용, 함수 바인딩 개념 탐구
  • 함수 합성으로 모듈적인 프로그램 제작
  • 함수 조합기로 프로그램의 흐름을 개선
[The Systems Bible](General Systemantics Press, 2012) 중에서
"잘 작동하는 복잡한 시스템은 십중팔구 잘 작동했던 단순한 시스템에서 진화한 경우가 많다."

대규모 소프트웨어 프로젝트에서 매우 중요한 특성 중 하나인 모듈성은, 프로그램을 더 작고 독립적인 부분으로 나눌 수 있는 정도를 뜻합니다. 모듈적 프로그램은 자신을 구성하는 부속들로부터 자신의 의미를 도출할 수 있다는 점에서 뚜렷이 구분됩니다. 모듈성은 개발자의 생산성을 높일 뿐만 아니라 코드 유지보수성 및 가독성을 향상시키는 데이도 도움이 됩니다.

프로그래밍 세계에서는 대부분 문제를 더 작은 조각으로 쪼갠 후, 이들을 다시 재구성하여 해법을 완성하는 방식을 선호합니다.

3장에서는 고수준 함수를 써서 하나의 래퍼 객체를 중심으로 단단히 결합된 메서드 체인으로 문제를 해결했습니다. 4장에서는 함수 합성을 통해 느슨하게 결합된 파이프라인을 만들고, 보다 유연한 독립적인 컴포넌트들을 재료 삼아 전체 프로그램을 구축하겠습니다. 컴포넌트 각자로는 별로 가치가 없지만 서로 뭉치면 작게는 함수, 크게는 온전한 모듈 형태로 만들어 전체 프로그램에 의미를 부여할 수 있습니다.

코드 모듈화는 쉬운 작업이 아닙니다. 선언적 함수 파이프라인을 통해 무인수 형태로 문제를 해결하려면 먼저 코드를 적정 수준으로 추상화해야 합니다.

4.1 메서드 체인 대 함수 파이프라인

파이프라이닝은 함수를 연결하는 또 다른 기법입니다. 함수형 프로그래밍에서 함수란 입력 형식과 출력 형식 간의 수학적인 매핑을 뜻합니다.

함수 체이닝과 파이프라이닝을 이해하려면 함수를 형식간의 매핑으로 바라보아야 합니다.

  • 메서드를 체이닝 (단단한 결합, 제한된 표현성)
  • 함수 파이프라인을 배열 (느슨한 결합, 유연성)

4.1.1 메서드를 여럿 체이닝

암시적으로 생성된 LodashWrapper 객체를 매개로 이들 함수를 서로 체이닝하면 물밑에서 전혀 새로운 자료구조를 만듭니다.

_chain(names)
.filter(isValid)
.map((s) => s.replace(/_/, " "))
.uniq()
.map(_.startCase)
.sort()
.value();

뒤에 "점(.)"을 붙여 로대시JS의 다른 메서드를 호출합니다.

명령형 코드에 비해 분명히 구조적으로 향상됐고 가독성도 좋아졌습니다. 다만, 자신을 소유한 객체에 부자연스럽게 매여 있어 아쉽게도 체인에서 실행 가능한 메서드 가짓수가 줄고 코드의 표현성도 제약을 받습니다. 또한 다른(또는 직접 만든) 라이브러리 함수를 쉽게 연결할 수 없습니다.

고수준에서 보면 배열 메서드의 순차열은 [그림 4-3]과 같이 처리됩니다. 여기서 체인을 끊어 버리고 독립적인 함수열을 자유롭게 배열할 수 있으면 좋겠군요. 그래서 함수 파이프라인이 필요한 것입니다.

img

4.1.2 함수를 파이프라인에 나열

함수형 프로그래밍에서는 메서드 체이닝의 한계에서 벗어나, 출신에 관계없이 어떤 함수라도 유연하게 결합할 수 있습니다. 파이프라인이란 한 함수의 출력이 다음 함수의 입력이 되게끔 느슨하게 배열한, 방향성 함수 순차열입니다.

img

체이닝은 객체 메서드를 통해 함수들을 단단히 결합하지만, 파이프라인은 함수 입출력을 서로 연결 지어 느슨하게 결합된 컴포넌트를 만듭니다. 단, 함수의 항수와 형식이 호환되지 않으면 연결할 수 없습니다.

4.2 함수 호환 요건

객체지향 프로그램에서는 파이프라인을 특정한 경우(보통 인증authentication/인가authorization 처리)에 드문드문 사용하지만, 함수형 프로그래밍에서는 파이프라인이 프로그램을 구축하는 유일한 수단입니다. 이 계산 단계가 코드에서는 함수로 표현되는데요. 각 함수는 두 가지 측면에서 입력과 출력이 서로 호환돼야 합니다.

  • 형식: 한 함수의 반환 형식과 수신 함수의 인수 형식이 일치해야 합니다.
  • 항수: 수신 함수는 앞 단계 함수가 반환한 값을 처리하기 위해 적어도 하나 이상의 매개 변수를 선언해야 합니다.

4.2.1 형식이 호환되는 함수

형식은 틀림없이 중요한 이슈지만, 자바스크립트는 함수가 취하는 인수 개수의 호환 여부가 더 중요합니다.

4.2.2 함수의 항수: 튜플

항수란 함수가 받는 인수의 개수입니다. 함수의 길이라고도 합니다. FP에서는 항수에 선언된 인수의 개수가 참조 투명성의 당연한 결과로서 복잡도와 정확히 비례하는 경우가 많습니다. 인수가 1개인 순수함수는 한 가지 용도, 즉 단일 책임을 담당하므로 가장 단순한 함수라고 볼 수 있습니다. 우리의 목표는 함수의 인수를 가능한 한 적게 하는 것입니다. 그래야 인수가 많은 함수보다 더 유연하고 다목적으로 활용할 수 있습니다.

함수형 언어는 튜플이라는 자료구조를 지원합니다. 튜플은 유한 원소를 지닌 정렬된 리스트로, 보통 한 번에 두세 게 값을 묶어 (a, b, c) 와 같이 씁니다.

튜플은 형식이 다른 원소를 한데 묶어 다른 함수에 건네주는 일이 가능한 불변성 자료구조입니다. 물론, 객체 리터럴이나 배열 같은 임의 형식으로 반환하는 방법도 있긴 합니다. 그래도 함수간에 데이터를 변환할 때에는 튜플이 다음 측면에서 더 유리합니다.

  • 불변성: 튜플은 한번 만들어지면 나중에 내용을 못 바꿉니다.
  • 임의 형식의 생성 방지: 튜플은 전혀 부관한 값을 서로 연관 지을 수 있습니다. 단지 데이터를 묶겠다고 새로운 형식을 정의하고 인스턴스화하는 건 괜스레 데이터 모형을 복잡하게 할 뿐입니다.
  • 이형 배열의 생성 방지: 형식이 다른 원소가 배열에 섞여 있으면 형식을 검사하는 방어 코드를 수반하므로 다루기가 까다롭습니다. 배열은 태생 자체가 동일한 형식의 객체를 담는 자료구조입니다.

4.3 커리된 함수를 평가

커링을 이해하려면 먼저 일반평가와 커리된 평가의 차이점을 인지해야 합니다. 자바스크립트에서는 비커리된 일반 함수를 호출할 때 인수가 모자라도 별문제 없이 실행됩니다. 이를테면 함수 f(a,b,c)를 호출할 때 a 값만 넣어도 자바스크립트 런타임은 b,c를 undefined로 자동 세팅하므로 f 함수는 정상적으로 실행됩니다. 하지만 안타깝게도 이런 자바스크립트 특성 탓에 언어 자체에서 커링을 기본 지원하지는 않는 듯합니다. 인수를 선언하지 않고 함수 안에서 arguments 객체에 전적으로 의존하는 건 문제를 키울 위험이 있습니다.

이와 달리 모든 매개 변수가 명시된 커리된 함수에 일부 인수만 넣어 호출하면, 함수가 실행되는게 아니라 모자란 나머지 인수가 다 채워지기를 기다리는 새로운 함수가 반환됩니다.

img

커링은 다변수 함수가 인수를 전부 받을 때까지 실행을 보류, 또는 '지연'시켜 단계별로 나뉜 단항 함수의 순차열로 전환하는 기법입니다.

curry는 (a,b,c)라는 입력을 인수가 하나뿐인 호출로 해체하는 함수 간의 매핑입니다.

[코드 4-5] 두 인수를 수동으로 커리
function curry2(fn) {
return function (firstArg) {
return function (secondArg) {
return fn(firstArg, secondArg);
};
};
}

코드에서 보다시피, 반환된 함수는 나중에 사용할 인수를 포착하기 위해 함수 래퍼를 중첩한 코드에 불가합니다. 이런 점에서 커링은 어휘 스코프(클로저)의 또 다른 사례라고 볼 수도 있습니다.

const name = curry2((last, first) => new StringPair(last, first));

[first, last] = name("Curry")("Haskell").values();
first; // -> 'Curry'
last; // -> 'Haskell'

name("Curry"); // -> Function

R.curry를 쓰면 인수 개수에 상관없이 순수 함수형 언어의 자동 커링 장치를 모방할 수 있습니다. 자동 커링은 선언된 인수 개수만큼 중첩된 함수 스코프를 인위적으로 생성하는 작업입니다.

커링은 다음 디자인 패턴을 구현할 때 많이 사용합니다.

  • 함수 팩토리를 모방
  • 재사용 가능한 모듈적 함수 템플릿을 구현

4.3.1 함수 팩토리를 모방

객체지향 세계에서 인터페이스는 클래스가 반드시 구현해야 할 규약을 정해놓은 추상적 형식입니다.

public interface StudentStore {
Student findStudent(String ssn);
}

public class DbStudentStore implements StudentStore {
public Student findStudent(String ssn) {
// ...
ResultSet rs = jdbcStmt.executeQuery(sql);
while(rs.next()) {
String ssn = rs.getString("ssn");
while(rs.next()) {
String ssn = rs.getString("ssn");
String name = rs.getString("firstname") + rs.getString("lastname");
return new Student(ssn, name);
}
}

public class CacheStudentStore implements StudentStore {
public Student findStudent(String ssn) {
// ...
return cache.get(ssn);
}
}
}
}

동일한 인터페이스를 두 클래스로 구현했습니다. 학생 데이터를 전자는 DB에서 읽고 후자는 캐시에서 읽습니다. 호출자 관점에선 메서드를 호출한다는 사실이 중요하지, 객체의 출처는 관심 없습니다. 함수 팩토리의 도움을 받아 적합한 구현부를 가져오는 겁니다.

StudentStore store = getStudentStore();
store.findStudent("444-44-4444");

자바 코드를 자바스크립트 언어로 바꿔 저장소와 배열에 보관된 학생 객체를 조회하는 함수를 작성해봅시다.

// fetchStudentFromDb :: DB -> (String -> Student)
const fetchStudentFromDb = R.curry(function (db, ssn) {
return find(db, ssn);
});

// fetchStudentFromArray :: Array -> (String -> Student)
const fetchStudentFromArray = R.curry(function (arr, ssn) {
return arr[ssn];
});

이 함수는 커리를 해놔서 일반 팩토리 메서드 findStudent로 평가하는 부분과 함수를 정의한 부분을 떼어놓을 수 있습니다. 실제 구현부는 둘 중 하나겠죠.

const findStudent = useDb ? fetchStudentFromDb(db)
: fetchStudentFromArray(arr);

findStudent('444-44-4444');

호출자는 실제 구현부를 알지 못해도 얼마든지 findStudent를 불러 쓸 수 있습니다. 커링은 재사용 측면에서도 함수 템플릿을 여럿 만들 수 있어 좋습니다.

4.3.2 재사용 가능한 함수 템플릿 구현

재사용 가능한 함수 템플릿을 커링 기법으로 정의하면 유연성, 재사용 측면에서 좋습니다.

[코드 4-6] 로거 함수 템플릿을 만듦
const logger = function(appender, layout, name, level, message) {
const appenders = {
'alert': new Log4js.JSAlertAppender(),
'console': new Log4js.BrowserConsoleAppender(),
};

const layouts = {
'basic': new Log4js.BasicLayout(),
'json': new Log4js.JSONLayout(),
'xml': new Log4js.XMLLayout()
};
const appender = appenders[appender];
appender.setLayout(layouts[layout]);

const logger = new Log4js.getLogger(name);
logger.addAppender(appender);
logger.log(level, message, null);
}

로거를 커리하면 상황별로 적합한 로거를 모두 한곳에서 관리하고 재사용할 수 있습니다.

const log = R.curry(logger)('alert', 'json', 'FJS'); // 마지막 두 인수만 빼고 모두 평가합니다.

log('ERROR', '에러가 발생하였습니다!!');

여러 에러 처리 구문을 하나의 함수나 파일로 구현하고 싶으면, 유연하게 마지막 매개변수를 제외한 나머지 매개변수를 부분 세팅하면 됩니다.

const logError = R.curry(logger)('console', 'basic', 'FJS', 'ERROR');

logError('코드 404 에러가 발생했습니다!');

재사용이 획기적으로 향상되는 것도 장점이지만, 무엇보다 커링의 가장 중요한 의의는 다인수 함수를 단항 함수로 바꾼다는 것입니다.

4.4 부분 적용과 매개변수 바인딩

부분 적용은 함수의 일부 매개변수 값을 처음부터 고정시켜 항수가 더 작은 함수를 생성하는 기법입니다. 쉽게 말해, 매개변수가 5개인 함수가 있을 때 3개의 값을 제공하면 나머지 두 매개변수를 취할 함수가 생겨납니다.

  • 커링은 부분 호출할 때마다 단항 함수를 중첩 생성하며, 내부적으로는 이들을 단계별로 합성하여 최종 결과를 냅니다.
  • 부분 적용은 함수 인수를 미리 정의된 값으로 묶은(할당한) 후, 인수가 적은 함수를 새로 만듭니다. 이 결과 함수는 자신의 클로저에 고정된 매개변수를 갖고 있으며, 후속 호출 시 이미 평가를 마친 상태입니다.

커링은 부분 적용을 자동화한 것입니다. 이것이 두 기법의 가장 큰 차이점입니다.

// 커링
var curriedFun = function(a) {
return function(b) {
return function(c) {
return a + ', ' + b + ', ' + c;
}
}
}

// 부분 적용
var partialAppliedFn = function(a) {
var upperA = a.upperCase();
return function (b, c) {
return upperA + ', ' + b + ', ' + c;
}
}

4.4.1 언어의 핵심을 확장

부분 적용은 String, Number 같은 핵심 자료형을 확장하여 언어의 표현성을 풍부하게 할 목적으로 사용할 수 있습니다. 단, 이렇게 언어를 확장하면 차후 플랫폼을 업그레이드할 때 언어에 추가된 새 메서드와 충돌할 가능성이 있어 이식성은 떨어집니다.

// 처음 N개 문자를 얻습니다.
String.prototype.first = _.partial(String.prototype.substring, 0, _);

'Functional Programming'.first(3); // -> 'Fun'

// 성명을 '성, 이름' 형식으로 바꿉니다.
String.prototype.asName = _.partial(String.prototype.replace, /(\w+)\s(\w+)/, '$2, $1');

'Alonzo Church'.asName(); // -> 'Church, Alonzo'

여러분이 직접 함수를 구현하기 전에 최근 업데이트된 기능과 겹치는 부분이 있는지 미리 확인하세요.

if (!String.prototype.explode) {
String.prototype.explode = _.partial(String.prototype.match, /[\w\]/gi);
}

4.4.2 지연된 함수에 바인딩

소유 객체를 전제로 메서드를 다룰 때에는 함수 바인딩으로 콘텍스트 객체를 세팅하는 일이 중요합니다. 브라우저에서 setTimeout, setInterval 같은 함수의 this 레퍼런스는 전역 콘텍스트인 window 객체를 가리켜야 별 탈 없이 작동합니다. 이때는 간단히 런타임에 undefined를 전달하면 됩니다.

const Scheduler = (function() {
const delayedFn = _.bind(setTimeout, undefined, _, _);

return {
delay5: _.partial(delayedFn, _, 5000),
delay10: _.partial(delayedFn, _, 10000),
delay: _.partial(delayedFn, _, _),
}
})();

Scheduler.delay5(function() {
consoleLog('5초 후에 실행합니다!');
});

바인딩한 함수와 부분 적용한 함수를 합성하여 지연된 연산을 하나씩 쌓아 올리면 됩니다. 함수 바인딩은 함수 콘텍스트를 세팅하는 작업이 까다로운 편이라 함수형 프로그래밍에서 부분 적용만큼 큰 도움이 되지는 않습니다.

부분 적용, 커링 모두 유익한 기법입니다. 가장 널리 쓰이는 커링은 함수의 인수를 미리 세팅하거나 부분 평가하기 위해 함수 기능을 추상한 래퍼를 만듭니다. 당연히 인수가 적은 함수가 인수가 많은 함수보다는 다루기 쉬우니 이렇게 하면 도움이 됩니다. 어느 기법을 택하든지, 함수를 여러 단항 함수들로 몸집을 줄이는 동시에 맘대로 자신의 스코프 밖에 위치한 객체에 접근하지 못하게끔 적정한 개수의 인수를 공급하는 효과가 있습니다. 필요한 데이터를 얻는 로직을 분리하면 재사용 가능한 함수로 만들 수 있습니다. 무엇보다, 함수의 합성을 단순화한다는 장점이 있습니다.

4.5 함수 파이프라인을 합성

부수효과 없는 함수는 외부 데이터에 절대 의존하지 않으며 필요한 정보는 반드시 인수를 통해서만 받습니다. 합성을 하려면 반드시 함수에서 부수효과를 없애야 합니다.

순수함수로 작성한 프로그램은 그 자체로 순수한 프로그램으로, 시스템의 다른 부분을 손대지 않아도 더 복잡한 프로그램의 일부로 합성할 수 있습니다.

4.5.1 HTML 위젯에서 합성하기

Node라는 재귀적 튜플을 다음과 같이 정의하겠습니다.

const Node = Tuple(Object, Tuple);

한 객체와 다른 노드(튜플)를 가리키는 레퍼런스로 구성된 튜플입니다. 이 튜플은 리스트를 함수형으로 정의한 모습으로, 머리와 꼬리가 재귀적으로 이루어져 있습니다. 다음의 element는 커리된 함수로,

const element = R.curry((val, tuple) => new Node(val, tuple));

이 함수만 있으면 null로 끝나는 어떤 형식의 리스트라도 생성할 수 있습니다. 다음은 단순 숫자 리스트입니다.

숫자 리스트를 형성하는 머리/꼬리 부분, 함수형 언어에서는 배열을 처리할 때 머리/꼬리를 함수로 이용할 수 있습니다.
var grades = element(1, element(2, element(3, element(4, null))));

다른 외부 객체와 결합도가 높고 구조가 복잡한 객체는 합성 규칙도 명확하지 않고 다루기가 매우 어려울 수 있습니다. 함수 합성 역시 부수효과와 변이가 존재하면 그렇겠죠.

4.5.2 함수 합성: 서술과 평가를 구분

함수 합성이란 한마디로 복잡한 작업을 한데 묶어 간단한 작업으로 쪼개는 과정입니다.

const str = 'We can only see a short distance'

const explode = (str) => str.split(/\s+/);

const count = (arr) => arr.length;

const countWords = R.compose(count, explode);

countWords(str) // -> 7

이 코드는 읽기 편한 데다 함수 구성부만 얼핏 봐도 의미가 쉽게 와닿습니다. 여기서 흥미로운 사실은, countWords를 실행하기 전에는 아무 평가도 하지 않는다는 점입니다. 합성이 끝나면 해당 인수를 받아 호출되기를 기다리는 또 다른 함수가 반환됩니다. 함수의 서술부와 평가부를 분리하는 함수 합성의 미학입니다.

합성은 함수의 출력과 입력을 연결하여 진정한 함수 파이프라인을 완성합니다. 수학적으로 쓰면, 다음과 같이 두 함수 f와 g의 입출력 형식이 맞아야 합니다.

g :: A -> B
f :: B -> C

img

참조 투명한 함수는 사실상 한 객체를 다른 객체에 연결하는 화살표와 같습니다.

이는 모듈화 시스템의 근간을 이룹니다. 형식이 호환되는 함수를 경계선(입력과 출력) 부근에서 느슨하게 묶는 합성은 인터페이스에 따른 프로그래밍의 원리와 일맥상통합니다.

SSN이 올바른지 확인하는 프로그램
const trim = (str) => str.replace(/^\s*|\s*$/g, ''); // 입력 문자열 앞뒤 공백을 없앱니다.

const normalize = (str) => str.replace(/\-/g, ''); // 대시를 모두 지웁니다.

const validLength = (param, str) => str.length === param; // 문자열 길이를 체크합니다.

const checkLengthSsn = _.partial(validLength, 9); // SSN 길이가 9인지 체크하기 위해 인수 9로 함수를 구성합니다.

위 함수들을 응용해서 다른 함수도 만들 수 있습니다.

const cleanInput = R.compose(normalize, trim);
const isValidSsn = R.compose(checkLengthSsn, cleanInput);

cleanInput(' 444-44-444 '); // -> '44444444'
isValidSsn(' 444-44-444 '); // -> true

이런 개념을 바탕으로 단순한 함수들을 조합해서 전체 프로그램을 구축하는 것입니다. 함수에만 국한된 개념이 아닙니다. 전체 프로그램 역시 부수효과 없이 다른 프로그램/모듈을 합성해서 만들 수 있습니다.

합성은 결합 가능한 연산이라서 논리 AND 연산자로 원소를 합칠 수 있습니다. 이를테면 isValidSsn는 checkLengthSsn 및(AND) cleanInput을 합한 함수입니다.

자바스크립트의 Function 프로토타입에 compose를 추가해서 기능을 확장할 수도 있습니다.

Function.prototype.compose = R.compose;

const cleanInput = checkLengthSsn.compose(normalize).compose(trim);

이렇게 메서드를 체이닝하는 기법이 모나드라는 함수형 대수 자료형에서 매우 일반적입니다.

4.5.3 함수형 라이브러리로 합성

람다JS 같은 함수형 라이브러리는 처읍부터 커링을 염두에 두고 모든 함수를 구성했기 떄문에 파이프라인으로 합성하면 함수를 다용도로 활용할 수 있습니다.

const students = ['Rosser', 'Turing', 'Kleene', 'Church'];
const grades = [80, 100, 90, 99];

이 학급에서 최고 점수를 받은 학생을 찾고 싶습니다. 데이터 컬렉션을 다루는 일은 함수형 프로그래밍의 기초입니다.

  • R.zip: 인접한 배열 원소끼리 서로 짝지어 새로운 배열을 만듭니다. [['Rosser', 80], ['Turing', 100], ...]
  • R.prop: 정렬한 값을 정합니다. 하위 배열의 두 번째 원소인 점수를 가리키기 위해 인덱스 1을 넘기겠습니다.
  • R.sortBy: 주어진 속성을 기본 오름차순으로 정렬합니다.
  • R.reverse: 전체 배열을 거꾸로 뒤집으면 첫 번째 원소가 최고 득점이 됩니다.
  • R.pluck: 주어진 인덱스에 위치한 원소를 추출해서 새 배열을 만듭니다. 인덱스 0을 넘겨 학생 이름을 가리키겠습니다.
  • R.head: 첫 번쨰 원소를 얻습니다.
[코드 4-9] 가장 똑똑한 학생 구하기
const smartestStudent = R.compose(
R.head,
R.pluck(0),
R.reverse,
R.sortBy(R.prop(1)),
R.zip
);

smartestStudent(students, grades); // -> 'Turing'
// 두 배열을 함수에 넘겨 R.zip()부터 실행됩니다. 각 단계를 거치면서 데이터는 한 표현식에서 다음 표현식으로 불변 변환되고 최종 결과는 R.head()로 얻습니다.
[코드 4-10] 알기 쉽게 함수 별칭을 사용
const first = R.head;
const getName = R.pluck(0);
const reverse = R.reverse;
const sortByGrade = R.sortBy(R.prop(1));
const combine = R.zip;

R.compose(first, getName, reverse, sortByGrade, combine);

이런 식으로 작성하면 가독성은 좋아지지만, 특정한 경우에만 쓸 수 있는 함수들이라서 재사용성 측면에선 특별히 나아진 게 없습니다. 이보다는 차라리 head, pluck, zip 같은 함수형 어휘를 숙지해서 포괄적인 지식을 습득하는 편이 좋습니다.

순수 코드와 불순 코드를 반드시 떼어놓고 분별할 수 있어야 합니다.

[코드 4-9]와 [코드 4-10]은 전체 코드를 순수함수로 표현했지만, 항상 이렇게 할 수 있는 건 아닙니다. 애플리케이션을 개발하다보면 지역 저장소에서 데이터를 읽어 오거나 원격 HTTP를 요청하는 등 부수효과를 피할 수 없는 상황이 잦습니다. 따라서 순수 코드와 불순 코드를 반드시 떼어놓고 분별할 수 있어야 합니다.

4.5.4 순수/불순 함수 다루기

불순한 코드는 실행 후 부수효과가 드러나고 외부 디펜던시 탓에 구성 함수의 스코프 바깥에서 데이터에 접근할 수밖에 없습니다. 함수 하나만 불순해도 전체 프로그램이 금세 불순해지기 십상이지요.

함수형 프로그래밍의 덕을 보겠다고 100% 함수를 순수하게 만들 필요는 없습니다. 그렇게 하면 이상적이겠지만, 순수/불순한 코드가 어느 정도 섞여 있음을 받아들이되, 양쪽을 확실하게 구분하고 가급적 불순 코드를 격리하는 방법을 찾아야 합니다. 이런 작업이 선행되어야 순수/불순 코드 조각을 합성하여 이어 붙일 수 있겠죠.

const showStudent = compose(append, csv, findStudent);

이런 함수는 대부분 자신이 받은 인수를 통해 부수효과를 일으킵니다.

  • findStudent는 지역 객체 저장소 또는 외부 배열을 참조하는 레퍼런스를 사용합니다.
  • append는 HTML 요소를 직접 추가/수정합니다.

각 함수의 불변 매개변수를 커링으로 부분 평가하여 프로그램을 조금이라도 개선합니다. 입력 매개변수를 정제하는 코드를 추가하고, 여러 함수로 잘게 나누어 HTML 작업을 처리하는 방향으로 리팩터링하겠습니다.

[코드 4-11] 커링 및 합성을 응용한 showStudent 프로그램
// findObject :: DB -> String -> Object
const findObject = R.curry((db, id) => {
const obj = find(db, id);
if (obj === null) {
throw new Error(`ID가 ${id}인 객체는 없습니다.`);
}
return obj;
})

// findStudent :: String -> Student
const findStudent = findObject(DB('students'));

const csv = ({ ssn, firstname, lastname }) => `${ssn}, ${firstname}, ${lastname}`;

// append :: String -> String -> String
const append = R.curry((elementId, info) => {
document.querySelector(elementId).innerHTML = info;
return info;
});

// showStudent :: String -> Integer
const showStudent = R.compose(
append('#student-info'),
csv,
findStudent,
normalize,
trim
);

showStudent('44444-4444'); // -> 444-44-444, Alonzo, Church

showStudent는 함수 4개로 구성되어 있습니다. trim -> append 방향으로 네 함수가 거슬러 올라가면서 한 함수의 출력을 다음 함수의 입력으로 전달합니다.

함수를 합성하는 순서가 거꾸로라서 부자연스러워 보인다면 혹은 시각적으로 왼쪽부터 결합하는 식의 코드를 선호한다면 람다JS에서 compose 대신 pipe 함수를 쓰면 유닉스 셀과 같은 방향으로 실행할 수 있습니다.

R.pipe(
trim,
normalize,
findStudent,
csv,
append('#student-info')
);

4.5.5 무인수 프로그래밍

[코드 4-10]의 함수를 잘 뜯어보면, 기존 함수 선언과 달리 자신의 구성 함수의 매개변수를 하나도 드러내지 않습니다.

R.compose(first, getName, reverse, sortByGrade, combine);

compose(pipe) 함수를 사용하면 인수를 선언할 필요가 전혀 없기 때문에 간결하면서도 선언적인 무인수 코드를 작성할 수 있습니다.

함수를 평가하는 저수준의 세부 사항은 신경 쓰지 않고 구수준의 컴포넌트를 합성하는 방향으로 사고방식을 전환함으로써 추상화 수준을 높일 수 있습니다. 커링은 마지막 인수를 제외한 나머지 인수들을 유연하게 부분 정의하는 중요한 역할을 담당합니다. 이런 스타일로 코딩하는 걸 암묵적 프로그래밍이라고 합니다.

무인수 프로그래밍은 함수들이 어떤 형식의 인수를 받는지, 전체 표현식 안에서 어떻게 연결되는지는 아무 선언도 하지 않습니다. 하지만 이런 모습에 이를 정도로 합성을 과용하면 모호하고 헷갈리는 프로그램이 될 수 있으니 유의하세요. 모든 것을 무인수로 할 필요는 없습니다. 함수 합성을 두세 조각으로 나누는 편이 더 이로울 때도 있으니까요.

4.6 함수 조합기로 제어 흐름을 관리

명령형 코드는 if-else, for 같은 절차적 제어 장치로 프로그램의 흐름을 통제하지만, 함수형 코드는 다릅니다.

조합기란, 함수 또는 다른 조합기 같은 기본 장치를 조합하여 제어 로직처럼 작동시킬 수 있는 고계함수입니다. 조합기는 대부분 함수형 프로그램이 잘 흘러가도록 조정하는 일이 주임무라서 자신의 변수를 선언하거나 비즈니스 로직을 두진 않습니다.

4.6.1 항등 (I-조합기)

identity 조합기는 주어진 인수와 똑같은 값을 반환하는 함수입니다.

identity :: (a) -> a

주로 함수의 수학적 속성을 살펴보는 용도로 쓰이지만 실용적인 쓰임새도 있습니다.

  • 무인수 코드를 작성할 때, 함수 인수를 평가하는 시점에 데이터를 고계함수에 제공합니다.
  • 함수 조합기의 흐름을 단위 테스트하면서 단순한 함수 결과에 대해 단언하고 싶을 때가 있습니다.
  • 캡슐화한 형식에서 데이터를 함수형으로 추출합니다.

4.6.2 탭 (K-조합기)

tap 조합기는 코드 추가 없이 공형 함수(로깅이나 파일/HTML 페이지 쓰기 등)를 연결하여 합성할 때 아주 유용합니다. 자신을 함수에 넘기고 자신을 돌려받지요.

tap :: (a -> *) -> a -> a

이 함수는 입력 객체 a와 함수 하나를 받아 a에 이 함수를 실행하고 다시 a를 반환합니다. 다음처럼 R.tap으로 공형 함수 debugLog를 받아 다른 함수와 합성하며 끼워 넣을 수 있습니다.

const debugLog = _.partial(logger, 'console', 'basic', 'MyLogger', 'DEBUG');

몇 가지 예를 보겠습니다.

const debug = R.tap(debugLog);
const cleanInput = R.compose(normalize, debug, trim);
const isValidSsn = R.compose(debug, checkLengthSsn, debug, cleanInput);

이 조합기는 자신에게 전달한 함수의 결과를 그냥 날려버리기 때문에 다음과 같이 실행하면 계산 결과도 나오고 디버깅도 병행할 수 있습니다.

isValidSsn('444-44-4444');

// 출력
MyLogger [DEBUG] 444-44-4444
MyLogger [DEBUG] 444444444
MyLogger [DEBUG] true

4.6.3 선택 (OR-조합기)

alt 조합기는 함수 호출 시 기본 응답을 제공하는 단순 조건 로직을 수행합니다. 함수 2개를 인수로 받아 값이 있으면 첫 번째 함수의 결과를, 그렇지 않으면 두 번째 함수의 결과를 반환합니다.

const alt = function (func1, func2) {
return function (val) {
return func1(val) || func2(val);
}
};

curry와 람다 표현식으로 표현하면 이렇습니다.

const alt = R.curry((func1, func2, val) => func1(val) || func2(val));

alt 함수를 showStudent의 일부로 편입하면 데이터 조회 실패시 새 학생을 생성하도록 구성할 수 있습니다.

const showStudent = R.compose(
append('#student-info'),
csv,
alt(findStudent, createNewStudent)
);

showStudent('444-44-4444');

4.6.4 순차열 (S-조합기)

seq 조합기는 함수 순차열을 순회합니다. 2개 또는 더 많은 함수를 인수로 받아, 동일한 값에 대해 각 함수를 차례로 실행하는 또 다른 함수를 반환합니다.

const seq = function() {
const funcs = Array.prototype.slice.call(arguments);
return function (val) {
funcs.forEach(function (fn) {
fn(val);
});
};
};

이 조합기를 이용하면 서로 연관되어 있지만 독립적인 일련의 연산을 수행할 수 있습니다. 학생 객체를 조회 후, HTML 페이지에 그리고 콘솔에 로깅하는 작업이 있다고 합시다. seq 조합기로 묶어 실행하면 각 함수가 동일한 학생 객체를 대상으로 순차 실행됩니다.

const showStudent = R.compose(
seq(
append('#student-info'),
consoleLog
),
csv,
findStudent
)

seq 조합기는 정해진 일을 하나씩 차례로 수행할 뿐 값을 반환하지 않습니다. seq를 합성 중간에 끼워 넣고 싶으면 R.tap으로 나머지 함수들과 연결하면 됩니다.

4.6.5 포크(조인) 조합기

fork 조합기는 하나의 자원을 두 가지 방법으로 처리 후 그 결과를 다시 조합합니다. 하나의 join 함수와 주어진 입력을 처리할 함수 2개를 받습니다. 분기된 각 함수의 결과는 제일 마지막에 인수 2개를 받는 join 함수에 전달됩니다.

구현부는 다음과 같습니다.

const fork = function (join, func1, func2) {
return function(val) {
return join(func1(val), func2(val));
};
};

점수 배열을 받아 평균 점수를 구하는 문제입니다.

const computeAverageGrade = R.compose(getLetterGrade, fork(R.divide, R.sum, R.length));  // -> 'B'

다음은 점수 배열의 평균과 중앙값이 동일한지 비교하는 예제입니다.

const eqMedianAverage = fork(R.equals, R.median, R.mean);
eqMedianAverage([80, 90, 89]); // -> True
eqMedianAverage([81, 90, 89]); // -> False

조합기를 쓰면 무인수 프로그래밍이 가능합니다. 조합기는 순수하기 때문에 다른 조합기와 재합성이 가능하고, 결국 어떤 애플리케이션을 작성하더라도 무수히 많은 방법으로 표현할 수 있고 복잡성도 줄일 수 있습니다.

함수형 프로그래밍은 불변성, 순수성이 기본 원리이므로 프로그램을 구성하는 함수의 모듈성과 재사용성을 세세하게 조절할 수 있습니다.

모듈적인 함수형 프로그램은 이해하기 쉽고 독립적으로 재사용 가능한 추상적인 함수들로 이루어집니다. 이 장에서 배운 기법들은 순수함수를 추상하여 합성 가능한 형태로 만드는 게 목적입니다.

4.7 마치며

  • 함수형 체인과 파이프라인은 재사용 가능한, 모듈적인 프로그램 조각들을 연결합니다.
  • 람다JS는 커링과 합성이 주특기인 함수형 라이브러리입니다. 아주 강력한 유틸 함수로 가득 차 있습니다.
  • 커링, 부분 적용을 하면 함수 인수르 일부만 평가하거나 단항 함수로 변환하여 함수 항수를 낮출 수 있습니다.
  • 직업을 단순한 함수들로 쪼갠 후 다시 조합하는 식으로 전체 해법에 도달합니다.
  • 함수 조합기를 쓰면 실무에서 복잡한 프로그램의 흐름을 조화롭게 편성하고 무인수 스타일로 개발할 수 있습니다.