합성으로 연결되는 함수형과 객체지향
작년에 저는 객체지향과 함수형을 관통하는 핵심 아이디어 중 하나를 발견했다고 생각했습니다. 뭐 전부는 아니겠지만 말이죠.
그건 바로 합성하기 쉬운Composable 프로그램을 만드는 것이었어요. 작고 한 가지를 잘 하는 함수 / 모듈 / 객체를 합성해서 거대한 프로그램을 만드는 거죠.
저는 이 아이디어로 객체지향과 함수형의 여러 아이디어를 쉽게 이해할 수 있었어요. 이 글에서는 두 패러다임의 몇 가지 사례를 통해서, 이를 이해하기 쉽게 풀어보고자 해요.
이를 통해 객체지향과 함수형의 화해를 도모할 수도 있지 않을까 싶기도 하네요.
객체지향 프로그래머들은 왜 함수형에 빠졌을까?
이따금 두 '세력'은 서로를 적대시하고는 합니다. 세력이라는 말은 농담인데, 저는 최근에 실제로 "함수형 세력"이라는 말을 듣기도 했고요. 지금까지 함수형하는 분들과 대화를 하면서 객체지향을 좋아하는 분을 많이 본 적은 없습니다.
뭔가 정적 타입 하는 분들이 동적 타입 하는 사람들을 혐오하고 경멸하듯이. 객체지향 = 자바 = 상속 = 사회악! 같은 공감대가 있더군요.
하지만 저는 박쥐 같은 사람입니다. 아니면 다원주의자인 여우라고 할까요. 저는 동적 타입과 정적 타입을 모두 좋아하듯이, 객체지향과 함수형을 모두 좋아합니다.
객체지향은 꼭 클래스나 상속일 필요는 없고, 자바일 필요는 더더욱 없다고 생각해요. 함수형의 좋은 관습들이 객체지향에서도 상식이 되어갈 수록 더 나은 세상이 될 거라고도 생각합니다.
이게 저만 하는 생각은 아닙니다. 객체지향 하는 사람들이 함수형과 커플이 되고 싶어하거든요.
제가 싫어하는 [클린 아키텍처]의 저자이자, craftMAN 을 고집하는 꼰대 엉클 밥 아저씨는 객체지향 구루로 유명한 사람이죠. 이분이 동적 타입 불변을 좋아하는 함수형 언어인 Clojure로 전향하셨다는 걸 아시나요?
클로저를 만든 리치 히키는 TDD와 애자일은 물론이고, 정적 타입과 '모나드'를 혐오하는 사람인데요. 히키가 2011년에 강연한 Simple Made Easy에 찬사를 보내며 Simple Hickey라는 글을 쓰더니. 그 후로 클로저가 내 삶의 마지막 언어가 될 것이라고 이야기하고 있거든요. 그는 객체지향과 함수형은 직교하고 모순되지 않기에, 둘 다 중요하다는 글을 쓴 적도 있습니다.
하지만 저도 이 MAN 아저씨를 별로 좋아하지 않아요. 그러니 또 다른 객체지향 유명인을 찾아봅시다.
프로그래밍 루비와 실용주의 프로그래머의 저자인 데이브 토머스가 동적타입 액터 함수형 언어인 엘릭서로 전향해서 책도 썼다는 사실을 아시나요? [처음 배우는 엘릭서 프로그래밍]이라는 이름으로 한국에도 번역되었습니다.
데이브 토머스 역시 실용주의 프로그래머 20주년 개정판에서, 프로그램을 입력에서 출력으로 변환하는 과정으로 생각할 것을 촉구합니다. 객체지향적 사고는 잠시 미뤄두라고도 해요.
아니 그래서. 이 사람들은 왜 함수형에 빠진 걸까요? 심플이니, 불변이니, 변환은 합성이랑 무슨 상관일까요?
합성이란 말이죠
compose: to form by putting together
Merriam-Webster 사전
합성은 단순(simple)한 개념입니다. 앞서 말씀 드린 것처럼 작고 한 가지를 잘 하는 함수 / 모듈 / 컴포넌트 / 객체를 합성해서 거대한 프로그램을 만들자는 거지요.
재사용이니 캡슐화니 불변이니 하는 것은 이런 목표를 위한 수단일 뿐입니다. 변경하고 갈아끼우기 쉬워지는 건 따라나오는 결과고요.
이게 너무 뻔해보일 수 있어요. 세상에 부품을 조립해서 만들지 않는 게 어디있나요?
그러니 비슷해보이는 반댓말과 비교를 해보고 싶습니다. 바로 만악의 근원인 '결합(coupling)'이에요.
여러 비유와 실제 예시를 들어서 하나씩 설명해보겠습니다. 비유는 익숙하지만, 현실과 동떨어지기도 하니까요. 조심스럽게 시작해보죠.
일체형 맥북과 조립식 컴퓨터
어떤 컴퓨터나 여러 부품을 조립해서 만들어집니다. 컴퓨터는 모니터, 키보드, 트랙패드나 마우스, 스피커, CPU, GPU, 램, SSD, 운영체제 등등... 여러 구성요소로 되어 있지요.
문제는 결합입니다. 물론 2개 뿐인 C타입 포트에 문어발 멀티탭을 연결해서 마우스나 모니터를 연결해서 쓸 수도 있긴 한데요.
그렇다고 모든 걸 커스텀할 수 있는 건 아닙니다. CPU를 다른 회사 걸로 갈아 끼울 수는 없지요. 뭐 나는 M1이면 충분하다 하실지도 모르겠어요. 그러면 최소한 M2가 나왔을 때 M2칩만 새로 사서 갈아 끼울 수도 있어야 하지 않을까요?
물론 안 됩니다. 맥북은 이 모든 게 강하게 결합되어 있기 때문이죠.
반대로 저는 2019년에 산 조립식 컴퓨터를 쓰고 있는데요. 최근에 새 CPU와 그래픽 카드를 사서 갈아 끼웠습니다. 저는 게임이나 영상 작업 같은 건 하지 않기 때문에. 서로 다른 회사의 제품을 비교해보고, 제 상황에 맞는 걸 골라서 쓸 수 있었어요.
객체지향이나 함수형은 단연코 후자입니다.
계층형 아키텍처니 클린 아키텍처니 하는 걸 보면... 객체지향에서는 인터페이스를 정의하고, 같은 인터페이스를 공유하는 구현체를 갈아끼우곤 합니다. 이를 거창하게 "구체가 아니라 추상에 의존한다"던가 "의존 관계의 역전"이라고도 부릅니다.
MySQL을 쓰다가 PostgreSQL로 갈아타는 건 물론이고, 상황에 따라서는 MongoDB나 DynamoDB로 갈아끼울 수도 있어야 하는 거죠. 물론 고성능 게임을 하는데 싸구려 그래픽 카드를 쓸 수는 없듯이, 프로젝트가 요구하는 상황에 맞는 기술을 쓰고 싶어 하는 거지요.
이게 객체지향만의 이야기도 아닙니다. 예를 들어 클로저에서는 map
함수를 쓰다가 앞에 p
를 붙여서 pmap을 돌리면, 순차적으로 하나씩 도는 게 아니라 멀티 쓰레드로 병렬(parallel) 실행합니다. map이나 pmap이나 같은 인터페이스를 가지고 있기 때문에 갈아끼울 수 있는 거지요. 병렬 연산은 매우 단순해지고요.
일하고 협력하는 세포
하지만 함수나 객체를 갈아 끼우기 쉬운 부품이라고만 생각하기에는, 현실은 더 복잡하니 다른 비유도 있으면 좋겠습니다.
객체지향을 창시한 앨런 케이는 세포에서 객체지향의 영감을 받았다고도 해요.
“I thought of objects being like biological cells and/or individual computers on a network, only able to communicate with messages. (...) I wanted to get rid of data.”
"저는 객체를 생물학적 세포나 네트워크 위의 개별 컴퓨터들처럼 생각했어요. 이들은 오로지 메세지로만 소통할 수 있죠. (...) 저는 데이터를 없애버리고 싶었습니다."
Alan Kay 앨런 케이
케이가 생각한 객체지향의 핵심은 무엇일까요. 그건 바로 상태를 '캡슐화'해서, 객체들이 서로 '메세지'만으로 소통하는 겁니다.
앨런 케이는 분자 생물학과 수학을 전공했다고 합니다. 신경 세포, 근육 세포, 소화 기간과 호흡기의 세포, 백혈구와 적혈구 등등... 다양한 역할을 가진 수 많은 세포들이 협력해서 생물체를 움직이는 걸 상상해보세요.
세포들은 한 가지 역할을 잘 합니다. 골수 세포는 적혈구와 백혈구를 만듭니다. 적혈구는 산소를 운반합니다. 세포들은 적혈구가 준 산소를 가지고 각자의 일을 하지요.
세포들은 서로의 내부 상태에는 관심이 없습니다. 인터페이스에만 관심이 있지요. 즉 서로 무엇을 주고 받을지만 신경 씁니다.
세포는 끝 없이 만들어지고 죽어갑니다. 죽은 세포의 자리를 새로운 세포가 대신하고, 아무런 문제도 일어나지 않죠. 어떻게 보면 슬프지만, 덕분에 다른 사람의 피를 수혈 받거나, 심지어 장기를 이식 받아서 생명을 살릴 수도 있지요.
우리의 앱과 서버도 그렇게 할 수는 없을까요? 왜 안 되는 걸까요?
앨런 케이가 말하는 data는 함수형 프로그래머들이 말하는 '불변 데이터'가 아니었습니다. 그보다는 현실에 넘쳐나는 고통과 부수효과, 불투명의 원천인... "공유되는 가변 상태 Shared Mutalbe State"였죠.
시간이라는 외부 상태 의존성을 다루기
함수형은 상태를 바꾸는 부수효과가 없어서 순수하다고들 합니다. 정말 그랬다면 좋았겠지만, 현실은 그렇게 행복하지 않았어요.
세상에는 상태가 넘쳐납니다. 예를 들어 '시간'은 대표적인 외부 상태 의존성 중에 하나입니다.
상태는 특히 저 같이 테스트를 하고 싶은 사람이나, 순수 함수를 좋아하는 함수형 프로그래머에게 짜증나는 존재입니다.
예를 들어 정확히 2022년 10월 1일부터 시작하는 이벤트가 있다고 합시다. 흔히 있는 비즈니스 로직인데요. 그러면 2022년 10월 1일이 되기 전에 이 로직을 정확하게 짰는지는 어떻게 테스트할 수 있을까요?
컴퓨터의 시간을 돌리면 된다고 생각하실지 모르지만, 그래도 시간은 또 흘러가버립니다. 서버를 실행하는 중에 또 시간이 지나가면 다시 과거로 돌리고, 이걸 반복해야 해요.
흠... 다른 방법은 없을까요? 해결책은 단순합니다. 시계를 갈아끼울 수 있으면 되거든요.
함수형은 상태를 밖으로 밀어냅니다
함수형에서는 이런 상태를 밖으로 밀어내고 순수한 함수를 남기려 합니다. 순수 함수는 숨겨진 외부 의존성이 없이, 정해진 값을 받으면 항상 똑같은 결과를 반환하는 함수입니다.
예를 들어 현재 시간이 2022년 10월 1일이 지났는지 확인하는 함수는 계속 결과가 달라지기 때문에 순수하지 않아요.
참 일부러 버그를 심은 것도 보이시나요?
function isOverEventDay(){
return Date.now() >= new Date(2022,10,1).valueOf();
}
// 2022년 9월 30일에 호출했을 때
isOverEventDay(); // false
// 2022년 10월 1일에 호출했을 때
isOverEventDay(); // false
// 2022년 11월 1일에 호출했을 때
isOverEventDay(); // true
js에서 Date의 month는 특이하게도 0부터 숫자를 셉니다. 여기에는 각 월에 해당하는 문자열이 담긴 배열에서 값을 편하게 꺼내고 싶다는 어른의 사정이 있는데. 덕분에 전 세계 사람들이 헷갈리게 되었어요.
문제는 이 로직이 시간이라는 외부 상태에 의존하기 때문에, 2022년 10월 1일이 되어서야 에러를 발견하게 될 거라는 점이에요! 그리고 이 이슈를 고치더라도, 잘 고쳤는지 확인하기가 번거롭습니다. 여기에 타입스크립트가 도움을 줄지는 모르겠네요.
함수형 하는 분들은 단순한 해법을 가지고 있습니다. 바로 순수 함수 버전을 만들어서 보여 드리죠.
function isOverEventDay(date){
return date >= new Date(2022,9,1).valueOf();
}
// 2022년 9월 30일에 해당하는 값
isOverEventDay(new Date(2022,8,30).valueOf()); // false
// 2022년 10월 1일에 해당하는 값
isOverEventDay(new Date(2022,9,1).valueOf()); // true
isOverEventDay
는 이제 순수함수입니다. 내가 판정하고 싶은 날짜를 넘겨주면, 항상 올바른 결과를 반환하지요. 1년 뒤에 이 함수를 호출하더라도 똑같은 결과가 나올 거에요. 불순한 Date.now()
는 밖으로 밀어냈습니다.
isOverEventDay
는 자기 역할에 충실함으로서, 더 범용적인 함수가 되었습니다. 예를 들어 어떤 사용자들이 컴퓨터 시계를 조작해서, 부정한 방법으로 선착순 이벤트에 당첨되었다고 해봅시다. 데이터베이스에 들어 있는 과거 기록에 createdAt처럼 서버 기준으로 요청 시간이 남아 있다면... isOverEventDay
를 과거 기록에 돌려보는 것만으로도 부정한 응모 기록을 걸러낼 수 있을 겁니다.
더 합성하기 쉬워진 것이죠!
하지만 이 방식에는 뭔가 한계가 있습니다. 외부 상태를 밖으로 밀어내긴 했지만, 언젠가는 결국 실제 상태를 가져와야 합니다.
이건 시간에 한정된 문제가 아닙니다. 세상에는 가변 상태가 많습니다. SQL 데이터베이스, 파일 시스템, 심지어 사용자의 환경설정이나, 사용자의 입력까지... 상태가 아닌 것이 없습니다.
함수형은 이런 상태를 잘 다룰 수 없는 걸까요? 물론 아닙니다. 하지만 먼저 객체지향식 해법부터 살펴봅시다.
객체지향식 해법. 가짜 서버로 갈아 끼우기
앞에서 앨런 케이는 객체를 네트워크 위의 컴퓨터처럼 생각했다고 했지요. 여기서 좋은 비유가 하나 또 나옵니다. 바로 클라이언트와 서버에요.
어떤 객체가 다른 객체의 메서드를 호출하는 건, 어떤 서버에 요청(request)을 보내는 것과 같습니다. 그리고 그 메서드가 반환(return)하는 값은 서버가 응답(response)하는 것과 비슷하죠. 서버 컴퓨터란 거대한 객체인 셈입니다.
상태를 가둔다고 상태가 사라지진 않습니다. 하지만 적어도 상태가 우리 시스템 밖에 있게 만들 수는 있어요. 그리고 객체를 순수한 친구로 갈아끼울 수 있다면, 우리의 테스트는 더 편해질 거에요.
우리의 시계(Clock)도 그렇습니다.
Date.now()
는 Date 객체에게 현재(now) 시간이 언제냐고 물어보는 요청, 그러니까 메세지를 보내는 것처럼 생각할 수 있습니다. 그래서 1970년 1월 1일 부터 몇 초가 지났는지, 즉 unix 시간을 응답으로 돌려주는 거지요.
그렇게 생각해보면 내부의 시계 상태가 어떻게 굴러가던 저희가 알 바가 아니라는 걸 알 수 있습니다. Date 객체가 우리가 원하는 메세지를 돌려주게 하면 되는 거거든요.
이게 mock timer 같은 테스트 도구가 하는 일이기도 합니다. 대충 다음처럼 구현할 수 있습니다.
const originalNow = Date.now
// 스텁을 주입
Date.now = () => new Date(2022,09,01).valueOf()
// 저희가 원하는 값을 반환합니다.
Date.now() // 1664550000000
// 기대하는 결과가 나옵니다
isOverEventDay(Date.now()); // true
// 원상 복귀 시킵니다.
Date.now = originalNow
어떤 분들은 이게 now의 값을 재할당하니까, 순수하지 못하다고 생각하실지 모르겠어요. 이는 함수의 매개변수로 1664550000000
를 주입한 것과 다르지 않습니다.
React와 Redux에 영감을 준 Elm 같은 순수 함수형 언어에서도, Command
와 Msg
를 이용해서 시간을 주입 받지요. 생각해보면 Redux가 상태를 관리하는 방식도 떠오르시지 않나요? 메세지를 보내는 것과, action을 dispatch하는 것 사이의 유사성을 생각해보시면 좋겠어요.
정 불편하시면 대수적 효과가 없는 언어에서, 열등한 방식으로 따라했다고 생각하셔도 됩니다.
객체와 서버의 유사성
저는 이 방법의 본질을 깨닫고 나서 여러 문제를 쉽게 해결할 수 있었습니다.
예를 들어 프런트엔드 개발을 하다보면 서버에서 어떤 데이터를 가져오는 경우가 많습니다. 문제는 실제 서버의 데이터는 매번 바뀌기도 하고요. 결제를 한다던가, 이메일을 보낸다던가 하는 요청을 실제로 보내는 건 끔찍한 일입니다.
이런 때 Mocking을 하는 방법도 있지만, 테스트용 서버를 연결하는 것도 방법입니다. 객체지향적인 관점에서 보면 이 둘은 전혀 차이가 없습니다.
저는 Python FastAPI로 단순하게 실제 서버와 똑같은 API를 가졌지만, DB는 존재하지 않는 서버를 만들었습니다. 이제 프런트엔드에서는 .env
같은 설정으로 서버의 URL을 주입 받게 하고, URL만 정해진 값을 반환하는 테스트용 stub 서버를 향하도록 바꿔주면 됩니다. 이 서버는 순수하기 때문에 항상 같은 응답을 주겠지요.
상태는 없앨 수 없지만, 역할은 갈아끼울 수 있습니다. 갈아끼울 수 있는 시스템은 더 합성하기 쉽죠. 상태로 가득찬 객체를, 상태가 없이 불변하는 객체로 갈아끼울 수도 있으니까요.
다형성
사람들은 객체지향의 SOLID 원칙 같은 걸 이야기하고는 합니다. 하지만 저는 이게 객체지향의 원칙인지 잘 모르겠어요. 단일 책임은 함수형에서도 강조하는 것이기 때문입니다. 예를 들어 함수형에서는 범용적이고 위험한 for문보다는 map, filter, every, some과 같이 구체적이고 한 가지 역할을 잘 하는 함수 여러가지를 좋아하니까요.
그런데 여기서 오해를 하나 짚고 넘어가고 싶습니다. 바로 메서드냐 함수냐 하는 지루한 논쟁인데요. 함수와 메서드의 차이를 이야기하면서 다음과 같은 코드를 예시로 드는 분들이 있습니다. 재미있는 건 객체지향이나 함수형 중에 한 쪽이 열등하다는 증거로 가져온다는 거에요.
// 함수형 방식?
map([1,2,3,4], x => x*2)
// 객체지향 방식?
[1,2,3,4].map(x => x*2)
결론부터 말하자면 저는 이 둘이 큰 차이가 없다고 생각합니다. 언어의 구현에 따라 차이가 있을 뿐이지, 둘은 서로의 방식으로 이해할 수 있어요.
같은 메세지로 다양한 객체를 다루기
객체지향에서 메서드는 메세지(Message)의 이름입니다. "map"이라는 문자열이나 atom
, key
라 생각하셔도 다르지 않아요. 메세지에 내용(payload)으로 x => x*2라는 함수를 넘긴 것이죠. 함수는 값이자 데이터로 취급할 수 있으니 역시 이상할 게 없습니다.
보시는 것처럼 객체지향의 메서드는 대부분 객체 자신을 첫 번째 인자로 넘기는 함수로 변환할 수 있는데요. 이는 그렇게 신기한 일이 아닙니다. python
같은 언어에서는 이를 더 명시적으로 표현하기 위해서, 메서드의 첫 번째 인자로 self
를 받게 하기도 합니다.
class MemoryTodoRepository:
"""TodoList의 인 메모리 구현체"""
_todo_list = {
"taehee": []
}
async def getAll(self, user_name: str):
"""해당 사용자의 todoList 전체를 읽어옵니다."""
return self._todo_list.get(user_name)
보통 논쟁은 **다형성(Polymorphism)**을 두고 벌어지는 것 같습니다.
In programming language theory and type theory, polymorphism is the provision of a single interface to entities of different types or the use of a single symbol to represent multiple different types. The concept is borrowed from a principle in biology where an organism or species can have many different forms or stages.
그러니까 다형성은 같은 인터페이스로 서로 다른 엔티티를 사용할 수 있는 특성입니다. 똑같은 map을 list
나 set
에 적용할 수 있다던가. 어떤 객체라도 toString
이라는 메세지를 보내면, 자기를 표현하는 문자열을 뱉는다는 식입니다.
아래 예시를 봐주시죠. 각 값을 서버처럼 생각하고 toString
으로 요청을 보내면 어떤 응답이 오는지 본다고 생각해주세요.
(5).toString()
// '5'
(12.4).toString()
// '12.4'
"foo".toString()
// 'foo'
다형성이 필요한 이유는 어렵지 않습니다. 제네릭이 없던 Golang이나, 자바의 원시 타입 지원 같은 걸 보면 알 수 있는데요. 같은 map함수인데 타입마다 mapToInt, mapToLong, mapToFloat, mapToDouble을 하나하나 정의해야 한다고 생각하면 정말 괴롭습니다.
다형성은 객체지향과 함수형을 가리지 않습니다. 언어들은 각자 나름의 방식으로 다형성을 지원합니다.
그럼에도 오해가 있으니 하나씩 다뤄볼까요.
객체지향은 다형성을 지원하지 못한다?
전에 어떤 분의 강의를 듣는데, 이게 객체지향이 열등한 이유라고 했습니다. js
에서는 map
메서드가 Array
프로토타입에만 정의되어 있어요. 그래서 string
이나 Set
, Dict
등등 다른 iterable
한 객체에는 map
을 쓰지 못합니다. "이것봐라 객체에 메서드가 정의되어 있지 않으니 사용하질 못하지 않냐!"
반면에 python
에서는 이터러블한 객체라면 무엇이라도 map
을 적용할 수 있습니다.
def square(n):
return n ** 2
list(map(square, [1, 2, 3, 4, 5]))
# [1, 4, 9, 16, 25]
set(map(square, {1, 2, 3, 4, 5}))
# {1, 4, 9, 16, 25}
elixir
같은 함수형 언어에서는 더 강력한데, |>
같은 pipe 연산자를 이용하면 앞의 값을 뒤에 오는 함수의 첫 번째 인자로 넘길 수 있습니다. 파이프 연산자는 js
에도 제안되어서 현재 stage 2 단계입니다.
Enum.map([1, 2, 3, 4, 5], fn(x) -> x * x end)
# [1, 4, 9, 16, 25]
# pipe 연산자
[1, 2, 3, 4, 5] |> Enum.map(fn(x) -> x * x end)
# [1, 4, 9, 16, 25]
하지만 이는 js의 특이한 사례일 뿐입니다.
Iterable
인터페이스를 구현한 객체에게 map 등의 함수를 제공하는 건 그렇게 어려운 일은 아닙니다. 예를 들어 js의 map은 전혀 수정하지 않고도 String에 적용할 수 있지요. Java
에서도 Stream API
를 이용해서 비슷한 유연함을 얻을 수 있습니다. 하지만 또 반론이 들어오는 건 자바는 클래스에 모든 인터페이스를 구현해야하지 않냐는 것입니다. 이는 타당한 지적으로도 보입니다. 서로 다른 인터페이스의 관심사를 하나의 클래스 파일에 몰아 넣는 건 좀 이상하니까요.
하지만 이는 당장 JS에도 잘 통하지 않습니다. JS는 프로토타입 객체지향 언어로, this가 동적으로 resolve됩니다. 한 번 정의한 함수를 여러 함수에 embed하여 합성하여, 쉽게 객체의 기능을 확장할 수 있지요. 그 예시로 Array의 map을 그대로 String에 추가할 수도 있습니다.
String.prototype.map = Array.prototype.map
"test".map(c => c.toUpperCase())
// ['T', 'E', 'S', 'T']
// 더 나은 구현
String.prototype.map = function(callback){ return Array.prototype.map.call(this, callback).join('')}
"test".map(c => c.toUpperCase())
// 'TEST'
또 Rust
나 Golang
는 각각 trait
, receiver
를 이용해서 특정 객체 타입에 메서드 구현을 별도로 정의할 수 있게 해놓았습니다.
let v: Vec<i32> = [1, 2, 3].into_iter().map(|x| x * x).collect();
assert_eq!(v, [1, 4, 9]);
함수형은 다형성을 지원하지 못한다?
거꾸로 객체지향을 하는 분들은, 함수형에는 이런 기능이 없다고 오해하시는 경우가 많습니다. 특히 타입이 있는 함수형 언어에서는 함수를 만들어도 하나의 타입에 밖에 못 쓰지 않냐!... 그런 생각을 하시는 분도 있는데요. 당연하지만 그렇지 않습니다.
앞서 말씀 드린 것처럼 함수 호출과 메서드 호출은 관점이 조금 다를 뿐이지, 실상은 비슷합니다. 같은 메세지를 보내도 객체에 따라 다르게 동작할 수 있다면. 같은 함수를 다른 타입에 호출했을 때, 다른 함수 구현체를 호출하게 만들 수도 있지 않겠어요?
한 가지 방법은 완전 패턴 매칭입니다. 간단하게 말하자면 타입과 고급스러운 문법의 지원을 받은 if문인데요. 값이 어떤 패턴에 매칭되는지에 따라서 다른 구현으로 처리할 수 있습니다. 많은 함수형 언어는 물론, 요즘은 Python 같은 주류 언어도 지원하기 시작했습니다.
def handle(event: Event):
match event.get():
case Click(position=(x, y)):
handle_click_at(x, y)
case KeyPress(key_name="Q") | Quit():
game.quit()
case KeyPress(key_name="up arrow"):
game.go_north()
# ...
case KeyPress():
pass # Ignore other keystrokes
case other_event:
raise ValueError(f"Unrecognized event: {other_event}")
보시는 바와 같이, 이는 매개변수로 들어올 타입을 모두 합타입으로 처리해야 하고, 모든 경우가 늘어지기도 합니다. 그래서 클로저를 만든 리치 히키가 말하듯 패턴 매칭은 끔찍한 결합(coupliing)을 만든다는 주장도 일리가 있습니다.
그래서 클로저나 엘릭서 같은 함수형 언어에서는 프로토콜이라는 기능도 지원합니다.
프로토콜은 Elixir에서 다형성을 성취하기 위한 도구입니다. Erlang의 불편한 부분 중 하나는 새로 정의된 타입을 사용해 기존의 API를 확장하는 것입니다. Elixir는 많은 프로토콜을 가지고 있으며, 예를 들어 String.Chars 프로토콜은 이전에 보았던 to_string/1 함수를 책임집니다. to_string/1을 간단한 예제와 함께 살펴보죠.
to_string(5) "5" to_string(12.4) "12.4" to_string("foo") "foo"
여기에서 볼 수 있듯, 여러 타입에 대해서 함수를 호출하고 그 모두와 잘 동작합니다.
보시는 것처럼 5.toString()
과 to_string(5)
는 딱히 다를 게 없습니다. 물론 엘릭서는 파이프 연산자를 써서 5 |> to_string()
라고 쓰는 것도 가능하고요. 프로토콜을 구현하는 코드도 살펴보시면, Rust나 Golang이 떠오르실지도 모르겠습니다. 객체지향을 하다 클로저로 넘어간 꼰대 로버트 C 마틴 같은 아저씨가 함수형에서 객체지향의 기능을 훔쳐갔다고 껄껄 웃는 글을 어디선가 찾을 수 있을 것도 같군요.
다형성 역시 결국 합성하기 쉬운 프로그램을 만들기 위한 수단입니다. 굳이 말하자면 C타입으로 표준을 통일하는 것과 다르지 않아요. 인터페이스를 통일해서 서로 다른 타입을 하나의 코드로 다룰 수 있다면? 손쉽게 다양한 타입의 객체 / 함수를 합성할 수 있을 테니까요. 이는 갈아끼우기 쉽고 변경하기 쉽고 재사용하기 쉬운 프로그램으로 이어지겠지요.
불변성은 왜 합성을 쉽게 만들까?
앞서 이야기했던 것처럼 공유되는 가변 상태는 만악의 근원입니다.
이 역시 합성의 관점으로 생각할 수 있습니다. 신뢰할 수 없는 코드는 조합하기 어렵습니다. 계속 변하는 공유 상태를 머릿 속에 넣고 있어야 하니, 지금 작은 영역에 한정해서 생각(think local)할 수가 없습니다.
멀티 쓰레드나 동시성 좀 하려 하면 순서를 꼬아버리고. 교착 상태를 만들며, 데이터 무결성을 깨버리고, 조심하려고 lock을 걸면 성능에 병목을 만들며, 수정하니 기존 데이터가 날아가서 원본을 찾을 수 없게 만들고, 테스트를 시작하기 전과 후마다 세팅하고 정리해주느라 고통 받게 됩니다.
예를 들어 전에 테스트를 하는데, 분명 아무 문제가 없어보이는데 깨지는 로직이 있었습니다. 알고 보니 테스트 데이터를 가변으로 수정하는 코드가 문제였습니다. 이전 테스트에서 바뀐 데이터가 다음 테스트에 영향을 준 것이죠...
함수형하는 분들은 그래서 최대한 모든 걸 불변으로 유지하려 합니다. 값을 바꾸는 대신 이전 상태를 받아서 새로운 값을 계산하는 함수를 만듭니다. 이 고통이 끔찍한 트라우마가 되시기도 하나봐요. 어떤 분은 객체지향 하는 분들이 가변 상태를 캡슐화하니 어쩌고 하는 것은 기만이며, 전역 변수나 다름이 없다고 화를 내시기도 했습니다.
하지만 놀랍...지 않게도. 객체지향하시는 분들도 가변을 고통스러워하는 것은 마찬가지입니다. 객체지향 세계에는 동적 타입과 TDD, Repl을 좋아하는 사람이 많은데요. 앞서 말씀 드린 것처럼 가변 상태는 테스트를 어렵게 만들고, 비결정적이고 제어하기도 재현하기도 힘들게 만듭니다.
나는 도메인 로직만 분리해서 테스트하고 싶은데, 데이터베이스 동작 원리를 생각해야 하는 상황은 뭔가 잘못된 거지요.
그러니 TDD를 하는 사람은 불변과 순수 함수를 사랑하게 됩니다.
- "불변 클래스는 가변 클래스보다 설계하고 구현하고 사용하기 쉬우며, 오류가 생길 여지도 적고 훨씬 안전하다." "불변 객체는 단순하다." "불변 객체는 근본적으로 스레드 안전하여 따로 동기화할 필요가 없다." "불변 객체는 안심하고 공유할 수 있다."
- "객체를 만들 때 다른 불변 객체들을 구성요소로 사용하면 이점이 많다." "불변 객체는 그 자체로 실패 원자성을 제공한다."
- "클래스는 꼭 필요한 경우가 아니라면 불변이어야 한다."
[이펙티브 자바 - 아이템 17. 변경 가능성을 최소화하라]
- 제어할 수 없는 값에 의존하는 코드들을 최대한 줄이는 것을 목표로 하여
- 제어할 수 없는 값을 함수의 인자로 받도록 해서 함수 자체는 순수 함수로서 구현되도록 한다.
- 제어할 수 없는 값의 위치는 최대한 진입점에 위치시켜 테스트하기 어려운 코드들의 숫자를 최대한 줄이되, 가능하다면 함수의 기본값 혹은 의존성 주입 등을 통해서 해결한다.
- 1편에 나온 코드 를 다시 한번 생각해보면 얼마나 부수효과가 많았는지, 매번 다른 결과가 나오는 함수인지를 알 수 있다.
- 테스트 하기 좋은 코드가 될수록 우리의 코드는 부수효과가 없고, 항상 같은 결과가 반환되는 순수 함수가 될 수 있다.
물론 상태가 없는 세상은 없습니다. CPU와 메모리도 공유되는 가변 상태이고, 이는 실제로 문제를 일으키기도 합니다. 함수형을 하는 분들도 "함수형은 부수효과를 배제하지 않는다. 부수효과를 밖으로 밀어내고, 더 잘 다룰 수 있게 해준다."고 하시기도 해요.
"변이는 코드의 부작용이고, 코드가 어떻게 행동할지 효과적으로 생각하기 어렵게 만들기 때문에, 우리는 가능한한 순수 함수와 불변 데이터를 선호합니다."
아무리 밀어내고 잘 다뤄도 상태는 어딘가에 들어가야 하는데. 객체는 이런 일을 참 잘 합니다. 그래서 아까부터 계속 나오는 리치 히키도 이렇게 말했을 정도입니다.
객체 있죠? 객체는 입출력 장치를 캡슐화하죠. 화면도 캡슐화 해요. 제가 화면을 직접 건드리진 못하죠. 마우스도 있어요. 마우스도 제가 직접 제어하진 못하죠. 이럴 때 쓰려고 객체가 있는 거예요. 이런 데에선 객체가 바른 역할을 합니다. 하지만 정보에 적용하라고 존재한 적은 없어요. 정보를 객체에 적용했다면 그건 명확한 잘못입니다.
이제 컴퓨터에 마우스와 화면을 연결할 수 있겠어요. Compose의 시대입니다.
Long Live Composition
아마 여러 논쟁이 그렇듯이 객체지향과 함수형 논쟁은 계속될 것 같습니다. 공통점과 차이점을 궁금해하고 알아가기 보다는, 편견으로 오해하고 경멸하는 건 인류에게 오랜 전통입니다.
저는 그 논쟁 속에서 상처를 많이 받았습니다. 원래 박쥐 같은 사람은 양쪽에서 욕을 먹으니까요. 제가 객체지향을 좋아한다는 말만 해도 객체지향이 얼마나 쓰레기 같은지 늘어놓는 분도 보았고요. 함수형을 한다고 하면 저를 채식주의자 보듯이 경멸하는 분도 보았습니다.
아 물론 둘 다 하는 분도 있었습니다. "객체지향이나 함수형이나 학계에서나 떠드는 거지 실무랑은 무관하다"던 분이 아직도 기억나네요.
늘 그렇지만 오해와 편견은 내가 아는 게 세상의 전부라는 착각에서 시작됩니다. 우리를 고통스럽게 하는 건 잘못된 설계이지, 패러다임은 아닙니다. 객체지향과 함수형은 형제로서 함께 발전해왔습니다.
참 앨런 케이도 함수형을 좋아한다는 걸 아시나요? 그는 리스프를 늘 칭찬하며, 최초의 객체지향 언어인 스몰톡은 리스프와 닮은 점이 많습니다. 그러니 객체지향은 함수형에서 태어난 셈이죠.
OOP와 함수적 계산은 완전히 조화될 수 있습니다. (그리고 그래야만 하죠!)
- Alan Kay
그 목표는 결국 합성하기 쉽고, 갈아끼우기 쉬우며, 변경하기 쉬운 프로그램을 만드는 것입니다. 우리는 모두 조립식 컴퓨터를 좋아하는 사람들인 것입니다. 맥북에게 C타입 포트 좀 쓰라고 말하면서, 수리할 권리를 요구하는 사람들이죠.
서로 다른 사람들이 협력해서 멋진 일을 해낸다는 이야기를 저는 좋아합니다. 객체지향과 함수형도 함께 멋진 프로그램을 만들 수 있으리라 믿습니다. 정적 타입과 동적 타입도 그렇고. 불변과 가변도 그렇습니다. (관측할 수 없는 부수효과는 순수한 거니까요) 다른 모든 것도 그렇습니다.
저는 물리학과를 나왔으니 전자기파 이야기를 하면서 끝내보겠습니다. 과거에 학자들은 전기와 자기가 별개의 현상이라 생각했습니다. 하지만 전기로 자석을 만들 수 있다는 게 밝혀졌고, 대장장이의 아들이었던 마이클 패러데이는 자기장으로 발전기를 만들 수 있다는 걸 발견했습니다.
그리고 맥스웰에 이르러 수학을 통해 전기장이 자기장을 유도하고, 자기장이 전기장을 유도하며. 이 직교하는 두 파동이 교차하면서 나아가는 것이 전자기파이고. 빛 역시 이 전자기파의 일부임을 밝혔습니다.
제가 이 이야기를 한 이유를 이제 이해하시리라 믿습니다. 또 찾아올게요.