[도서][모던 자바 인 액션] - 함수형 관점으로 생각하기

Junseo Kim·2021년 4월 14일
0

[모던 자바 인 액션]

목록 보기
13/13

시스템 구현과 유지보수

쉽게 유지 보수할 수 있는 프로그램이란 시스템의 구조를 이해하기 쉽게 클래스 계층으로 반영하는 프로그램이다. 프로그램 구조는 아래의 2가지 도구로 평가할 수 있다.

  • 결합성: 시스템의 각 부분의 상호 의존성을 가리키는 것
  • 응집성: 시스템의 다양한 부분이 서로 어떤 관계를 갖는지 가리키는 것

그러나 이런 구조여도 유지보수 중 예상하지 못한 변수값에 의한 문제가 많이 발생한다. 이 문제는 함수형 프로그래밍이 보장해주는 부작용 없음불변성이 해결해 줄 수 있다.

공유된 가변 데이터

예상하지 못한 변수값 문제는 시스템의 여러 메서드에서 공유된 가변 데이터를 읽고 갱신할 때 발생한다.

따라서 값이 바뀌지 않는 것이 보장되어 있다면 여러 메서드에서 동시에 접근한다하더라도 예상하지 못한 변수값이 발생하지 않게 되고 유지보수하기 쉽다. 이런식으로 클래스의 상태나 다른 객체의 상태를 바꾸지 않으면서 return문을 통해서만 자신의 결과를 반환하는 메서드를 순수 메서드 또는 부작용 없는 메서드라고 한다.

부작용의 예시는 아래와 같다.
1) 자료구조를 고치거나 필드에 값을 할당(setter를 통해 상태자체가 변경되는 경우)
2) 예외 발생(메서드 실행 중 예외가 발생하여 return값을 반환하지 않는 경우)
3) I/O 동작 수행(입력에 따라 결과가 얼마든지 달라질 수 있기 때문)

부작용을 없애기 위해서는 불변 객체를 이용하는 방법이 있다. 불변 객체는 한 번 생성되고 나면 값이 바뀌지 않으므로 함수 동작이 불변 객체 상태에 영향을 줄 수 없다. 따라서 불변 객체는 공유되더라도 스레드 안정성을 제공한다.

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

선언형 프로그래밍

프로그램으로 시스템을 구현하는 방식은 크게 2가지가 있다.
1) 어떻게(how) 수행할 것인지 집중하는 방법 - 명령형 프로그래밍(절차지향, 객체지향)

public Piece pieceByPosition(final Position position) {
    for (Piece piece : pieces) {
        if (piece.isSamePosition(position)) {
            return piece;
        }
    }
    throw new WrongMoveCommandException();
}

2) 무엇을(what) 수행할 것인지 집중하는 방법 - 선언형 프로그래밍(함수형 프로그래밍)

public Piece pieceByPosition(final Position position) {
    return pieces.stream()
            .filter(piece -> piece.isSamePosition(position)) // filter가 어떻게 구현되어있는지는 모르겠고 그냥 걸러내라
            .findFirst()
            .orElseThrow(WrongMoveCommandException::new);
}

선언형 프로그래밍을 이용하면 문제 자체가 명확하게 드러난다.

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

함수형 프로그래밍은 1) 선언형 프로그래밍을 따르는 대표적인 방식이다. 2) 위에서 설명한 부작용 없는 계산을 지향한다.

선언형 프로그래밍 + 부작용을 멀리 = 시스템 구현과 유지보수가 쉬워진다.

함수형 프로그래밍을 이용하면 부작용이 없는 복잡하고 어려운 기능을 수행하는 프로그램을 구현할 수 있다.

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

말그대로 함수를 이용하는 프로그래밍이다. 그럼 함수는 무엇인가? 함수형 프로그래밍의 함수는 수학적인 함수를 말한다. 함수는 0개 이상의 인수를 가지며, 한 개 이상의 결과를 반환하지만 부작용이 없어야한다.

자바와 같은 언어에서 함수와 메서드의 차이는 수학적인 함수냐 아니냐이다. 수학적인 함수는 부작용을 포함하지 않는다. 즉, 몇 번을 반복하더라도 동일한 인수에 대해 항상 동일한 결과값이 나온다. 따라서 함수형부작용이 없는을 의미한다고 볼 수 있다.

