💋 인트로
지난주 우테코의 강의 주제는 함수형 프로그래밍이었다. 하지만 잘 이해가 되지 않았다....
또 이리내와 함께 한 람다와 스트림 발표에서, 마코에게 질문을 받았다.
"왜 stream의 최종연산 forEach에서 출력 외에 외부 변수에 변화를 주는 작업을 하면 안되나요?"
이 질문에 대답은 이펙티브 자바 아이템 46에 나와 있다.
아마 읽어도 잘 와닿지 않을 것이다....
1. 스트림은 그저 또 하나의 API가 아닌, 함수형 프로그래밍에 기초한 패러다임이다.
2. 스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성하고 각 변환 단계는 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
3. 순수 함수란 오직 입력만이 결과에 영향을 주는 함수를 말한다. 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다. 스트림 연산에 건네는 함수 객체는 모두 부작용(side effect)이 없어야 한다.
개발을 하면서 기본적 프로그래밍 문법에 대해 배우면 이런 용어들을 접할 수 있다.
객체지향 프로그래밍, 절차지향 프로그래밍, 함수형 프로그래밍...
모두 프로그래밍의 스타일(패러다임)에 대해 설명하는 단어들이다.
하지만 이 스타일들은 손톱깎이처럼 딱 잘라 구분하기는 어렵다.
서로 다른 스타일이라 하더라도 공존하기도 하고, 상호 보완하기도 할 수 있다고
일단 머릿속에 박고 이 글을 읽으면 더 도움이 될 것 같다!
💋 그래서 함수형 프로그래밍은 어떤 패러다임인데?
함수형 프로그래밍은 처음 접하면 굉장히 이질적이다.
우선, 함수형 프로그래밍은 객체지향과 대립하는 개념이 아니다. 오히려 보완에 가깝다고 생각한다.
하지만, 절차지향에서 객체지향이 생기며 프로그래머들의 생각의 틀(패러다임)이 변했듯
함수형 프로그래밍은 개발자들에게 새로운 생각의 틀을 제공하고 있다.
객체지향 프로그래밍에서는 각각의 객체들에 역할이 있고, 상태가 있으며 다른 객체와 협력했다.
함수형 프로그래밍에서는 다른 객체에 대해 알지 못한다.
아래 적힌 세 가지는 함수형 프로그래밍의 특징이다.
1. 메서드는 각자 자신의 input에 대해서 일정한 output을 내놓는다.
2. 하나의 메서드는 다른 객체, 메서드가 어떤 역할을 하는 지에 대해서 모르므로 외부 환경에 대해 철저히 독립적이다.
당연한 말이지만, 다른 객체의 필드나 메서드를 알지도, 참조하지도 않는다.
3. 외부 요인에 영향을 받지 않은 채 오로지 주어진 input을 통해서만 정해진 작업을 수행하기 때문에,
동일한 input에 대해서는 언제나 동일한 output을 반환한다. 이런 것을 순수 함수라고 한다.
조금 낯설고 이질적으로 느껴진다.
아래에서 하나하나 천천히 설명해 보겠다!
함수형 프로그래밍을 통해서 우리 개발자들이 얻을 수 있었던 것은 무엇일까?
함수형 프로그래밍은 서로의 상태에 대해 모르기 때문에 더 안정적인 프로그램을 만들 수 있다.
그렇다면 왜 더 안정적일까...?
💋 함수형 프로그래밍 이전의 세계
함수형 프로그래밍이 도입되기 이전으로 돌아가보자.
우리는 늘 하나의 메서드를 메서드 외부의 변수를 변경하기 위해 사용했다. 따라서 메서드는 항상 외부의 변수나 메서드를 참조하는 형태로 작성되었다. 다른 변수를 참조한다는 것은 그 자체로 위험에 대한 경우의 수가 증가한다.
외부 변수는 뭐가 문제일까?
외부 변수는 그것을 참조하는 메서드의 내부 로직의 의도와 무관한 결과를 발생시킬 수 있다. 이 결과는 메서드 내부에서는 통제할 수 없는 상황이다. 외부의 요인 때문에 외부 변수에 변화가 생길 수도 있고, 메서드 내부에서 수정이나 조회에서 개발자가 실수를 하게 된다면, 예상하지 못한 결과가 발생할 수 있다.
예상하지 못하는 결과는 이 자체로 굉장한 변수가 되어서 문제의 소지가 될 수 있기 때문이다. 말하지 않아도 알 것이다... 예상을 하지 못하면 디버깅도 어렵고, 어디서 뭐가 잘못되었는지 파악할 수 없는 상태가 되기 때문에 유지보수에서도 굉장히 어려워 진다.
💋 함수형 프로그래밍 이후의 세계
함수형 프로그래밍의 설계는, 외부 변수의 개입이 전혀 없는 상태여서 굉장히 단순하다.
메서드를 사용하면서 부작용이 발생했다는 말을 자주 한다. 이 때 부작용은 무엇일까?
부작용은 주작용의 반대말로, 주작용은 내가 이 메서드를 통해 얻고 싶었던 결과이고
부작용은 원하던 결과 외의 모든 변화(예. 메서드 외부의 변수가 변경되는 것)를 말한다.
함수형 프로그래밍은 메서드 실행을 통한 외부 변수의 값 변경에 대한 위험을 모두 없애는 방식이라고 이해하면 될 것 같다. 따라서 문제의 소지가 있는 일을 일체 하지 않는 프로그래밍의 방식이다.
함수형 프로그래밍은 그러면 외부 변수를 사용하지 않을까? 외부 변수로부터 작업하지 않는다면 어떻게 프로그래밍이 이루어질 수 있는거지? 라는 의문이 들 수 있다. 외부 변수를 사용하더라도, 본체에 접근해 변경하는 것이 아니라 그 복사본으로 복사해 작업하기 때문에 어떤 작업을 하든 부작용이 일어나지 않는다. 이 과정은 스트림이 원본을 변화시키지 않는다는 것과 연관지어 생각하면 이해하기 쉬울 것이다.
💋 함수형 프로그래밍의 특징
메서드를 하나의 값처럼 생각할 수 있게 되었다.
함수형 프로그래밍에서의 메서드는 위에서 언급했듯 순수 함수여서 어떤 input을 넣었을 때 늘 일정한 output이 나오게 된다. 따라서 여러 개의 메서드가 서로를 input과 output으로 사용하며 연결되어 있더라도 input을 넣는 순간 그 output을 곧바로 예상할 수 있다.
이전까지 우리는 함수를 변수를 처리하는 행위라고 생각해 왔다.
반면 함수형 프로그래밍에서는 메서드도 변수처럼 일정한 작업을 하니, 메서드도 하나의 값처럼 생각할 수 있게 된다.
JAVA8부터 등장한 람다식이 바로 메서드도 변수로 바라볼 수 있다는 생각을 보여주는 예시다. 실제로 람다식은 익명 객체를 구현한 인스턴스이지만, 코드에서 만나면 하나의 값처럼 사용되고 있다.
아래의 코드를 통해 설명하겠다.
public class Car {
private final String name;
private final int position;
public Car(String name, int position) {
this.name = name;
this.position = position;
}
public Car move(MoveStrategy moveStrategy) {
if (moveStrategy.isMovable()) {
return new Car(name, position + 1);
}
return this;
}
}
Car 객체의 move 메서드는 MoveStrategy라는 인터페이스를 구현한 인스턴스를 파라미터로 받고 있다.
MoveStrategy를 살펴 보자.
@FunctionalInterface
public interface MoveStrategy {
boolean isMovable();
}
이 함수형 인터페이스를 구현한 객체를 통해서 실제로 자동차를 움직일 수 있다.
@SuppressWarnings("NonAsciiCharacters")
class CarTest {
@Test
public void 이동() {
Car car = new Car("pobi", 0);
Car actual = car.move(() -> true);
assertThat(actual).isEqualTo(new Car("pobi", 1));
}
@Test
public void 정지() {
Car car = new Car("pobi", 0);
Car actual = car.move(() -> false);
assertThat(actual).isEqualTo(new Car("pobi", 0));
}
}
우리가 일반적으로 생각했던 .move()의 파라미터는 변수가 들어가는 자리였다. 이제는 람다식으로 함수형 인터페이스를 구현해서 사용할 수 있게 되었다. 메서드조차도 늘 일정한 결과를 낸다고 확신할 수 있으니 변수의 자리에 사용할 수 있게 된 것이다.
이처럼 메서드를 값으로 볼 수 있다면, 메서드를 다른 메서드의 파라미터로 넣어줄 수 있다. 우리는 메서드 자체를 다른 메서드의 파라미터로 받거나, 반환값으로 사용해 더 유연한 프로그래밍을 할 수 있게 된다.
💋 플루언트 API
이제까지 반복문(for, while)을 사용할 때에는 늘 특정 변수의 상태 변화(=부수효과)를 주는 식으로 사용해 왔다.
반면에 함수형 프로그래밍에서는 부수효과를 일으키지 않는 재귀함수를 많이 사용한다.
이펙티브 자바 아이템 45에서 플루언트 API라는 용어가 등장한다.
스트림 API는 메서드 연쇄를 지원하는 플루언트 API이다.
플루언트 API가 뭔가 찾아봤는데 산문처럼 유창하게 읽히는 것을 말하는 것 같았다.
코드로 예를 들어 설명해 보겠다.
public final class Score {
private final int value;
public Score(final int value) {
this.value = value;
}
public Score add(final Score score) {
return new Score(this.value + score.value);
}
}
이 메서드를 호출하면, 내부의 value 필드를 변경하는 대신 변화된 Score 객체 그 자체를 반환한다. 따라서 연달아 점을 계속 찍을 수 있다. 아래와 같이 계속해서 반환받은 객체에서 메서드를 실행할 수 있다는 말이다.
score.add(new Score(3)).add(new Score(45));
이것이 부수효과를 일으키지 않는 플루언트 API라고 이해하면 이해가 더 쉬울 것 같다.
메소드 체이닝으로 객체의 변화를 문장을 읽는 것처럼 자연스럽게 연산 과정을 메소드 이름으로 파악하는 것이라고 이해할 수 있다.
💋 스트림은 왜 부작용이 없어야 하나요?
다시 처음으로 돌아가서 첫 질문에 대한 답을 하겠다.
"왜 stream의 최종연산 forEach에서 출력 외에 외부 변수에 변화를 주는 작업을 하면 안되나요?"
스트림은 JAVA8부터 함수형 프로그래밍을 위해 등장했다. 항상 하나의 기술을 사용할 때에는 그 등장 이유를 알아야 한다.
따라서 스트림은 함수형 프로그래밍의 특징인 순수 함수의 역할을 하겠다고 보통 기대한다. forEach에서 외부 List나 Map에 add, put을 하게 된다면 더이상 순수 함수가 아니게 된다. 따라서 forEach에서는 단순 출력과 같은 작업을 하라고 하는 것이다. 이펙티브 자바에서는 forEach가 최종연산 중 가장 '덜' 스트림 답다고 이야기하는데, 다른 최종연산과 달리 외부의 메서드를 forEach를 통해 변화시킬 수 있기 때문이라고 생각한다.
이제 질문에 굉장히 쉽고 당연하게 답할 수 있게 되었다!
물론 객체지향 프로그래밍을 위해 만들어진 클래스를 객체지향적으로 사용하지 않을 수 있는 것처럼, 람다와 스트림도 함수형 프로그래밍을 위해 만들어지긴 했지만 하나의 방법론일 뿐이므로 이런 개념이 자신이 속해있는 조직에서 통용되지 않는다면, forEach를 부수효과 있도록 작성할 수도 있다. 하지만 내가 동료 개발자라면 람다 스트림이 적힌 곳에서는 순수함수일 것이라고 예상해 디버깅이 어려워질 수 있다! 부수 효과를 일으킬 생각이라면 코드 블록(반복문)으로 작성하는 방식이 있는데, 굳이 혼선을 줄 필요가 없다고 생각한다.
JAVA에 함수형 프로그래밍이라는 새로운 생각의 틀이 들어오면서 함께 도입된 람다와 스트림은 함수형 프로그래밍의 정신을 잘 담고 있다. 람다와 스트림에 대해 공부하다 보면 함수형 프로그래밍에 대해 더 와닿게 이해하고 설명할 수 있게 될 것이다!
💋 함수형 프로그래밍을 내 코드에 어떻게 적용할까?
그렇다면 모든 것을 함수형으로 만들 수 있을까?
모든 코드를 100% 함수형으로 만드는 것은 굉장히 어려울 것이다.
함수형 프로그래밍을 똑똑하게 사용하기 위해서는 domain에서 중요한 일부분에 대해서 부수 효과가 일어나지 않는 안정적이고 예측 가능한 코드를 작성하는 식으로 내 코드에 적절히 도입할 수 있을 것이다.