시스템 구현과 유지보수
이미 구현되어 있지만 실제 경험해본 적은 없는 대규모 소프트웨어 시스템 업그레이드 관리를 요청한 상황에서 ‘먼저 프로그램 내에 synchronized라는 키워드가 발견된다면 제안을 거절하고, synchronized가 없다면 시스템의 구조를 자세히 검토해보라’ 라는 풍문이 있다. 쉽게 유지보수할 수 있으려면 프로그램이 어떤 모습이어야 할까? 시스템의 각 부분의 상호 의존성을 가리키는 결합성과 시스템의 다양한 부분이 서로 어떤 관계를 갖는지 가리키는 응집성이라는 소프트웨어 엔지니어링 도구로 프로그램 구조를 평가할 수 있다.
함수형 프로그래밍은 이를 보장하기 위해 부작용 없음과 불변성이라는 개념을 제공한다.
공유된 가변 데이터
어떤 자료구조도 바꾸지 않는 시스템은 예상치 못하게 자료구조의 값이 바뀔 일이 없으니 당연히 유지보수가 쉽다. 자신을 포함하는 클래스의 상태와 다른 객체의 상태를 바꾸지 않으며 return 문을 통해서만 결과를 반환하는 메서드를 순수 메서드 혹은 부작용 없는 메서드라고 부른다.
여기서 부작용이란 함수 내에 포함되지 못한 기능으로, 예시는 다음과 같다.
- 자료구조를 고치거나 필드에 값을 할당(setter 메서드 같은 생성자 이외의 초기화 동작)
- 예외 발생
- 파일에 쓰기 등의 I/O 동작 수행
불변 객체를 이용해 부작용을 없애는 방법도 있다. 불변 객체는 인스턴스화한 다음에는 상태를 바꿀 수 없으므로 스레드 안전성을 제공한다.
부작용 없는 시스템의 개념은 함수형 프로그래밍에서 유래되었다. 먼저 함수형 프로그래밍의 기반을 이루는 개념인 선언형 프로그래밍을 살펴보자.
선언형 프로그래밍
프로그램으로 시스템을 구현하는 방식은 크게 ‘어떻게’ 와 ‘무엇을’ 로 구분할 수 있다. 먼저, ‘어떻게’에 집중하는 방식은 고전의 객체지향에서 이용하는 방식으로 명령형 프로그래밍이라고 부르기도 한다.
//리스트에서 가장 비싼 트래잭션 구하기
for (Transaction t: transactions.subList(1, transactions.size())) {
if (t.getValue() > mostExpensive.getValue()) {
mostExpensive = t;
}
}
‘어떻게’가 아닌 ‘무엇을’에 집중하는 방식은 4장과 5장에서 스트림 API를 이용하는 방법으로 적용할 수 있다. 선언형 프로그래밍은 문제 자체가 코드로 명확하게 드러난다는 장점이 있다.
Optional<Transaction> mostExpensive = transactions.stream().max(comparing(Transaction::getValue));
이와 같은 구현 방식을 내부 반복 이라고 한다. 질의문 자체를 어떻게 푸는지 명확하게 보여준다는 것이 내부 반복 프로그래밍의 장점이다.
왜 함수형 프로그래밍인가?
함수형 프로그래밍은 선언형 프로그래밍과 부작용을 멀리하기 때문에 더 쉽게 시스템을 구현하고 유지보수할 수 있다. 이전에 학습했던 람다 표현식과 스트림은 함수형 프로그래밍의 특징을 잘 보여준다.
함수형 프로그래밍이란 무엇인가?
함수형 프로그래밍에서 함수 란 수학적인 함수와 같다. 즉, 함수는 0개 이상의 인수를 가지며, 한 개 이상의 결과를 반환하지만 부작용이 없어야 한다. 함수 그리고 if-then-else 등의 수학적 표현만 사용 하는 방식을 순수 함수형 프로그래밍이라고 하고, 시스템의 다른 부분에 영향을 미치지 않는다면 내부적으로는 함수형이 아닌 기능도 사용 하는 방식을 함수형 프로그래밍이라 한다.
함수형 자바
실질적으로 자바로는 완벽한 함수형 프로그래밍을 구현하기 어렵기 때문에 순수 함수형이 아니라 함수형 프로그램을 구현할 것이다. 실제 부작용이 있지만 아무도 이를 보지 못하게 함으로써 함수형을 달성하는 것이다.
함수나 메서드는 지역변수만을 변경해야 함수형이라 할 수 있고, 함수나 메서드에서 참조하는 객체는 불변객체여야 한다. 예외적으로 메서드 내에서 생성한 객체의 필드는 갱신할 수 있지만, 갱신이 외부에 노출되지 않아야 하고 다음에 메서드를 다시 호출한 결과에 영향을 미치지 않아야 한다. 함수형이라고 말할 수 있으려면 이 외에 다른 조건도 만족해야 한다. 함수형은 함수나 메서드가 어떤 예외도 일으키지 않아야 한다. 예외가 발생하면 블랙박스 모델에서 return으로 결과를 반환할 수 없게 될 수 있기 때문이다. 치명적인 에러가 있을 때 처리되지 않은 예외를 발생시키는 것은 괜찮지만 예외를 처리하는 과정에서 함수형에 위배되는 제어 흐름이 발생한다면 결국 ‘인수를 전달해서 받는다’는 블랙박스의 모델이 깨진다고 생각할 수도 있다. 이런 경우에는 Optional<T>
를 사용하면 문제를 해결할 수 있다. Optional을 사용하면 예외없이도 성공적으로 수행했는지 확인할 수 있지만, 호출 결과로 빈 Optional이 반환되는지 확인해야 하는 귀찮은 작업이 추가된다.
마지막으로, 함수형에서는 비함수형 동작을 감출 수 있는 상황에서만 부작용을 포함하는 라이브러리 함수를 사용해야 한다. 자료구조를 복사해서 사용한다거나, 발생할 수 있는 예외를 적절하게 내부적으로 처리하여 자료구조 변경을 호출자가 알 수 없도록 감춘다.
참조 투명성
‘부작용을 감춰야 한다’라는 제약은 참조 투명성이라는 개념으로 귀결된다. 즉, 같은 인수로 함수를 호출했을 때 항상 같은 결과를 반환한다면 참조적으로 투명한 함수라고 할 수 있다. 따라서, Random.nextInt
는 함수형이 될 수 없다.
자바에는 참조 투명성과 관련한 작은 문제가 있다. List를 반환하는 메서드를 두 번 호출한다고 가정할 때, 두 번의 호출 결과로 같은 요소를 포함하지만 서로 다른 메모리 공간에 생성된 리스트를 참조할 것이다. 그렇기 때문에 결과 리스트가 가변 객체라면 이 메서드는 참조적으로 투명한 메서드가 아니라는 결론이 나오게 되는 것이다. 만약, 결과 리스트를 (불변의) 순수값으로 사용할 것잉라면 두 리스트가 같은 객체라고 볼 수 있으므로 이 때는 이 메서드가 참조적으로 투명하다고 간주할 수 있다. 함수형 코드에서는 이런 함수를 참조적으로 투명한 것으로 간주한다.
재귀와 반복
순수 함수형 프로그래밍 언어에서는 while, for와 같은 반복문을 포함하지 않는다. 이러한 반복문 때문에 변화가 자연스럽게 코드에 스며들 수 있기 때문이다. while의 경우, 무한루프를 방지하기 위해 조건을 계속 갱신해줘야 한다. 함수형 프로그래밍에서는 변화를 알아차리지만 못하면 괜찮다고 했으므로 지역 변수는 자유롭게 갱신할 수 있다.
Iterator<Apple> it = apples.iterator();
while (it.hasNext()) {
Apple apple = it.next();
}
위 코드에서 호출자는 변화를 확인할 수 없으므로 아무 문제가 없지만, 다음 코드는 문제가 될 수 있다.
public void searchForGold(List<String> l, Stats stats) {
for(String s: l) {
if("gold".equals(s)) {
stats.incrementFor("gold");
}
}
}
루프의 내부에서 프로그램의 다른 부분과 공유되는 stats객체의 상태를 변화시키므로 함수형과 상충하는 부작용이 발생한다.
이론적으로 반복을 이용하는 모든 프로그램은 재귀로도 구현할 수 있는데 재귀를 이용하면 변화가 일어나지 않는다. 재귀를 이용하면 루프마다 갱신되는 반복 변수를 제거할 수 있다.
효율성 측면에서 살펴보면, 함수형 프로그래밍의 장점이 분명 있긴 하지만 무조건 반복보다 재귀가 좋다고 할 수 있을까? 일반적으로 반복코드보다 재귀 코드가 더 비싸다. 함수를 호출할 때마다 호출 스택에 각 호출시 생성되는 정보를 저장할 새로운 스택 프레임이 만들어지므로 메모리 사용량이 증가하기 때문이다. 함수형 언어에서는 꼬리 호출 최적화라는 해결책을 제공한다.
static long factorialTailRecursive(long n) {
return factorialHelper(1, n);
}
static long factorialHelper(long acc, long n) {
return n == 1 ? acc : factorialHelper(acc * n, n-1);
}
factorialHelper에서 재귀 호출이 가장 마지막에서 이루어지므로 꼬리 재귀다. 꼬리 재귀는 컴파일러가 하나의 스택 프레임을 재활용할 가능성이 생긴다. 결과적으로, 순수 함수형을 유지하면서도 유용성뿐 아니라 효율성까지 두 마리의 토끼를 모두 잡을 수 있다.
결론적으로 자바 9에서는 반복을 스트림으로 대체해서 변화를 피할 수 있고, 재귀로 바꿈으로써 더 간결하고 부작용 없는 알고리즘을 만들 수 있다.