직접 사용하면서 느낀 함수형 프로그래밍의 장점

함수형 프로그래밍 진짜 좋은가?

함수형 프로그래밍에 대한 장점을 인터넷에 검색하면 나오는 내용은 “부작용(side effect)이 없다”, “디버깅이 쉽다”, “테스트하기 쉽다” 등의 내용을 볼 수 있습니다. 하지만 이러한 장점을 느끼기 위해서 1급 객체(First Class Object), 고차 함수(Higher-Order Function), 불변성(Immutabiliy) 등 알아야 할 개념이 많고, 기존 명령형 프로그래밍 언어(C, Java)를 사용하던 사람이라면 순수 함수를 쓰는 것 자체가 불편하게 느껴질 것입니다. 저 또한 함수형 프로그래밍을 처음 시작할 때 장점이 많다는 소문에 비해 실속이 없다는 생각을 많이 했었던 기억이 있었습니다. 하지만 꾸준히 함수형 프로그래밍에 대해 공부하고 재미를 느끼게 되면서 비로소 몇 가지 장점이라고 할 만한 부분을 알게 되었습니다. 동시에 그 장점이 위에서 말한 알아야 할 지식이 없어도 충분이 느낄 수 있다는 것을 알 수 있었습니다. 지금부터 제가 느낀 함수형 프로그래밍의 장점을 간단한 프로그래밍만 알면 이해할 수 있도록 최대한 쉽게 설명해보겠습니다.

이 글에서는 간단한 행렬 회전 예제를 통해서 함수형 프로그래밍이 기존 명령형 프로그래밍과 비교하여 어떤 차별점을 갖는지 설명합니다. 여기서부터 장점이 아닌 차별점이라고 표현하는데 그 이유는 이것들이 누군가에게는 장점으로 느껴지지 않을 수 있기 때문입니다.

두 가지 패러다임으로 예제 구현하기

간단한 행렬(2차원 배열) 회전 문제를 생각해봅시다. 우리에게 행렬이 하나 주어져 있고 이를 반시계방향으로 회전한 결과 행렬을 반환하는 프로그램을 각각 명령형 프로그래밍, 함수형 프로그래밍으로 나눠서 작성해보겠습니다. 언어는 둘 다 자바스크립트를 사용하였습니다. (브라우저의 개발자 도구 켜시면 돌려보실 수 있도록 자바스크립트로 작성하였습니다. ^^7)

명령형 프로그래밍으로 행렬(2차원 배열) 회전

const input = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]];

const는 제 취향일 뿐 var을 사용해도 무방합니다.

먼저 2차원 배열을 반시계 방향으로 회전해야 한다는 문제를 받았을 때, 기존에 명령형 프로그래밍 언어(C, Java 등)로 프로그래밍을 하신 분들이라면 2차원 배열이라는 단어를 보자마자 반복문을 두 번 사용해야겠다는 생각이 매우 빠르게 들 것입니다.

const input = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]];

const m = input.length // 가로 길이 m행
const n = input[0].length // 세로 길이 n행

var output = new Array(n).fill(0).map(() => new Array(m).fill(null)) // null로 채워진 결과 행렬

for (i = 0; i < m; i++) {
  for (j = 0; j < n; j++) {
    // something happen
  }
}

그렇다면 저 중첩 반복문 내부에는 어떤 코드가 들어가야 할까요? 중첩 반복문 내부에서는 회전하기 전인 input 배열의 내부 요소를 하나씩 뽑아서 결과 배열의 내부 요소를 하나씩 채워나가야합니다.

output[x][y] = input[i][j] // x = ???, y = ???

또한 여기서 input의 각 요소가 output의 요소에 대응되는 규칙이 있기 때문에 x, y는 i, j로 표현된다는 것을 알 수 있습니다. 따라서 이를 통해 2개의 방정식으로 얻을 수 있습니다. 그리고 a, b, c, d, e, f가 우리가 구해야 할 값이 될 것입니다.

  • x = ai + bj + c
  • y = di + ej + f

