13장 함수형 관점으로 생각하기

자바 8 인 액션 13장을 요약한 내용 입니다.

함수형 프로그래밍은 람다, 일급 함수와 관련 있으며 때로는 객체 변화를 제한하는 것이 함수형과 밀접한 관련을 갖는다.

시스템 구현과 유지보수

많은 프로그래머가 유지보수 중 코드 크래시 디버깅 문제를 가장 많이 겪게 된다. 코드 크래시는 예상하지 못한 변숫값 때문에 발생할 수 있다. 왜 그리고 어떻게 변숫값이 바뀐 걸까? 유지보수 중 이런 일이 얼마나 자주 일어나는지 생각해보라. 함수형 프로그래밍이 제공하는 부작용 없음불변성이라는 개념이 이 문제를 해결하는 데 도움을 준다.

공유된 가변 데이터

변수가 예상하지 못한 값을 갖는 이유는 결국 우리가 유지보수하는 시스템의 여러 메서드에서 공유된 가변 데이터 구조를 읽고 갱신하기 때문이다.

자신을 포함하는 클래스의 상태 그리고 다른 객체의 상태를 바꾸지 않으며 return 문을 통해서만 자신의 결과를 반환하는 메서드를 순수 메서드 또는 부작용 없는(side-effect free) 메서드라고 부른다.

구체적으로 부작용은 무엇일까?

  • 자료구조를 고치거나 필드에 값을 할당

  • 예외 발생

  • 파일에 쓰기 등의 I/O 동작 수행

불변 객체를 이용해서 부작용을 없애는 방법도 있다. 불변 객체는 복사하지 않고 공유할 수 있으며, 객체의 상태를 바꿀 수 없으므로 스레드 안전성을 제공한다.

왜 함수형 프로그래밍인가?

함수형 프로그래밍은 선언형 프로그래밍을 따르는 대표적인 방식이며 부작용이 없는 계산을 지향한다. 이전에 람다 표현식을 이용해서 보여준 것처럼 작업을 조합하거나 동작을 전달하는 등의 언어 기능은 선언형을 활용해서 자연스럽게 읽고 쓸 수 있는 코드를 구현하는 데 많은 도움을 준다.

선언형 프로그래밍이란? '어떻게'로 접근하는 방식을 선언형 프로그래밍이라고 부르기도 한다. 선언형 프로그래밍에서는 우리가 원하는 것이 무엇이고 시스템이 어떻게 그 목표를 달성할 것인지 등의 규칙을 정한다. 문제 자체가 코드로 명확하게 드러난다는 점이 선언형 프로그래밍의 강점이다.

함수형 프로그래밍이란 무엇인가?

함수는 0개 이상의 인수를 가지며, 한 개 이상의 결과를 반환하지만 부작용이 없어야 한다. '함수 그리고 if-then-else 등의 수학적 표현만 사용'라는 방식을 순수 함수형 프로그래밍이라고 하며 '시스템의 다른 부분에 영향을 미치지 않는다면 내부적으로는 함수형이 아닌 기능도 사용'하는 방식을 함수형 프로그래밍이라 한다.

함수형 자바

실질적으로 자바로는 완벽한 순수 함수형 프로그래밍을 구현하기 어렵다. 부작용을 일으키지 않는 어떤 함수나 메서드가 있는데, 다만 진입할 때 어떤 필드의 값을 증가시켰다가 빠져나올 때 필드의 값을 돌려놓는다고 가정하자. 단일 스레드로 실행되는 프로그램의 입장에서는 이 메서드가 아무 부작용을 일으키지 않으므로 이 메서드는 함수형이라 간주할 수 있다.

하지만 다른 스레드가 필드의 값을 확인한다든가 아니면 동시에 이 메서드를 호출하는 상황이 발생할 수 있다면 이 메서드는 함수형이 아니다. 메서드의 바디를 잠금으로써 이 문제를 해결할 수 있으며 따라서 이 메서드는 함수형이라고 할 수 있다. 결국 프로그램 입장에서 부작용이 사라졌지만 프로그래머 관점에서는 프로그램의 실행 속도가 느려지게 된 것이다.

함수나 메서드는 지역 변수만을 변경해야 함수형이라 할 수 있다. 그리고 함수나 메서드에서 참조하는 객체가 있다면 그 객체는 불변 객체여야 한다.

함수형이라면 함수나 메서드가 어떤 예외도 일으키지 않아야 한다. 예외가 발생하면 블랙박스 모델에서 return으로 결과를 반환할 수 없게 될 수 있기 때문이다.

