Skip to main content

14장 중첩된 데이터에 함수형 도구 사용하기

written by
saengmotmi
saengmotmi 🏆Front End Engineer

이번 장에서 살펴볼 내용

  • 해시 맵에 저장된 값을 다루기 위한 고차 함수를 만듭니다.
  • 중첩된 데이터를 고차 함수로 쉽게 다루는 방법을 배웁니다.
  • 재귀를 이해하고 안전하게 재귀를 사용하는 방법을 살펴봅니다.
  • 깊이 중첩된 엔티티에 추상화 벽을 적용해서 얻을 수 있는 장점을 이해합니다.

카피-온-라이트 기법에 대해 알아보면서 이야기하기도 했지만 중첩된 데이터를 불변값으로 다루기란 쉬운 일이 아닙니다. 중첩된 객체에 참조로 접근할 수 있다는 사실은 언제든 그 객체가 개발자 모르게 혹은 부주의로 인하여 쉽게 손상될 수 있음을 뜻하기 때문이죠.

이러한 중첩된 객체들을 함수형 프로그래밍의 방식으로 안전하게 다루는 또 다른 기법에 대해 알아보겠습니다. 먼저 객체를 다루기 위한 고차 함수를 직접 만들고 리팩터링하며 이야기해보겠습니다.

필드명을 명시적으로 만들기

주어진 함수들을 리팩터링 해보자

// 수량 필드명이 함수에 하드코딩 되어있음
function incrementQuantity(item: Item) {
const quantity = item.quantity;
const newQuantity = quantity + 1;
const newItem = objectSet(item, 'quantity', newQuantity);
return newItem;
}
// 크기 필드명이 함수에 하드코딩 되어있음
function incrementSize(item: Item) {
const size = item.size;
const newSize = size + 1;
const newItem = objectSet(item, 'size', newSize);
return newItem;
}

두 함수는 함수 이름에 암묵적 인자가 들어 있습니다. 이전 장에서 배운 방식대로 암묵적 인자를 드러내기 리팩터링 해보겠습니다.

function incrementField(item: Item, field: string) {
const value = item[field];
const newValue = value + 1;
const newItem = objectSet(item, field, newValue);
return newItem;
}

하지만 더하기, 빼기, 곱하기, 나누기, ... 등의 연산을 수행하는 함수를 만들려면 어떻게 해야 할까요? 이런 함수들은 모두 업데이트를 하기 위한 목적을 가지고 있습니다. 또 한겹의 중복이 눈에 띕니다. 업데이트를 하기 위한 목적을 가지고 있는 함수들을 업데이트 함수로 만들어보겠습니다.

인자로 객체를 받고, 어떤 키값에 접근할지에 대한 단서를 함께 제공하면 원하는 인터페이스를 구성할 수 있을 것 같습니다.

function incrementField(item: Item, field: string) {
return updateField(item, field, function (value) {
return value + 1;
});
}

function updateField(item: Item, field: string, modify: (value: any) => any) {
const value = item[field];
const newValue = modify(value);
const newItem = objectSet(item, field, newValue); // objectSet은 카피-온-라이트를 수행하는 함수
return newItem;
}

함수 본문을 콜백으로 바꾸기 리팩터링을 통해 updateField 함수에 콜백을 전달하였고, 제어권을 전달 받은 본래 함수는 콜백을 호출하는 모습을 볼 수 있습니다.

함수형 도구 : update()

update()는 객체(해시 맵 대신 쓰고 있는)를 다루는 함수형 도구로, 값 하나를 인자로 받아 객체에 적용합니다. 하나의 키에 하나의 값을 변경합니다. 따라서 1) 객체, 2) 변경할 값이 어디 있는지 알려주는 키, 3) 값을 변경하는 동작이 서술된 함수, 총 3가지 요소가 필요합니다.

앞선 코드에서 update()만 추출해보겠습니다.

function update(
object: Record<string, any>,
key: string,
modify: (value: any) => any
) {
const value = object[key];
const newValue = modify(value);
const newObject = objectSet(item, key, newValue);
return newObject;
}