여기까지 이해가 되셨나요? 조금 어렵게 표현한 것 같지만 input 행렬 i행 j열의 요소가 output 행렬의 어떤 위치로 가는지 알아내야 한다는 의미입니다.

다행히 우리에게는 한 가지 힌트가 더 있습니다. 각 행렬의 꼭지점의 위치 이동은 짐작하기가 쉽다는 것입니다. 예를 들어 input 행렬 1행 1열의 값(왼쪽 위 꼭지점)은 output 행렬 n행 1열이 될 것입니다. 즉 (i, j) = (0, 0) 일 때 (x, y) = (n-1, 0) 로 정리해볼 수 있겠습니다. (1~n 행을 코드에서는 편의상 0~n-1행으로 표현하였습니다.)

그럼 이러한 꼭지점 4개를 위의 연립방정식에 대입하면 a, b, c, d, e, f 값을 얻을 수 있습니다. 값을 대입하여 나온 x, y 입니다.

  • x = n - 1 - j
  • y = i

이를 통해 명령형 프로그래밍으로 행렬 반시계방향 회전을 끝냈습니다.

const input = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]];

const m = input.length // 가로 길이 m행
const n = input[0].length // 세로 길이 n행

var output = new Array(n).fill(0).map(() => new Array(m).fill(null)) // null로 채워진 결과 행렬

for (i = 0; i < m; i++) {
  for (j = 0; j < n; j++) {
    output[n-1-j][i] = input[i][j] 
  }
}

함수형 프로그래밍으로 행렬(2차원 배열) 회전

이번엔 함수형 프로그래밍으로 행렬의 반시계 회전을 구현해보겠습니다.

함수형 프로그래밍에서는 이름답게 먼저 재료가 될 함수들을 먼저 정의합니다. 반시계 방향의 회전이라는 동작을 여러 개의 함수로 쪼개보겠습니다. (이 과정에서 다양한 함수로 회전이라는 동작을 표현할 수 있습니다. 제가 만든 재료들이 정답이 아니라는 것 알아두시면 될 것 같습니다.) 큰 동작을 쪼갤 때는 마치 머리 속에 그림을 그리듯이 움직임을 상상하면서 각 움직임이 어떻게 함수로 표현될지 생각하면 도움이 됩니다.

저는 행렬의 반시계 회전을 각 행을 하나씩 떼어내서 왼쪽으로 회전시켜 세운 다음 쌓아나가는 그림을 생각했습니다. 이를 그림으로 표현해보겠습니다.

step 1. 간단한 3*3 행렬을 예시로 생각해보겠습니다.

step 2. 먼저 첫 번째 행을 하나 떼어냅니다.

step 3. 떼어낸 행을 왼쪽으로 돌려서 쌓습니다.

img1

step 4. 다시 행 하나를 떼어냅니다.

step 5. 떼어낸 행을 왼쪽으로 돌려서 쌓습니다.

step 6. 마지막 행도 똑같이 돌려서 붙이면 반시계 반향 회전이 됩니다.

img2

위의 진행이 이해가 되시나요? 저는 지금 반시계 방향 회전을 3개의 단계으로 표현했습니다. 첫 번째는 행렬의 가장 첫 번째 행을 떼어내는 것, 두 번째는 떼어낸 행을 왼쪽으로 돌리는 것, 세 번째는 그것을 쌓는 것입니다. 그리고 이를 계속 반복합니다. 여기서 첫 번째 단계의 하나씩 행을 떼어내는 부분은 조금 뒤에서 어떻게 함수형 스타일 프로그래밍이 처리하는지 보고 나머지 2개의 단계를 함수로 표현해 보겠습니다.

rowToColumnReverse 함수

