반복문 관련 High order function의 장점

#1. 너무 간단한 예제

“objects”라는 리스트에 연산 A, B, C를 차례로 수행한다.

for (Object obj : objects) {
  A(obj);
  B(obj);
  C(obj);
}
(map A objects)
(map B objects)
(map C objects)
// 또는 (map (compose C B A) objects)

두 번째 스타일의 “map”과 “compose”라는 함수는 함수를 매개변수로 받거나 반환할 수 있기 때문에 high order function이라고 불린다. 함수형 언어 또는 그 비슷한걸 지원하는 언어를 사용하면 두 번째 스타일처럼 각 연산을 리스트 전체에 간결하게 적용할 수 있고, 그렇지 않은 언어의 경우 첫 번째 스타일처럼 foreach 문 내부에서 연산을 나열해야 한다.

첫 번째 스타일의 경우, 항상 리스트를 한 번만 돌도록 구현해낼 수 있다. 두 번째 스타일에서 compose 연산을 사용할 수 없다면 수행해야 하는 연산의 갯수만큼 리스트를 돌아야 한다. 하지만 리스트가 아~주 길지 않는 이상 실제 성능 차이는 크게 나지 않을 것이다.

지금까지 생각해본 두 번째 스타일의 장점은 세 가지이다.

  1.  foreach 구문과 각 연산을 map이라는 함수에 한 줄로 녹여낼 수 있어 코드가 간결하다.
  2. 반복문 내부 구문이 비교적 간결하고 직관적이다. 첫 번째 스타일의 경우 순차적으로 적용되어야 할 연산이 많다면 (그리고 각 연산이 함수로 적절하게 추상화되지 않으면) 반복문 구문 안이 지저분해지게 되고 나중에 또 들여다보기 싫을 것이다.
  3. 중요한 부분만 단위테스트를 수행하면 된다. 여기서 중요한 부분이란 반복문 내부 연산이고, 반복문의 동작은 부차적인 기능이다. 두 번째 스타일의 코드에 대해 단위테스트를 작성해야 한다면, 해당 함수가 (compose C B A)라는 함수를 호출하여 사용하고 있는지만 확인하면 된다. map은 라이브러리에서 제공하는 함수이기 때문이다.

#2. 덜 간단한 (현실에 한발짝 가까운) 예제

“order items” 리스트에 다음과 같은 연산을 수행해야 한다고 가정하자: 장바구니에 사은품이 담겨있는 경우 사은품의 배송모드를 장바구니에 담긴 가장 비싼 제품의 배송모드로 설정한다. 만약 가장 비싼 제품의 배송모드를 해당 사은품이 지원하지 않는다면 그 다음 비싼 제품의 배송모드를 설정한다.

  • isGift: OrderItem -> Boolean 사은품인지 여부를 반환
  • isAvailable: OrderItem Delivery -> Boolean 해당 제품의 배송모드 지원 여부
List<OrderItem> allItems = getItems();
// step 1: 사은품과 일반 제품으로 리스트 분리
List<OrderItem> normalItems = new ArrayList<>();
List<OrderItem> giftItems = new ArrayList<>();
for (OrderItem item : allItems) {
  if (isGift(item)) {
    giftItems.add(item);
  } else {
    normalItems.add(item);
  }
}

// step 2: 일반 제품을 가격 내림차순으로 정렬
Collections.sort(normalItems, comparator);

// step 3: 사은품의 배송모드 설정
for (OrderItem gift : giftItems) {
  for (OrderItem normal : normalItems) {
    if (isAvailable(gift, normal.getDelivery())) {
      gift.setDelivery(normal.getDelivery());
      break;
    }
  }
}
// step 1
(define giftItems (filter is-gift allItems))
(define normalItems (filter (compose not is-gift) allItems))

// step 2
(sort normalItems comparator)

// step 3
(define deliveryModes (map get-delivery normalItems))
(define (set-delivery gift)
  (local [(define (set-delivery gift modes proper?)
            (cond [(empty? modes) gift]
                  [(proper? gift (first modes))
                   (set-delivery gift (first modes))]
                  [else (set-delivery gift (rest modes) proper?)]))]
  (set-delivery gift deliveryModes is-available)))
(for-each set-delivery giftItems)

Step 1에서는 boilerplate 코드의 부재로 두 번째 스타일이 간결하다.
Step 2는 두 스타일 모두 사용자 정의 함수를 (또는 그걸 수행할 수 있는 객체를) sort 함수에 넘기는 것이라 함수형 스타일의 동일한 구현이다.
Step 3가 관건이다. 첫 번째 스타일은 누구나 쉽게 작성 가능하다 (읽기도 쉽다). 단, 이중 for문 내부에 모든 로직이 한데 뭉쳐져 있는 것이 단점이다. 두 번째 스타일은 구현이 다소 장황하다. 맨 마지막 한 줄을 실행시키기 위해 deliveryModes란 별도 리스트와 두 개의 set-delivery 함수를 정의해야 했다. 또한 절차형 스타일에 더 익숙하기 때문에 생각하고 작성하는데 시간이 더 걸렸다 (이것보다 더 낫게 구현할 수도 있을까). 이에 대한 가치를 찾아보자면…

  • 라인 수는 길어도 정의 두 개를 건너뛰면 “각 사은품에 대해 배송모드를 적절하게 적용”하라는 최상위 레벨의 행위가 마지막 한 줄로 깔끔하게 표현된다. 결국 제일 핵심이 되는 로직은 적절한 배송모드의 선정과 이를 사은품에 적용하는 행위인데, 이에 대한 것은 set-delivery 함수 안만 들여다보면 된다.
  • set-delivery는 다소 복잡하게 생긴 inner helper function을 끌어안고 이를 어떻게 사용하고 있는지 규정하고 있다. 즉 configuration으로서 의미를 가지는데, 특히 배송모드 선정을 좌우하는 is-available이라는 함수를 다른 곳으로부터 차용하고 있다. 배송모드 선정은 비즈니스 로직이기 때문에 추후 수정 및 확장의 가능성이 있다. inner helper를 high order function으로 작성할 수 있었기 때문에 배송모드 선정하는 로직을 할당하는 로직으로부터 분리하기 수월하다 (비즈니스 로직만 한데 모아놓은 유틸 같은 곳에 따로 정의해둘 것이다). 이로서 inner helper에는 기계적인 할당 로직만 남아있게 되고, 이는 굳이 set-delivery 바깥으로 노출될 필요가 없다.
    결과적으로는 이것 저것 정의한 것이 많았지만, 각 함수들이 한 단위의 일만 할 수 있도록 기능을 분리해낼 수 있었다.
  • 바깥으로 노출되지는 않지만 inner helper도 단위테스트가 필요하다. 두 번째 스타일에서는 사실상 inner helper에 대한 단위테스트 중요도가 가장 높다. 첫 번째 스타일의 경우 두 번째 만큼 단위테스트를 촘촘하게 작성하기는 어려울 것 같다.