본문 바로가기
프로그래밍 놀이터/안드로이드, Java

[Java8 In Action] #13 함수형 관점으로 생각하기

by 돼지왕왕돼지 2019. 1. 2.

[Java8 In Action] #13 함수형 관점으로 생각하기


Java8 In Action 내용을 보며 정리한 내용입니다.

정리자는 기존에 Java8 을 한차례 rough 하게 공부한 적이 있고, Kotlin 역시 공부한 적이 있습니다.

위의 prerequisite 가 있는 상태에서 추가적인 내용만 정리한 내용이므로, 제대로 공부를 하고 싶다면 책을 구매해서 보길 권장합니다!


Caching, internal iteration, java8 in action, memorization, optional, pure method, recursive vs functional, referential transparency, side-effect free method, StackOverflowError, tail call optimization, what vs how, 객체지향 프로그래밍 vs 함수형 프로그래밍, 공유 가변 데이터, 꼬리 물기 최적화, 반복, 불변 객체, 선언형 프로그래밍, 순수 메서드, 스칼라 그루비, 재귀, 재귀 vs 함수형, 참조 투명성, 함수형 관점으로 생각하기, 함수형 자바, 함수형 프로그래밍



13.1. 시스템 구현과 유지보수


13.1.1. 공유된 가변 데이터


-

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



-

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



-

함수 내에 포함되지 못한 기능을 부작용이라고 하는데, 이 예는 다음과 같다.


자료구조를 고치거나 필드에 값을 할당 (setter 메서드 같은 생성자 이외의 초기화 동작)

예외 발생

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



-

불변 객체를 이용해서 부작용을 없애는 방법도 있다.

불변 객체는 인스턴스화한 다음에는 객체의 상태를 바꿀 수 없는 객체이므로 함수 동작에 영향을 받지 않는다.



-

실제 시스템에서 부작용을 없앤다는 것이 현실적으로 가능한지 의아해 할 수 있다.

그러나 가능하다.


부작용 없는 시스템의 개념은 함수형 프로그래밍에서 유래되었다.




13.1.2. 선언형 프로그래밍


-

어떻게(how) 에 집중하는 프로그래밍 형식을 명령형 또는 선언형 프로그래밍이라고 부르기도 한다.

문제 자체가 코드로 명확하게 드러난다는 점이 선언형 프로그래밍의 장점이다.


무엇을(what) 에 집중하는 프로그래밍의 예는 스트림 API 가 있다.

스트림 API 는 내부 반복(internal iteration)을 사용하기 때문에 구현 방법은 라이브러리가 결정하고, 사용자는 what 에 집중 할 수 있다.




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


-

함수형 프로그래밍은 부작용 없는 계산을 지향한다.





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


-

함수형 프로그래밍은 “함수” 를 이용하는 프로그래밍이다.

함수형 프로그래밍에서 함수란 수학적인 함수와 같다.

즉 함수는 0개 이상의 인수를 가지며, 한 개 이상의 결과를 반환하지만 부작용이 없어야 한다.

함수는 여러 입력을 받아서 여러 출력을 생성하는 블랙박스와 같다.

인수가 같다면 수학적 함수를 반복적으로 호출했을 때 항상 같은 결과가 반환된다.



-

함수형이라는 말은 ‘수학의 함수처럼 부작용이 없는 것’ 을 의미한다.

“함수, 그리고 if-then-else 등의 수학적 표현만 사용” 하는 방식을 순수 함수형 프로그래밍이라고 하며, “시스템의 다른 부분에 영향을 미치지 않는다면 내부적으로는 함수형이 아닌 기능도 사용” 하는 방식을 함수형 프로그래밍이라 한다.




13.2.1. 함수형 자바


-

실질적으로 자바로는 완벽한 순수 함수형 프로그래밍을 구현하기 어렵다.