함수형도 2가지로 나눌 수 있다.
1) 순수 함수형 프로그래밍 : 함수와 if-then-else 등의 수학적 표현만 사용하는 방식

public int sum(int a, int b) {
    return a + b;
}

2) 함수형 프로그래밍 : 시스템의 다른 부분에 영향을 미치지 않는다면 내부적으로는 함수형이 아닌 기능도 사용하는 방식

즉, 내부적으로 부작용이 발생한다고 하더라도, 호출자에 아무런 영향을 미치지 않는다면 함수형 프로그래밍으로 볼 수 있다.

함수형 자바

자바로는 완벽한 순수 함수형 프로그래밍을 구현하기 어렵다.(ex. I/O 모델 자체에 부작용 메서드가 포함되어있음) 그렇지만 순수한 함수형인 것처럼 동작하도록 코드를 구현할 수는 있다. 즉, 위에서 말한 2) 함수형 프로그래밍의 경우를 말한다. 실제로는 부작용이 있지만 아무도 모르게 함으로써 함수형 프로그래밍을 구현할 수 있는 것이다.

int a = 1;

public void func() {
    a++;
    a--;
}

위와 같은 경우 단일 스레드일 경우, func 메서드는 아무 부작용을 일으키지 않으므로 함수형이라고 볼 수 있다. 하지만 여러 스레드가 동시에 접근한다면 부작용이 발생할 수 있기 때문에 함수형이라고 볼 수 없게된다. 메서드 바디에 lock을 건다면 부작용을 막을 수 있기 때문에 함수형이라고 볼 수 있지만, 이 경우는 메서드를 병렬로 호출할 수 없게 된다. 즉, 부작용을 없에는 대신 속도가 느려지게 된다.

함수형의 조건

1) 함수나 메서드는 지역 변수만을 변경해야하며, 함수나 메서드에서 참조하는 객체는 불변 객체여야한다.
예외적으로 메서드 내에서 생성한 객체의 필드는 갱신할 수 있다.(단, 필드 갱신이 외부에 노출되지 않아야하며, 다음에 다시 메서드를 호출한 결과에 영향을 미치지 않아야한다.)

2) 함수나 메서드가 어떤 예외도 일으키지 않아야한다.
return으로 결과를 반환할 수 없게 될 수 있기 때문이다. 예외를 사용하지 않으려면 Optional을 이용한다. Optional을 이용하면 예외 없이도 결과값으로 연산을 성공적으로 수행했는지 아니면 요청된 연산을 성공적으로 수행하지 못했는지 확인할 수 있다.(빈 Optional 객체가 반환되는지 확인)

3) 함수형에서는 비함수형 동작을 감출 수 있는 상황에서만 부작용을 포함하는 라이브러리 함수를 사용해야한다.
먼저 자료구조를 복사해서 사용한다거나, 발생할 수 있는 예외를 적절하게 내부적으로 처리하여 자료구조 변경을 호출자가 알 수 없도록 감춘다.

public static List<List<Integer>> insertAll(Integer first, List<List<Integer>> lists) {
    List<List<Integer>> result = new ArrayList<>();
    for (List<Integer> list : lists) {
        List<Integer> copyList = new ArrayList<>();
        copyList.add(first);
        copyList.addAll(list);
        result.add(copyList);
    }
    return result;
}

참조 투명성

함수에 같은 인수를 넣었을 때 항상 같은 결과를 반환한다면 이것을 참조적으로 투명한 함수라고 할 수 있다.

"raoul".replace('r', 'R'); // 항상 같은 결과가 나오므로 String.replace는 참조적으로 투명(원본을 변경하지 않고 새로운 객체를 생성한다)

-------------------------------------------------

Random random = new Random();
Random.nextInt() // 호출시 매번 다른 값이 나오므로 참조 투명 x 

-------------------------------------------------

Scanner scanner = new Scanner(System.in);
scanner.nextLine() // 호출시 매번 다른 결과가 나오므로 참조 투명 x

-------------------------------------------------

int a = 1;
int b = 2;

// a나 b의 값이 바뀔 수 있으므로 참조 투명성 x
public int sum(int a, int b) {
    return a + b;
}