예외를 사용하지 않고 나눗셈 같은 표현하려면 어떻게 해야 할까?

Optional<T>를 사용하면 이 문제를 해결할 수 있다. 하지만 모든 코드가 Optional을 사용하도록 반드시 고쳐야 하는 것은 아니며 함수형 프로그래밍과 순수 함수형 프로그래밍의 장단점을 실용적으로 고려해서 다른 컴포넌트에 영향을 미치지 않도록 지역적으로만 예외를 사용하는 방법도 고려할 수 있다.

순수 함수형 프로그래밍에서 예외를 처리할 수 있는 다른 방법은 무엇이 있을까?

참조 투명성

같은 인수로 함수를 호출했을 때 항상 같은 결과를 반환한다면 참조적으로 투명한 함수라고 표현한다.

참조 투명성은 프로그램 이해에 큰 도움을 준다. 또한 참조 투명성은 비싸거나 오랜 시간이 걸리는 연산을 기억화 또는 캐싱을 통해 다시 계산하지 않고 저장하는 최적화 기능도 제공한다.

기억화 또는 캐싱은 실제로 어떻게 구현할 수 있을까?

객체지향 프로그래밍과 함수형 프로그래밍

프로그래밍 형식을 스펙트럼으로 표현하자면 스펙트럼의 한 쪽 끝에는 모든 것을 객체로 간주하고 프로그램이 객체의 필드를 갱신하고, 메서드를 호출하고, 관련 객체를 갱신하는 방식으로 동작하는 익스트림 객체지향 방식이 위치한다. 스펙트럼의 반대쪽 끝에는 참조적 투명성을 중요시하는, 즉 변화를 허용하지 않는 함수형 프로그래밍 형식이 위치한다.

재귀와 반복

순수 함수형 프로그래밍 언어에서는 while, for 같은 반복문을 포함하지 않는다. 이러한 반복문 때문에 변화가 자연스럽게 코드에 스며들 수 있기 때문이다.

static long factorialRecursive(long n) {
    return n == 1 ? 1 : n * factorialRecursive(n-1);
}

무조건 반복보다는 재귀가 좋다고 주장은 주의해야 한다. 일반적으로 반복 코드보다 재귀 코드가 더 비싸다. 왜 그럴까? 재귀 팩토리얼의 입력값에 비례해서 메모리 사용량이 증가한다. 따라서 큰 입력값을 사용하면 다음처럼 StackOverflowError가 발생한다.

Exception in thread "main" java.lang.StackOverflowError

그러면 재귀는 쓸모가 없는 것일까?

물론 그렇지 않다. 함수형 언어에서는 꼬리 호출 최적화라는 해결책을 제공한다.

static long factorialTailRecursive(long n) {
    return factorialHeper(1, n);
}
static long factorialHelper(long acc, long n) {
    return n == 1 ? acc : factorialHelper(acc*n, n-1);
}

factorialHelper에서 재귀 호출이 가장 마지막에서 이루어지므로 꼬리 재귀다. 반면 이전의 factorialRecursive에서 마지막으로 수행한 연산은 n과 재귀 호출의 결과값의 곱셈이다.

중간 결과를 각각의 스택 프레임으로 저장해야 하는 일반 재귀와 달리 꼬리 재귀에서는 컴파일러가 하나의 스택 프레임을 재활용할 가능성이 생긴다.

자바 8에서는 반복을 스트림으로 대체해서 변화를 피할 수 있다.

또한 반복을 재귀로 바꾸면 더 간결하고, 부작용이 없는 알고리즘을 만들 수 있다. 실제로 재귀를 이용하면 좀 더 쉽게 읽고, 쓰고, 이해할 수 있는 예제를 만들 수 있다. 또한 약간의 실행 시간 차이보다는 프로그래머의 효율성이 더 중요할 때도 많다.

요약

  • 공유된 가변 자료구조를 줄이는 것은 장기적으로 프로그램을 유지보수하고 디버깅하는데 도움이 된다.

  • 함수형 프로그래밍은 부작용이 없는 메서드와 선언형 프로그래밍 방식을 지향한다.

  • 함수형 메서드는 입력 인수와 출력 결과만을 갖는다

  • 같은 인수값으로 함수를 호출했을 때 항상 같은 값을 반환하면 참조 투명을 갖는 함수다. while 루프 같은 반복문은 재귀로 대체할 수 있다.

  • 자바에서는 고전 방식의 재귀보다는 꼬리 재귀를 사용해야 추가적인 컴파일러 최적화를 기대할 수 있다.

Last updated