우리가 input 행렬로부터 이미 하나의 행을 떼어냈다고 생각하고 그 행을 왼쪽으로 돌릴 차례입니다. 행을 왼쪽으로 돌려서 열로 만드는 것은 어떤 함수일까요? 위의 head 함수와는 다르게 쉽게 방법이 떠오르지 않습니다. 저는 이럴 때 함수의 input과 output의 타입 또는 좀 더 구체적인 값을 생각해봅니다. 우선 input은 하나의 행이므로 input = [1, 2, 3] 일 것입니다. output은 열인데 우리가 현재 2차원 배열로 행렬을 표현하고 있으니 이에 맞춰 표현하면 output = [[3], [2], [1]]이 될 것입니다. 어떤가요? 나름 합리적으로 느껴지시나요? 제가 제시한 것은 정답이 아닙니다. 더 좋은 방법으로 input과 output을 나타낼 수 있다면 그렇게 해도 좋습니다.

input과 output을 정하고 나니 함수가 어떤 일을 해야 할 지 좀 더 명확해진 것 같습니다. input의 각 요소들을 선회하면서 output에 [x] 의 형태로 앞 쪽에 붙이면 될 것 같습니다. 코드로 써보면 다음과 같습니다.

// functional programming style
function rowToColumnReverse(row) {
    const reversed = row.reverse();
    return reversed.map(elem => [elem]);
}
// imperative programming style
function rowToColumnReverse2(row) {
  var result = [];
  for (var i = 0; i < row.length; i++) {
    result[i] = [row[row.length - i - 1]];
  }
  return result;
}

이해를 돕기 위해 함수형 프로그래밍 스타일, 명령형 프로그래밍 스타일로 나누어 코드를 적어보았습니다. 함수형 프로그래밍 스타일 코드에서 reverse, map 이 어떤 역할을 하는지 차근차근 보겠습니다. 우선 reverse 는 이름답게 행 내부의 요소들의 순서를 거꾸로 뒤집습니다. 그 다음 map 은 처음 보시는 분들에게는 생소할 수 있지만 함수형 프로그래밍에서는 매우 자주 쓰이는 함수입니다. map 함수의 특징은 파라미터로 다른 함수를 받는 점인데 이를 고차함수(Higher-order function)라고 합니다. map 은 행의 모든 요소를 순회하면서 각 요소에 대해 자신이 파리미터로 받은 함수를 실행시킵니다. 위의 예시에서는 행의 요소를 대괄호로 감싸 인자를 하나만 가진 array로 만들었습니다. 두 번째 명령형 스타일 코드와 비교하면서 어떤 동작을 하고 있는지 정확히 이해하시면 좋을 것 같습니다.

zip 함수

두 번째는 zip 함수입니다. 행을 왼쪽으로 돌려놓은 열을 차곡차곡 붙이는 함수입니다. 이 함수도 쉽게 동작을 상상하기 어려우니 input, output을 생각해봅시다. 먼저 input은 내가 지금까지 쌓아놓은 행렬, 그리고 붙일 1개의 열이 필요합니다. acc = [[1, 2,], [4, 5], [7, 8]], col = [3, 6, 9] 그리고 이 둘을 붙이고 난 후 output = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] 가 될 것입니다. 그럼 바로 이에 맞춰서 함수를 작성해보겠습니다.

// functional programming style
function zip(acc, col) {
  return acc.map((elem, index) => elem.concat(col[index][0]));
}
// imperative programming style
function zip2(acc, col) {
  const rowLength = acc[0].length;
	for (var i = 0; i < acc.length; i++) {
    acc[i][rowLength] = col[i][0];
  }
  return acc;
}

이번에도 두 가지 스타일로 나누어서 작성해보았는데 함수형 스타일에 대해서만 설명하겠습니다. 이번에는 map 함수가 2개의 파라미터를 받고 있습니다. 이런 경우 첫 번째 파라미터는 acc의 각 요소에 대응되고 두 번째 파라미터는 그 요소가 몇 번째인지 나타냅니다. (첫 번째 파라미터는 acc의 각 요소를 바인딩 한다고 표현하기도 합니다.) concat 함수는 행의 마지막에 파라미터로 받은 인자를 붙이고 그 결과를 반환합니다. (push와 다르게 원본 행의 변화가 없습니다. 둘이 어떤 차이가 있는지 직접 확인해보세요.) 한 줄로 작성된 코드가 일기 어려울 수 있지만 명령형 스타일의 코드와 비교하시면서 천천히 이해해보시길 바랍니다.

