(번역) 어쨌든 함수형 프로그래밍의 장점은 뭔가요?
원문: https://jrsinclair.com/articles/2022/whats-so-great-about-functional-programming-anyway/
이 글은 제 책 ‘함수형 프로그래밍에 대한 회의론자의 가이드’의 챕터 중 하나입니다. 책은 구매해서 읽을 수 있습니다.
어떤 사람들이 함수형 프로그래밍에 관해 이야기하는 것을 들으면 그들이 일종의 컬트에 가입했다고 생각할 것입니다. 그 사람들은 코드에 대해 생각하는 방식이 어떻게 바뀌었는지에 대해 수다를 떱니다. 또한, 순수성의 이점을 극찬할 것입니다. 그리고 마치 다른 모든 코드가 비합리적이고 이해할 수 없는 것처럼 이야기하며 이제 “코드에 대해 추론”할 수 있다고 선언합니다. 이런 부분은 누군가를 회의적으로 만들기에 충분합니다.
그래도 궁금합니다. 이 광신자들이 그렇게 흥분한 데는 이유가 있을 것입니다. 제 개인적인 경험에 따르면 함수형 프로그래밍에 흥미를 갖고 개발하는 사람들은 무능하고 게으른 프로그래머들을 의미하지는 않습니다. 좋은 코드 작성에 대한 열성적인 사람들(비록 그들은 스펙트럼의 맨 끝에 있는 경향이 있었습니다)을 의미했습니다. 이런 부분은 결국 이런 질문을 제기하게 합니다. 그들은 무엇에 그렇게 흥분할까요?
이 질문에 직면하면 대부분 교육자는 기초부터 시작합니다. 은유적으로 표현하자면, 얕은 수심의 수영장부터 시작하듯 함수형 프로그래밍이 무엇인지부터 설명하려고 노력할 것입니다. 그들은 “표현식을 사용한 코딩”과 부수 효과, 순수성에 대해 이야기할 것입니다. 그러나 사람들에게 함수형 프로그래밍이 무엇인지 말하는 것은 함수형 프로그래밍의 무엇이 좋은지에 대해 설명하는 것을 의미하지 않습니다.
솔직해져 봅시다. 적어도 처음에는 함수형 프로그래밍이 무엇인지 아무도 신경 쓰지 않습니다. 우리가 관심을 두는 것은 “더 나은 코드를 더 빨리 제공할 수 있는가?”입니다. 그리고 우리 프로젝트 관리자들은 그에 반대되는 관점에서 일합니다. 얕은 수심은 건너뛰고 바로 깊은 수심으로 들어가봅시다. 함수형 프로그래밍의 정의에 관해 이야기하는 대신 좋은 점에 대해 먼저 이야기하겠습니다. 그럼 이제 대수적 구조(Algebraic Structures)에 관해 이야기해 보겠습니다.
대수적 구조
대수적 구조를 사용하면 자신있게 표현적인 코드를 작성할 수 있습니다. 풍부한 정보를 전달하기 때문에 표현력이 뛰어납니다. 코드를 재사용하고, 최적화하고, 재정렬하는 방법을 알려줍니다. 그리고 이 모든 것들은 우리가 어떤 것도 깨지 않을 것이라는 완전한 확신을 갖고 있습니다. 경우에 따라 자동 코드 생성도 가능합니다.
이건 대담한 주장입니다. 그러나 이 장의 끝에서 아래 두 가지 모두를 시연할 것입니다.
- 재사용 가능한 코드 그리고
- 안전성이 보장된 성능 최적화
또한, 이후 장에서 대수적 구조가 코드에서 더 많은 정보를 전달하는 방법을 보여줄 겁니다.
대수적 구조가 뭔가요?
그들이 그렇게 좋다고 하는 대수적 구조는 뭘까요? 요컨대, 많은 사람이 함수형 프로그래밍의 두려운 부분이라고 생각하는 지점이 이런 부분입니다. 여기에는 ‘monoids’, ‘semigroups’, ‘functors’ 그리고 두려운 ‘monad’ 같은 개념도 포함됩니다. 문자 그대로의 의미만 보면 매우 추상적이기도 합니다. 대수적 구조는 추상화의 추상화입니다. 이런 식으로 그들은 책 ‘gang of four’의 Design Patterns: Elements of Reusable Object-Oriented Software.에 설명된 디자인 패턴과 약간 비슷합니다. 그러나 몇 가지 중요한 차이점도 있습니다.
다시 돌아와서 그런 개념이 무엇인지 초점을 맞추기보다 그들이 할 수 있는 것부터 살펴보겠습니다.
현실 세계의 문제
함수형 프로그래밍(및 대수적 구조)의 좋은 점이 알고 싶다면 쉬운 문제를 푸는 것으론 알기 힘듭니다. 두 개의 숫자를 더하는 것보다 더 많은 것을 할 수 있습니다. 대신 자바스크립트 개발자가 자주 다루는 다른 예를 살펴보겠습니다.
웹 애플리케이션을 개발하고 있다고 상상해보죠. 사용자에게 표시할 알림 목록이 있습니다. 그리고 처리할 알림 들을 POJO 배열에 가지고 있습니다. 그러나 프런트엔드 UI 코드가 처리할 수 있는 형식으로 변환해야 합니다. 데이터가 아래와 같다고 가정해보겠습니다.
역주: POJO는 Plain Old Java Object의 약어입니다. 자바스크립트 진영에서 POJO는 일반적으로 프로토타입이 Object.prototype인 객체를 의미한다고 합니다.
const notificationData = [
{
username: "sherlock",
message: "Watson. Come at once if convenient.",
date: -1461735479,
displayName: "Sherlock Holmes",
id: 221,
read: false,
sourceId: "note-to-watson-1895",
sourceType: "note",
},
{
username: "sherlock",
message: "If not convenient, come all the same.",
date: -1461735359,
displayName: "Sherlock Holmes",
id: 221,
read: false,
sourceId: "note-to-watson-1895",
sourceType: "note",
},
// ..등등. 여기 더 많은 데이터가 있다고 상상해보세요.
];
이제 이 데이터를 템플릿 시스템에서 처리할 수 있도록 변환하려면 아래 과정을 수행해야 합니다.
- 읽을 수 있는 날짜를 생성합니다.
- XSS 공격을 방지하기 위해 메시지를 세니타이징 합니다.
- 보낸 사람의 프로필 페이지에 대한 링크를 생성합니다.
- 알림 소스 링크를 생성합니다.
- 그리고 소스 타입에 따라 표시할 아이콘을 템플릿에 지정합니다.
시작하기 위해 각각 함수를 작성해보겠습니다.
const getSet = (getKey, setKey, transform) => (obj) =>
({
...obj,
[setKey]: transform(obj[getKey]),
});
const addReadableDate = getSet(
'date',
'readableDate',
t => new Date(t * 1000).toGMTString()
);
const sanitizeMessage = getSet(
'message',
'message',
msg => msg.replace(/</g, '<')
);
const buildLinkToSender = getSet(
'username',
'sender',
u => `https://example.com/users/${u}`
);
const buildLinkToSource = (notification) => ({
...notification,
source: `https://example.com/${
notification.sourceType
}/${notification.sourceId}`
});
const iconPrefix = 'https://example.com/assets/icons/';
const iconSuffix = '-small.svg';
const addIcon = getSet(
'sourceType',
'icon',
sourceType => `${urlPrefix}${sourceType}${iconSuffix}`
);
이 모든 것을 함께 연결하는 한 가지 방법은 하나씩 실행하며 결과를 변수에 저장하는 것입니다. 예를 들어,
const withDates = notificationData.map(addReadableDate);
const sanitized = withDates.map(sanitizeMessage);
const withSenders = sanitized.map(buildLinkToSender);
const withSources = withSenders.map(buildLinkToSource);
const dataForTemplate = withSources.map(addIcon);
그러나 이런 중간 변수는 새로운 정보가 더해지지 않습니다. 우리가 매핑하고 있는 함수의 이름에서 무슨 일이 일어나고 있는지 알 수 있습니다. 이 과정을 연결하는 또 다른 방법 중 하나는 지루하고 오래된 자바스크립트 배열 메서드 체인을 이용하는 것입니다. 이렇게 작성함으로써 코드가 약간 ‘함수형’으로 보이기 시작합니다.
const dataForTemplate = notificationData
.map(addReadableDate)
.map(sanitizeMessage)
.map(buildLinkToSender)
.map(buildLinkToSource)
.map(addIcon);
이것이 진정한 ‘함수형’ 코드이지만, 지나치게 특별하지는 않습니다. 우리는 방금까지 대수적 구조의 놀라운 이점에 대해 이야기하지 않았나요?
조금만 참아주세요. 몇 가지 헬퍼 함수를 사용해 이 코드를 작성하겠습니다. 첫 번째는 복잡하지 않습니다. 우리는 .map()
을 호출하는 map()
함수를 작성할 겁니다.
const map = f => functor => functor.map(f);
다음으로 일련의 함수를 통해 값을 ‘수송해 넘겨줄 수 있는(pipe)’ pipe()
함수를 작성하겠습니다. 함수 합성의 변형입니다.
const pipe = (x0, ...funcs) => funcs.reduce(
(x, f) => f(x),
x0
);
pipe 함수는 스프레드 연산자를 사용해 첫 번째 매개변수를 제외한 모든 매개변수를 배열로 바꿉니다. 그다음 첫 번째 매개변수를 첫 번째 함수에 전달합니다. 그리고 각 함수의 실행 결과를 다음 함수로 넘깁니다. 계속해서요.
이제 코드를 아래처럼 다시 작성할 수 있습니다.
const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon)
);
여기서 주목해야 할 첫 번째 부분은 메서드 체이닝을 사용하는 이전 버전과 매우 유사하다는 것입니다. 하지만 그 외에도 여전히 진부하긴 합니다. 배열을 매핑할 수 있습니다. 그래서요? 더 나쁜 것은 비효율적이라는 점입니다.
잠시 기다려보세요. 점점 더 흥미진진해질 것입니다.
Maybe
논쟁을 위해 시나리오를 약간만 바꿔보겠습니다. 알림 목록 대신 가장 최근 알림을 받는다고 해보겠습니다. 그러나 우리는 서버를 완전히 신뢰하지 않습니다. 때에 따라 문제가 발생해 JSON 대신 HTML 페이지를 보낼 수 있습니다. 그럴 때 알림이 아닌 undefined
를 반환하며 끝나도록 해보겠습니다.
이를 처리하는 한 가지 방법으로 if 문을 통해 코드를 어지럽히며 처리하는 방식이 있습니다. 먼저 오류를 포착하고 응답이 파싱 되지 않으면 undefined
를 반환합니다.
const parseJSON = (dataFromServer) => {
try {
const parsed = JSON.parse(dataFromServer);
return parsed;
} catch (_) {
return undefined;
}
};
그런 다음 각 유틸리티 함수에 if 문을 추가합니다.
const addReadableDate = (notification) => {
if (notification !== undefined) {
return getSet(
'date',
'readableDate',
t => new Date(t * 1000).toGMTString()
)(notification);
} else {
return undefined;
}
}
const sanitizeMessage = (notification) => {
if (notification !== undefined) {
return getSet(
'message',
'message',
msg => msg.replace(/</g, '<')
)(notification)
} else {
return undefined;
}
};
const buildLinkToSender = (notification) => {
if (notification !== undefined) {
return getSet(
'username',
'sender',
u => `https://example.com/users/${u}`
);
} else {
return undefined;
}
};
const buildLinkToSource = (notification) => {
if (notification !== undefined) {
return ({
...notification,
source: `https://example.com/${
notification.sourceType
}/${notification.sourceId}`
});
} else {
return undefined;
}
};
const iconPrefix = 'https://example.com/assets/icons/';
const iconSuffix = '-small.svg';
const addIcon = (notification) => {
if (notification !== undefined) {
getSet(
'sourceType',
'icon',
sourceType =>
`${urlPrefix}${sourceType}${iconSuffix}`
);
} else {
return undefined;
}
};
모든 작업이 끝나도 우리의 pipe()
호출은 여전히 동일하게 보입니다.
const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon)
);
그러나 보시다시피 이 방법은 각 함수의 코드를 장황하고 반복적으로 만듭니다. 당연히 대안이 있어야겠죠? 실제로 있습니다. 아래와 같은 한 쌍의 함수를 작성해보겠습니다.
const Just = (val) => ({
map: f => Just(f(val)),
});
const Nothing = () => {
const nothing = { map: () => nothing };
return nothing;
};
Just
와 Nothing
은 모두 .map()
메서드가 있는 객체를 반환합니다. 함께 사용될 때 우리는 이 쌍을 Maybe라고 부르겠습니다. 그럼 이걸 어떻게 사용하는지 살펴보겠습니다.
const parseJSON = (data) => {
try {
return Just(JSON.parse(data));
} catch () {
return Nothing();
}
}
const notificationData = parseJSON(dataFromServer);
이제 코드를 매핑하는 부분을 살펴보겠습니다. 새로운 시나리오에서는 더 이상 배열을 사용하지 않습니다. 대신, Nothing
이라는 단일 값을 사용합니다. 또는 Just
라는 알림일 수도 있습니다. 다시 한번 상기시켜드리면 배열 코드는 다음과 같습니다.
const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon)
);
단일 값으로 이 작업을 수행하려면 무엇이 필요할까요? 거의 아무것도 필요없습니다. 마지막에 Just
래퍼에서 값을 가져오는 방법만 있으면 됩니다. 이를 위해 Just
와 Nothing
에 다른 메서드를 추가합니다.
const Just = (val) => ({
map: f => Just(f(val)),
reduce: (f, x0) => f(x0, val),
});
const Nothing = () => {
const nothing = {
map: () => nothing,
reduce: (_, x0) => x0,
};
return nothing;
};
Just
와 Nothing
모두에 reduce()
를 어떻게 추가했는지 주목하세요. 이를 통해 map()
에서 했던 것처럼 독립형 reduce()
함수를 작성할 수 있습니다.
const reduce = (f, x0) => foldable =>
foldable.reduce(f, x0);
Just
에서 값을 얻으려면 다음과 같이 reduce()
를 호출하면 됩니다.
reduce((_, val) => val, fallbackValue);
만약 reduce()
가 Nothing
을 만나면 폴백 값을 반환합니다. 그렇지 않으면 폴백 값을 무시하고 데이터를 반환합니다.
따라서 파이프라인은 다음과 같습니다.
const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon),
reduce((_, val) => val, fallbackValue),
);
이제 .reduce()
를 사용해 이 모든 난해한 작업을 수행하는 이유가 궁금할 것입니다. 대체 값을 바로 제공하는 메서드를 추가하지 않는 이유가 뭘까요? 예를 들어 보겠습니다.
const Just = (val) => ({
map: f => Just(f(val)),
fallbackTo: (_) => val,
});
const Nothing = () => {
const nothing = {
map: () => nothing,
fallBackTo: (x0) => x0,
};
return nothing;
};
다시 한번 말하자면 둘 다 .fallbackTo()
를 추가했기 때문에 다른 유틸리티 함수를 작성할 수 있습니다. 우리가 Just
를 얻든, Nothing
을 얻든 관계없이 작동할 것입니다. 어느 쪽이든 기대하는 대로 작동할 겁니다.
const fallBackTo = (x0) => (m) => m.fallBackTo(x0);
이 유틸리티 함수인 fallbackTo()
는 간결하고 효과적입니다. 왜 reduce()
를 귀찮게 사용해야 할까요?
좋은 질문입니다. 얼핏 보면 함수형 프로그래머를 짜증 나게 만드는 불필요하게 복잡한 코드처럼 보입니다. 항상 코드를 읽기 어렵게 만들고 후배들을 혼란스럽게 만드는 추상화 계층을 추가합니다. 맞죠?
하지만 fallbackTo()
대신 reduce()
를 사용하는 데는 그만한 이유가 있습니다. reduce()
는 Just
와 Nothing
외에 다른 데이터 구조와 함께 작동할 수 있기 때문입니다. 이식할 수 있는 코드라는 의미죠. 진실은 이 코드에서 Just
와 Nothing
을 다른 것으로 대체할 수 있다는 점입니다. 다음과 같이 구문 분석 코드를 다시 작성하면 어떻게 될까요?
const parseJSON = strData => {
try { return [JSON.parse(strData)]; }
catch () { return []; }
};
const notificationData = parseJSON(dataFromServer);
Just
와 Nothing
을 사용하는 대신 이제 일반 자바스크립트 배열을 반환합니다. 파이프라인을 다시 살펴보면 다음과 같습니다.
const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon),
reduce((_, val) => val, fallbackValue),
);
우린 한 줄도 바꾸지 않고 동일한 결과를 생성하고 있습니다.
Result
이 시나리오를 조금 더 사용하겠습니다. JSON 파싱 코드에서는 catch
절의 오류를 무시합니다. 하지만 오류에 유용한 정보가 있다면 어떻게 될까요? 문제를 디버깅할 수 있도록 어딘가에 오류를 기록할 수도 있습니다.
이전의 Just
/Nothing
코드로 돌아가 보죠. Nothing
을 약간 다른 함수인 Err
로 바꾸겠습니다. 또한, Just
를 OK
로 바꿔보겠습니다.
const OK = (val) => ({
map: (f) => OK(f(val)),
reduce: (f, x0) => f(x0, val),
});
const Err = (e) => ({
const err = {
map: (_) => err,
reduce: (_, x0) => x0,
};
return err;
});
우리는 이 새로운 함수 쌍을 Result라고 부르겠습니다. 이를 통해 Result를 사용하도록 parseJSON()
코드를 변경할 수 있습니다.
const parseJSON = strData => {
try { return OK(JSON.parse(strData)); }
catch (e) { return Err(e); }
}
const notificationData = parseJSON(dataFromServer);
이제 오류를 무시하는 대신 Err
객체에서 오류를 캡처합니다. 파이프라인으로 돌아가면 아무것도 변경할 필요가 없습니다. Err
에는 호환할 수 있는 .map()
및 .reduce()
메서드가 있으므로 여전히 작동합니다.
const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon),
reduce((_, val) => val, fallbackValue),
);
물론 현재는 최종 reduce()
에 도달했을 때 여전히 오류를 무시하고 있습니다. 이를 해결하기 위해서는 우리가 그 오류로 무엇을 할 것인지에 대해 확고한 결정을 내려야 합니다. 콘솔에 기록해 부수 효과를 일으키고 싶나요? 네트워크를 통해 로깅 플랫폼으로 보내고 싶나요? 아니면 오류에서 무엇인가를 추출해 사용자에게 보여주고 싶나요?
지금은 약간의 부수 작용이 있어도 괜찮다고 가정하고 콘솔에 기록하겠습니다. 아래와 같이 OK
와 Err
에 .peekErr()
메서드를 추가해보겠습니다.
const OK = (val) => ({
map: (f) => OK(f(val)),
reduce: (f, x0) => f(x0, val),
peekErr: () => OK(val),
});
const Err = (e) => {
const err = {
map: (_) => err,
reduce: (_, x0) => x0,
peekErr: (f) => { f(e); return err; }
}
return err;
};
OK
에 추가한 버전은 엿볼 오류가 없기 때문에 아무 작업도 수행하지 않습니다. 그러나 해당 위치에 있으면 OK
와 Err
모두에서 작동하는 유틸리티 함수를 작성할 수 있습니다.
const peekErr = (f) => (result) => result.peekErr(f);
이제 peekErr()
함수도 우리 파이프라인에 추가할 수 있습니다.
const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon),
peekErr(console.warn),
reduce((_, val) => val, fallbackValue),
);
오류가 발생하면 로그를 작성하고 계속 진행합니다. 더 복잡한 오류 처리가 필요한 경우 다른 구조를 사용할 수 있습니다.
물론 peekErr()
를 추가하면 배열 및 Maybe 구조와의 호환성이 깨집니다. 하지만 괜찮습니다. 배열과 Maybe는 처리할 추가 오류 데이터가 없기 때문입니다.
Task
자, 모두 훌륭한 것처럼 보이지만 우린 중요한 것을 무시하고 있습니다. 지금까지 이 데이터가 서버에서 온다고 말했습니다. 그러나 서버에서 데이터를 검색한다는 것은 일종의 네트워크 호출이 있음을 의미합니다. 그리고 자바스크립트에서 이는 대부분 비동기 코드를 의미합니다.
예를 들어 표준 자바스크립트 Promise를 사용하여 알림 데이터를 가져오는 코드가 있다고 가정해 보겠습니다.
const notificationDataPromise = fetch(urlForData)
.then(response => response.json());
비동기 코드에서도 작동하는 구조를 만들 수 있는지 보겠습니다. 이를 위해 Promise와 매우 유사한 생성자 함수가 있는 구조를 만들겠습니다. 두 개의 매개변수를 사용하는 함수가 필요합니다.
- 성공적인 결과를 처리할 무엇과
- 뭔가 잘못되면 처리할 다른 무엇
다음과 같이 처리할 수 있습니다.
const notificationData = Task((resolve, reject) => {
fetch(urlForData)
.then(response => response.json())
.then(resolve)
.catch(reject);
});
이 예에서는 fetch를 호출하고 알림에 대한 URL을 전달합니다. 그리고 응답에서 .json()
을 호출하여 데이터를 파싱합니다. 그리고 완료되면 성공 시 resolve()
, 실패하면 reject()
를 호출합니다. Promise를 사용하는 fetch()
코드와 비교하면 조금 어색해 보입니다. 그러나 이런 부분은 resolve
와 reject
를 연결할 수 있도록 하기 위한 작업입니다. 잠시 후 fetch()
와 같은 비동기 함수를 연결하기 위한 헬퍼 함수를 추가할 것입니다.
Task
구조의 구현은 그렇게 복잡하지 않습니다.
const Task = (run) => {
map: (f) => Task((resolve, reject) => {
run(
(x) => (resolve(f(x))),
reject
);
}),
peekErr: (f) => Task((resolve, reject) => {
run(
resolve,
(err) => { f(err); reject(err); }
)
}),
run: (onResolve, onReject) => run(
onResolve,
onReject
);
}
우리는 .map()
과 .peekErr()
를 갖고 있습니다. 그러나 .reduce()
메서드는 비동기 코드에 적합하지 않습니다. 비동기화되면 다시 되돌릴 수 없기 때문입니다. 또한 Task
를 시작하기 위해 .run()
메서드를 추가했습니다.
Promise 작업을 좀 더 쉽게 하기 위해 Task
에 정적 헬퍼 함수를 추가할 수 있습니다. 그리고 JSON 데이터를 가져오는 또 다른 헬퍼 함수가 있습니다.
Task.fromAsync = (asyncFunc) => (...args) =>
Task((resolve, reject) => {
asyncFunc(...args).then(resolve).catch(reject);
});
const taskFetchJSON = Task.fromAsync(
(url) => fetch(url).then(data => data.json())
);
이런 헬퍼 함수를 사용해 notificationData
를 다음과 같이 정의할 수 있습니다.
const notificationData = taskFetchJSON(urlForData);
Task
를 사용하기 위해서는 파이프라인을 약간 변경해야 합니다. 아주 작은 변화입니다.
const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon),
peekErr(console.warn),
);
reduce()
함수를 제외하고 대부분 여전히 작동합니다. 그러나 네트워크 요청 또는 파싱이 실패할 경우 대체 값을 도입할 방법이 여전히 필요합니다. 이를 위해 .scan()
메서드를 추가합니다. .reduce()
와 비슷하지만 결과가 여전히 Task
'내부'에 있음을 인지하기 위해 다른 이름을 지정했습니다.
const Task = (run) => {
map: (f) => Task((resolve, reject) => {
run(
(x) => (resolve(f(x))),
reject
);
}),
peekErr: (f) => Task((resolve, reject) => {
run(
resolve,
(err) => { f(err); reject(err); }
)
}),
run: (onResolve, onReject) => run(
onResolve,
onReject
);
scan: (f, x0) => Task((resolve, reject) => run(
x => resolve(f(x0, x)),
e => resolve(x0),
)),
}
그리고 이전처럼 매칭되는 유틸리티 함수를 만듭니다.
const scan = (f, x0) => (scannable) =>
scannable.scan(f, x0);
이를 통해 파이프라인을 다음과 같이 조정할 수 있습니다.
const taskForTemplateData = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon),
peekErr(console.warn),
scan((_, val) => val, fallback)
);
그리고 실행하려면, 아래와 같이 처리합니다.
taskForTemplateData.run(
renderNotifications,
handleError
);
왜 Promise를 사용하지 않나요?
자바스크립트에는 이미 비동기 코드용 데이터 구조가 내장되어 있습니다. Promise를 사용하지 않는 이유가 뭘까요? 왜 이 Task
작업에 신경을 써야 할까요? 모두를 혼란스럽게만 한다면 무슨 소용이 있을까요?
여기에는 적어도 3가지 이유가 있습니다. 첫 번째는 Promise에 .run()
메서드가 없다는 것입니다. 즉, 생성하자마자 시작됩니다. Task를 사용하면 모든 것이 시작되는 시기를 정확하게 제어할 수 있습니다.
만약 이 제어가 필요하지 않아 Task가 필요 없다고 가정해보겠습니다. 그럴 경우 원한다면 Promise를 함께 함수 안에 넣어 지연시킬 수 있습니다. 그러면 Promise는 함수를 호출할 때까지 ‘시작’되지 않습니다. 그 과정에서 우리는 Task를 재발명했습니다. Promise는 구문이 다르고 유연성이 떨어집니다.
Task를 선호하는 두 번째 이유는 Promise에는 없는 기능이 있기 때문입니다. 가장 중요한 것은 작업을 중첩할 수 있다는 점입니다. 작업을 실행하고 다른 작업을 가져올 수 있습니다. 그런 다음 기다렸다가 다음 작업을 실행할 시기를 결정할 수 있습니다. 이런 작업은 Promise로는 불가능합니다. Promise는 .map()
과 .flatMap()
을 함께 하나의 .then()
메서드로 결합합니다. 결과적으로 우린 (다시) 유연성을 잃습니다.
Task를 선호하는 마지막 이유는 다른 대수적 구조와 일관성이 있기 때문입니다. 이런 구조를 충분히 자주 계속 사용하면 익숙해집니다. 그러면 코드가 어떤 작업을 수행하는지, 또는 (더 중요한) 어떤 작업을 수행하지 않는지를 추론하기가 더 쉬워집니다. 이에 대해서는 잠시 후 더 자세히 설명해보겠습니다.
요약하면 Task는 더 많은 기능, 유연성 및 일관성을 제공합니다. 이것은 Task를 사용하면 트레이드오프가 없다는 것을 의미하지 않습니다. async
, await
키워드를 통해 자바스크립트는 '즉시 사용할 수 있는' Promise를 지원합니다. Task를 사용하기 위해 이런 편리함을 포기하고 싶지 않을 수 있습니다. 괜찮습니다.
그래서 당신은 다형성(Polymorphism)을 사용했습니다. 대단한 일이죠.
우리는 “함수형 프로그래밍의 장점이 무엇인가요?”라는 질문을 던지며 이 장을 시작했습니다. 그러나 지금까지 제가 한 것은 일부 메서드 이름을 공유하는 소수의 객체에 대해 이야기한 것이 전부입니다. 그것은 평범한, 오래된 다형성입니다. OOP 구루들은 수십 년 동안 다형성에 대해 반복적으로 이야기해왔습니다. 다형성을 사용하기 때문에 함수형 프로그래밍이 훌륭하다고 주장할 수는 없습니다.
아니면 우리가 할 수 있을까요?
대수적 구조(및 함수형 프로그래밍)을 멋지게 만드는 것은 다형성 그 자체가 아닙니다. 그러나 다형성은 자바스크립트에서 대수적 구조를 가능하게 합니다. 알림 예제에서는 이름과 시그니처가 일치하는 몇 가지 메서드를 정의했습니다. 예를 들어, .map()
및 .reduce()
가 있습니다. 우리는 map()
과 reduce()
같은 시그니처가 일치하는 메서드를 통해 작동하는 유틸리티 함수를 작성했습니다. 다형성은 이런 유틸리티 기능이 작동하도록 합니다.
이런 메서드 정의(및 유틸리티 함수)는 임의적이지 않습니다. 일반적인 아키텍처 패턴을 관찰하여 누군가가 만든 디자인 패턴이 아닙니다. 대수적 구조는 수학에서 나옵니다. 집합론이나 범주론 같은 분야에서요. 즉, 특정 메서드 시그니처뿐만 아니라 이런 구조에도 *법칙
*이 적용됩니다.
처음에는 이게 이상하게 들리지 않습니다. 우리는 수학을 혼란과 지루함에 연관시킵니다. 그리고 우리는 법칙을 제한과 연관시킵니다. 법칙은 우리를 방해하고 우리가 원하는 것을 하는 걸 막습니다. 법칙은 불편합니다. 잠시 시간을 내서 법칙을 읽으면 놀라게 될 것입니다. 왜냐면 엄청나게 엄청나게 지루하기 때문입니다.
당신은 “왜 이게 놀라운 일이라고 하는 건지 모르겠어요. 제가 아는 놀라운 일 중 가장 덜 놀라운 일입니다.”라고 생각할 수 있습니다. 그러나 이런 법칙은 특별한 종류의 지루함입니다. 법칙은 명백한 것을 진술한다는 점에서 지루합니다. 왜 누군가가 그것을 기록하려고 했는지에 같은 것들이 궁금할 겁니다. 우리는 법칙을 읽고 “물론 그렇게 작동하겠지. 그럼 어떤 특정 시나리오에서는 다를까?”에 대해 생각하는 경향이 있습니다. 이런 부분에 대수적 구조의 아름다움이 있습니다.
설명을 위해 다시 알림 예제를 살펴보겠습니다. 우리는 적어도 두 개의 대수적 구조를 사용했습니다. 그중 하나를 Functor라고 합니다. 이는 Maybe, Result, Task에서 우리가 .map()
메서드를 작성했다는 것을 의미합니다. 그리고 .map()
메서드를 작성하는 방식은 각각 몇 가지 법칙을 따릅니다. 이 외에, Foldable이라는 또 다른 대수적 구조를 사용했습니다. 데이터 구조에 .reduce()
메서드가 있고 몇 가지 법칙을 따르는 경우의 데이터 구조를 Foldable이라 합니다.
Functor의 법칙 중 하나는 다음 두 코드 조각이 항상 동일한 결과를 생성해야 한다는 것입니다. 무슨 일이 있어도요. 두 개의 순수 함수 f
와 g
가 있다고 가정하면 첫 번째 코드는 다음과 같습니다.
const resultA = a.map(f).map(g);
두 번째 코드는 다음과 같습니다.
const resultB = a.map(x => g(f(x)));
이 두 코드 조각은 동일한 입력이 주어졌을 때 동일한 결과를 내야 합니다. 즉 resultA ≣ resultB
여야 합니다. 우리는 이것을 합성 규칙이라고 부릅니다. 그리고 이를 파이프라인 코드에 적용할 수 있습니다. x => g(f(x))
는 x => pipe(x, f, g)
라고 쓰는 것과 같기 때문입니다. 즉, 우리의 pipe()
함수는 합성의 한 형태입니다. 따라서, 배열 기반 버전의 파이프라인으로 돌아가면 아래와 같은 결과가 나타납니다.
const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon),
);
이걸 아래처럼 재작성할 수 있습니다.
const dataForTemplate = map(x => pipe(x,
addReadableDate,
sanitizeMessage,
buildLinkToSender,
buildLinkToSource,
addIcon,
))(notificationData);
합성 규칙 때문에 이 두 코드는 동일하다는 것을 알고 있습니다. Maybe, Result, Task 또는 배열로 작업하는지에 대한 여부는 중요하지 않습니다. 이 두 코드는 항상 동일한 결과를 생성하기 때문입니다.
이제 가능해졌습니다. 이 차이는 당신에게 대단하게 느껴지지 않을 수 있습니다. 그러나 배열의 경우 두 번째 버전이 더 효율적입니다. 첫 번째 버전은 파이프를 통해 데이터 전달을 할 때 최소 5개의 중간 배열을 생성합니다. 두 번째 버전은 한 번에 모든 작업을 수행합니다. 우리는 우리가 시작한 코드와 동일한 결과를 보장하는 성능 향상을 이뤄냈습니다. 순수 함수를 사용하는 한 보장됩니다.
그래서요?
이건 전부 자신감에 관한 내용입니다. 이런 법칙은 대수적 구조를 사용하면 예상대로 작동할 것임을 알려줍니다. 그리고 저는 계속 그렇게 될 것이라는 수학적 보증을 갖게 됩니다. 100% 항상.
약속한 대로 재사용할 수 있는 코드를 시연했습니다. map()
, reduce()
및 pipe()
같은 유틸리티 함수는 다양한 구조와 함께 작동합니다. 배열, Maybe나 Task 같은 구조에서도요. 그리고 대수적 구조의 법칙이 코드를 완전히 안전하게 재정렬하는데 어떻게 도움이 되는지를 보여드렸습니다. 그리고 그 재배치가 어떻게 성능 향상을 가져왔는지 보여줬습니다. 다시 한번 완전한 자신감과 함께요.
이런 내용은 차례대로 함수형 프로그래밍의 핵심에 도달합니다. 대수적 구조에 관한 내용이 주는 아닙니다. 대수적 구조는 거대한 도구 상자에 있는 도구 세트에 불과합니다. 함수형 프로그래밍은 코드에 대한 확신을 갖는 것입니다. 우리 자신이 코드가 우리가 기대하는 대로 작동하고 있다는 것을 아는 것과 관련이 있습니다.
이것을 이해하면 함수형 프로그래밍의 기이함이 좀 더 이해되기 시작합니다. 예를 들어 함수형 프로그래머가 부수 효과에 매우 신중한 이유가 여기 있습니다. 순수 함수로 더 많이 작업할수록 더 많은 확실성을 얻을 수 있습니다. 그것은 또한 일부 프로그래머가 Haskell과 같은 멋진 타입 시스템에 대해 애정을 갖는 것을 설명하기도 합니다. 그들은 확실성이란 마약에 중독되어 있습니다.
함수형 프로그래밍은 코드에 대한 자신감에 관한 것을 이해하는 것은 비밀 키를 갖는 것과 동일합니다. 함수형 프로그래머가 겉보기에는 사소한 문제에 대해 몰두하는 이유를 설명합니다. 그들은 지나치게 세세한 규칙에 얽매이는 것(pedantry)을 즐기는 것이 아닙니다.(그런데도, 그들 중 일부는 세세하게 얽매이는 것을 즐기는 것 같기도 합니다) 대부분의 경우 그들은 자신감을 유지하기 위해 싸우고 있습니다. 그리고 그를 위해 필요한 모든 것을 기꺼이 할 것입니다. 그것이 수학의 어두운 예술을 탐구하는 것을 포함하더라도요.
🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요.