함수형으로 문제 해결하기 (스크린샷)
원본: 클로저(Clojure) 방식으로 문제 해결하기 by Rafal Dittwald at Clojure/North
좋은 내용이라 영상 일부를 캡쳐해서 정리해봤습니다. 시간이 되신다면 캡쳐한 내용보다는 영상을 보시길 추천합니다. 코드는 Clojure가 아니고 Javascript라서 대부분 쉽게 이해하실 겁니다.
OOP vs FP
상태(state)는 헷갈린다.
OOP에선…
[ 상태를 정리해야 → 객체를 사용해서 → interacting agents의 형태 ]
FP에선…
[ 상태를 피해야 → 함수를 사용해서 → pipelines of input to output의 형태 ]
Funtional Procedure는 이름있는 함수를 argument에 넣어서 건네줄 수 있지만, 함수를 동적으로 만들어서 건네줄 수는 없다.
자바스크립트의 경우, 익명 함수가 사방에 있다.
엄격한 OOP의 경우, 그런 함수가 없다. 자바에는 람다(Lambda)가 있지만 객체 메서드가 있기에 함수를 거의 사용하지 않는다.
Side-effect는 피할 수 없다.
숫자 하나를 출력하는 것도 부작용(side-effect)이다.
변할 수 있는 상태, 상태 변이, 부작용을 어떻게 막을 것인가?
minimize, concentrate and defer
변할 수 있는 상태(mutable state), 상태 변이(mutating state), 부작용(side-effect)를 가능한 한
줄이고(minimize)
한군데로 모으고(concentrate)
나중에 처리하도록 미루거나(defer), 분리한다.
How to minimize?
Derive values: 값 추론
틱택토를 예로 들면, 턴의 순서를 따로 저장하지 않아도 Grid를 통해 알아낼 수 있다.
Copy data instead of mutate-in-place: 값을 변경하지 않고 변경된 값을 복사
클로저 같은 함수형 언어에서는 배열에 한 아이템을 추가할 때 전체를 복사하지 않고 원본에 대한 참조(reference)와 추가한 아이템에 대한 참조를 잇는 것으로 이루어진다.
Make and pass lambdas around: 람다 혹은 익명함수 사용
map이나 reduce같은 고차(high order)함수에 람다를 건네주어 반복문(loop)을 줄인다.
Recursion: 재귀
반복문(loop)은 대부분의 경우 재귀함수로 바꿀 수 있다. 재귀적으로 해결하면 반복문을 사용하는 것에 비해 상태를 줄일 수 있다.
영상에서 Dittwald는 Immutable data structure와 Persistent data structure의 개념을 설명하지만, 딱히 구분하지 않고 Immutable data structure라고 부르는데요. 상태(state)를 변경(mutable)하지 않는다는 의미에서 불변(immutable)이라고 설명했다고 받아들이면 될 듯 합니다.
React(Facebook)의 경우 상태(state)를 어디에 두어야 할까?
사람들은 전역(global)은 나쁘다고 하지만 수백 개일 때 얘기고, 하나만 있으면 괜찮다.
클로저의 React 래퍼(wrapper) 라이브러리인 Reagent는 왼쪽, Re-frame은 오른쪽처럼 구현한다.
함수형 개발자인 나(Dittwald)의 의견은, 왼쪽 그림은 상태(state)가 여기저기 흩어져 있는 면에서 객체 지향(object oriented)처럼 느껴진다.
가운데 그림은 가장 함수적(functional)으로 보인다.
오른쪽 그림은 상태를 전역(global)에 둬서 쉽게 접근이 가능하지만, 컴포넌트들은 외부에 상태를 조작함으로써 부작용(side-effect)을 일으키므로 순수(pure)하지 않다.
하지만 실용적인(pragmatic) 함수형 개발자의 입장에서는, 오른쪽 방식이 코드가 좀 순수하지 않게 되더라도 가치가 있다.
내(Dittwald)가 Haskell 개발자였다면, Root에 뒀을 것. (가운데)
내(Dittwald)가 Clojure 개발자였다면, 전역(external global)에 뒀을 것이다. (오른쪽)
Functional 리팩토링
게임 설명
Dittwald는 간단한 카드 게임 예제로 설명합니다. 객체지향 예제보다 아래 명령형 예제를 보시면 이해가 쉬울 겁니다. 턴마다 제일 높은 카드를 내는 사람이 포상 카드(bounty card)를 얻어서 마지막에 포상카드 합계 점수가 가장 높은 사람이 이기는 게임입니다.
↑ 객체 지향(object oriented) 예제
곳곳에 변할 수 있는 상태(mutable state)와 변이(mutation), 부작용(side-effect)이 퍼져있다.
↑ 명령형(imperative) 예제
상태(mutable state)는 한곳에 모여있지만 변이(mutation), 부작용(side-effect)이 여기저기 있는 모습
이 명령형(imperative) 예제에서 리팩토링을 시작합니다.
리팩토링하기
↑ 다른 함수에 있는 console.log()
를 한 곳에 모은다. (concentrate)
↑ console.log()
를 runGame()
으로 옮겼다.
↑ popRandom()
을 순수(pure)하게 바꿔보자.
↑ popRandom()
은 변이(mutation)의 의미가 있어서 이름을 selectRandom()
으로 바꿨다.
arr.splice(index, 1)
은arr
의index
번째부터 1개를 삭제하는 변이(mutation)이기 때문에 삭제합니다.
↑ 배열에서 타깃을 제외하는 without()
을 만들었다.
자바스크립트는 기본(standard) 라이브러리에서 원하는 것들을 지원하지 않기 때문에 without()
등의 헬퍼를 만들었다.
자바스크립트 기본 함수의 절반은 데이터를 그 자리에서 변이(mutate in place)하든가, 아니면 복사본을 리턴하는 등 엉망(?)이다.
클로저의 경우엔 아이템을 리스트로부터 필터링하거나 제거하는 함수가 있어서 직접 작성하지 않아도 돼 수고를 덜 수 있다.
selectRandom()
은 이제 순수(pure)하다. 엄밀히 따지면, Math.random()
에 의해 상태를 가지기 때문에 순수하지 않다.
runGame()
에서는 console.log()
를 가져오거나 bountyCards
를 변경(mutation)하게 되어 단언컨대 더 나빠졌지만, 전체적으로 보면 나쁜 것들을 한 곳에 모으고(concentrate) 있으므로 더 낫다고 할 수 있다.
↑ playEqualStrategy()
를 바꿔보자.
이것 역시 splice()
로 변경(mutation)을 하고 있다.
↑ 그냥 값을 리턴하도록 바꿨다.
변경(mutation)는 호출되는 곳에서 하도록 했다.
↑ selectRandom()
, play**Strategy()
, without()
은 이제 순수(pure)하므로 접어두자.
↑ if
안의 console.log()
를 끄집어내보자.
↑ 부작용(side-effect)이 있는 부분(console.log()
)을 밖으로 옮기고
어떤 메시지를 출력할지 리턴하는 winMessage()
함수를 만들었다.
↑ console.log()
2개가 붙어있는데, 어떻게든 합칠 수 있을 것 같다.
↑ scoreMessage()
와 endMessage()
를 만들었다.
↑ 상태(state)를 하나로 합치는게 어떨까?
지금은 별로 달라보이지 않겠지만, 조금 뒤엔 왜 도움이 되는지 알 수 있을 것이다.
↑ 이것도 집중(concentrating)같은 것이라고 생각할 수도 있다.
↑ 게임의 다음 상태(state)를 가져오는 함수를 생각해보자. 이름은 nextState()
로…
↑ 상태(state)를 받아서 새로운 상태(state)를 리턴하도록 한다.
대부분은 코드를 그저 복붙(copy and paste)할 것이다. 순수 함수(pure function)를 호출하여 변경(mutating)하는 내용 뿐이라…
nextState()
에서 하는 일은 그저 포상 카드(bounty card)와 플레이 카드 2장을 만들어서 리턴하는 것이다.
↑ nextState()
를 만들었다면 이제 필요없는 부분은 지우자.
변경(mutation)하는 부분은 아직 남아있다. 하지만 대부분 로직은 순수 함수(pure function) 안에 있다.
약간 복잡할 수 있지만 우리 코드의 알맹이(?)다.
이제 다음 턴의 상태(state)는 nextState()
에 의해 이전 상태가 변형된(transformed) 것이라고 볼 수 있다.
↑ 더 이상 없는 값을 console.log()
하고 있는데 이 부분을 상태(state)에 추가한다.
암시된 상태(implicit state)가 있었구나 하는걸 깨달았다.
지금 우리 코드 대부분은 순수(pure)하다. 아주 약간의 부작용(side-effect)과 상태(state)가 있다.
내(Dittwald)가 여기서 끝낼 수도 있다. 클로저를 사용했다면 atom을 변경(mutate)하는 방법으로 짤 수 있다.
하지만 계속 할 것이다. 아직 할 게 남아있다.
↑ 여기 console.log()
를 보자면, 엄청 뭉쳐있다.
↑ 로깅하는 내용을 함수에서 생성하도록 합쳐보았다.
다시 말하지만, 상태를 가지고 지저분하게 변이(mutation)하는 코드를 순수(pure)하고 완벽한 함수로 옮기는 것은 좋다.
코드가 이러저리 바뀌고 있지만, 오른쪽의 함수들은 모든 것이 순수(pure)하고, 이전 코드에서 큰 덩어리의 일부로써 이해했을 때보다 하나를 보더라도 잘 이해할 수 있다.
아직 끝나지 않았다. 여기서 한가지 제안을 하겠다. 말도 안 되는 소리일 수도 있는데…(다음 슬라이드)
↑ 하나의 상태(state)만 가지고 있기보다 배열로 만든 다음, 값을 계속 변경(mutate)하기보다는…(다음 슬라이드)
↑ 새 상태(state)가 나오면 그냥 추가(append)하려고 한다.
그럼 여러분은 미쳤다고 그러겠지. 상태(state)가 수 천개 생기면 메모리가 어쩌고 저쩌고 큰 일이라고.
하지만 함수형 데이터 구조(functional data structures)에서는 그게 꽤 효율적(reasonably efficient)이다.
한 번에 저장할 수 있는 데이터보다 많은 양을 다루는 시스템에서는 이렇게 하기 힘들겠지만, 이정도의 시스템에서는 괜찮은 선택일 것이다.
↑ 프로그램을 두 단계로 나누어 보는건 어떨까? 한 단계는 상태(state)를 생성하고, 다른 한 단계는 상태를 출력(log)하도록…
↑ runGame()
에서 상태(state)배열을 리턴하도록 한다.
↑ 상태(state)배열을 잠시 뒤 만들 어떤 함수를 이용해 map()
하면서 - 즉, loop하면서 - 보고하는 어떤 함수를 만들 것이다. (만들었다.)
그 어떤 함수는 턴마다 무엇을 할지 알려주는 함수(onTurn
)이고, 다른 어떤 함수는 마지막에 무엇을 할지 알려주는 함수(onEnd
)이다.
↑ 코드 실행은 runGame()
으로 할 것이고, 그 어떤 함수 2개는 이미 작성해둔 메시지 헬퍼 함수인 turnMessage()
와 endMessage()
이다.
각 턴마다 혹은 마지막에 뭘 출력할지 결정한다.
↑ 이것들은 더이상 필요없다. 없애자.
↑ 이제 runGame()
에는 부작용(side-effect)이 없다. 아래쪽에 하나 있을 뿐이다.
한 곳에 모아서(concentrated) 분리(separated)했다. good
↑ 마지막 스텝은, 배열에 추가하는 while 루프(loop)는 재귀(recursion)로 가능하고 어떠한 변경 가능한 상태(mutable state)도 제거할 수 있다.
이 부분은 선택 사항이다. 당신이 하는 일에 따라서는 작고 하찮을 수 있다. 클로저에서 while 반복문(loop)은 반복문처럼 보이지만 내부적으로는 재귀이고, 생각하기에 따라 JVM에서 루프(loop)를 돈다고 생각할 수 있지만, 우리가 하려는 것은 변경(mutation)을 하는 코드를 없애자는 것이고 시스템 내부적으로 변경(mutation)하는 것은 괜찮다.
↑ 그래서 재귀함수를 만들었다. 상태들(states), stateChange
함수, 종료 조건(endCondition
)함수를 받아서 재귀 호출을 한다.
↑ 종료 조건
이제 코드의 95%가 순수(pure)하다. 그 모두가 입력(input)을 받아서 출력(output)으로 변환한다.
이렇게 함으로써, '다음 상태를 가져오는구나', '종료 메시지를 결정하는구나' 등.. 무슨 일을 하는지 의미상 조금이나마 알아볼 수 있다.
그리고 이들은 테스트하기 굉장히 쉽다. 이 함수들을 가지고 상태(state)를 건네준다음 원하는 값이 나오는지만 확인하면 된다.
객체(object)를 모킹(mocking)할 필요도 없다. 불가피한 부작용(side-effect)은 여기 조그맣게 한 줄 있다. 전체가 부작용(side-effect)과 변경(mutation), 상태(state)로 엉망이었는데, 이제 상태 변경은 전혀 없고 조그맣게 부작용(side-effect) 하나 남았다.
이것이 좋은 이유?
순수 함수(pure function)가 좋기 때문. 함수형 프로그래밍은 순수 함수를 되도록이면 많이 만드는 것
순수함수는 테스트하기 쉽다.
이해하기 쉽다.
병렬 시스템에서 사용하기 쉽다.
입력과 출력이 일정하기 때문에 메모(memoize)하기 쉽다.(캐싱)
클로저의 memoize 함수를 쓰면 피보나치 결과를 빠르게 구할 수 있음(https://clojuredocs.org/clojure.core/memoize)
Data-driven programming
프로그램을 설계할 때, 데이터를 최우선으로 생각한다. 데이터 파이프라인. 데이터가 무엇인지. 어디로 옮길지. 어떻게 변형시킬지 그것을 데이터를 먼저 생각하는 접근법(data-first thinking approach)이라고 한다.
타입이나 구조체를 만들지 않고 벡터나 맵으로만.. (trade off는 있겠지만)
제어 흐름의 일부를 (코드가 아닌) 데이터를 정의하는 곳에 프로그래밍 한다. 클로저에서는 코드가 데이터이기 때문에 매크로와 유사하다고 할 수 있다. 데이터(구조체)에 로직을 작성하고, 다른 코드가 그 데이터를 읽어서 작동하도록 하는 것. 데이터는 가장 순수(pure)하다. 행동(behavior)을 가지지 않는다. 난 사람들에게 설명할 때 Configuration driven development라고 부른다. 당신의 프로그램을 설정(config) 파일에 더 넣을 수 있다면?
아마존은 sdk를 20-30개 언어로 제공한다.
json을 컴파일해서 여러 언어의 라이브러리로 만든다.
이렇게 하면 유지보수하기 쉽다.
끝
잘못된 내용이나 오역을 발견하시면 제보 부탁드립니다.