[FP] lodash 와 함수형 프로그래밍

yongkini ·2024년 4월 21일
0

Functional Programming

목록 보기
3/4

Lodash와 함수형 프로그래밍

이름은 거창하지만 별건 아니다.. 단지 함수형 프로그래밍을 할 때 유용한 라이브러리인 lodash가 제공하는 함수들을 바탕으로 함수형 프로그래밍을 좀 더 이해해보고자 글을 쓴다.

_.chain 을 통한 지연 평가

const arr = [0, 1, 2, 3, 4, 5]
const result = arr.map(num => num + 10).filter(num => num % 2).slice(0, 2)

위와 같이 각각의 배열 요소에 10을 더한 다음에 홀수를 필터링하고 나온 값들 중 맨 앞 2개를 출력하는 혹은 추출하는 프로그램이 있다고 해보자. 이 때, 위와 같이 로직을 돌리면 map에서 6번의 시행 후 [10, 11, 12, 13, 14, 15] 를 리턴하고, filter에서 이를 받아서 6번의 시행 후 [11, 13, 15]를 리턴하고, 마지막으로 slice에서 [11, 13]를 최종 리턴한다.

이 때, 생각해보면(결과론적으로) map을 전부 돌리고, filter를 전부 돌리는 방식 말고, 0을 num => num + 10 에 mapping 하고, 그 다음에 바로 num => num % 2로 필터링하고 살아남으면?(조건에 맞으면) slice(0,2)에 포함되는 식이라면, 끝에 4,5는 계산할 필요도 없지 않을까?. 무슨 말인가 하면,

배열의 맨 앞부터

0
1) num => num + 10 적용하면 => 10
2) num => num % 2로 필터링 하면 => 홀수가 아니므로 filtered
3) slice(0,2)에 의해 slicing 되지 않음.
pass

1
1) num => num + 10 적용하면 => 11
2) num => num % 2로 필터링 하면 => 홀수이므로
3) slice(0,2)에 의해 slicing 됨
[11] 이 쌓였다고 하자(최종 결과물에)

2
1) num => num + 10 적용하면 => 12
2) num => num % 2로 필터링 하면 => 홀수가 아니므로 filtered
3) slice(0,2)에 의해 slicing 되지 않음.
pass

3
1) num => num + 10 적용하면 => 13
2) num => num % 2로 필터링 하면 => 홀수이므로
3) slice(0,2)에 의해 slicing 됨
[11, 13] 이 쌓였고, slice(0,2) 이므로 이제 뒤에 데이터는 의미가 없으므로 끝.

이렇게 생각해보면 시행 회수가 훨씬 줄어들 수 있다.
처음처럼 지연 평가를 안하면
map = 6번
filter = 6번
slice = 2번
이지만,
지연 평가를 하면
map = 4번
filter = 4번
slice = 2번

으로 끝난다. 데이터 수가 훨씬 클 경우 더욱 이득이라고 할 수 있다.
이러한 지연 평가를 lodash로 구현하면 다음과 같다.

    const arr = [0, 1, 2, 3, 4, 5];
    const result2 = _.chain(arr)
      .map((num) => num + 10)
      .filter((num) => num % 2)
      .slice(0, 2)
      .value();

맨 앞에 배열을 .chain에 감싸주는게 지연 평가를 위한 문법이다. 이와 같이 lodash를 통해 함수형 프로그래밍도 할 수 있으며, .chain을 통해 효율화도 할 수 있다(by 지연 평가).

map, filter, reduce, flatten 등 다양한 메서드를 통한 선언적 프로그래밍

  const data = [
    {
      tag: {
        name: "tag1",
        media: {
          nodes: [
            {
              id: "uid1",
              user: "gracefullight",
              caption: "caption1",
              likes: 10,
            },
            {
              id: "uid2",
              user: "gracefullight",
              caption: "caption2",
              likes: 20,
            },
          ],
        },
      },
    },
    {
      tag: {
        name: "tag2",
        media: {
          nodes: [
            {
              id: "uid3",
              user: "gracefullight",
              caption: "caption3",
              likes: 30,
            },
            {
              id: "uid4",
              user: "gracefullight",
              caption: "caption4",
              likes: 40,
            },
          ],
        },
      },
    },
  ];

