우리는 함수형, 객체지향, 명령형 패러다임을 제공하는 멀티패러다임 언어를 효율적으로 활용하는 법을 배워야 한다. - 마이크 루키데스

좋은 계기로 마플코퍼레이션 CTO 유인동님이 집필하신 멀티패러다임 프로그래밍을 먼저 읽어보고 추천사까지 남길 수 있었습니다. 좋은 책에 추천사까지 남길 수 있어 영광이라 생각합니다.

이번 글에서는 이 책을 읽고 간단한 요약과 제 개인적인 소감도 남겨보려 합니다.

멀티패러다임 프로그래밍은 왜 등장했는가?

개발자는 소프트웨어로 문제를 해결하는 사람입니다. 그리고 비즈니스 관점에선 소프트웨어를 통해 가치를 창출하는 사람입니다. 사실, 오늘날의 소프트웨어는 과도한 경쟁으로 인해 빠른 생산성이 없다면 금방 다른 소프트웨어에 대체될 수 있습니다. 그렇기에 소프트웨어는 비즈니스 관점에서 생산성을 높이는 것이 중요합니다.

생산성이 중요해지며 DX(개발자 경험)라는 관점이 부각되기 시작했습니다. 더이상 철학만을 따질 수 없는 상황이 되었고 좋은건 빠르게 도입해야 하는 시대가 온 것입니다. 멀티패러다임은 그런 관점에서 탄생했다고 생각합니다. 가장 많이 언급되는 패러다임인 객체지향과 함수형은 각각 장단점이 존재합니다. 객체지향의 모델링⋅캡슐화⋅메시지, 함수형의 불변성⋅순수성은 대표적인 장점입니다.

멀티패러다임 프로그래밍은 각 패러다임의 장점만을 사용할 수 있게 해줍니다. 예를 들면, 데이터를 모델링하거나 관련한 것을 모아 캡슐화 할 때는 객체지향 관점, 로직의 안정성을 위해 순수 함수, 불변 데이터를 사용할 때는 함수형을 사용하는 방식으로 멀티패러다임 프로그래밍을 할 수 있습니다.

즉, 정리하자면 멀티패러다임 프로그래밍은 생산성이 중요한 시대의 요구에 따라 등장했다고 생각합니다.

멀티패러다임의 본질

사실 프로그래밍 패러다임이라는 것은 매우 모호합니다. 큰 틀은 있지만 명확한 규칙은 없습니다. 예를 들어, 객체지향 프로그래밍은 캡슐화 관점으로 보느냐, 메시징 관점으로 보느냐에 따라 다르게 해석될 수 있습니다. 개인적으로는 멀티패러다임도 여러 해석이 존재할 수 있다고 생각합니다.

패러다임의 철학을 중요하게 생각하지 않는다면 단순히 일급 함수가 존재하며 map, filter, reduce을 사용할 수 있다면 함수형 프로그래밍이라 여길 수도 있습니다. 거기에 클래스를 한 스푼 끼얹으면 멀티패러다임 프로그래밍의 완성이라 생각할 수도 있습니다. 물론 이렇게 말하면 많은 분들께 혼나겠죠.

제가 말하고 싶었던 것은 결국 여러 해석이 존재한다는 것입니다. 그리고 그 해석은 각자 중요하게 여기는 것이 무엇이냐에 따라 다릅니다.

예를 들어, 이 글을 통해 리뷰 중인 '멀티패러다임 프로그래밍'은 객체지향, 함수형, 명령형 패러다임의 교차점에 대해 주목합니다. 조금 더 구체적으로 반복자 패턴과 일급 함수, 제너레이터의 조합을 통해 좀 더 효과적인 리스트 프로세싱을 할 수 있습니다. 그래서 이러한 방법은 리스트 프로세싱의 특징인 안정성과 가독성을 더욱 잘 살린 멀티패러다임 프로그래밍이라고 할 수 있습니다.

반면, 최근에 읽었던 데이터 지향 프로그래밍에서는 데이터와 코드를 분리하면서 시작합니다. 이 과정에서 데이터 파이프라인을 구성하여 각 단계마다 검증하는 방식으로 안정성을 보장합니다. 제가 느낀점으로, 각 단계에 대한 절차를 만든다는 점에서 프로시저적 프로그래밍 사고가 포함되고 불변으로 처리한다는 관점에서 함수형 프로그래밍 사고가 포함된다고 생각합니다. 따라서 이 방법은 절차에는 제약을, 데이터에는 자유를 준 멀티패러다임 프로그래밍이라 할 수 있습니다.

