목차
- 시작하면서
- 순수함수
- 고차함수
- 콜벡함수
- 커링
- 함수조합
- RxJS (비동기 처리)
- 마무리
시작하면서
함수형 스터디 1기, 2기를 운영하면서 갈고닦은 기법들을 직접 사이드 프로젝트에 접목 시켜 봤습니다. 함수형 프로그래밍을 통해 코드 품질을 향상 시킬 수 있었습니다. 실제 작성한 코드를 바탕으로 하나씩 소개해 볼텐데요. 다양한 개념들이 있지만 아래의 개념들만 이야기 해보겠습니다.
- 순수함수
- 고차함수
- 콜벡함수
- 커링
- 함수조합
- RxJS (비동기 처리)
물론 더 많은 매력적인 개념들이 있습니다.
- 모나드
- 꼬리재귀
- 메모라이제이션
- 이터러블 & 제너레이터
등등이 있지만, 억지로 모든걸 접목시킬 필요는 없기 때문에, 나머지 개념들은 차차 해보도록 하겠습니다.
순수함수
순수 함수의 정의는 아래와 같습니다.
동일한 입력값을 넣으면, 동일한 출력값이 나오는 함수
위의 특징 덕분에 부수효과가 없고 데이터의 불변성을 유지할 수 있습니다. 더 나아가서 함수조합을 위한 기본이라고 할 수 있습니다.
아래와 같은 순수 함수들을 늘려가려면,
const convertTimeToString = ({ minutes, seconds }: Time) => {
return `${digitsTime(minutes)}:${digitsTime(seconds)}`
}
const digitsTime = (time: number) => {
return time < 10 ? `0${time}` : `${time}`;
}
- 계속 역할을 쪼개어서, 단일 책임 함수들을 늘려가야한다.
- 데이터의 불변성을 지키기 위해, 카피온라이트와 깊은 복사를 적절히 활용한다.
를 신경써야 합니다. 가장 어렵지 않게 실무에 적용해볼 수 있는 개념들입니다.
고차함수
고차 함수의 정의는 아래와 같습니다.
함수를 인자로 받거나 결과로 반환하는 함수
자바스크립트에서 함수는 일급입니다. String, Boolean, Integer 와 같이 함수도 변수에 담에 넘겨줄 수 있습니다. 덕분에 고차함수와 같은 기법을 활용할 수 있습니다.
React 에서는 hoc 라는 개념이 나옵니다. 함수형 컴포넌트에서 hoc 개념을 활용한다면, 사실 hoc은 이 고차함수의 개념을 적극 활용하는 개념입니다.
저는 사이드 프로젝트에서 Next.js와 LocalStorage를 함께 쓰는 부분이 있는데요. 매번 window 객체 여부를 확인하는 코드가 발생했습니다. 그래서 이 부분을 고차함수로 해결하여, 코드의 재사용성을 높이고자 했습니다.
const wrapRunWhenWindow = <T>(fn: Function) => {
if (typeof window === 'undefined') {
return;
}
return (...arg: T[]) => fn(...arg);
}
위 함수로 아래와 같이 감싸주는 식으로 사용합니다.
const getItems = wrapRunWhenWindow(<T extends ItemWithId>(key: string): T[] => {
const tasks = localStorage.getItem(key);
return tasks ? JSON.parse(tasks) : [];
})
const setItems = wrapRunWhenWindow(<T extends ItemWithId>(key: string, items: T[]) => {
localStorage.setItem(key, JSON.stringify(items));
})
덕분에, 코드의 가독성도 높였고 불필요한 예외처리를 매번 하지 않아도 됐었습니다.
콜벡함수
콜벡 함수의 정의는 아래와 같습니다.
인자로 다른 함수에 전달되는 함수, 전달된 이후 특정 시점에 호출하기 위함
콜벡 함수를 모르는 분은 없을 겁니다. 사실 콜벡 함수는 적정 수준으로 사용하는 것이 좋습니다. 무분별한 사용은 오히려 가독성을 낮추고 코드의 추적을 어렵게 만듭니다.
하지만 아래와 같은 경우는 적절해 보였습니다.
재사용할 코드를 분리시키구요
const conditionForDone = (task: Task) => task.done
아래와 같은 고차함수에 넘겨줍니다.
저는 stack 에서 pop()을 해주거나, 배열의 길이를 측정하는 함수에 넘겨줬는데요.
const popTask = (cond?: (task: Task) => boolean) => {
const tasks = getTasks()
if (!cond) {
return tasks.shift();
}
return tasks
.filter((task: Task) => cond(task))
.shift();
}
const getTaskLength = (cond?: (task: Task) => boolean) => {
if (!cond) {
return store.tasks.length;
}
return store.tasks.filter(cond).length;
}
덕분에 popTask() 함수와 getTaskLength() 는 입맛대로 사용할 수 있습니다. 함수의 활용도가 높아졌다고 할 수 있죠
const task = popTask()
const task2 = popTask(conditionForTodo)
const task3 = popTask((task) => task.createdAt < new Date('2024-10-10'))
const length = getTaskLength()
const length2 = getTaskLength(conditionForTodo)
const length3 = getTaskLength((task) => task.id > 10)
커링
함수형 프로그래밍의 매력중 하나가 바로 커링입니다. 커링의 정의는 아래와 같습니다. 루이스 아텐시오의 책 '함수형 JavaScript' 책의 정의를 조금 빌리겠습니다.
다변수 함수가 인수를 전부 맏을 때까지 실행을 지연시켜, 단계별로 나뉜 단항 함수를 순차적으로 실행시키는 기법
기호로 표시하자면 아래와 같이 됩니다.
curry(f) :: ((a,b,c) => d) => a => b => c => d
이 커링은 코드를 재활용하기 위한 고급 기법입니다. 로버트의 '클린코드' 에 보면 클린한 함수의 인자의 개수가 2개이상의 함수는 피해야 한다고 말합니다. 물론 우리가 실제 개발을 하다보면 인자가 항상 1, 2개 일 수 없습니다. 이때 커링이라는 기법으로 인자를 줄일 수 있습니다.
그 뿐만 아니라 인자를 줄여 줌으로써 이후에 살펴볼 함수를 조합하기 좋게 가공할 수 도 있습니다.
저 같은 경우 아래 같이 2개의 인자를 1개로 줄여 줌으로써,
const updateDone = _.curry((id: number, task: Task) => {
return task.id === id ? { ...task, done: !task.done } : task
})
const changeTask = _.curry((newTask: Task, task: Task) => {
return task.id === newTask.id ? newTask : task
})
위 두 함수가 사용되는 사용부 코드를 더 보기좋게 만들 수 있었습니다.
const checkTask = (id?: number) => {
if (!id) {
throw new Error("id is required")
};
const newTasks = getTasks().map(updateDone(id));
store.checkTask(id);
}
const updateTask = (task: Task) => {
const tasks = getTasks().map(changeTask(task));
store.setTasks(tasks);
}
map 함수에 콜벡으로 넘길 때, id나 task 같은 첫번째 인자를 미리 셋팅해준 상태로 넘겨줬습니다.
덕분에 map에서 굳이 인자를 넘겨주는 코드를 작성할 필요 없이 한줄로 코드 작성이 가능했습니다. 훨씬 보기 좋죠!
함수조합
함수형 프로그래밍의 꽃이라고 할 수 있는 함수조합입니다. 마찬가지로 정의를 루이스 아텐시오의 책에서 빌려오겠습니다.
함수의 출력과 입력을 연결하여 함수 파이프라인으로 결합하는 기법
이때, 중요한 것은 연결짓는 함수들의
- 입출력 데이터 개수
- 입출력 데이터 타입
이 일치해야한다는 것입니다. 아래의 기호로 좀 더 명확히 표현해보죠.
f :: A => B
g :: B => C
f(g) = compose :: ((B => C) , (A => B)) => (A => C)
저같은 경우 Lodash.js 를 적극 활용하였는데요. Lodash 에서는 Ramda.js의 compose 와 같은 기능을 하는 flow 라는 함수가 지원됩니다. 하지만 flow 라는 용어가 마음에 와닿지 않아서 아래와 같이 이름을 변경해서 사용했습니다.
import _ from 'lodash-es'
const pipe = (...fns: any[]) => _.flow(fns);
그리고 위 pipe 함수를 통해 아래와 같은 경우에 사용했는데요. 새로운 Task 를 생성하기 위해 3개의 함수들을 조합하여 createNewTask 라는 새로운 함수를 생성했습니다.
const createNewTask = (value: string) => pipe(
getItems,
incrementId,
createTask(value)
)
인자로 넘어간 3개의 함수의 코드는 아래와 같은데요.
const getItems = wrapRunWhenWindow(<T extends ItemWithId>(key: string): T[] => {
const tasks = localStorage.getItem(key);
return tasks ? JSON.parse(tasks) : [];
})
const incrementId = <T>(datas: T[]) => datas.length + 1
const createTask = _.curry((value: string, nextId: number) => {
return { id: nextId, text: value, done: false, startTime: new Date(), endTime: new Date() }
})
함수를 조합한 덕분에, 이 함수를 사용하는 사용부는 아래와 같이 필요한 값들만 넘겨주면 되었습니다. 함수의 가독성과 재사용성이 높아졌다고 볼 수 있습니다.
const newTask: Task = createNewTask(value)(STORAGE_KEY)
좀 더 나아간다면, STORAGE_KEY는 고정값이니까 이걸 또 커링으로 미리 값을 담아주면, 아래와 같이 사용할 수 있겠죠.
const newTask: Task = createNewTask(value)
함수조합에서 연결되는 입출력값의 갯수와 타입이 같아야 한다고 했는데요. 이때 커링 기법을 활용하여 함수의 인자 갯수를 줄여줄 수 있습니다. 멋지죠?
RxJS (비동기 처리)
이제 비동기 처리만 남았습니다. 함수형에서 비동기처리를 함께 한다면 우리는 좀 더 멋진 것들을 해볼 수 있습니다. 하지만 안타깝게도 Lodash.js 와 Ramda.js 에서는 비동기 처리에 대한 대응이 되어 있지 않습니다.
인프런에 유인동 강사님이 강의하는 4개의 함수형 강의를 모두 봤는데요. 거기서 소개되는 mapple 이라는 라이브러리에는 비동기 지원이 됩니다. 개인적으로 기능적으로는 Lodash 보다 더 좋다고 생각하는데요. 다만 너무 마이너하다보니 좀 더 다른 대안을 살펴봤습니다.
그래서 RxJS의 도움을 받아보기로 했습니다. RxJS 라이브러리의 정의는 아래와 같습니다.
Think of RxJS as Lodash for events (=RxJS를 이벤트용 Lodash로 생각하십시오)
우리가 Lodash와 같은 함수형 라이브러리를 통해서, Array나 iterable 데이터들을 함수 체이닝 형태로 다룰 수 있습니다. 꼭 Lodash가 아니라도 ES6 이상의 문법에서도 가능한데요. 아래와 같은 코드인거죠.
const sum = [1, 2, 3, 5]
.map(n => n * 2)
.filter(n => n > 4)
.reduce((acc, n) => acc + n)
우아하게 데이터를 처리했죠. 비동기를 위와 같이 다룬다면 어떻게 될까요?. 우선 데이터의 형태는 아래와 같을 것입니다.
const events = [clickEvent, clickEvent, clickEvent, ..., clickEvent, clickEvent];
그리고 이 click Event Array을 RxJS가 생성해줄 뿐만 아니라 우아하게 다룰 수 있습니다.
const sum5X = events
.map(([x, y]) => x)
.filter(x => x > 0)
.take(5)
.reduce((acc, x) => acc + x)
이벤트를 마치 배열처럼 다루고 있습니다. 제가 RxJS를 활용한 부분은 포모도로의 타이머 부분인데요. 포모도로가 진행되는 모든 이벤트 과정을 RxJS로 한번에 처리했습니다.
- 학습 시간을 잰다.
- 쉬는 시간을 잰다.
- 완료한 작업을 체킹한다.
- 모든 작업이 체크 될때까지 반복한다.
코드를 봐 볼까요.
const learnTime$ = interval(1000).pipe(
takeWhile(x => x <= learnTimeRef.current),
map(x => learnTimeRef.current - x),
tap(x => {
setLearnTime(x)
if (x === 0) ringAlarm(300)
})
)
const breakTime$ = interval(1000).pipe(
takeWhile(x => x <= breakTimeRef.current),
map(x => breakTimeRef.current - x),
tap(x => {
setBreakTime(x)
})
)
const cut$ = from([CUT]).pipe(take(1))
그리고 위의 3개의 스트림을 1개로 조합하여, 업무가 모두 체크 될때까지 반복시킵니다. 아래와 같이
const repeatUntil = _.curryRight((times: number, callback: () => void) => {
repeatRef.current = concat(learnTime$, breakTime$, cut$)
.pipe(
repeat(times),
tap((x) => {
if (x === CUT) callback()
})
).subscribe({
complete: () => {}
})
})
이벤트를 다루는 작업은 꽤 까다로운 작업이지만, RxJS를 통해서 훨씬 깔끔하게 처리할 수 있었습니다.
물론! RxJS가 아쉬운 부분이 있었습니다. 가상돔의 변경을 위해 상태를 다루는 React 에서 RxJS의 이벤트 처리와 함께 쓴다는게 은근 사용성이 좋지 못합니다. 어떤 부분에서는 부수효과를 감안해야하는 영역이 늘어나게 됩니다.
또한 React 18에서는 일반적인 동시성에 대한 hooks를 제공해주기 시작했습니다. 무난한 서비스 개발일 수록 RxJS까지 활용할 필요는 없어 보입니다.
하지만 섣불리 결론을 내리기 보다는 좀 더 React 에서 활용해보는 것을 실험해보기 위해서, React-RxJS (https://react-rxjs.org/) 마이너한 라이브러리의 내부를 살펴볼 생각입니다.
마무리
지금까지 함수형 프로그래밍을 직접 접목시킨 경험을 풀어 썼습니다. 간혹 함수형을 객체지향 패러다임과 비교하는 블로그 글들이 꽤 있는데요. 여러 함수형 책에서도 가장 먼저 강조하듯이 함수형과 객체지향은 비교대상이 아닙니다. 둘은 상생과 보완이 가능하며, 둘을 적절히 활용하면 코드의 품질을 더 높일 수 있습니다.
비교를 하려면 우리가 익숙한 명령형 코드와 선언형 코드를 비교해야합니다.
먼저 우리가 익숙한 명령형 코드입니다.
const str = '...';
let count = 0;
for (let i = 0; i < str.length -1; i++) {
count++;
}
console.log(count);
다음은 함수형 코드입니다.
const str = '';
const explode = (str) => str.split(/\s+/);
const count = (arr) => arr.length;
const countWords = _.flow(explode, count);
console.log(countWords(str));
어느 코드가 더 나아 보이나요? 혹시 명령형이 코드가 더 짧으니 좋아보인다는 답변은 하시지 않겠죠?
아래 함수형 코드가 아래와 같은 장점이 있습니다.
- 코드의 재활용성이 높습니다.
- 각각의 코드는 독립적입니다 (결합도가 낮고, 응집도가 높습니다)
- 테스트 친화적입니다.
- 가독성이 좋습니다. (글 읽듯이 읽어 내려가면 됩니다.)
- 최종 함수를 사용하기 편합니다.
이 정도만 해도 우리가 함수형 프로그래밍을 안할 이유가 없습니다.
물론 항상 모든 기술은 trade-off 죠. 아무리 좋은 함수형 기법도 적절한 상황과 판단은 필요해 보입니다. 예를들어 커링과 같은 기법도 결국 함수에 함수를 감싸는 형태입니다. 모든 함수에 커링기법을 써버리면 결국 1개의 함수를 호출하는데도 여러 함수가 줄줄이 콜스텍에 쌓이게 될 것입니다. 성능적 이슈가 발생하겠죠.
하지만, 이것도 현대의 컴퓨터에서 돌아가는 일반적인 서비스에서는 당장 두려워할 일은 아니라고 생각은 합니다. 다만 이런 한계점은 인지를 하면서 사용하자는 거죠.
함께 스터디를 하며, 성장합시다.
현재 함수형 프로그래밍 스터디는 2기를 리딩하고 있는데요. 그 외 많은 스터디들을 커뮤니티에서 운영하고 있습니다. 저희 스터디는 일시적으로 모여서, 이도저도 아닌 채로 시간만 흘러가는 스터디와는 다릅니다. 효율적으로 성장을 하고자 한다면, 스터디 커뮤니티에 참여해 보세요.
디스코드 초대 링크 : https://discord.gg/e3q5wu8JVz
스터디가 어떻게 운영되는지 궁금하시다면, '스터디 캠프 오픈'(https://shorttrack.tistory.com/11) 글을 참고해주세요.
'Dev.' 카테고리의 다른 글
함수형 코딩 - 방어적 복사 Vs. Copy On Write (0) | 2023.07.31 |
---|---|
함수형 코딩을 읽고 (1~6챕터) (0) | 2023.06.29 |
Playwright, Auth 자동화와 API Mocking (0) | 2023.06.13 |
테스트도 전략이다 (0) | 2023.05.27 |
E2E 테스트로 왜 Playwright 선택했는가? (0) | 2023.05.27 |