-------------------------------------------------

final int a = 1;
final int b = 2;

// a나 b의 값이 바뀔 수 없으므로 참조 투명
public int sum(int a, int b) {
    return a + b;
}

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

자바에는 참조 투명성 관련 작은 문제가 있다. 아래와 같이 List를 반환하는 메서드가 있는 경우

public class Pieces {
    private List<Piece> pieces;
    
    // ...
    
    public List<Piece> toList() {
        return new ArrayList<>(pieces);
    }

이 메서드를 2번 호출하면 같은 요소를 가진 다른 주소를 가진 2개의 리스트가 존재하게 된다.

List<Piece> a = pieces.toList(); // 같은 요소, 다른 주소
List<Piece> b = pieces.toList(); // 같은 요소, 다른 주소

이 경우 a와 b는 서로 다른 주소를 참조하고 있으므로 참조 투명성을 가지고 있지 않다고 볼 수 있지만, 자료구조를 변경하지 않는 상황에서는 참조가 다르다는 것은 큰 의미가 없고, a와 b는 논리적으로 같다고 판단할 수 있다. 일반적으로 함수형 프로그래밍의 관점에서는 데이터가 변경되지 않으므로 같다는 의미는 참조가 같음이 아니라 구조적인 값이 같다는 것을 의미한다.

따라서 결과 리스트가 가변 객체라면 참조적으로 투명하지 않다고 볼 수 있지만, 불변 객체라면 주소값이 다르더라도 같은 참조적으로 투명하다고 볼 수 있다.

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

자바 8은 함수형 프로그래밍을 익스트림 객체지향 프로그래밍(모든 것을 객체로 간주하고 프로그램이 객체의 필드를 갱신하고, 메서드를 호출하고, 관련 객체를 갱신하는 방식)의 일종으로 간주한다.

하드웨어의 변경 + 질의와 비슷한 방식으로 데이터를 조작하고자하는 프로그래머의 기대치 -> 함수형으로 바뀌고 있는 추세

자바 프로그래머는 익스트림 객제지향 프로그래밍과 함수형 프로그래밍(참조적 투명성을 중시하는, 변화를 허용하지 않는)을 혼합해서 사용한다.

함수형 실전 연습

public static void main(String[] args) {
    List<List<Integer>> subs = subsets(Arrays.asList(1, 4, 9));
    subs.forEach(System.out::println);
}

// 인수로 받는 list가 변경 되지 않으므로 함수형이라고 볼 수 있다.
public static List<List<Integer>> subsets(List<Integer> list) {
    if (list.isEmpty()) {
        List<List<Integer>> answer = new ArrayList<>();
        answer.add(Collections.emptyList());
        return answer;
    }
    Integer first = list.get(0);
    List<Integer> rest = list.subList(1, list.size());
    List<List<Integer>> subAnswer = subsets(rest);
    List<List<Integer>> subAnswer2 = insertAll(first, subAnswer);
    return concat(subAnswer, subAnswer2);
}

public static List<List<Integer>> insertAll(Integer first, List<List<Integer>> lists) {
    List<List<Integer>> result = new ArrayList<>();
    for (List<Integer> list : lists) {
        List<Integer> copyList = new ArrayList<>(); // 받아온 리스트를 복사해서 사용. Integer가 불변이 아니라면 각 요소도 모두 복사해야함.
        copyList.add(first);
        copyList.addAll(list);
        result.add(copyList);
    }
    return result;
}

// 순수 함수. 내부적으로 리스트 r에 요소를 추가하는 변화가 생기지만, 반환 결과는 인수에 의해서만 이루어지며, 인수의 상태가 변경되지도 않는다.
static List<List<Integer>> concat(List<List<Integer>> a, List<List<Integer>> b) {
    List<List<Integer>> r = new ArrayList<>(a);
    r.addAll(b);
    return r;
}

----------------------------------------------------------------------------------

// a의 값이 다시 참조된다면 상태의 변화가 생길 수 있다. 
static List<List<Integer>> concat(List<List<Integer>> a, List<List<Integer>> b) {
    a.addAll(b);
    return a;
}

재귀와 반복

재귀는 함수형 프로그래밍의 한 기법이다.

순수 함수형 프로그래밍 언어에서는 while, for 같은 반복문을 포함하지 않는다. -> 반복문으로 인해 변화가 생길 수 있기 때문

함수형 스타일에서는 지역 변수는 자유롭게 갱신할 수 있다.(변화를 알아차리지만 못한다면 아무 상관이 없다.)

// 호출자는 변화를 알 수 없으므로 상관없다.
Iterator<Apple> it = apples.iterator(); // iterator()는 새로운 Itr 객체를 반환
while (it.hasNext()) {
    Apple apple = it.next(); 
    // ...
}
// 공유되는 stats의 상태가 변화되므로 문제가 발생할 수 있다.
public void searchForGold(List<String> list, Stats stats) {
    for (String string : list) {
        if ("gold".equals(string)) {
            stats.incrementFor("gold"); // stats가 다른 부분과 공유되고 있는 상태인데 반복문 안에서 상태가 변화되고 있음
        }
    }
}

이렇게 반복문을 사용할 경우, 함수형 프로그래밍이 깨질 수 있다. 이럴때 재귀를 사용하면 변화가 일어나지 않는다.(이론적으로 반복을 이용하는 모든 프로그램을 재귀로도 구현 가능)

// 반복 방식 팩토리얼
public int factorialIterative(int n) {
    int r = 1;
    for (int i = 1; i <= n; i++) { 
        r *= i; 
    }
    return r;
}
// 재귀 방식 팩토리얼
public long factorialRecursive(long n) {
    if (n == 1) {
        return 1;
    }
    return n * factorialRecursive(n - 1); // 최종 연산이 n * 재귀 호출 결과값
}
// 스트림을 사용한 팩토리얼
public long factorialStreams(long n) {
    return LongStream.rangeClosed(1, n)
            .reduce(1, (a, b) -> a * b);
}

무조건 반복보다 재귀가 좋다고는 할 수 없다.
재귀코드가 자원을 더 많이 사용한다. 재귀는 호출될 때마다 호출 스택에 호출시 생성되는 정보를 저장할 스택 프레임이 만들어진다. 즉, 입력값에 따라 만들어지는 스택 프레임이 늘어나므로 메모리 사용량이 증가한다.

이 문제를 해결하기 위해 꼬리 호출 최적화라는 해결책을 제공해준다.

// 꼬리 재귀 팩토리얼
public long factorialTailRecursive(long n) {
    return factorialHelper(1, n);
}

private long factorialHelper(long acc, long n) {
    if (n == 1) {
        return acc;
    }
    return factorialHelper(acc * n, n - 1); // 최종 연산이 재귀호출
}

일반 재귀는 중간 결과를 각각의 스택 프레임으로 저장해야하지만, 꼬리 재귀는 컴파일러가 하나의 스택 프레임을 재활용할 수 있다.(재귀 호출이 최종 연산일 경우, 스택에 있는 결과값을 교체하는 식)

자바는 이런 최적화를 제공하지 않지만 꼬리 재귀를 사용해야 추가적인 컴파일러 최적화를 기대할 수 있다.

자바에서 꼬리 재귀 최적화를 지원하지 않는 이유

jdk 클래스들에는 몇몇 보안에 민감한 메소드들이 있는데, 이 메소드들은 메소드 호출을 누가 했는지를 알아내기 위해 jdk 라이브러리 코드와 호출 코드간의 스택 프레임 갯수에 의존한다. 스택 프레임의 수의 변경을 유발하게 되면 이 의존관계를 망가뜨리게 되고 에러가 발생할 수 있다. 이게 멍청한 이유라는 것을 인정하며, JDK 개발자들은 이 메커니즘을 교체해 오고 있다.

그리고 추가적으로, tail recursion이 최상위 우선순위는 아니지만,
결국에는 지원될 것이다.

출처: Tail call Optimization

현재는 자바에서 꼬리 재귀 최적화를 지원하지는 않지만 람다와 함수형 인터페이스를 이용해서 비슷하게 동작하도록 할 수 있다고 한다.

참고 : Tail Recursion in JAVA 8

결론적으로는 자바에서는 반복을 스트림으로 대체해서 변화를 피할 수 있다. 또한 반복을 재귀로 바꿔 부작용이 없는 알고리즘을 만들 수 있다.

0개의 댓글