재밌게도 이 두 책은 모두 생산성을 강조합니다. 결국 실용을 추구하며 각자의 방식으로 생산성을 올리는 것, 그것이 멀티패러다임의 본질이라 생각합니다.

리스트 프로세싱

저는 개발 방법론 사이엔 우열이 없다고 생각합니다. 사람들이 선호하지 않는 언어에도, 그 언어의 철학과 아름다움이 살아있다고 믿습니다. 개발을 사랑하는 사람으로서 그 다양성과 깊이를 맛볼 수 있다는 건 정말 큰 행복입니다. 그리고 이번 책을 읽으면서 새로운 행복을 얻을 수 있었습니다.

이번에 느낄 수 있는 맛은 바로 리스트 프로세싱입니다. 리스트를 흐름처럼 다루고, 데이터를 선언적으로 변형하며, 신뢰할 수 있는 코드를 만들어내는 그 철학은 제가 오랫동안 주장해온, 좋은 코드를 만드는 방법과도 깊이 연결되어 있습니다.

// JavaScript
const numbers = [1, 2, 3, 4, 5];
reduce(
  map(
    filter(numbers, x => x % 2 === 0),
    x => x * x
  ),
  (acc, x) => acc + x,
  0
);

위 코드는 주어진 숫자 중 짝수만 필터링하고 제곱을 한 후 모두 더하는 코드입니다. 만약 함수만을 이용해서 로직을 처리하고 싶다면 위와 같이 작성할 수 있습니다. 하지만 이 코드는 가독성이 떨어집니다. 그래서 별도로 다음과 같이 pipe와 같은 함수를 만들어 가독성을 높일 수 있습니다.

// JavaScript
pipe(
  [1, 2, 3, 4, 5],
  filter(x => x % 2 === 0),
  map(x => x * x),
  reduce((acc, x) => acc + x, 0)
);

파이프 함수는 커링 함수를 받아 값을 연쇄적으로 처리하는 방식입니다. 이런 방식을 통해 가독성을 더 좋게 만들 수 있습니다. 이러한 파이프 함수 유용하므로 특정 언어에선 별도 연산자로 제공할 때도 있습니다.

# Elixir
1..5
|> Enum.filter(fn x -> rem(x, 2) == 0 end)
|> Enum.map(fn x -> x * x end)
|> Enum.reduce(0, fn acc, x -> acc + x end)

다만, 커링을 사용해야 한다는 점과 자동 완성 측면에서 불리한 점이 있습니다. 따라서 요즘 언어는 객체를 통한 메서드 체인을 통해 리스트 프로세싱을 지원합니다.

// JavaScript
const result = [1, 2, 3, 4, 5]
  .filter(x => x % 2 === 0)
  .map(x => x * x)
  .reduce((acc, x) => acc + x, 0);

console.log(result); // 20

요즘은 대부분의 언어가 이러한 방식을 지원합니다. 많은 개발자들에게 친근하고 자동 완성이 잘되므로 사용하기 쉬운 방법입니다. 이 방식은 객체와 함수의 순수성을 이용하므로 멀티패러다임 프로그래밍의 장점을 잘 살린 방법입니다. (물론 고차 함수의 순수성은 개발자가 보장해야 합니다)

가독성과 생산성 측면에서 함수의 추상화 레벨을 조정하여 선언적으로 만드는 것도 가능합니다.

// 표현식을 선언적으로 만든다면
function isEven(x) {
  return x % 2 === 0;
}

function square(x) {
  return x * x;
}

function sum(a, b) {
  return a + b;
}

const result = [1, 2, 3, 4, 5]
  .filter(isEven)
  .map(square)
  .reduce(sum, 0);

console.log(result); // 20

// 함수 자체를 선언적으로 만든다면
Array.prototype.filterEven = function () {
  return this.filter(x => x % 2 === 0);
}

Array.prototype.sumOfSquares = function () {
  return this
    .map(x => x * x)
    .reduce((acc, x) => acc + x, 0);
}

const result = [1, 2, 3, 4, 5]
  .filterEven()
  .sumOfSquares();