대통합

그럼 이제 우리가 만든 함수들을 이용해 우리의 최종 목표인 행렬의 반시계 방향 회전을 해보겠습니다. 그 전에 행렬에서 행을 하나 떼어내고 그 행으로 위에서 정의한 rowToColumnReverse, zip을 사용하는 과정을 반복하는 것은 어떻게 처리하는지 주목하시기 바랍니다.

const input = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]];

const m = input.length // 가로 길이 m행
const n = input[0].length // 세로 길이 n행

const zero = new Array(n).fill([]) // 비어있는 행렬 [[], [], [], ...]

function rowToColumnReverse(row) {
    const reversed = row.reverse();
    return reversed.map(elem => [elem]);
}

function zip(acc, col) {
  return acc.map((elem, index) => elem.concat(col[index][0]));
}

const output = input.reduce((acc, row) => zip(acc, rowToColumnReverse(row)), zero)

위에서 이야기한 반복을 새로운 함수 reduce를 통해 해결했습니다. reducemap과 마찬가지로 함수를 파라미터로 받는 고차함수입니다. reduce는 배열의 각 원소를 순회하면서 하나로 뭉치는 역할을 합니다. reduce가 받는 함수의 첫 번째 파라미터는 현재까지 뭉쳐놓은 값을 바인딩합니다. 두 번째 원소는 input에서 꺼내온 행 한 개가 바인딩됩니다. 그리고 그 두 가지를 또 하나의 덩어리로 뭉칩니다. reduce는 두 번째 파라미터로 초기 값을 받습니다. reduce함수도 map 처럼 함수형 프로그래밍에서 자주 사용하는 함수 중 하나입니다. 처음에는 어색할 수 있지만 사용하다보면 금방 익숙해질 것이라 믿습니다.

여기까지 함수형 프로그래밍 스타일로 행렬의 반시계 방향 회전을 구현해보았습니다. 이제 두 스타일을 비교하기 전에 조금 어려울 수 있는 함수형 프로그래밍 스타일 코드를 제대로 이해하고 넘어가길 바라겠습니다.

두 가지 패러다임 비교 분석

const input = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]];

const m = input.length // 가로 길이 m행
const n = input[0].length // 세로 길이 n행

// impeartive programming style
var output = new Array(n).fill(0).map(() => new Array(m).fill(null)) // null로 채워진 결과 행렬

for (i = 0; i < m; i++) {
  for (j = 0; j < n; j++) {
    output[n-1-j][i] = input[i][j] 
  }
}

// functional programming style
const zero = new Array(n).fill([]) // 비어있는 행렬 [[], [], [], ...]

function rowToColumnReverse(row) {
    const reversed = row.reverse();
    return reversed.map(elem => [elem]);
}

function zip(acc, col) {
  return acc.map((elem, index) => elem.concat(col[index][0]));
}

const output2 = input.reduce((acc, row) => zip(acc, rowToColumnReverse(row)), zero)

이제 두 가지 스타일을 비교하면서 어떤 차이가 있는지 알아보겠습니다.

먼저 설계 과정의 차이입니다. 명령형 스타일로 코딩할 때는 위에서도 언급했듯이 아주 빠르게 2중 for문을 써야 한다는 것까지 알아내고 그 후에 연립방정식을 풀어내는 순서였습니다. 연립 방정식도 어떻게 보면 펜을 빠르게 움직이면 풀어낼 수 있습니다. 반면 함수형 프로그래밍은 쉽게 키보드에 손가락이 올라가지 않습니다. 행렬의 반시계 방향 회전이라는 동작을 구현하기 위해 어떤 함수들이 필요한지 생각하고 그 함수들을 어떻게 구현할 지도 고민해야 합니다.