예를 들어 자바의 I/O 모델 자체에는 부작용 메서드가 포함된다. ( Scanner.nextLine 을 호출하면 파일의 행을 소비하는데, 두 번 호출하면 다른 결과가 반환될 가능성이 있다. )

자바에서는 순수 함수형이 아니라, 함수형 프로그램을 구현할 수 있다.

실제 부작용이 있지만 아무도 이를 보지 못하게 함으로써 함수형을 달성할 수 있다.



-

함수나 메서드는 지역 변수만을 변경해야 함수형이라 할 수 있다.

그리고 함수나 메서드에서 참조하는 객체가 있다면 그 객체는 불변 객체여야 한다.

즉 객체의 모든 필드가 final 이어야 하고, 모든 참조 필드는 불변 객체를 직접 참조해야 한다.



-

함수형이라면 함수나 메서드가 어떤 예외도 일으키지 않아야 한다.

예외가 발생하면 블랙박스 모델에서 return 으로 결과를 반환할 수 없게 될 수 있기 때문이다.

이러한 제약은 함수형을 수학적으로 활용하는 데 큰 걸림돌이 될 것이다.



-

어떤 수를 나누는 함수가 있다고 할 때, 인자로 0 이 들어와, 어떤 수를 0 으로 나누어야 하는 케이스는 어떨까?

그래서 예외를 추가해야 한다고 주장하는 이들도 있다.

그러나 Optional<T> 를 이용하여 문제를 해결할 수 있다.



-

함수형에서는 비함수형 동작을 감출 수 있는 상황에서만 부작용을 포함하는 라이브러리 함수를 사용해야 한다.

이와 같은 설명을 주석으로 표현하거나 마커 어노테이션으로 메서드를 정의할 수 있다.

마커 어노테이션을 사용하면, Stream.map 같은 병렬 스트림 처리 연산에 전달할 때 이와 같은 제약이 있는지 쉽게 확인할 수 있다.




13.2.2. 참조 투명성


-

“부작용을 감춰야 한다” 라는 제약은 참조 투명성(referential transparency) 개념으로 귀결된다.

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

예를 들어 “seoul”.replace(’s’, ’S’) 라는 코드는 항상 같은 결과가 나오므로 String.replace 는 참조적으로 투명하다.

다시 말해 함수는 어떤 입력이 주어졌을 때 언제, 어디서 호출하든 같은 결과를 생성해야 한다.



-

참조 투명성은 프로그램 이해에 큰 도움을 준다.

또한 참조 투명성은 비싸거나 오랜 시간이 걸리는 연산을 기억화(memorization) 또는 캐싱(caching) 을 통해 다시 계산하지 않고 저장하는 최적화 기능도 제공한다.



-

자바에는 참조 투명성과 관련한 작은 문제가 있다.

List 를 복사해서 반환하는 메서드를 두 번 호출한다고 가정하자.

두 번의 호출 결과로 같은 요소를 포함하지만 서로 다른 메모리 공간에 생성된 리스트를 참조할 것이다.

결과 리스트가 가변 객체라면 (반환된 두 리스트가 같은 객체라 할 수 없으므로) 리스트를 반환하는 메서드는 참조적으로 투명한 메서드가 아니라는 결론이 나온다.

결과 리스트를 불변 순수값으로 사용할 것이라면 두 리스트가 같은 객체라고 볼 수 있으므로 리스트 생성 함수는 참조적으로 투명한 것으로 간주할 수 있다.

일반적으로 함수형 코드에서는 이런 함수를 참조적으로 투명한 것으로 간주한다.




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


-

자바 8 은 함수형 프로그래밍을 익스트림 객체지향 프로그래밍의 일종으로 간주한다.

프로그래밍 형식을 스펙트럼으로 표현하자면 스펙트럼의 한 쪽 끝에는 모든 것을 객체로 간주하고 프로그램이 객체의 필드를 갱신하고, 메서드를 호출하고, 관련 객체를 갱신하는 방식으로 동작하는 익스트림 객체지향 방식이 위치한다.