위 데이터에서 tag.media.nodes 안의 요소로 이루어진 배열을 추출하고자 한다고 해보자. 일단 절차형 프로그래밍으로는 이렇게 짤 수 있다(좀 과장이 들어간다. 2중 for문을 쓸 정도는 아니기에)

  const getNodes = (arr) => {
    const result = [];
    for (let i = 0; i < arr.length; i++) {
      const nodes = arr[i].tag.media.nodes;
      for (let j = 0; j < nodes.length; j++) {
        result.push(nodes[j]);
      }
    }
    return result;
  };

위와 같이 짜볼 수 있다. 물론 프로그램은 정상적으로 돌아가지만,

  • 재사용이 어렵다는 점
  • 안에서 i, j 등 상태 값을 변화 시키면서 loop를 돌린다는 점
  • 출력을 어떻게 얻는지에 집중하게 하기에 추상화가 부족함(함수형 프로그래밍은 선언적으로 어떤 데이터가 출력돼야 할지 서술할 뿐이다.)

이런 단점이 있다. 이 때, 이를 함수혈으로 짜보면(혹은 선언형),

    const result = _.chain(data)
      .map("tag.media.nodes") 
      .flatten() 
      .value(); 

이렇게 직관적이고 심플하게 짤 수 있다. 내부 로직은 알 수 없지만, 즉 출력을 어떻게 얻는지는 알 수 없지만, 데이터가 어떻게 출력될지는 심플하게 알아챌 수 있음. reduce를 써서도 표현할 수 있다.

	const concatArray = (prev, next) => {
      return prev.concat(next);
  	};
    
	const result = _.chain(data).map("tag.media.nodes").reduce(concatArray).value();

또 다른 예시로 살펴보자

: 함수형 프로그래밍 혹은 선언적 스타일의 장점

  • 특정 문제 해결에만 집중하지 않기 때문에 데이터의 흐름이 더 잘 보인다.
  • 에러 가능성이 낮다.
  • 코드 재사용 기회가 높다.
  • 구조가 심플하고, 명료하다.
  • 추상화 정도가 높다.

물론 이것들은 절차형에 비해 상대적인 장점이라는 것을 기억해야 한다.

  const mapNames = (names) => {
    const result = [];

    for (let i = 0; i < names.length; i++) {
      const name = names[i];
      if (name === undefined || name === null) {
        continue;
      }
      const nameWithoutUnderBar = name.replace(/_/, " ");
      const splitName = nameWithoutUnderBar.split(" ");
      const upperCasedName = splitName
        .map((name) => name[0].toUpperCase() + name.substring(1))
        .join(" ");
      if (result.indexOf(upperCasedName) === -1) result.push(upperCasedName);
    }

    return result;
  };

  const names = [
    "alonzo church",
    "Haskell curry",
    "stephn_kleene",
    "John Von Neumann",
    "stephen_kleene",
  ];

위에서 절차형 프로그래밍으로 쓴 mapNames는 다음과 같은 요구 사항을 충족시키는 로직이다.

    // 1) 이름에서 _ 를 없애고,
    // 2) 성과 이름의 맨 앞글자를 upperCase로
    // 3) 이름들의 중복 제거
    // 4) undefined, null 값이 있으면 제거

물론 저렇게 해도 정상적으로 동작한다. 그럼 저 함수와 조건들을 다시 살펴보자

1) 특정 문제 해결에만 집중하지 않기 때문에 데이터의 흐름이 더 잘 보인다.
2) 에러 가능성이 낮다.
3) 코드 재사용 기회가 높다.
4) 구조가 심플하고, 명료하다.
5) 추상화 정도가 높다. 

생각해보면, 저 로직은 일단 코드 재사용성은 확실히 낮다. 위의 요구 사항을 모두 충족하지만 동시에 로직을 함수형 처럼 쪼개놓은게 아니라서 해당 함수를 재사용하려면 해당 기능을 사용할 때 뿐이다. 확장성도 좋은 편이 아니다. 이에 더해 구조가 심플하냐 하면 그냥 절차형 그자체?라고 표현할 수 있다. 이 코드를 해석하려면 그리고, 요구 사항을 모른채로 해석할 때는 더욱 복잡하게 느껴질 것이다.

그러면 이쯤에서 위의 코드를 함수형으로 바꿔보자(lodash 를 써본다).

const convertUnderBar = (word) => word.replace(/_/, " ");
  
const changeFirstToUppercase = (word) =>
    word[0].toUpperCase() + word.substring(1);
    
const isValid = (el) => !_.isUndefined(el) && !_.isNull(el);
  
const result = _.chain(names)
      .filter(isValid)
      .map(convertUnderBar)
      .map(changeFirstToUppercase)
      .uniq()
      .value();

