Eternity's Chit-Chat

aeternum.egloos.com



행위 매개변수화(Behavior Parameterization) - 1부 Software Design

중요한 소프트웨어 설계 원칙 중 하나는 변하는 부분과 변하지 않는 부분을 분리하는 것이다. 이 원칙의 바탕에는 부수효과(side effect)에 대한 개발자들의 두려움이 잠재되어 있다. 변경과 무관한 코드가 변경될 코드와 함께 얽혀 있을 경우 코드 수정 시 예상치 못한 오류가 발생할 가능성이 높다. 부수효과를 방지할 수 있는 한 가지 방법은 자주 변경되는 부분을 독립적인 모듈로 캡슐화시켜 수정으로 인한 파급효과를 차단하는 것이다.

변경 부분을 분리는 두 번째 이유는 코드 중복(code duplication)때문이다. 변하는 부분과 변하지 않는 부분이 함께 뭉쳐 있을 경우, 부분적으로 다르게 행동하는 새로운 코드를 추가하는 유일한 방법은 코드를 복사한 후 수정하는 것뿐이다. 따라서 새로운 변경 사항을 추가할 때마다 중복 코드의 양이 증가하게 된다. 변경의 축을 따라 두 부분을 별도의 모듈로 분리하고 런타임 시점이나 컴파일 시점에 조합할 수 있는 훅(hook)을 제공함으로써 코드 중복을 방지할 수 있다.

중복 코드 제거를 제거하면 코드의 재사용성(reusability)과 유연성(flexibility)을 향상시킬 수 있다. 분리를 통해 변하는 부분과 변하지 않는 부분의 결합도를 느슨하게 유지하면 변하지 않는 부분의 코드를 다양한 상황에서 재사용할 수 있다. 또한 런타임 시점이나 컴파일 시점에 두 부분을 자유롭게 조합하기 위해 훅을 제공함으로써 코드의 유연성 역시 향상된다.

변경의 시점이 다른 두 부분을 분리시키고 이를 조합할 수 있는 다양한 기법이 존재하지만 여기에서는 행위를 매개변수화 할 수 있는 한 가지 기법에 관해 살펴 보기로 한다. 이 기법은 함수형 프로그래밍 패러다임(functional programming paradigm)에 그 기원을 두고 있으며 일반적으로 ‘고차 함수(higher-order function)’의 형태를 취한다.

map() 함수 예제

추상적인 개념을 이해하는 가장 좋은 방법은 구체적인 예제를 통해 역으로 그 원리를 탐구하는 것이다. 고차 함수를 이야기할 때 빠지지 않고 등장하는 예제는 map 함수다. map 함수는 리스트의 각 항목을 동일한 규칙에 따라 변환한 후, 변환 결과를 포함하고 있는 동일 크기의 리스트를 반환한다.

예를 들어 리스트의 각 항목을 제곱하는 map 함수를 생각해 볼 수 있을 것이다.

      map(x * x): [1 2 3 4 5] =>  [1 4 9 16 25]

또는 각 항목을 1씩 증가시키는 map 함수도 유용할 것 같다.

      map(x + 1): [1 2 3 4 5] => [2 3 4 5 6]

두 map 함수의 메커니즘은 거의 유사하다. 둘 모두 리스트를 순회하면서 정해진 규칙에 따라 항목을 변환시킨다. 두 함수의 차이점은 항목을 변환하는 규칙이 다르다는 점뿐이다. 첫 번째 map 함수는 각 항목의 제곱 값을 구하지만 두 번째 map 함수는 항목을 1 증가시킨 값을 구한다. map 함수에 포함된 변환 규칙의 미묘한 차이점이 코드에 어떤 영향을 미치는 지를 살펴 보기 위해 Lisp 계열의 함수형 언어인 Scheme을 이용해 두 map 함수를 구현해 보기로 한다.

간단한 Lisp 소개

Lisp(LiSt Processing을 의미한다)에서는 모든 것이 리스트(List)다. Lisp에서 데이터와 프로그램은 모두 리스트의 형태를 취한다. 예를 들어 1과 2의 합을 구할 경우 Lisp에서는 함수 ‘+’와 피연산자 1, 2를 ()안에 리스트 형태로 나열하여 구현한다. 이 경우 1과 2에 함수 ‘+’를 ‘적용(application)’한다고 말한다. Lisp에서 함수 적용은 전위 표기법(prefix notation)을 사용해 표현한다.

    (+ 1 2)

리스트 데이터를 만드는 경우에는 () 안에 항목을 나열하면 된다. 항목 없이 ‘()로 표기할 경우에는 빈 리스트를 의미한다

    ‘(1 2 3 4)                                 => (1 2 3 4)

