최근에 함수형 프로그래밍이라는 용어를 접했다면 자연스럽게 몇 가지 궁금증이 생기기 마련입니다.
- 함수형 프로그래밍이 정확히 무엇일까?
- 객체지향 프로그래밍(OOP)과는 어떻게 다를까?
- Ruby에서도 함수형 프로그래밍을 활용해야 할까?
이 글에서 이러한 질문들에 차근차근 답하며, 함수형 프로그래밍이 어떻게 동작하는지 명확하게 이해하실 수 있도록 안내해 드리겠습니다.
함수형 프로그래밍이란 무엇인가?
함수형 프로그래밍은 단순한 유행이나 화려한 용어가 아니라, 오랜 역사를 지닌 실제 프로그래밍 패러다임입니다. 다만 최근에 들어서야 다시 큰 주목을 받고 있을 뿐이죠.
그리고 이 패러다임의 기본 개념은 생각보다 훨씬 쉽게 이해할 수 있습니다.
함수형 프로그래밍에서는 상태 변경을 피하고, 순수한(pure) 함수를 작성하려고 노력합니다.
여기서 상태 변경을 피한다는 것은, 함수가 자신의 외부에 있는 어떤 것도 변경하지 않는다는 의미입니다. 인스턴스 변수를 수정하지 않고, 인자로 전달받은 객체를 변경하지도 않습니다.
그 어떤 것도요!
Haskell 같은 함수형 프로그래밍 언어에서는 모든 데이터가 불변(immutable)입니다.
변수라는 개념 자체는 존재하지만, 수학의 세계에서처럼 동작합니다. 변수에 값이 한 번 할당되면, 컴파일러는 해당 변수를 다른 값으로 재정의하는 것을 허용하지 않습니다.
함수형 프로그래밍의 장점
불변성(immutability)은 함수형 프로그래밍의 가장 큰 장점입니다. 가변(mutable) 데이터는 추적하기 어려운 미묘한 버그를 만들어내곤 하기 때문입니다.
예시:
def all_different_from_first?(arr)
first = arr.shift
arr.all? { |n| n != first }
end
arr = [1,3,5,7,9]
p all_different_from_first?(arr)
# true
이 예제에서는 배열의 모든 요소가 첫 번째 요소와 서로 다른지 확인하고 싶습니다.
이를 위해서는 배열에서 첫 번째 요소를 제거하면서, 동시에 그 요소를 따로 저장해 나머지 요소들과 비교할 수 있어야 합니다.
어떻게 하면 될까요?
배열을 다루고 있으니 사용 가능한 메서드 목록을 살펴보면, Array#shift 메서드가 정확히 원하는 작업을 수행해 줍니다.
메서드를 한 번 호출한 후 arr의 값을 확인하기 전까지는 아무 문제없이 잘 동작하는 것처럼 보입니다.
all_different_from_first?(arr) # true arr # [3,5,7,9]
깜짝 놀라셨나요?
배열에서 요소 하나(1)가 조용히 사라졌는데, 우리는 그것을 전혀 눈치채지 못했습니다.
바로 이렇게 가변성으로 인한 버그는 아주 교황하게 숨어듭니다.
수정된 버전:
def all_different_from_first?(arr)
arr[1..-1].all? { |n| n != arr.first }
end
함수형 프로그래밍 vs 객체지향 프로그래밍
그렇다면 우리 모두 함수형 프로그래밍으로 완전히 전환해야 할까요?
불변 상태만 강조하면 함수형 프로그래밍이 OOP의 정반대처럼 느껴질 수 있습니다. 어느 면에서는 맞는 말이지만, 두 프로그래밍 패러다임이 함께 작동할 수 있는 방법은 여전히 존재합니다.
따라서 성급하게 결론 내릴 필요는 없습니다. Ruby는 애초에 OOP를 위해 설계된 언어이므로, 완전한 함수형 스타일로 전환하려 하면 언어의 설계 방향과 계속 부딪히게 됩니다.
좋은 소식은 다음과 같습니다:
함수형 프로그래밍의 검증된 아이디어들을 골라서 Ruby 코드에 그대로 적용할 수 있다는 점입니다.
구체적인 방법을 하나씩 살펴보겠습니다.
가변성을 최대한 줄이기
첫 번째 방법은 attr_accessor 사용을 중단하고 attr_reader만 사용하는 것입니다.
그다음에는 문자열, 배열, 해시를 주의 깊게 살펴야 합니다. 이 객체들을 직접 변경하는 메서드들이 존재하기 때문입니다.
- 대부분의 !로 끝나는 메서드 (예: gsub!)
- delete
- update
- clear
- shift / unshift / pop / push
첫 번째 단계는 이런 메서드들을 인식하는 것입니다.
어쩔 수 없이 이런 메서드를 사용해야 하는 상황이라면, 원본이 아닌 복제본 객체를 대상으로 작업하세요.
문자열과 그 복제본이 준비되어 있다고 가정해 보겠습니다:
str = "abcd" dup = str.dup
복제된 문자열에 clear를 호출하면 다음과 같은 결과를 얻습니다:
dup.clear # str => "abcd" # dup => ""
이렇게 하면 원본 문자열은 안전하게 그대로 유지됩니다.
부분 적용(Partial Application)
함수형 프로그래밍은 불변 데이터와 순수 함수만으로 이루어져 있지 않습니다.
커링(currying)이라고도 알려진 함수의 부분 적용(partial application)도 대표적인 기법 중 하나입니다.
예시:
def add(a,b) a + b end add_five = method(:add).curry[5] add_five.call(5) # 10 add_five.call(20) # 25
add 메서드는 원래 두 개의 인자를 받지만, curry 메서드를 활용하면 인자 하나를 미리 고정해 둘 수 있습니다.
그 결과 남은 인자 하나만으로 호출할 수 있는 람다(lambda)를 얻게 됩니다.
또 다른 예시:
list = (1..10)
greater_than = ->(x,y) { y > x }.curry
list.select(&greater_than.(5))
# [6, 7, 8, 9, 10]
list.select(&greater_than.(8))
# [9, 10]
마지막 예시:
divisible_by = ->(x,y) { y % x == 0 }.curry
list.select(&divisible_by.(5))
# [5, 10]
list.select(&divisible_by.(2))
# [2, 4, 6, 8, 10]
마무리
이번 글에서는 함수형 프로그래밍에 대해 살펴보았습니다. 핵심은 순수 함수와 불변 데이터이며, 이는 코드를 바라보는 하나의 사고방식일 뿐 OOP와 완전히 양립할 수 없는 개념이 아닙니다.
읽어주셔서 감사합니다. 아직 구독하지 않으셨다면 뉴스레터 구독도 잊지 마세요! 🙂