update()에 전달하는 함수는 값을 돌려주는 계산이어야 하고, 현재 값을 인자로 받아 새로운 계산 값을 리턴합니다.

이제 앞선 예제에서의 incrementField()update()를 사용하여 다시 작성해보겠습니다.

function incrementField(item: Item, field: string) {
return update(item, field, function (value) {
return value + 1;
});
}

사실 이름 말고는 달라질 건 없습니다만, Item이라는 맥락을 벗어나서 어떤 객체에서든 사용할 수 있는 함수가 되었습니다. 여기에서 update의 세 번째 인자로 넘겨준 콜백 함수 덕분에 update 함수는 동작을 개발자에게 위임하고, 다양한 동작을 할 수 있게 되었습니다.

중첩된 update 시각화하기

update 함수를 작성하여 객체를 다룰 수 있게 되었습니다. 하지만 객체가 꼭 한 단계만 있을 수 있는 것은 아닙니다. 객체 안에 객체가 있을 수도 있고, 객체 안에 배열이 있을 수도 있습니다. 중첩된 객체를 다룰 수 있도록 조금 더 손봐주도록 하겠습니다.

중첩된 데이터에 update() 사용하기

function incrementSize(item: Item) {
return update(item, 'size', function (value) {
return update(value, 'width', function (value) {
return value + 1;
});
});
}

앞서 작성했던 incrementSize 함수를 update를 이용하여 한번 리팩터링 해보았습니다. 하지만 이렇게 작성하면 코드가 너무 길어지고, 가독성이 떨어집니다.

우리는 'size' -> 'width' 순서대로 객체를 탐색할 계획입니다. 이처럼 순서에 의존하는 자료형으로는 배열이 가장 적합하겠습니다. 즉, 다음과 같은 형태의 코드가 되면 좋겠네요.

function incrementSize(item: Item) {
return update(item, ['size', 'width'], function (value) {
return value + 1;
});
}

이제 이 코드를 작성해보겠습니다. 객체의 키를 타고 들어가며 update라는 동작을 반복해주면 됩니다. 하지만 for문을 가지고 작성하려니 코드가 지저분해질 것만 같고 조금 막막해지는 기분입니다.

우리는 앞서 문(Statement)표현식(Expression)의 차이에 대하여 배운 바 있습니다. 이럴 때 자바스크립트에서 일급으로 취급하는 값인 함수를 이용하면 코드를 깔끔하게 작성할 수 있습니다. 즉, 재귀를 사용하는 것이지요.

function update<T>(target: T, path: string[], updater: (value: any) => any): T {
// 재귀 종료 조건
if (path.length === 0) {
return updater(target);
}
// 경로에서 첫번째 키를 추출한 다음, 나머지 경로는 다음 재귀 호출에 전달
const [key, ...rest] = path;
return update(target[key], rest, updater);
}

안전한 재귀 사용법

이렇게 재귀를 사용하면 코드가 깔끔해지지만, 재귀를 사용할 때는 주의해야 할 점이 있습니다. 재귀를 사용할 때는 반드시 종료 조건을 명시해주어야 합니다. 그렇지 않으면 무한 루프에 빠질 수 있습니다.

개인적으로 생각하기에 재귀는 함수형 프로그래밍이 가지는 정말 강력한 도구 중 하나라고 생각합니다. 객체지향에서 각 클래스가 특정 역할을 수행하도록 책임을 나누는 것과 같이, 함수형 프로그래밍에서는 재귀가 특정 동작을 수행하도록 동작을 추상화하는 것이라고 생각합니다.

1. 종료 조건

재귀를 멈추려면 종료 조건이 필요합니다. 위의 코드에서는 path.length === 0이 종료 조건입니다. 이 조건이 없다면 무한 루프에 빠질 수 있습니다.

배열의 인자가 비었다는 조건이나, 점점 줄어드는 어떠한 값을 종료 조건으로 만들 수 있습니다.

2. 재귀 호출

재귀를 사용할 때는 최소한 하나의 재귀 호출(recursive call)이 있어야 합니다. 재귀 호출이 없다면 재귀는 단순히 반복문과 다를 바가 없습니다.

위의 코드에서는 update(target[key], rest, updater)가 재귀 호출입니다.