그리고 둘의 결과에서 더 확연한 차이가 나타납니다. 먼저 명령형 스타일의 코딩의 경우 행렬 내부의 하나의 요소에 집중합니다. 쉽게 말해 i행 j열의 원소가 어디로 가는지가 가장 중요한 관심사입니다. 반면 함수형 스타일은 내부의 하나의 요소는 어디로 가는지 알 필요가 없습니다. 오히려 좀 더 큰 단위에서 어떻게 움직이는지 설계합니다. 하나의 행을 떼어내서 회전시킨 후 쌓아나간다. 이 과정에서 i행 j열의 원소를 고려할 필요가 있을까요? 즉 함수형 프로그래밍에서는 집합의 움직임에 집중합니다. 물론 여기서 집합은 수학적 의미가 아닌 요소들의 모음을 의미합니다.

그럼 하나의 프로그래밍을 구현할 때 하나의 요소의 움직임에 집중할 때와 집합의 움직임에 집중할 때 나타나는 차이점은 무엇일까요? 먼저 명령형 프로그래밍에서는 하나의 요소의 움직임을 정의하고 이를 반복문으로 감싸기 때문에 코드가 어떤 일을 하는지 쉽게 알기가 어렵습니다. 여러분은 output[n-1-j][i] = input[i][j] 이 한 줄을 보고 2차원 배열 반시계 방향 회전이라는 것을 쉽게 알 수 있나요? 만약 이 코드가 output[n-1-j][m-i-1] = input[i][j] 이렇게 바뀐다면 어떨까요? 쉽게 어떤 동작인지 알 수 있나요? 만약 이 코드에 주석이 없는데 (주석이 없는 상황은 심지어 흔한 편입니다.) 유지보수를 해야 하는 상황이라면 어떨까요? 이처럼 명령형 프로그래밍에서는 짜여진 코드는 반복적인 작업을 처리하는 컴퓨터 입장에서는 좋을지 모르나 인간의 입장에서는 쉽게 이해하기 어렵습니다.

함수형 스타일 코드는 각각의 동작을 함수로 쪼개놓습니다. 물론 rowToColumnReverse, zip 함수들을 처음보면 명령형 프로그래밍처럼 쉽게 알아듣기 어렵다고 말할 수도 있겠습니다. 하지만 익숙해진다면 마치 사람이 사용하는 언어로 글을 쓰듯 훨씬 편하게 프로그램을 작성할 수 있습니다. 그 의미 또한 명확합니다. rowToColumnReverse 는 행을 왼쪽으로 돌려서 열을 만든다. zip은 두 개의 열을 합친다. 마지막 줄의 프로그래밍은 위 동작을 반복해서 반시계 방향 회전이라는 목적을 달성한다. 이러한 코드는 주석이 없는 경우에도 이해하기 비교적 쉽습니다. (물론 주석은 있는 것이 좋습니다.) 테스팅, 디버깅이 쉽다는 말도 이러한 맥락에서 등장합니다. 명령형 스타일의 경우 에러가 발생했을 때 연립방정식을 통째로 다시 풀어야 할 것입니다. 반면 함수형 스타일의 경우 각 함수별로 더 작은 단위로 테스트를 구성할 수 있습니다. 따라서 모든 부분을 점검할 필요없이 재료가 되는 함수 중에 문제가 있는 부분만 고치면 됩니다.

그런데 명령형 스타일에서도 똑같이 함수를 쪼개면 되지 않냐는 의문이 있을 수 있습니다. 옳은 말입니다. 하지만 그것을 의식하고 프로그램을 작성하는 것이 쉽지 않은 경우가 많습니다. 우리가 객체 지향 프로그래밍 패러다임을 사용한다고 해서 모두 좋은 확장성, 유지보수성을 갖는 코드가 만들어지는게 아니듯이 많은 노력이 필요합니다. 그런데 처음 함수형 프로그래밍 언어를 사용하면 여러 가지 제약처럼 느껴지는 규칙들이 있습니다. 이 제약들이 몸에 편해질 정도로 연습을 하다보면 명령형 스타일에서는 의식하고 작성해야만 완성되는 코드가 함수형 스타일에서는 자연스럽게 작성되는 광경을 볼 수 있습니다. 물론 이 점은 저의 직접적인 경험이 담긴 이야기이기 때문에 항상 참은 아닙니다. 함수형 프로그래밍을 할 때도 끊임없이 자신의 코드가 확장성, 유지보수성 측면에서 좋은지 나쁜지를 생각하면서 프로그램을 작성해야 합니다.

