[모던 자바] 함수형 프로그래밍이란 무엇인가?
시스템 구현과 유지보수
대부분의 대규모 소프트웨어 시스템에서 실질적으로 많은 프로그래머가 유지보수 중 코드 크래시 디버깅 문제를 가장 많이 겪게 된다.
쉽게 유지보수할 수 있으려면 프로그램이 어떤 모습이어야 할까?
프로그램이 시스템의 구조를 이해하기 쉽게 클래스 계층으로 반영한다면 좋을 것이다.
시스템의 각 부분의 상호 의존성을 가리키는 결합성(coupoing)과 시스템의 다양한 부분이 서로 어떤 관계를 갖는지 가리키는 응집성(cohesion)이라는 소프트웨어 엔지니어링 도구로 프로그램 구조를 평가할 수 있다.
이러한 문제들을 함수형 프로그래밍이 제공하는 부작용 없음(no side effect)과 불변성(immutablility)이라는 개념으로 해결하는데 도움을 준다.
공유된 가변 데이터
코드 크래시는 예상하지 못한 변숫값 때문에도 발생할 수 있다.
변수가 예상하지 못한 값을 갖는 이유는 결국 우리가 유지 보수하는 시스템의 여러 메서드에서 공유된 데이터를 읽고 갱신하기 때문이다.
이러한 공유 가변 데이터 구조를 사용하면 어느 메서드에서 어떠한 값을 언제 경신했는지 추적하기 어려워지고 프로그래밍에 부작용을 일으킨다.
부작용을 없애려면 여러 가지 방법이 있는데, 순수 또는 부작용이 없는 메서드를 사용하는 것이다.
자신을 포함하는 클래스의 상태 그리고 다른 객체의 상태를 바꾸지 않으며 return 문을 통해서만 자신의 결과를 반환하는 메서드를 순수(pure) 메서드 또는 부작용 없는(side-effect free) 메서드라고 부른다.
이외에 객체의 상태를 바꿀 수 없는 불변 객체를 이용해서 부작용을 없애는 방법도 있다.
선언형 프로그래밍
프로그램으로 시스템을 구현하는 방식은 명령형, 선언형 크게 두 가지로 구분할 수 있다.
만약 가장 여러 개의 상품 중 가장 비싼 상품을 도출하는 프로그램을 구현한다고 가정해보자.
"A를 먼저 실행하고, 그다음에 B를 실행해 값을 갱신한다."처럼 작업을 "어떻게" 수행할 것인지에 집중하는 방식이 있다.
List<Product> products = List.of(new Product(10_000), new Product(20_000), new Product(30_000));
if (products == null || products.isEmpty()) throw new RuntimeException("Empty list!");
Product mostExpensiveProduct = products.get(0);
for (Product product : products.subList(1, products.size())) {
if (product.getPrice() > mostExpensiveProduct.getPrice()) {
mostExpensiveProduct = product;
}
}
"어떻게(how)"에 집중하는 프로그래밍 방식은 고전의 객체지향 프로그래밍에서 이용하는 방식이다. 이를 명령형 프로그래밍이라고 부르기도 한다.
또한 Stream API를 사용해 "어떻게"가 아닌 "무엇을"에 집중하는 방식도 있다.
Optional<Product> mostExpensiveProduct = products.stream()
.max(Comparator.comparing(Product::getPrice));
위처럼 질의를 만들게 되면 질의문 구현 방법은 라이브러리가 결정하고, 이와 같은 구현 방식을 내부 반복(internal iteration)이라고 한다.
질의문 자체로 문제를 어떻게 푸는지 명확하게 보여준다는 것이 내부 반복 프로그래밍의 큰 장점이다.
이처럼 "무엇을"에 집중하는 방식을 선언형 프로그래밍이라고 부른다.
선언형 프로그래밍에서는 우리가 원하는 것이 무엇이고 시스템이 어떻게 그 목표를 달성할 것인지 등의 규칙을 정한다.
문제 자체가 코드로 명확하게 드러난다는 점이 선언형 프로그래밍의 강점이다.
왜 함수형 프로그래밍인가?
함수형 프로그래밍은 선언형 프로그래밍을 따르는 대표적인 방식이며, 이전에 설명한 것처럼 부작용이 없는 계산을 지향한다.
부작용을 멀리한다는 개념과 선언형 프로그래밍은 좀 더 쉽게 시스템을 구현하고 유지 보수하는 데 도움을 준다.
람다 표현식을 이용해서 작업을 조합하거나 동작을 전달하는 등의 언어 기능은 선언형을 활용해서 자연스럽게 읽고 쓸 수 있는 코드를 구현하는데 많은 도움을 주고, Stream으로는 여러 연산을 연결해서 복잡한 질의를 표현할 수 도 있다.
이러한 기능은 함수형 프로그래밍의 특징을 고스란히 보여준다.
함수형 프로그래밍을 이용하면 부작용이 없는 복잡하고 어려운 기능을 수행하는 프로그램을 구현할 수 있다.
함수형 프로그래밍이란 무엇인가?
간단히 답변하자면 함수를 이용하는 프로그래밍이라고 말할 수 있다. 그렇다면 함수란 무엇일까?
함수형이라는 말은 "수학의 함수처럼 부작용이 없는"을 의미한다.
즉, 함수는 0개 이상의 인수를 가지며, 한 개 이상의 결과를 반환하지만 부작용이 없어야 한다.
자바와 같은 언어에서는 바로 수학적인 함수냐 아니냐가 메서드와 함수를 구분하는 핵심이다.
특히 인수가 같다면 수학적 함수를 반복적으로 호출했을 때 항상 같은 결과가 반환된다.
결론적으로 수학적 표현만 사용하는 방식을 순수 함수형 프로그래밍이라고 하며,
시스템의 다른 부분에 영향을 미치지 않는다면 내부적으로는 함수형이 아닌 기능도 사용하는 방식을 함수형 프로그래밍이라 한다.
함수형 자바
실질적으로 자바로는 완벽한 순수 함수형 프로그래밍을 구현하기 어렵다. 예를 들어 자바의 I/O 모델 다체에는 부작용 메서드가 포함된다.
하지만 실제 부작용이 있지만 아무도 이를 보지 못하게 함으로써 함수형을 달성할 수 있다.
만약 내부에서 데이터를 변경시키고 다시 되돌리는 함수가 있다고 가정해보자.
단일 스레드에서는 아무 문제가 되지 않지만 멀티 스레드에서 동시에 해당 함수를 실행했을 때는 문제가 생길 수 도 있다.
이번 문제는 함수의 바디를 잠금(lock)으로써 해결할 수 있지만 이 함수를 병렬로 처리할 수 없게 된다.
프로그램 입장에서는 부작용이 사라졌지만 프로그래머 관점에서는 프로그램의 실행 속도가 느려진 것이다.
함수나 메서드에서 참조하는 객체가 있다면 이는 불변 객체(final) 여야 한다.
함수나 메서드는 지역 변수만을 변경해야 함수형이라 할 수 있다.
그리고 함수에서 예외가 발생한다면 return으로 어떠한 값을 반환할 수 없기 때문에 어떤 예외도 일으키지 않아야 한다.
그렇다면 어떻게 예외를 사용하지 않고 함수를 표현할까?
바로 Optional<T>를 사용해 메서드 호출 결과로 빈 Optional이 반환되었는지 확인해야 한다.
발생할 수 있는 예외를 적절하게 내부적으로 처리함으로써 자료구조의 변경을 호출자가 알 수 없도록 감추는 것이다.
하지만 모든 코드가 Optional을 사용해야 하는 것은 아니며 다른 컴포넌트에 영향을 미치지 않도록 지역적으로만 예외를 사용하는 방법도 고려할 수 있다.
마지막으로 함수형에서는 비 함수형 동작을 감출 수 있는 상황에서만 부작용을 포함하는 라이브러리 함수를 사용해야 한다. 즉, 먼저 자료구조를 복사한다든가 발생할 수 있는 예제를 적절하게 내부적으로 처리함으로써 자료구조의 변경을 호출자가 알 수 없도록 감춰야 한다.
이와 같은 설명을 주석으로 표현하거나 마커 어노테이션으로 메서드를 정의할 수 있다.
우리가 만든 함수형 코드에서는 일종의 로그 파일로 디버깅 정보를 출력하도록 구현하는 것이 좋다.
물론 이처럼 디버깅 정보를 출력하는 것은 함수형의 규칙에 위배되지만 로그 출력을 제외하고는 함수형 프로그래밍의 장점을 문제없이 누릴 수 있다.
참조 투명성
"부작용을 감춰야 한다."라는 제약은 참조 투명성(referential transparency) 개념으로 귀결된다.
같은 인수로 함수를 호출했을 때 항상 같은 결과를 반환한다면 참조적으로 투명한 함수라고 표현한다.
즉, 함수는 어떤 입력이 주어졌을 때 언제, 어디서 호출하든 같은 결과를 생성해야 한다.
이러한 참조 투명성은 프로그램 이해에 큰 도움을 준다. 또한 참조 투명성 비싸거나 오랜 시간이 걸리는 연산을 기억화 또는 캐싱을 통해 다시 계산하지 않고 저장하는 최적화 기능도 제공한다.
Java는 참조 투명성과 관련한 작은 문제가 있는데 만약 List를 반환하는 메서드가 있다고 가정해보자.
두 번의 호출 결과로 같은 요소를 표현하지만 서로 다른 메모리 공간에 생성된 리스트를 참조할 것이다.
결과 리스트를 반환하는 메서드는 참조적으로 투명한 메서드가 아니라는 결론이 나온다.
결과 리스트를 불변 값으로 사용할 것이라면 두 리스트가 같은 객체라고 볼 수 있으므로 해당 함수는 참조적으로 투명한 것으로 간주할 수 있다.
일반적으로 함수형 코드에서는 이런 함수를 참조적으로 투명한 것으로 간주한다.
기억화(memorization) 기법
만약 참조 투명성이 유지가 된다면 추가 오버헤드를 피할 수 있는 기억화(memorization)이라는 기법을 사용할 수 있다.
기억화는 메서드에 래퍼로 캐시(HashMap 같은)를 추가해, 래퍼가 호출되면 인수, 결과 쌍이 캐시에 존재하는지 먼저 확인 후 존재한다면 저장된 캐시를 가져오는 기법이다.
하지만 해당 캐시 자료구조(HashMap 같은)는 다른 기억화에서도 공유되야 하므로 가변 상태이고 동기화되지 않았으므로 스레드 안정성이 없는 코드이다.
만약 HashMap을 사용한다면 잠금으로 보호되는 HashTable이나 잠금 없이 동시 실행을 지원하는 ConcurrentHashMap을 사용할 수 있지만 다중 코어에서 동시에 호출하면 캐시를 찾고, 추가하는 동작 사이에서 레이스 컨디션이 발생해 성능이 크게 저하될 수 있다.
가장 좋은 방법은 함수형 프로그래밍을 사용해서 동시성과 가변 상태가 만나는 상황을 완전히 없애는 것이다.
캐싱을 구현할 것인지 여부와는 별개로 코드를 함수형으로 구현했다면 우리가 호출하려는 메서드가 공유된 가변 상태를 포함하지 않음을 미리 알 수 있으므로 동기화 등 신경 쓸 필요가 없어진다.
객체지향 프로그래밍과 함수형 프로그래밍
사실 Java8은 함수형 프로그래밍을 익스트림 객체지향 프로그래밍의 일종으로 간주한다. 대부분의 자바 프로그래머는 무의식적으로 함수형 프로그래밍의 일부 기능과 익스트림 객체지향 프로그래밍의 일부 기능을 사용하게 될 것이다.
프로그래밍 형식을 스펙트럼으로 표현하자면 스펙트럼 한쪽 끝에는 모든 것을 객체로 간주하고 프로그램이 객체의 필드를 갱신하고, 메서드를 호출하고, 관련 객체를 갱신하는 방식으로 동작하는 익스트림 객체지향 방식이 위치한다. 스펙트럼 반대쪽 끝에는 참조적 투명성을 중시하는, 즉 변화를 허용하지 않는 함수형 프로그래밍이 위치한다. 실제로 자바 프로그래머는 이 두 가지 프로그래밍 형식을 혼합한다. 예를 들어 Iterator로 가변 내부 상태를 포함하는 자료구조를 탐색하면서 함수형 방식으로 자료구조에 들어 있는 값의 합계를 계산할 수 있다.