console.log(result); // 20

그러나 이렇게 매 함수마다 리스트를 처리하면 성능 이슈가 있을 수 있습니다. 따라서 지연 평가를 사용하여 최적화를 할 수 있습니다. 예를 들어, 책에서 소개하는 FxTS를 사용하면 손쉽게 지연 평가를 리스트 프로세싱에 적용할 수 있습니다.

// JavaScript
[1, 2, 3, 4, 5, ..., 100]
  .filter(x => x % 2 === 0)  // [2, 4, 6, ..., 100]
  .map(x => x * x) // [4, 16, 36, ..., 10000]
  .take(2) // [4, 16]

const result = fx([1, 2, 3, ..., 100])
  .filter(x => x % 2 === 0) // [2, 4]
  .map(x => x * x) // [4, 16]
  .take(2) // [4, 16]

이처럼 리스트 프로세싱은 가독성을 높이면서도 생산성을 높일 수 있는 방법입니다. 그리고 이 책은 멀티패러다임 관점에서 그 방법을 잘 설명하고 있습니다.

객체를 리스트 프로세싱 할 수는 없을까?

책에 제시된 개념은 아니지만 조금 색다른 관점으로 보면 Yegor Bugayenko라는 개발자는 순수 객체지향에서 리스트 프로세싱을 흉내내기위한 Composable Decorator라는 개념을 제시하기도 했습니다.

// Java
new Reduced(
  (acc, x) -> acc + x,
  new Mapped(
    x -> x * x,
    new Filtered(
      x -> x % 2 == 0,
      new IterableOf<>(1, 2, 3, 4, 5)
    )
  )
);

해당 방식은 각 기능에 대한 불변 객체를 만들어 조합하는 방식입니다. 객체기 때문에 스스로 상태를 가지고 있을 수 있고, 불변을 보장하도록 만들어 안전하게 사용하는 것도 가능합니다.

다만, 오른쪽 아래에서 위로 올라가는 방향으로 읽기 때문에 가독성엔 좋지 않아보입니다. 이런 문제로 비판점도 있습니다만, 그래도 새로운 방식을 제시했다는 점에서 의미가 있다고 생각합니다.

언어의 한계가 있기 때문에 Composable Decorator를 선형적으로 만드는건 어렵습니다. 그래도 흉내낸다면 다음과 같이 작성은 할 수 있습니다.

// Java
var result = new Pipe(
  new IterableOf<>(1, 2, 3, 4, 5),
  new Filtered(x -> x % 2 == 0),
  new Mapped(x -> x * x),
  new Reduced((acc, x) -> acc + x)
);

System.out.println(result.value); // 20

위 코드는 잘 만들면 실제로 동작하게 만들 수는 있습니다. 다만 타입 이슈가 있어 범용성이 떨어집니다. 따라서 다음과 같이 구현할 수도 있습니다.

Pipe<Void, Integer> pipe = new Pipe<>(new IterableOf<>(1, 2, 3, 4, 5))
    .then(new Filtered<Integer>(x -> x % 2 == 0))
    .then(new Mapped<Integer, String>(x -> x.toString()))
    .then(new Mapped<String, Integer>(s -> s.length()))
    .then(new Reduced<Integer>((acc, x) -> acc + x));

Integer result = pipe.execute();

System.out.println(result); // 2

그렇지만 이러면 메서드 체인과 뭐가 다른가.. 싶기도 합니다. 그래도 객체 자체가 동작이 되고 이러한 객체를 리스트 프로세싱한다는 점에서 또 다른 방식의 객체지향과 함수형의 조합이 아닐까 생각합니다.

마치며

멀티패러다임 프로그래밍은 유연합니다. 리스트 프로세싱은 선언적, 선형적, 신뢰적인 코드를 만들기 좋은 방법이지만 필수적으로 사용해야 하는 것은 아닙니다. 상태가 필요하다면 불변성을 과감히 깨는 것도 좋습니다.

자신의 팀에 맞는 방법을 찾아보세요. 그리고 그 방법이 효율적인 멀티패러다임 프로그래밍이라면 더욱 좋겠죠.

마지막으로 멀티패러다임 프로그래밍의 모든 저자 수익은 서울아산병원 어린이병원에 기부됩니다. 좋은 내용과 의도를 가진 책이니 한 번 읽어보시길 추천드립니다.