함수형 관점으로 생각하기

김종준·2023년 3월 8일
0

모던자바

목록 보기
14/15

함수형 관점으로 생각하기

시스템 구현과 유지보수

실질적으로 많은 프로그래머가 유지보수 중 코드 크래시 디버깅 문제를 가장 많이 겪는다.

코드 크래시는 예상하지 못한 변숫값 때문에 발생할 수 있다.

왜 그리고 어떻게 변숫값이 바뀌는지 생각해보면 함수형 프로그래밍이 제공하는 부작용 없음과 불변성이라는 개념이 이 문제를 해결하는 데 도움을 준다.

공유된 가변 데이터

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

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

이때 부작용은 함수 내에 포함되지 못한 기능을 부작용이라고 한다.

다음은 부작용의 예다.

  • 자료구조를 고치거나 필드에 값을 할당
  • 예외 발생
  • 파일에 쓰기 등의 I/O 동작 수행

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

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

즉, 인스턴스화한 불변 객체의 상태는 결코 예상하지 못한 상태로 바뀌지 않는다.

따라서 불변 객체는 복사하지 않고 공유할 수 있으며, 객체의 상태를 바꿀 수 없으므로 스레드 안정성을 제공한다.

선언형 프로그래밍

"어떻게"에 집중하는 프로그래밍 형식은 고전 객체지향 프로그래밍에서 이용하는 방식이다.

때로는 이를 명령형 프로그래밍이라 부르기도 하는데 이는 명령어가 컴퓨터의 저수준 언어와 비슷하게 생겼기 때문이다.

"무엇을"에 집중하는 방식도 있다.

스트림 API로 질의를 만들 수 있다.

질의문 구현 방법은 라이브러리가 결정한다.

이와 같은 구현 방식은 내부 반복이라고 한다.

질의문 자체로 문제를 어떻게 푸는지 명확하게 보여준다는 것이 내부 프로그래밍의 큰 장점이다.

이처럼 "무엇을"에 집중하는 방식을 선언형 프로그래밍이라 한다.

선언형 프로그래밍에서는 우리가 원하는 것이 무엇이고 시스템이 어떻게 그 목표를 달성할 것인지 등의 규칙을 정한다.

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

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

함수형 프로그래밍은 선언형 프로그래밍을 따르는 대표적인 방식이며, 이전에 설명한 것처럼 부작용이 없는 계산을 지향한다.

선언형 프로그래밍과 부작용을 멀리한다는 두 가지 개념은 좀 더 쉽게 시스템을 구현하고 유지보수하는 데 도움을 준다.

함수형 프로그램이이란?

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

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

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

그렇다면 시스템의 다른 부분에 영향을 미치지 않는다면 내부적으로는 함수형이 아닌 기능도 사용할 수 있을까?

즉, 내부적으로는 부작용이 발생하지만, 호출자가 이를 알아차리지 못하면 실제로 부작용이 발생한 것이라고 말할 수 있을까?

호출자에게 아무 영향을 미치지 않는다면 호출자는 내부적인 부작용을 파악하거나 신경쓸 필요가 없다.

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

함수형 자바

시스템의 컴포넌트가 순수한 함수형인 것처럼 동작하도록 코드를 구현할 수 있다.

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

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

부작용을 일으키지 않는 어떤 함수나 메서드가 있는데, 다만 진입할 때는 어떤 필드의 값을 증가시켰다가 빠져나올 때 필드의 값을 도려놓는다고 가정하자.

단일 스레드로 실행되는 프로그램의 입장에서는 이 메서드가 아무 부작용을 일으키지 않으므로 이 메서 드는 함수형이라 간주할 수 있다.

하지만 다른 스레드가 필드의 값을 확인한다든가 아니면 동시에 이 메서드를 호출하는 상황이 발생할 수 있다면 이 메서 드는 함수형이 아니다.

메서드의 바디를 잠금(lock)으로써 이 문제를 해결할 수 있으며, 따라서 이 메서 드는 함수형이라고 할 수 있다.

하지만 이런 식으로 문제를 해결하려면 멀티코어 프로세서의 두 코어를 활용해서 메서드를 병렬로 호출할 수 없게 된다.

결국 프로그램 입장에서 부작용이 사라졌지만, 프로그래머 관점에서는 프로그램의 실행 속도가 느려지게 된 것이다.

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

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

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

예외적으로 메서드 내에서 생성한 객체의 필드는 갱신할 수 있다.

단, 새로 생성한 객체의 필드 갱신이 외부에 노출되지 않아야 하고 다음에 메서드를 다시 호출한 결과에 영향을 미치지 않아야 한다.

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

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

자바에서는 비정상적인 입력값이 있을 때 예외를 일으키는 것이 자연스러운 방식이다.

치명적인 에러가 있을 때 처리되지 않은 예외를 발생시키는 것은 괜찮지만 예외를 처리하는 과정에서 함수형에 위배되는 제어 흐름이 발생한다면 결국 "인수를 전달해서 결과를 받는다"는 블랙박스의 단순한 모델이 깨진다.

스크린샷 2023-03-08 오전 11 13 39

위의 사진에서 볼 수 있듯 예외 때문에 세 번째 화살표가 추가된다.

예외를 사용하지 않으려면 Optional를 통해 이 문제를 해결할 수 있다.

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

참조 투명성

"부작용을 감춰야 한다"라는 제약은 참조 투명성 개념으로 귀결된다.

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

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

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

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

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

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

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

결과 리스트가 가변 객체라면 리스트를 반환하는 메서드는 참조적으로 투명한 메서드가 아니라는 결론이 나온다.

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

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

재귀와 반복

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

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

public void searchForGold(List<String> l, Stats tats) {
  for (String s : l) {
    if ("gold".equals(s)) {
      stats.incrementFor("gold");
    }
  }
}

위의 코드는 루프의 바디에서 함수형과 상충하는 부작용이 발생한다.

즉, 루프 내부에서 프로그램의 다른 부분과 공유되는 stats 객체의 상태를 변화시킨다.

그럼 어떻게 구현해야 할까?

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

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

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

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

첫 번째 예제는 일반적인 루프를 사용한 코드로 반복마다 변수 r과 i가 갱신된다.

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

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

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

즉, 재귀 팩토리얼의 입력값에 비례해서 메모리 사용량이 증가한다.

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

0개의 댓글