정리하면 명령형 스타일은 비교적 빠르게 큰 구조를 정하고 하나의 요소가 어떻게 움직이는지를 기술합니다. 반면 함수형 프로그래밍은 전체 동작을 쪼개어 작은 부분 동작을 구성하는 함수들을 작성하여 집합의 움직임을 기술합니다. 이 때문에 명령형 프로그래밍은 인간의 입장에서 코드를 이해하기 쉽지 않고 함수형 프로그래밍은 비교적 이해하기 쉽습니다. 또한 함수형 스타일은 프로그래밍 과정에서 자연스럽게 작은 부분 동작을 함수로 만들기 때문에 디버깅, 테스팅이 쉽습니다.

그래서 정말 함수형 프로그래밍?

아쉽게도 함수형 프로그래밍은 마법의 열쇠가 아닙니다. 위의 상황에서도 명령형 프로그래밍은 연립방정식 푸는 시간까지 30분이 걸린다면 함수형 프로그래밍은 반시계 방향 회전을 작은 동작으로 나눠서 생각하는데 오래 걸린다면 하루도 걸릴 수 있습니다. (연습하면 분명 짧아질 것입니다.) 언어에 따라 제약도 많습니다. 특히 하스켈 같은 매우 엄밀한 순수 함수형 프로그래밍 언어는 분명 흥미롭지만 사용하기가 너무 어렵습니다.

그럼에도 불구하고 함수형 프로그래밍만이 가진 가치가 분명히 있다고 생각합니다. 다만 그 가치가 어쩌면 그게 확장성, 유지보수성 같은게 아니고 단순히 배우는 재미일지도 모릅니다. 프로그래밍은 분명히 도구이지만 그래도 저는 재미를 추구해야 한다고 생각합니다. 프로그래밍을 자신의 업으로 삼은 이상 끊임없이 새로운 기술들을 배워야 하는데 그 과정이 지루하면 너무 불행할테니까요. 그래서 저는 함수형 프로그래밍을 선택한 것을 후회하지 않습니다.

이 글을 읽고 함수형 프로그래밍을 시작하고 싶으시다면 저는 Scala로 시작하는 것을 추천합니다. 함수형 프로그래밍 언어지만 자바와 거의 비슷한 문법으로 명령형 스타일 코딩을 같이 할 수 있습니다. 언어의 선택이 중요한 이유는 함수형 프로그래밍 자체는 하나의 패러다임이지만 언어의 지원이 많이 필요한 편이기 때문입니다. 예를 들어 C언어로 함수형 프로그래밍을 한다면 매우 느린 코드가 작성될 가능성이 높습니다. 그래서 되도록이면 함수형 프로그래밍 언어를 선택해야 합니다. 또한 개인적으로 정적 타입 언어를 추천합니다. 함수형 프로그래밍이 정적 타입과 만났을 때 나타나는 시너지가 있다고 생각합니다. (개인적인 취향도 있습니다.) Scala 외 추천 언어는 Ocaml, F#, Clojure, Haskell 정도 입니다.

함수형 프로그래밍을 직접 써보면서 느낀 장점을 예시를 통해 최대한 쉽게 전달해보려고 노력했는데 잘 전달 되었는지 모르겠습니다. 읽으면서 생긴 궁금증이나 이 글의 잘못된 점이 있다면 편하게 댓글이나 이메일로 문의주시면 감사드리겠습니다.

comments powered by Disqus

Related