3. 종료 조건에 다가가기

재귀 함수를 만들 때는 최소 하나 이상의 인자가 점점 줄어들어 종료 조건으로 다가가야 합니다. 만약 배열을 기준으로 종료 조건을 잡고 있다면, 각 단계를 거칠 때마다 배열의 항목이 사라져야 합니다.

위의 코드에서는 pathrest로 점점 줄어들어 종료 조건으로 다가가고 있습니다.

깊이 중첩된 구조를 설계할 때 생각할 점

하지만 여전히 문제가 남아있습니다. 처음보다는 훨씬 사용하기 좋은 인터페이스의 함수가 되었지만, 그 반대 급부로 특정 상황에서 사용하는 경우 사용자가 update 함수에 너무 많은 구체적인 정보를 전달해야 한다는 부작용이 생겼습니다.

아래 코드를 살펴보겠습니다.

httpGet('http://my-blog/com/api/category/blog', (blogCategory) => {
renderCategory(
update(blogCategory, ['posts', '12', 'author', 'name'], (name) =>
name.toUpperCase()
)
);
});

과연 ["posts", "12", "author", "name"]이 무엇을 의미하는지 알 수 있을까요? 이 코드는 블로그 카테고리의 12번째 포스트의 작성자의 이름을 대문자로 바꾸는 코드입니다. 하지만 이 코드를 처음 보는 사람은 이 코드가 무엇을 의미하는지 알기 어렵습니다.

이럴 때는 적당히 추상화를 해서 사용자가 너무 많은 정보를 전달하지 않도록 해야 합니다. 바로 추상화 벽을 이용할 때입니다.

특정한 포스트를 id를 기준으로 찾아 수정하는 함수를 만들어보겠습니다.

function updatePostById(
category: Category,
postId: string,
modifyPost: (post: Post) => Post
): Category {
return update(category, ['posts', postId], modifyPost);
}

사용자 이름을 대문자로 바꾸는 함수를 만들어볼 수도 있습니다.

function capitalizeName(user: User) {
return update(user, ['name'], (name) => name.toUpperCase());
}

추상화 벽으로 감싼 두 함수를 합치면 다음과 같은 결과물을 얻을 수 있습니다.

httpGet('http://my-blog/com/api/category/blog', (blogCategory) => {
renderCategory(
updatePostById(blogCategory, '12', (post) => capitalizeName(post.author))
);
});

앞에서 배운 고차 함수들

그동안 배운 고차 함수들을 정리해보겠습니다.

배열을 반복할 때 for 반복문 대신 사용하기

  • forEach()
  • map()
  • filter()
  • reduce()

중첩된 데이터를 효율적으로 다루기

  • update()
  • nestedUpdate()

카피-온-라이트 원칙 적용하기

  • withArrayCopy()
  • withObjectCopy()

try/catch 로깅 규칙을 코드화

  • wrapLogging()

요점 정리

  • update()는 일반적인 패턴을 구현한 함수형 도구입니다. 이 함수를 사용하면 중첩된 데이터를 효율적으로 다룰 수 있습니다.

  • nestedUpdate()는 update()를 확장한 함수입니다. 키 경로를 배열로 전달하면 깊이 중첩된 데이터도 쉽게 다룰 수 있습니다.

  • 보통 일반적인 반복문은 재귀보다 명확합니다. 하지만 중첩된 데이터를 다룰 때는 재귀가 더 쉽고 명확합니다.

  • 재귀는 스스로 불렸던 곳이 어디인지 유지하기 위해 스택을 사용합니다. 재귀 함수에서 스택은 줕첩된 데이터 구조를 그대로 반영합니다. (오호;)

  • 깊이 중첩된 데이터는 이해하기 어렵습니다. 깊이 중첩된 데이터를 다룰 때 모든 데이터 구조와 어떤 경로에 어떤 키가 있는지 기억해야 합니다.

  • 많은 키를 가지고 있는 깊이 중첩된 구조에 추상화 벽을 사용하면 알아야 할 것이 줄어듭니다. 추상화 벽으로 깊이 중첩된 데이터 구조를 쉽게 다룰 수 있습니다.