기존의 사고 방식을 깨부수는 함수형 사고

    기존의 사고 방식을 깨부수는 함수형 사고


    최근 많은 언어들이 함수형 프로그래밍 패러다임을 도입하며, 이에 대한 개발자들의 관심 또한 나날히 높아지고 있다. 필자 또한 함수형 사고라는 책을 읽으면서 기존의 패러다임과 사뭇 다른 함수형 프로그래밍에 대해 많은 관심을 가지게 되었던 기억이 있다.

    functional thinking book 재밌어서 여러 번 읽고 있는 귀여운 다람쥐 책

    물론 이 책을 읽었다고 해서 함수형 프로그래밍을 자유롭게 할 수 있는 것은 아니다. 함수형 프로그래밍에서 사용하는 커링, 모나드, 고계 함수와 같은 개념와 기법들은 열심히 공부해서 이해하고 많이 써보면 금방 익숙해질 수 있는 것들이지만, 앞서 이야기했듯이 어떤 패러다임을 사용하여 프로그램을 제대로 설계하기 위해서는 말 그대로 사고 방식 자체를 바꿔야하기 때문이다.

    그런 이유로 이번 포스팅에서는 함수형 프로그래밍의 스킬들보다는 함수형 프로그래밍이 왜 이렇게 각광받는지, 또 이 패러다임이 어떤 개념들을 사용하여 프로그램을 바라보고 있는지에 대한 이야기를 해보려고 한다.

    함수형 프로그래밍을 왜 알아야하나요?

    사실 객체지향적 사고와 명령형 프로그래밍을 사용하기만 해도 왠만한 프로그램을 설계하고 작성하는 데는 무리가 없다. 우리는 이미 몇십 년간 이러한 사고방식으로 거대한 프로그램을 만들어왔지 않은가?

    그러나 함수형 프로그래밍이 이렇게 주목받는 이유는 분명 기존의 그것들과는 분명한 차이가 있기 때문이고, 그 차이로 인해 개발자들이 조금 더 생산성있는 프로그래밍을 할 수 있기 때문일 것이다.

    그렇다면 도대체 함수형 프로그래밍의 어떤 점이 개발자들의 마음을 움직였던 것일까?

    뭐 개발자마다 각자 다른 이유들을 가지고 있겠지만, 일단 필자가 느꼈던 함수형 프로그래밍의 대표적인 장점은 바로 이런 것들이다.

    1. 높은 수준의 추상화를 제공한다
    2. 함수 단위의 코드 재사용이 수월하다
    3. 불변성을 지향하기 때문에 프로그램의 동작을 예측하기 쉬워진다

    아마 함수형 프로그래밍에 대해 설명하는 다른 블로그 포스팅이나 책들을 봐도 비슷한 점을 함수형 프로그래밍의 장점으로 이야기하고 있을 것이다.

    이 장점들 중에서 불변성에 대한 것은 함수형 프로그래밍 자체의 장점이라기보다는 순수 함수(Pure Functions)를 사용함으로써 자연스럽게 따라오는 장점에 가깝기 때문에, 여기에 대한 이야기는 추후 순수 함수에 대한 설명을 할 때 다시 이야기하도록 하고, 이번 포스팅에서는 높은 수준의 추상화함수 단위의 재사용라는 키워드에 대해 초점을 맞춰서 이야기해보려고 한다.

    높은 수준의 추상화

    기본적으로 함수형 프로그래밍은 선언형 프로그래밍의 특성을 함수들의 조합을 사용하여 구현하는 패러다임이고, 선언형 프로그래밍의 대표적인 장점 중 하나가 바로 명령형 프로그래밍이나 객체지향 프로그래밍에서 사용하는 방법들보다 높은 수준의 추상화를 통해 개발자가 문제 해결에만 집중할 수 있게 해준다는 점이다.

    선언형 프로그래밍이 제공한다는 높은 수준의 추상화라는 것이 정확히 무엇을 의미하는 것이고 어떤 장단점이 있는지 이해하기 위해 먼저 추상화에 대한 개념적인 정리가 먼저 필요할 것 같다.

    추상화의 정확한 개념

    아마 객체지향 프로그래밍을 알고 있는 분들이라면 추상화가 현실에 존재하는 무언가의 특징을 뽑아내어 정리하는 행위라고 이야기할지도 모르겠다. 뭐 이것도 틀린 말은 아니고, 객체지향 프로그래밍을 처음 공부할 때는 이렇게 이해하는 것이 더 편하기도 하다.

    하지만 사실 추상화라는 단어의 근본적인 의미는 단순히 객체의 특징을 정리하여 클래스를 정의하는 것보다 훨씬 넓은 범위의 개념이다.

    추상화의 근본적인 의미는 어떤 작업을 수행할 때 그 이면에 존재하는 복잡한 것들을 간단한 것처럼 보이게 만들어주는 것들에 가깝다. 객체지향 프로그래밍에서 객체를 추상화하여 클래스를 정의하는 행위 또한 근본적으로는 이 정의에 부합한다.

    자, 이름을 가지고 있고 자신의 이름을 말할 수 있는 사람을 추상화한 Human 클래스를 사용하여 객체를 생성하고 메소드를 사용한다고 생각해보자.

    class Human {
      private name: string = '';
    
      constructor(name: string) {
        this.name = name;
      }
    
      public say(): string {
        return `Hello, I am ${this.name}.`;
      }
    }
    
    const me = new Human('Evan');
    console.log(me.say());
    Hello, I am Evan.

    여기서 중요한 사실이 하나 있다. 대부분의 경우 우리가 직접 클래스도 만들고 객체도 만들기 때문에 쉽게 지나치는 사실이지만, 사실 Human 클래스가 어떻게 구현되었는지 전혀 모르는 상태라도 이 클래스를 사용하여 객체를 생성하고 메소드를 사용하는데는 아무 문제가 없다는 것이다.

    그냥 이 클래스가 외부로 노출하는 기능이 무엇인지만 알고 있으면 객체를 생성하고 메소드를 호출하여 원하는 동작을 이끌어낼 수 있다.

    interface {
      constructor: (string) => Human;
      say: () => string;
    }

    객체지향 프로그래밍에서는 사용자에게 높은 수준의 추상화를 제공하기 위해 public, private과 같은 접근 제한자를 사용하여 클래스 외부로 노출시키고 싶은 것만 노출시키는 캡슐화라는 기법을 사용한다.

    또한 클래스 외에 일반적으로 우리가 사용하는 라이브러리들도 일종의 추상화된 모듈이라고 할 수 있다.

    만약 우리가 ReactRxJS와 같은 라이브러리를 사용할 때 해당 라이브러리의 구현체를 전부 파악해야만 사용할 수 있다면 굉장히 힘들것이다. 그러나 우리는 라이브러리의 구현 소스를 일일히 파악하지 않더라도 공식 문서를 통해 기능을 파악하기만 한다면 일단 사용하는 데는 별 지장이 없다. (잘 쓰려면 봐야한다는 게 함정)

    그리고 우리가 프로그램을 작성할 때 유용하게 사용하고 있는 OS API나 브라우저 API와 같은 API들도 일종의 추상화된 기능 리스트이다. 이런 추상화된 기능 리스트가 있기에 우리는 어떤 API가 어떤 동작을 하는 지만 알고 있다면, 로우 레벨의 동작을 직접 다루지 않더라도 컴퓨터에게 편하게 명령을 내릴 수 있는 것이다.

    추상화된 OS API만 안다면 하드웨어 구조를 모르더라도 프로그램을 만들 수 있다

    만약 개발자가 아닌 분이라면 여러분이 평소에 사용하는 일반적인 프로그램을 생각해보면 된다.

    예를 들어 여러분이 포토샵을 사용하여 컬러 사진을 흑백 사진으로 변경한다고 생각해보자. 포토샵은 이미지 프로세싱이라는 복잡한 연산을 수행하는 프로그램이지만, 우리는 포토샵이 제공하는 여러가지 기능들을 사용하여 사진 보정이라는 행위에만 집중할 수 있다.

    실제로 컬러 사진을 흑백 사진으로 변경할 때는 행렬로 이루어진 사진의 픽셀 데이터를 순회하며 RGB 값의 평균을 내거나하는 등의 과정을 수행해야한다. 하지만 그런 복잡하고 귀찮은 과정이 추상화되어있기 때문에 사용자는 그저 포토샵이 외부로 노출해준 기능인 Image > Adjustments > Desaturate을 사용하면 되는 것이다.

    즉, 추상화란 복잡한 무언가에서 핵심적인 개념이나 기능을 간추려내어 단순하게 만드는 것을 의미하며, 추상화가 잘 되어있는 프로그램을 사용하는 사용자는 자신과 맞닿은 추상 계층 밑에 무엇이 있고 어떻게 작동하는지 모르더라도 해당 기능을 편하게 사용할 수 있다.

    추상화의 수준이 높으면 좋은 건가요?

    방금 알아본 바와 같이 추상화란, 복잡한 무언가를 단순해보이도록 만들어주는 행위를 의미한다. 즉, 추상화의 수준이 높다는 것은 복잡한 것을 단순해보이도록 만드는 행위 자체의 수준이 높아졌다는 것을 의미하는 것이다.

    그런 의미에서 보면 확실히 추상화의 수준이 높을 수록 사용자가 편하긴 하다. 이 편하다는 의미가 잘 감이 안오시는 분들을 위해 더 쉽게 이야기해보면, 우리가 사용하는 프로그래밍 언어를 예시로 들어볼 수 있겠다.

    우리는 프로그램을 만들 때 01로 이루어진 기계어를 사용할 수도 있고, 어셈블리를 사용할 수도, 자연어에 가까운 고급 언어인 자바를 사용할 수도 있다.

    만약 길가는 개발자를 붙잡고 “기계어로 코딩할래? 어셈블리로 코딩할래?”라고 묻는다면 기계어로 코딩하고 싶다는 변태는 거의 없을 것이다. 기계어는 거의 추상화되지 않은 날 것이나 마찬가지이기 때문에 사람이 이해하기 너무 어렵기 때문이다.

    또한 “어셈블리로 코딩할래? 자바로 코딩할래?”라고 물어본다면 어셈블리라고 대답하는 변태 또한 그렇게 많지 않을 것이다. 이 경우에는 어셈블리가 자바에 비해 추상화 수준이 매우 낮기 때문에, 자바로 코딩하는 것이 사람에게는 더 편한 것이다. 이게 바로 대표적인 추상화 수준의 차이이다.

    어셈블리로 거대한 프로그램을 작성하게 되면 개발자가 신경써줘야하는 것이 너무나도 많지만, 자바를 사용하면 for, if 등의 문법과 다양한 API를 사용하고 조합하여 프로그램을 작성함으로써 어셈블리보다 좀 더 편하게 프로그래밍 할 수 있다. 메모리에 값을 할당하고 레지스터로 모았다가 다시 빼내고하는 잡다한 일은 JRE(Java Runtime)가 다 알아서 해줄 것이기 때문이다.

    이렇게 예전의 기술보다 높은 수준의 추상화를 제공하며 사용자에게 편의를 안겨주는 경우는 함수형 프로그래밍이 처음이 아니고, 오히려 컴퓨터 공학 역사에서 굉장히 빈번하게 발생되었던 일이다. 이렇게 기술이 발전하며 추상화 수준을 높혀감으로써 점점 더 복잡한 프로그램을 만들 수 있게 되는 것이다.

    그러나 추상화의 수준이 높다는 것이 장점만 있는 것은 아니다. 이런 높은 추상화를 장점으로 내세우는 기술이 발표되면 높은 확률로 “성능이 안 좋다”, “이렇게 추상화해버리면 최적화는 어떻게 하냐”와 같은 논란에 휩싸이게 되는데, 이런 논란에 휩쓸렸던 대표적인 친구들이 바로 자바가비지 컬렉터이다. (그리고 어셈블리가 있다…)

    von neumann 존 폰 노이만 (1903 ~ 1957)
    괜히 어셈블리 같은 걸 만들어서 컴퓨터 성능 낭비하지 말라고 하신 분

    자바의 경우에는 바이트 코드와 JVM(Java Virtual Machine)이라는 개념을 사용하여 한번만 코드를 작성해도 모든 OS에서 동작하는 프로그램을 작성할 수 있다는 점이 굉장한 강점이었다. OS나 CPU에 종속되어있던 기존의 프로그래밍 언어들에 비해 추상화 수준이 높아진 것이다. 그러나 처음 자바가 나왔을 당시에는 C에 비해서 너무 느려서 못 써먹을 물건이라고 상당히 많이 까였다.

    또한 가비지 컬렉터도 개발자가 일일히 메모리를 할당하고 해제하지 않아도 되는 편리함을 제공하지만 GC가 객체의 메모리 해제 시점을 매번 추적하고 있어야하는 성능 문제나, 개발자가 객체가 메모리에서 해제되는 시점을 정확히 알기 어렵다던가, 참조 횟수 계산 방식을 사용할 때 순환 참조 객체를 해제하지 못하는 등 문제들이 여전히 존재하기 때문에 늘 완벽하게 동작하지는 않는다.

    하지만 그렇다고 자바나 가비지 컬렉터나 자바를 사용할 수 있는 환경에서 굳이 C를 사용하고 수동으로 메모리를 관리하고 싶어하는 사람이 많지는 않을 것이다. 애초에 이런 높은 수준의 추상화를 제공하는 기술이 가지는 성능 상의 단점을 머신이 어느 정도 커버할 수 있기 때문에 사람들이 많이 사용하는 것이기도 하다.

    함수형 프로그래밍도 높은 수준의 추상화를 지향하는 패러다임인 만큼 어느 정도 성능 면에서 불리한 점이 있긴 하다. 사실 함수 단위로 프로그램을 추상화하는 개념은 1958년에 LISP가 발표되었을 때부터 시작되었지만, 당시에는 아마 이런 개념이 사치로 느껴졌을 것이다. 메모리가 너무 부족해서 당장 작업 하나 하기도 빡센 데 추상화는 무슨 추상화란 말인가.

    그러나 요즘 머신의 성능은 함수형 프로그래밍이 지향하는 추상화 레벨을 충분히 커버할 수 있을 정도로 예전에 비해 많이 좋아졌기 때문에 성능에 관한 문제는 크게 중요하지 않다고 느껴진다.

    그리고 만약 성능이 진짜 중요한 프로그램을 작성할 때는 굳이 함수형 프로그래밍을 고집하지 않아도 된다. 애초에 이런 패러다임은 어떤 정답이라고 할 게 없기 때문이다. 지금도 최적화를 위해 C나 어셈블리를 사용하는 경우가 있는 것처럼, 함수형 프로그래밍도 상황에 따라 적재적소에 잘 사용하면 된다.

    그럼 함수형 프로그래밍이 제공하는 높은 수준의 추상화가 우리에게 어떤 형태로 나타나는지 알아보기 위해, 기존에 우리에게 익숙한 패러다임인 명령형 프로그래밍과 함수형 프로그래밍의 상위 개념인 선언형 프로그래밍을 한번 비교해보도록 하자.

    명령형과 선언형은 사고 방식이 다르다

    명령형 프로그래밍은 문제를 어떻게 해결해야 하는지 컴퓨터에게 명시적으로 명령을 내리는 방법을 의미하고, 선언형 프로그래밍은 무엇을 해결할 것인지에 보다 집중하고 어떻게 문제를 해결하는 지에 대해서는 컴퓨터에게 위임하는 방법을 의미한다.

    처음 프로그래밍이라는 개념이 등장했을 때부터 비교적 최근까지도 우리는 컴퓨터에게 명시적으로 명령을 내리는 방법인 명령형 프로그래밍을 주로 사용해왔지만, 함수형 프로그래밍은 문제를 해결하는 방법에 더 집중하고 사소한 작업은 컴퓨터에게 넘겨버리는 선언형 프로그래밍의 일종이다.

    이처럼 컴퓨터에게 사소한 작업들을 위임해버리는 패러다임의 특성 상, 선언형 프로그래밍에는 필연적으로 높은 수준의 추상화라는 키워드가 따라오는 것이다. 추상화 수준이 낮다면 저 사소한 작업들을 개발자가 일일히 다 컨트롤해줘야한다는 이야기니 말이다.

    자, 그럼 명령형 프로그래밍과 선언형 프로그래밍을 사용하여 문제를 해결하는 과정을 비교해보며 이 두 패러다임 간의 추상화 수준의 차이에 대해서 한번 살펴보도록 하자. 우리가 해결해야하는 문제는 바로 이것이다.

    const arr = ['evan', 'joel', 'mina', ''];

    배열을 순회하며 빈 문자열을 걸러내고, 각 원소의 첫 글자를 대문자로 변경해라.

    어떻게 문제를 해결할까? (명령형 프로그래밍)

    명령형 프로그래밍은 말 그대로 컴퓨터에게 어떻게 작업을 수행할 지에 대한 자세한 명령을 내리는 것이다. 더 정확히 말하자면, 어떻게 문제를 해결하는지 일일히 명령을 내리면서 내가 원하는 결과를 만들어나가는 방식이라고 할 수 있다.

    사실 오늘날 현업에서 일하고 있는 대부분의 개발자는 처음 프로그래밍을 접할 때 전통적인 패러다임인 명령형 프로그래밍으로 공부를 시작했던 경우가 많기 때문에, 필자를 포함한 많은 개발자들에게 익숙한 방법이라고도 할 수 있다.

    대부분의 개발자는 이런 문제를 만났을 때, for문과 같은 반복문을 사용하여 순차적으로 배열의 원소를 탐색하고 작업하는 코드를 떠올릴 것이다.

    const newArr = [];
    for (let i = 0; i < arr.length; i++) {
      if (arr[i].length !== 0) {
        newArr.push(arr[i].charAt(0).toUpperCase() + arr[i].substring(1));
      }
    }

    이렇게 for문을 사용하여 특정 행위를 반복하는 코드를 작성하는 일은 눈 감고도 작성할 수 있을 정도로 개발자들에게 익숙한 로직이지만, 너무 익숙한 나머지 이 짧은 코드 안에도 많은 명령이 들어가 있다는 사실을 간과하고는 한다.

    필자가 위 코드를 작성할 때 필자가 떠올렸던 생각을 대충 정리해보자면 이런 느낌이다.

    1. 변수 i0으로 초기화
    2. iarr 배열의 길이보다 작다면 구문을 반복 실행
    3. for문 내부의 코드의 실행이 종료될 때마다 i1씩 더함
    4. arr 배열의 i 번째 원소에 접근
    5. 만약 원소의 길이가 0이 아니라면 원소의 첫 번째 글자를 대문자로 변경
    6. 이렇게 합쳐진 문자열을 newArr 배열에 삽입

    필자가 코드를 작성할 때 생각했던 사고의 흐름은 필자가 컴퓨터에게 내려야하는 명령과 정확하게 매칭된다.

    즉, 필자는 i라는 상태를 직접 관리해야하며, 매 루프 때마다 i에 1을 더 해가면서 배열의 어느 인덱스까지 탐색했는지도 신경써줘야 하는 상황인 것이다. 게다가 배열의 원소에 접근할 때도 i를 사용하여 직접 접근 명령을 내려야하고 이 원소가 빈 문자열인지 아닌지 여부도 검사해줘야한다.

    이렇게 컴퓨터에게 일일히 명령을 내려서 자신이 원하는 결과를 만들어가는 과정을 통해 프로그램을 작성하는 방식을 명령형 프로그래밍이라고 하는 것이다.

    자 이 쯤에서 우리가 해결해야하는 문제를 다시 한번 보자.

    배열을 순회하며 빈 문자열을 걸러내고, 각 원소의 첫 글자를 대문자로 변경해라.

    저 명령들을 쭉 읽어보고 바로 이 문제를 떠올릴 수 있을까? 물론 이 문제는 굉장히 간단한 문제이기 때문에 바로 알아챌 수도 있겠지만, 이 문제보다 더 복잡한 문제라면 아마 몇 번은 읽어보고 그림도 그려봐야하지 않을까?

    즉, 명령형 프로그래밍은 사람이 생각하는 방식보다 컴퓨터가 생각하는 방식에 가깝기 때문에 그리 인간 친화적인 방식은 아니다. 그렇기 때문에 개발자들은 알고리즘 문제 풀이 등을 통해 이런 방식의 사고를 하는 것을 꾸준히 연습하기도 한다.

    자, 그럼 선언형 프로그래밍으로 같은 일을 수행하는 코드를 작성하면 어떻게 바뀔까?

    무엇을 해결할까? (선언형 프로그래밍)

    반면 선언형 프로그래밍은 컴퓨터에게 나 이거 할거야!라고 알려주기만 하는 느낌이다. 잡다한 일 처리는 컴퓨터가 알아서 하도록 위임해버리고 개발자는 문제 해결을 위해 무엇을 할 지만 신경쓰면된다.

    즉, 함수형 프로그래밍은 이런 선언형 프로그래밍의 특성을 함수를 통해 구현하게되는 패러다임이라고 할 수 있는 것이다. 그럼 방금 전과 같은 문제를 선언형 프로그래밍을 사용하여 구현해보도록 하자.

    배열을 순회하며 빈 문자열을 걸러내고, 각 원소의 첫 글자를 대문자로 변경해라.

    function convert(s) {
      return s.charAt(0).toUpperCase() + s.substring(1);
    }
    
    const newArr2 = arr.filter(v => v.length !== 0).map(v => convert(v));

    이 코드에서 사용된 filter 메소드는 배열을 순회하며 콜백 함수의 반환 값이 참이 아닌 원소를 걸러낸 새로운 배열을 생성 후 반환하는 역할을, map 메소드는 배열을 순회하며 콜백 함수의 반환 값을 사용한 새로운 배열을 생성한다.

    이 코드를 작성할 때 필자의 사고의 흐름은 다음과 같았다.

    1. 인자로 받은 문자열의 첫 글자만 대문자로 변경하는 함수를 선언
    2. arr 배열에서 원소의 길이가 0이 아닌 것들을 걸러냄
    3. 걸러진 배열을 순회하면서 1번에서 선언한 함수를 사용하여 원소의 첫글자를 대문자로 변경

    분명 같은 작업을 수행하는 코드를 작성했지만 사고의 흐름이 많이 다른 것을 볼 수 있다. 물론 내부적으로는 아까 명령형 프로그래밍을 사용할 때의 필자 사고 방식과 유사한 방법으로 처리되겠지만, 적어도 필자가 자잘한 인덱스 변수의 선언이나 관리에 대해서 생각할 필요는 없어졌다.

    그리고 더 중요한 점은 필자가 해결해야하는 문제와 사고 방식의 흐름이 비슷해졌다는 것이다.

    배열을 순회하며 빈 문자열을 걸러내고(filter), 각 원소의 첫 글자를 대문자로 변경해라(convert + map).

    이렇게 선언형 프로그래밍은 개발자가 문제의 본질에 집중할 수 있게 만드는 것에 초점을 맞추고 있고, 결국 함수형 프로그래밍은 이런 선언형 프로그래밍의 패러다임을 함수를 사용하여 구현하는 것이다.

    하지만 이렇게 사소한 제어를 컴퓨터에게 맡겨버린다는 것이 장점만 있는 것은 아니다. 여기서 발생하는 대표적인 트레이드오프는 바로 추상화를 설명할 때 이야기했던 성능이다.

    필자가 명령형 프로그래밍을 사용하여 이 작업들을 수행한 경우 필자는 단 한 번의 루프 안에서 여러가지 작업을 수행했지만, 선언형 프로그래밍으로 작성한 예시는 filtermap 메소드가 각각 전체 배열을 순회하기 때문에 성능 상 손해가 발생할 수 있는 것이다.

    아무리 요즘 머신의 성능이 좋아져서 저 정도의 추상화 레벨을 커버할 수 있다지만 만약 탐색해야하는 원소의 개수가 10억개라면 이런 사소한 차이가 전체 프로그램의 성능에 지대한 영향을 끼칠 수도 있다. 그래서 필자가 함수형 프로그래밍도 상황에 따라 적재적소에 잘 사용해야한다고 이야기 했던 것이다.

    객체로 이루어진 프로그램과 함수로 이루어진 프로그램

    자, 여기까지 명령형 프로그래밍을 사용할 때와 선언형 프로그래밍을 사용할 때 발생하는 사고의 차이에 대해 알아보았다.

    위에서 이야기했듯이 함수형 프로그래밍은 선언형 프로그래밍이라는 패러다임을 함수들의 집합과 연산으로 구현한 것이기 때문에 명령형 프로그래밍과도 많이 비교당하지만, 프로그램을 객체들의 집합과 관계로 정의하는 객체지향 프로그래밍과도 많이 비교를 당하게 된다.

    명령형 프로그래밍과 선언형 프로그래밍을 다루며 이야기했던 것과 마찬가지로, 객체지향 프로그래밍과 함수형 프로그래밍 간에도 어떠한 우위는 없다. 다만 서로의 차이에 따른 각기 다른 장단점이 있을 뿐이다.

    그렇다면 객체지향 프로그래밍을 사용하지 않고 함수형 프로그래밍을 사용함으로써 가져갈 수 있는 장점은 무엇일까?

    물론 여기에도 여러가지 장단점이 있겠지만 필자는 개인적으로 함수 단위의 코드 재사용이 더욱 쉬워진다는 것이 가장 큰 장점이라고 생각한다.

    더 작게 쪼개어 생각할 수 있다

    기존의 객체지향 프로그래밍에 익숙한 개발자는 어떤 프로그램의 요구 사항을 들었을 때 머릿 속에 객체의 설계도가 떠오르게 된다. 그리고 이러한 객체들의 관계를 정의하여 거대한 프로그램을 만들기 위한 기반을 다져나간다.

    객체지향패턴에서 객체란 멤버 변수(상태)와 메소드(행위)로 이루어진, 프로그램을 구성하는 최소 단위이기 때문에, 객체지향패턴을 사용할 때 우리는 이 객체보다 더 작은 무언가를 사용하여 프로그램을 설계할 수 없다.

    우리가 객체지향패턴을 사용할 때는 객체를 생성하기 위해, 객체를 추상화하여 일종의 설계 도면 역할을 하는 클래스를 사용하여 객체가 가질 상태와 행위를 정의하게 된다.

    class Queue<T> {
      private queue: T[] = []; // 내부 상태
    
      // 메소드로 표현된 큐의 행위
      public enqueue(value: T): T[] {
        this.queue[this.queue.length] = value;
        return this.queue;
      }
      public dequeue(): T {
        const head = this.queue[0];
        const length = this.queue.length;
        for (let i = 0; i < length - 1; i++) {
          this.queue[i] = this.queue[i + 1];
        }
        this.queue.length = length - 1;
        return head;
      }
    }
    const myQueue = new Queue<number>();
    myQueue.enqueue(1);

    Queue 클래스는 하나의 배열을 내부 상태로 가지고 이 배열에 원소를 추가하고 제거하는 큐의 기능을 구현한 클래스이다. 이때 이 클래스의 메소드를 통하지않고 클래스의 내부 상태인 queue 배열을 외부에서 맘대로 접근해서 수정하는 행위를 막기위해 private 접근제한자를 사용하여 외부에서의 접근을 막아주었다.

    사실 이 정도만 해도 일반적인 프로그래밍을 할 때 딱히 불편하거나 어려운 점은 없다. 하지만 여기에서 필자가 Stack이라는 클래스를 새로 만들면 어떻게 될까?

    간단히 생각해보면 큐의 동작인 dequeue는 그대로 사용하고 enqueue의 동작만 반대로 바꿔줘도 훌륭한 스택이 구현될 것 같다. 그러나 이미 클래스의 메소드로 구현되어버린 dequeue를 다른 클래스에서 가져다 자신의 메소드처럼 사용할 수 있는 방법은 상속 밖에 없는데, 그렇다고 큐를 상속한 스택을 만들어버리면 객체 간의 관계가 꼬이기 시작할 것이다.

    즉, 객체지향 프로그래밍에서 어떤 존재를 추상화하여 표현하고 재사용할 수 있는 최소 단위는 객체이기 때문에 그 이상 작게 쪼개기 힘들어지는 것이다. 하지만 함수형 프로그래밍에서는 객체로 표현된 큐나 스택이 아닌, 이 존재들이 자료를 다루는 동작에만 집중한다.

    1. 배열의 꼬리에 원소를 추가하는 동작 (push)
    2. 배열의 머리에서 원소를 빼오고 남은 원소를 앞으로 한 칸씩 당겨주는 동작 (shift)

    이처럼 자바스크립트의 빌트인 메소드인 pushshift를 사용하면 굳이 클래스나 객체를 선언하거나, 필자가 위에서 구현한 것처럼 명령형 프로그래밍으로 큐의 동작을 구구절절 작성하지 않더라도 큐의 동작을 완벽하게 구현할 수 있다. 또한 추가적으로 스택을 구현하고 싶다면, shift 메소드를 사용하여 원소를 빼오고 push 메소드 대신 unshift 메소드를 사용하여 원소를 추가하면 된다.

    const queue: number[] = [1, 2, 3];
    queue.push(4);
    const head = queue.shift();
    
    const stack: number[] = [1, 2, 3];
    stack.unshift(0);
    stack.shift();

    굉장히 당연하다고 느껴지겠지만, 이것이 객체 단위로 요소를 구성하는 것과 함수 단위로 요소를 구성하였을때 누릴 수 있는 근본적인 장점이다. 어떤 요소를 재사용할 수 있는 범위가 넓어지는 것이다.

    다만 이렇게 작은 단위의 함수를 넓은 범위로 재사용하게 되면 프로그램의 복잡성이 빠르게 증가하기 때문에 이를 방어하기 위해서 함수가 함수 외부에 있는 값을 수정하면 안된다거나, 동일한 인자를 받은 함수는 동일한 값을 반환해야 한다거나 하는 몇 가지 제약 조건이 필요하게 된다.

    이런 제약을 가진 함수를 바로 순수 함수(Pure Functions)라고 부르는 것이다. 근데 보통 순수 함수라는 개념을 설명하다보면 방금 필자가 이야기한 것처럼 “몇 가지 제약 조건이 있는 함수”라는 개념으로 설명하게 되는데, 이거 근본적으로 그냥 수학에서 사용하는 함수랑 거의 동일한 개념이다.

    컴퓨터 공학의 함수는 수학의 함수에서 유래되기는 했지만, 이 두 개의 학문이 추구하는 방향과 발전되어온 과정이 꽤나 다르기 때문에 함수라는 개념도 이름만 똑같을 뿐, 사실은 서로 다른 부분이 많다. 즉, 순수 함수는 수학에서 이야기했던 함수의 본질 그 자체로 회귀하여 단순함을 확보하자는 개념에서 시작하는 것이다.

    그런 이유로 필자는 개인적으로 순수 함수를 프로그래밍적인 관점에서 접근하여 이해하는 것보다 수학적인 관점에서 접근하여 이해하는 것이 더 쉽고 빠르다고 생각한다.

    일단 이 포스팅의 주제는 함수형 프로그래밍보다는 함수로 사고하는 방식의 장단점과 특징에 대한 이야기이므로, 순수 함수나 불변성에 대한 이야기는 다음에 함수형 프로그래밍의 특징과 스킬을 설명할 때 조금 더 자세히 이야기해보도록 하겠다. (이것도 은근히 꿀잼이다)

    마치며

    필자는 사실 얼마 전까지만 해도 함수형 프로그래밍에 대한 관심이 깊은 편이 아니었다. 명령형 프로그래밍과 객체지향적인 사고만으로도 대부분의 어플리케이션은 충분히 설계할 수 있다고 생각했기 때문이다.

    하지만 이건 필자가 학교에서 처음 배웠던 패러다임이 명령형 프로그래밍과 객체지향 프로그래밍이었기 때문에 몸에 더 익어서 그랬던 것이다. 사실 어떤 패러다임을 자유자재로 다룰 수 있다는 것은 해당 패러다임이 요구하는 사고 방식에 이미 익숙해졌다는 이야기이기 때문에, 새로운 설계 패턴이나 패러다임을 익힌다는 것은 이런 기존의 사고 방식을 깨야하는 상황이기도 하다.

    개인적으로 커링이나 고계 함수, 모나드 같은 것들을 익히는 것보다 이런 사고 방식을 바꾸는 것이 훨씬 더 어렵다는 생각을 한다. 솔직히 말하면 어느 정도 함수형 프로그래밍에 대해 공부하고 마음이 열린 상태인 지금도 어떤 요구사항을 들었을 때 명령형과 객체지향을 먼저 떠올리고 있기도 하고 말이다.

    물론 포스팅에서 여러 번 이야기했듯이 함수형 프로그래밍이 객체지향 프로그래밍이나 명령형 프로그래밍을 대체하는 패러다임도 아닐 뿐더러, 이 패러다임들간에는 어떠한 우위도 없다. 각자의 장단점만 있을 뿐이고 상황에 따라 적당히 골라쓰면 되는 것이다.

    게다가 아직까지 많은 라이브러리들이 객체지향적인 개념으로 설계되었고, 사용 방법 또한 대부분 멤버 변수와 메소드를 내장한 객체를 생성하여 사용하는 방법을 채택하고 있기 때문에, 이런 라이브러리와 내 프로그램을 연동하려면 프로그램의 전체적인 아키텍쳐 또한 객체지향으로 설계하는 것이 편하다는 점도 무시할 수는 없다.

    일례로 자바스크립트 진형에서 상태관리 라이브러리로 자주 사용하는 Redux의 경우 순수 함수와 불변성을 기반으로 하여 상태 변경을 감지하게 되는데, 필자는 Web Audio API를 사용한 토이 프로젝트를 진행할 때 AudioNode 객체들의 상태를 Redux로 관리하는 것에 상당히 애를 먹고 있기도 하다. (이건 그냥 필자가 Redux를 못 쓰는 걸 수도…)

    하지만 함수형 프로그래밍이 가져다주는 높은 수준의 추상화나 더 작은 수준의 코드 재사용과 같은 장점들은 분명히 복잡한 프로그램을 작성할 때 크게 도움이 되는 것들이다. 결국 함수형 프로그래밍을 잘 사용한다는 것은 단순히 이 패러다임을 깊게 이해하는 것보다는 이 패러다임이 어떤 상황에 적합한지 판단할 수 있는 능력 또한 포함하는 이야기라고 생각한다.

    다음 포스팅에서는 함수형 프로그래밍에서 빼놓을 수 없는 키워드인 순수 함수, 불변성, 지연 평가와 같은 개념들과 프로그램의 복잡도를 낮추기 위해 사용하는 다양한 스킬들을 소개하는 포스팅을 작성할 예정이다.

    이상으로 기존의 사고 방식을 깨부수는 함수형 사고 포스팅을 마친다.

    Evan Moon

    🐢 거북이처럼 살자

    개발을 잘하기 위해서가 아닌 개발을 즐기기 위해 노력하는 개발자입니다. 사소한 생각 정리부터 튜토리얼, 삽질기 정도를 주로 끄적이고 있습니다.