Lisp은 리스트를 조작할 수 있도록 cons, car, cdr 함수를 제공한다. cons는 두 개의 항목을 조합해서 새로운 쌍(pair)를 만들고, car는 리스트의 첫 번째 항목을 반환하며, cdr은 리스트의 두 번째 항목부터 마지막 항목을 포함하는 서브 리스트를 반환한다.

    (car ‘(1 2 3 4))                         => 1
    (cdr ‘(1 2 3 4))                         => (2 3 4)
    (cons 1 2)                               => (1 . 2)
    (cons (cons 1 2) (cons 3 4))     => ((1 . 2) 3 4)

cons, car, cdr을 사용하면 함수의 재귀(recursion) 호출을 통해 반복(iteration)을 구현할 수 있다.

Lisp에서는 lambda 식을 통해 익명 함수를 정의할 수 있다. 하나의 인자를 취해 제곱 값을 구하는 익명 함수는 다음과 같이 정의할 수 있다.

    (lambda (x) (* x x))


리스트의 각 항목을 제곱하는 map 함수인 map-square는 다음과 같다
.

<List 1>
map-square 함수 정의
1:  (define (map-square items)                            
2:      (if (null? items)                                           
3:          '()                                                          
4:          (cons                                                     
5:              ((lambda (x) (* x x)) (car items))      
6:              (map-square (cdr items)))))     

1:  리스트 형식의 items를 파라미터로 받는 map-square 함수를 정의한다.

2:  items가 null인 경우(null? itmes), 즉 비어 있는 리스트인 경우에는
3:  함수의 결과값으로 빈 리스트를 반환한다.
4:  5 라인과 6 라인의 결과를 조합해서 새로운 리스트를 만든 후(cons) 만들어진 리스트를 함수의 결과값으로 반환한다.
5:  car 함수를 이용해 리스트의 첫 번째 항목(car items)의 제곱 값을 구한다. 이 때 제곱 값을 구하는 함수가 map-square 함수 안에서만 사용되므로 lambda 식을 이용해 익명 함수로 정의한다(lambda (x) (* x x)).
6:  cdr 함수를 이용해 리스트의 두 번째 항목 이후의 모든 항목을 포함하는 서브 리스트를 구한다(cdr items). 구해진 서브 리스트에 포함된 항목의 제곱 값을 구하기 위해 map-square 함수를 재귀 호출한다.

이제 리스트의 각 항목을 제곱하는 대신 1씩 증가시키는 map-succ 함수를 추가하자. map-succ 함수는 map-square 함수와 변환 로직만 다를 뿐 리스트를 순회하는 알고리즘은 동일하다. 따라서 map-succ 함수를 구현하는 가장 간단한 방법은 map-square 함수를 복사한 후 항목을 변환하는 lambda 식을 수정하는 것이다.


<List 2>
map-succ 함수 정의
1:  (define (map-succ items)
2:      (if (null? items)
3:          '()
4:          (cons
5:              ((lambda (x) (+ x 1)) (car items))      
6:              (map-succ (cdr items)))))

5:  리스트의 첫 번째 항목을 구한 후(car items) 익명함수를 적용해 값을 1 증가시킨다(lambda (x) (+ x 1)).

이와 같이 약간의 행위적 변형을 구현하기 위해 코드를 복사한 후 수정하는 방식에는 다음과 같은 단점이 존재한다.
  • 코드 중복(code duplication): map-square와 map-succ 함수는 lambda 정의를 제외한 모든 코드가 동일하다. 중복 코드의 가장 큰 문제점은 결과 코드에 대한 이력을 계속 추적해야 한다는 것이다. 즉, 하나의 코드를 변경한 후에는 중복 코드를 찾아 함께 함께 수정해 줘야 한다. 예를 들어 속도 최적화를 위해 현재의 재귀 호출 구조를 꼬리 재귀(tail recursion)로 변경할 경우 두 함수의 리스트 순회 부분을 함께 수정해야 한다.
  • 부수효과(side effect): 변경의 빈도가 다른 순회 로직과 변환 로직이 동일한 코드 단위 내에 함께 뭉쳐 있기 때문에 어느 한 쪽을 변경하더라도 변경과 무관한 부분에 원하지 않는 부수효과가 발생할 가능성이 존재한다.
  • 재사용성(reusability)과 유연성(flexibility) 저하: 두 함수 내에서 리스트의 순회 로직이 중복된다는 것은 순회 로직이 재사용 가능한 코드라는 사실을 암시한다. 그러나 위와 같이 순회 로직과 변환 로직이 함께 뭉쳐 있는 경우 변환 로직에 독립적으로 순회 로직을 재사용하기 어렵다. 또한 두 로직이 강하게 결합되어 있기 때문에 다양한 변환 로직을 결합해서 새로운 map 함수를 만들 수 있는 유연성이 저하된다.