하스켈의 순수성은 부수효과를 막는 것이 아니라 순수한 코드와 불순한 코드 사이의 명확한 경계를 세우는 데 있다. 순수 함수의 의미, IO를 통한 부수효과의 표식, 테스트·추론 용이성, 그리고 Functional core, Imperative shell 패턴을 간단한 예제로 설명한다.
Haskell은 당신이 주로 사용하는 메인스트림 프로그래밍 언어가 할 수 있는 일은 무엇이든 할 수 있습니다.
순수성은 (데이터베이스 조회나 HTTP 요청 같은) 부수효과를 막는 것이 아니라, 부수효과가 있는 코드(불순)와 순수한 코드 사이에 명확한 경계 를 갖는 것에 관한 것입니다.
Haskell은 순수 함수형 프로그래밍 언어입니다.
오늘은 그중에서도 순수함(purity) 에 초점을 맞춥니다. 함수형(functional) 이라는 말은 오직 함수로만 작업한다는 뜻이라고 대충 말해도 됩니다. 서로 메시지를 주고받는 객체 같은 것은 없습니다 — 하스켈 프로그램은 레고 블록처럼 맞물려 있는 함수들의 큰 합성(composition) 일 뿐입니다.
많은 사람들이 순수함을 부수효과의 부재 와 연관 짓습니다. 저도 하스켈을 처음 배울 때는 그렇게 생각했고, 한동안 헷갈리곤 했습니다. 모든 것이 순수하다면 파일에는 어떻게 쓰나요? 이메일은 어떻게 보내죠?
하스켈을 접하면 곧바로 만나는 것 중 하나가 IO 입니다. 처음에는 이것이 정확히 무엇을 의미하는지 잘 몰랐지만, 쓸모 있는 일을 하려면 IO 안에서 무언가를 해야 한다는 건 이해했습니다.
예제를 만들어봅시다. ShoppingCart를 받아 장바구니의 총 아이템 수를 나타내는 Int(숫자)를 반환하는 함수의 타입 시그니처는 다음과 같을 것입니다:
numberOfItems :: ShoppingCart -> Int
당신이 하스켈을 많이 안다고 가정하고 싶지 않으니, 이 함수를 자바스크립트로 작성해보죠. 카트는 수량이 달린 아이템들의 리스트라고 합시다.
const numberOfItems = cart => {
return cart.items.reduce(
(acc, item) => acc + item.quantity, 0
)
}
const sampleCart = [
{ item: "A book", quantity: 1 },
{ item: "A chair", quantity: 3 },
]
console.log(numberOfItems(sampleCart)) // 4
간단하죠? 이제 이 함수는 어떤 부수효과도 없습니다. 이것이 우리가 순수 함수 라고 부르는 것입니다.
순수함에 대한 정의는 여러 가지가 있지만, 개념을 소개할 때 제가 즐겨 쓰는 정의는 이렇습니다:
같은 입력 을 주었을 때 항상 같은 출력 을 내놓는 함수가 순수하다.
정말 이게 전부입니다! numberOfItems를 몇 번 호출하든, 같은 ShoppingCart를 주면 항상 같은 수량을 돌려받습니다. 이 함수는 파일에서 읽거나 무작위 숫자를 생성하지 않으므로, 그 출력은 오로지 주어진 입력에 의해서만 결정 됩니다.
순수 함수는 바로 그 예측 가능성 때문에 매우 바람직 합니다. 다시 말해 다음과 같습니다:
이렇게 생각하실 겁니다. 다 좋지만 현실 세계 에서는 지저분한 일을 해야 하잖아요! 장바구니의 아이템을 가져오려면 데이터베이스에 연결해야 하고, 그러면 제 함수는 더 이상 순수하지 않을 겁니다. 그럼 무슨 의미가 있죠?
핵심은 순수한 함수와 불순한 함수 사이에 명확한 경계 를 만드는 것입니다.
우리의 간단한 예제를 확장해보고, 카트가 어떤 Postgres 테이블에 저장되어 있다고 합시다.
# Carts 테이블
-------------------------------
cart_id | product_id | quantity
-------------------------------
ab341 | A book | 1
ab341 | A chair | 3
이제 numberOfItems 함수는 더 이상 순수할 수 없습니다. SQL 쿼리를 작성하고 실행함으로써 부수효과를 일으켜야 하죠. 우리는 순수성을 잃었습니다. 왜냐하면 이제 함수의 출력 이 더 이상 그 입력 에 의해 결정되지 않기 때문입니다. 데이터베이스가 끼어들었고, 비어 있을 수도 있고 수백 개의 카트가 있을 수도 있습니다.
데이터베이스의 상태는 입력으로 전혀 제공되지 않으므로 출력은 더 이상 결정적이지 않습니다! 이론적으로, 만약 데이터베이스의 전체 내용 을 입력으로 넘길 수 있다면 이 함수는 여전히 순수할 것입니다. 물론 실용적이진 않지만, 여전히 멋진 사고 실험이죠. :)
const numberOfItems = cartId => {
return db.query(
'select * from Carts where cart_id = ?',
cartId
)
.then(items => {
return items.reduce(
(acc, item) => acc + item.quantity, 0
)
})
}
numberOfItems('ab341')
.then(count => console.log(count)) // 4
하스켈에서 이에 해당하는 함수는 타입 시그니처가 바뀌어야 합니다. 마침내 IO — 무엇이든 할 수 있게 해주는 거대한 망치 — 에 도착했습니다.
-- 더 이상 단순히 `Int`를 반환할 수 없습니다.
-- 결과는 `IO`로 감싸져야 합니다.
numberOfItems :: ShoppingCartId -> IO Int
하스켈의 함수는 기본적으로 멋지고 순수합니다. 부수효과가 있을 때는, 그 함수가 IO 안에 있어야 하며 그렇지 않으면 컴파일러가 당신의 프로그램을 컴파일하길 거부할 것입니다. 파일에서 읽나요? 그러면 IO String을 얻게 됩니다. 무작위 숫자를 생성하나요? 그러면 IO Int를 얻게 됩니다.
IO를 함수를 불순하다고 표시하는 방법 으로 생각할 수 있습니다. 출력이 IO로 감싸져 있다면 그 함수는 불순합니다(부수효과를 일으킬 수도 있습니다).
불순한 함수는 바람직하지 않습니다.
우리는 순수 함수가 이해하기 쉽고 테스트하기 쉽다고 말했습니다. 불순한 함수는 그 정반대입니다!
numberOfItems를 불순하게 만들면서 우리는 그것을 쉽게 추론할 수 있는 능력을 잃었습니다. 이제 입력만 보고 결과를 결정할 수 없습니다. 결과가 데이터베이스의 Carts 테이블 내용 같은 외부 상태 에 의존하기 때문입니다.
이제 데이터베이스가 관련되었으니, 그 함수를 어떻게 테스트할까요? 첫 번째 버전(순수한 버전)은 유닛 테스트가 매우 쉽고, 특정 입력이 특정 출력에 대응하는지를 확인함으로써 구현이 올바름을 증명할 수 있습니다.
부수효과가 생기면, 우리는 결정성을 잃습니다. 즉, 역겨운 방식으로 데이터베이스를 모킹하거나 필요한 데이터로 테스트 데이터베이스를 채워 넣어야 합니다. 썩 좋지 않습니다.
아직 희망은 남아 있습니다! 코드를 리팩터링해서, 깔끔한 순수한 numberOfItems 구현을 유지하고, 데이터베이스에서 데이터만 끌어오는 불순한 함수에 그것을 합성 할 수 있습니다.
타입 시그니처는 다음과 같을 수 있습니다:
-- 순수
numberOfItems :: ShoppingCart -> Int
-- 불순
fetchShoppingCart :: ShoppingCartId -> IO ShoppingCart
-- 이제 합성!
numberOfItemsByCartId :: ShoppingCartId -> IO Int
이제 numberOfItems만 본다면, 이 함수가 어떤 지저분한 부수효과 도 일으키지 않는다는 것을 100% 확신할 수 있습니다.
fetchShoppingCart가 뭔가 좋지 않은 일을 한다는 것(출력이 IO로 감싸져 있음을 통해 명시됨)은 받아들입니다. 하지만 저는 그것을 테스트하는 데 크게 신경 쓰지 않습니다. 왜냐하면 모든 로직을 분리해냈기 때문 입니다. 마지막으로, 두 함수를 합성하여 numberOfItemsByCartId를 정의합니다.
기억하세요. 순수 함수와 불순한 함수를 합성하면 결과는 항상 불순 해집니다.
IO는 전염성이 있어, 바이러스처럼 프로그램 전체로 퍼집니다. 우리는 부수효과를 프로그램의 가장자리(edge) 로 밀어내어, 코어 는 순수하게 유지하고 싶습니다. 이것이 바로 Functional core, Imperative shell 패턴입니다.
비즈니스 로직은 순수 함수로 작성하십시오.
코드는 이해하기 쉬워지고 테스트하기 쉬워집니다. 불순한 코드는 가능한 한 멍청하게 유지하십시오. 불순한 함수는 순수한 함수로 데이터를 들여보내고 내보내는 일만 해야 합니다!
순수 함수는 훌륭하며, 이를 유리하게 활용하는 법을 배워야 합니다. 물론 자바스크립트 코드에서도 동일한 리팩터링을 할 수 있었고, 그것만으로도 큰 개선이었을 것입니다. 하지만 거기에는 당신이 이러한 실수를 저지르지 못하도록 막아주는 것 이 없습니다. 순수한 코드와 불순한 코드를 분리하는 데 경계하고 규율을 지켜야 합니다. 하스켈이 놀라운 이유가 바로 여기 있습니다. 컴파일러가 당신에게 무언가 잘못하고 있을 때 알려주기 때문입니다.
이 사실은 제게 눈을 뜨게 해주었습니다. 하스켈에서는 부수효과에 대해 명시적 이어야 합니다. 부수효과가 전혀 없는 프로그램은 쓸모없겠지만, 요점은 부수효과를 막는 것이 아닙니다.
요점은 순수한 코드와 불순한 코드 사이에 명확한 분리를 갖는 것입니다. 순수 함수는 이해하기 쉽고 테스트하기 쉽기 때문에 견고하고 올바른 소프트웨어 를 작성하도록 도와줍니다.
나는 허튼소리 없이 하스켈을 배우고 싶은 사람들을 위한 연재물을 쓰고 있습니다.
연습문제와 영상도 있어요!
깃허브의 Zero Bullshit Haskell을 확인해보세요.
멋진 피드백을 주신 Giulio @giuliocanti와 Tom @am_i_tom께 감사드립니다.️