위와 같이 바꿀 수 있다. 다시 장점들을 가져와보면(FP의)

1) 특정 문제 해결에만 집중하지 않기 때문에 데이터의 흐름이 더 잘 보인다.
2) 에러 가능성이 낮다.
3) 코드 재사용 기회가 높다.
4) 구조가 심플하고, 명료하다.
5) 추상화 정도가 높다. 

1) 제어 흐름(분기 처리 루프문 등등)에 신경쓸 필요가 거의 없기 때문에(filter, map, uniq 등이 처리해줌), 어떤 데이터를 출력할 것인가에 집중이 가능하고, 그래서 데이터의 흐름이 더 잘보이게 추상화된 코드를 쓸 수 있다.
2) 에러 가능성이 낮다는건 아직은 잘 모르겠다.
3) 코드 재사용성이 높다가 가장 이해가 잘된다. 절차형에서는 모든 기능이 하나의 함수 혹은 스코프에 담기는데, 함수형에서는 isValid, changeFirstToUppercase, convertUnderBar 등으로 나뉘어져서 다른데서 재사용할 수 있게 됐다. 그리고 mapNames 함수(요건을 가지고 만들었던 기능)는 단지 재사용 가능한 이 코드들을 조합해서 만든(고계 함수들을 통해) 것에 불가하고, 다른 로직을 만들 때도 이렇게 재사용한 코드를 고계함수들(map, filter 등)과 조합해서 만들 수 있어서 유연하고, 확장성도 높다.
4) 구조가 심플하고, 명료하다도 공감이 된다. 이건 솔직히 코드를 비교하면 쉽게 인정(?)할 수 있는 부분이다.
5) 추상화 정도가 높은건 어찌보면 당연한 말이다. for 루프문, 다양한 분기 처리 등을 map, filter 등으로 퉁(?)쳤기 때문에 추상화 정도가 높고, 개발자가 이런 세세한 처리에 머리를 쓰지 않고, 어떤 데이터를 출력할지에 집중할 수 있도록 해준다.

위의 함수형 코드를 lodash의 메서드를 통해 더 깔끔하게 만들어보면(+ sort 기능까지 추가하면),

const convertUnderBar = (word) => word.replace(/_/, " ");    
const isValid = (el) => !_.isUndefined(el) && !_.isNull(el);
  
const result = _.chain(names)
      .filter(isValid)
      .map(convertUnderBar)
      .uniq()
      .map(_.startCase)
      .sort()
      .value();

위에서 크게 변한건 내가 앞서 만든 changeFirstToUppercase를 쓰지않아도 lodash에서 제공하는 _.startCase 를 쓰면 된다는 점이다. 이런식으로 lodash는 함수형 프로그래밍을 할 수 있도록 다양한 메서드를 지원하여 개발자가 좀 더 메인 로직에 집중할 수 있도록 한다.

일단은 내 기준에선

3) 코드 재사용 기회가 높다.
4) 구조가 심플하고, 명료하다.

이 두 개가 가장 크게 느껴지는 장점인 것 같다. 더 공부해보면서 장점을 인정?해나가보자.

테스트 코드 예시

사용한 lodash method : chain, filter, map, property, reduce, sortBy, reverse, first, value

JS Mixin

const onceFunction = (persons) =>
  _.from(persons)
    .where(isValid)
    .select(_.property("address.country"))
    .reduce(gatherStats, {})
    .values()
    .sortBy("count")
    .reverse()
    .first()
    .value().name;

_.mixin({
  select: _.map,
  from: _.chain,
  where: _.filter,
  sortBy: _.sortByOrder,
  once: onceFunction,
});

const result = _.once(persons);

css mixin 처럼 js에서도 lodash를 통해 mixin을 쓸 수 있다. 이를 통해 다중 상속을 지원하지 않는 js에서 다중상속 개념을 구현해서 쓸 수 있다.

재귀 함수 개념

본래 순수 함수형 프로그래밍 언어에서는 loop 구조가 없어서 재귀 구조가 당연하게 쓰인다. 아래는 덧셈을 가지고, 재귀적으로 생각하는 것이 무엇인지 간단하게 알아보는 코드이다.

const recursion = (arr) => {
  if (_.isEmpty(arr)) {
    return 0;
  }

  return _.first(arr) + recursion(_.drop(arr));
};

const numbers = [1, 2, 3, 4, 5, 6, 7];

console.log(recursion(numbers));
profile
완벽함 보다는 최선의 결과를 위해 끊임없이 노력하는 개발자

0개의 댓글