스펙트럼의 반대쪽 끝에는 참조적 투명성을 중시하는, 즉 변화를 허용하지 않는 함수형 프로그래밍 형식이 위치한다.


사실 자바는 이 두 가지 프로그래밍 형식을 혼합한다.




13.2.4. 함수형 실전 연습






13.3. 재귀와 반복


-

순수 함수형 프로그래밍 언어에서는 while, for 같은 반복문을 포함하지 않는다.

이러한 반복문 때문에 변화가 자연스럽게 코드에 스며들 수 있기 때문이다.

그러나 함수형 스타일에서는 다른 누군가가 변화를 알아차리지만 못한다면 아무 상관이 없다.



-

이론적으로 반복을 이용하는 모든 프로그램은 재귀로도 구현할 수 있는 재귀를 이용하면 변화가 일어나지 않는다.

재귀를 이용하면 루프 단계마다 갱신되는 반복 변수를 제거할 수 있다.

int factorialIterative(int n){
    int r = 1;
    for( int i = 1; i <= n; i++ ){
        r * i;
    }
    return r;
}

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

int factorialStreams(int n){
    return LongStream.rangeClosed(1, n)
                .reduce(1, (long a, long b) -> a * b);
}



-

함수형 프로그래밍의 장점이 분명히 있지만 무조건 반복보다는 재귀가 좋다고 주장하는 함수형 프로그래밍 광신도의 주장에 주의해야 한다.

일반적으로 반복코드보다 재귀 코드가 더 비싸다.

호출 시마다 호출 스택에 각 호출시 생성되는 정보를 저장할 새로운 스택 프레임이 만들어지기 때문이다.

즉 메모리 사용량이 증가한다.

큰 입력값을 사용하면 StackOverflowError 가 발생한다.


그렇다면 재귀는 쓸모가 없는 것일까? 그렇지 않다!

함수형 언어에서는 꼬리 호출 최적화(tail call optimization) 이라는 해결책을 제공한다.

재귀 호출이 가장 마지막에 이루어지는 것을 꼬리 재귀라고 부른다.

( n * recursiveCall(n-1) 은 꼬리 재귀가 아니다. 재귀호출 결과와 n 을 곱한 것이다. )

int factorialTailRecursive(int n){
    return factorialHelper(1, n);
}

int factorialHelper(int acc, int n){
    return n == 1 ? acc : factorialHelper(acc * n, n - 1);
}

일반 재귀는 중간 결과를 각각의 스택 프레임으로 저장해야 하지만,

꼬리 재귀에서는 컴파일러가 하나의 스택 프레임을 재활용할 가능성이 생긴다.


안타깝게도 자바는 이와 같은 최적화를 제공하지 않는다.

그럼에도 여전히 고전적인 재귀보다는 여러 컴파일러 최적화 여지를 남겨둘 수 있는 꼬리 재귀를 적용하는 것이 좋다.

스칼라, 그루비 같은 최신 JVM 언어는 이와 같은 재귀를 반복으로 변환하는 최적화를 제공한다. ( 속도 손실 없이 ).

결과적으로 순수 함수형을 유지하면서도 유용성뿐 아니라 효율성까지 두 마리의 토끼를 모두 잡을 수 있다.



-

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

또한 반복을 재귀로 바꾸면 더 간결하고, 부작용이 없는 알고리즘을 만들 수 있다.

실제로 재귀를 이용하면 좀 더 쉽게 읽고, 쓰고, 이해할 수 있는 예제를 만들 수 있다.

또한 약간의 실행시간 차이보다는 프로그래머의 효율성이 더 중요할 때도 많다.





13.4. 요약


-

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



-

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



-

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



-

같은 인수값으로 함수를 호출했을 때 항상 같은 값을 반환하면 참조 투명성을 갖는 함수다.

while 루프 같은 반복문은 재귀로 대체할 수 있다.



-

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




댓글0