Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save superLipbalm/0c9226769326f8e7f1c5a7a04f54cfca to your computer and use it in GitHub Desktop.
Save superLipbalm/0c9226769326f8e7f1c5a7a04f54cfca to your computer and use it in GitHub Desktop.
[번역] 여러 매개 변수를 사용하는 자바스크립트 함수를 작성하는 방법(역대급 가이드)

여러 매개 변수를 사용하는 자바스크립트 함수를 작성하는 방법(역대급 가이드)

thumbnail

원문: https://jrsinclair.com/articles/2024/how-to-compose-functions-that-take-multiple-parameters-epic-guide/

목차

함수 합성은 아름답습니다. 앞선 글1에서 compose()flow() 같은 도구를 살펴봤습니다. 이러한 합성 함수를 사용하면 함수 파이프라인을 만들 수 있습니다. 한 함수의 출력이 바로 다음 함수로 이어지도록 함수를 정렬하는 것입니다. 이러한 함수가 모두 함께 작동할 때 데이터는 마치 팬케이크 위에 뿌린 메이플 시럽 처럼 흐르게 됩니다. 하지만 함수가 정렬되지 않으면 어떻게 될까요? 일부 함수가 둘 이상의 인수를 기대한다면 어떻게 될까요? 이런 경우에 여러 개의 매개변수를 가진 함수는 어떻게 합성할 수 있을까요?

이 질문에 대한 간단한 답은 그럴 수 없다는 것입니다.

단항 함수만 합성할 수 있습니다.2 둘 이상의 인수를 가지면 합성은 사용할 수 없습니다. 적어도 유용한 방식은 아닙니다. 둘 이상의 매개변수를 받는 함수는 합성할 수 없습니다.

여러 매개 변수를 갖는 함수를 합성하는 것이 불가능하다면 왜 이 글을 작성했을까요? 제목이 완전 거짓말일까요?

분명 우리가 할 수 있는 무언가가 있을 겁니다. 결국, 그 기발한 함수형 프로그래머들은 합성의 즐거움을 영원히 찬양하고 있습니다. 다중 인수 함수를 사용할 수 있는 방법이 없다면 왜 그렇게 인기가 있을까요? 이를 해결할 방법이 있을 겁니다.

그리고 여기 그 방법이 있습니다.

우리는 속임수를 쓸겁니다.

우리는 함수를 변경하여 제약을 해결할 것 입니다. 함수를 래핑하거나 수정할 수 있으며, 이를 통해 다중 인수 함수를 단항 함수로 변환할 겁니다. 이 글에서는 이를 위한 다섯 가지 기법을 살펴보겠습니다. (더 많은 방법이 있지만 가장 일반적인 방법입니다.)

  1. 복합 데이터 구조
  2. 부분 적용
  3. 커링
  4. get/set 문제에 ap() 사용하기
  5. 합성 문제에 flatMap() 사용하기

복합 데이터 구조(Composite data structures)

이 문제의 가장 간단한 형태부터 시작하겠습니다. 두 개의 인수가 필요한 함수가 하나 있다고 가정해 보겠습니다. 그리고 두 개의 값을 반환하는 다른 함수도 있습니다. ... 여기서 이미 문제가 발생했습니다. 함수는 여러 값을 반환할 수 없습니다. 각 함수는 반드시 하나의 값만 반환할 수 있으며 그 이상은 불가능합니다.3

다행히 자바스크립트는 여러 값을 결합하는 다양한 방법을 제공합니다. 가장 일반적인 접근 방식은 복합 데이터 구조를 사용하는 것입니다. 즉, 배열과 객체를 사용하는 것입니다.4 예를 들어 함수에서 두 개의 값을 반환하는 것은 불가능하다는 것을 알고 있습니다. 하지만 하나의 배열에 두 개의 값을 반환하는 것은 전혀 문제가 되지 않습니다.

프런트엔드 프레임워크를 사용하신다면 이미 보셨을 것입니다. 리액트의 useState() 함수는 값과 setter 함수를 배열로 반환합니다. 다음과 같습니다.

const temperatureStatePair = useState(23);
const temperature = temperatureStatePair[0];
const setTemp = temperatureStatePair[1];

구조 분해(destructuring)를 활용하면 이를 한 줄로 줄일 수 있습니다.

const [temperature, setTemp] = useState(23);

SolidJS에는 비슷한 개념인 시그널(signal)이 있습니다. 동일한 패턴을 사용합니다.

const [temperature, setTemp] = createSignal(23);

이제 온도 조절기를 위한 사용자 인터페이스(UI)를 개발 중이라고 가정해 보겠습니다. 사람들이 섭씨와 화씨 사이를 전환할 수 있도록 하고 싶습니다. 이를 위해 변환 함수를 작성할 수 있습니다. 이 멋진 변환 함수는 temperature()setTemp()를 모두 변환합니다:

const celsiusToFahrenheit = t => t * 9 / 5 + 32;
const fahrenheitToCelsius = t => (t - 32) * 5 / 9;

const stateCelsiusToFahrenheit = (temperature, setTemp) => {
   const tempF = celsiusToFahrenheit(temperature);
   const setTempF = (tempC) => setTemp(fahrenheitToCelsius(tempC));
   return [tempF, setTempF];
}

이 함수는 값을 내보내기 전에 섭씨에서 화씨로 변환합니다(tempF). 그리고 값이 입력될 때 화씨에서 섭씨로 변환합니다(setTempF()). 하지만 이 함수에는 두 개의 매개변수가 필요합니다.

useState()stateCelsiusToFahrenheit() 함수를 합성하면 좋을 것입니다. 현재는 그럴 수 없습니다. 하지만 함수를 작성하고 있으므로 인수를 받는 방식을 변경할 수 있습니다. 두 개의 매개변수 대신 하나의 배열을 기대하도록 함수를 작성할 수 있습니다. 인수를 구조 분해하면 변경 사항은 두 글자에 불과합니다.

const stateCelsiusToFahrenheit = ([temperature, setTemp]) => {
   const tempF = celsiusToFahrenheit(temperature);
   const setTempF = (tempC) => setTemp(celsiusToFahrenheit(tempC));
   return [tempF, setTempF];
}

이 작업이 끝나면 compose() 함수를 사용하여 useState()를 합성할 수 있습니다.5

const compose = (...fns) => x0 => fns.reduceRight(
    (x, f) => f(x),
    x0
);

const useFahrenheit = compose(
    stateCelsiusToFahrenheit,
    useState
);

그리고 다음과 같이 컴포넌트 내부에서 새로 만든 useFahrenheit() 함수를 사용할 수 있습니다.

// 초기 온도를 23°C (~74°F)로 설정
const [tempF, setTempF] = useFahrenheit(23);

이 방법은 유효하지만 초기 온도를 섭씨로 설정해야 하기 때문에 그렇게 좋지는 않습니다. 파이프라인에 다른 함수를 추가하여 이 문제를 해결할 수 있습니다.

// compose()를 사용하면 데이터가 아래쪽 함수에서 위쪽으로 흐릅니다.
// 목록 맨 아래에 fahrenheitToCelcius()를 추가해 초기 입력값을 변환하도록 합니다.
const useFahrenheit = compose(
    stateCelsiusToFahrenheit,
    useState,
    fahrenheitToCelsius,
);

// 초기 온도를 74°F (~23°C)로 설정
const [tempF, setTempF] = useFahrenheit(74);

이제 상태는 섭씨로 저장하지만 UI는 화씨를 사용하고 있습니다. 이는 깔끔하지만 다소 무의미하기도 합니다. 적어도 여기서 사용하는 방식은 무의미합니다. 이제 화씨 사용할 수 있도록 코딩했습니다. 따라서 어디에도 해당 상태를 공유하지 않는다면 온도를 화씨로 저장하는 것이 좋습니다. 잠시 후에 이 주제로 돌아가서 좀 더 유용한 작업을 해보겠습니다. 이제 배열을 사용하여 다중 인자 함수를 합성하는 방법을 예제를 통해 살펴보았습니다.

객체는 어떤가요?

첫 번째 useState() 예제로 돌아가 보겠습니다. 배열 인덱스를 참조하여 값을 가져왔습니다.

const temperatureStatePair = useState(23);
const temperature = temperatureStatePair[0];
const setTemp = temperatureStatePair[1];

이것은 잘 작동하지만 순서가 임의적이라는 것을 눈치챘을 것입니다. 즉, 값이 슬롯 0에 있다는 것에는 특별한 의미가 없습니다. setter 함수가 슬롯 1에 있는 것도 의미가 없습니다. 슬롯을 바꿔도 별 차이가 없으니까요.6 임의의 각 슬롯에 어떤 것이 있는지 기억해야 합니다.

대신 객체를 사용하는 것도 대안이 될 수 있습니다. 이렇게 하면 각 값 슬롯에 의미 있는 이름을 지정할 수 있습니다. 예를 들어 stateCelsiusToFahrenheit()를 다시 작성하여 객체를 반환할 수 있습니다.

const stateCelsiusToFahrenheitObj = ([temperature, setTemp]) => {
   const tempF = celsiusToFahrenheit(temperature);
   const setTempF = (tempC) => setTemp(celsiusToFahrenheit(tempC));
   return { temperature: tempF, setTemperature: setTempF };
};

이제 배열 대신 객체를 반환합니다. 여기에는 temperaturesetTemperature 키가 있습니다. 이는 각각 값과 setter 함수를 나타냅니다. 합성 파이프라인은 동일하게 유지됩니다.

const useFahrenheit = compose(
    stateCelsiusToFahrenheitObj,
    useState,
    fahrenheitToCelsius,
);

하지만 훅을 사용할 때는 조금 다릅니다. 명명된 프로퍼티를 사용하여 합성된 함수의 반환값을 구조 분해합니다.

const { temperature, setTemperature } = useFahrenheit(74);

이렇게 변경하면 값을 어떤 순서로 분해하는지는 중요하지 않습니다. 예를 들어 다음과 같이 쓸 수 있습니다.

const { setTemperature, temperature } = useFahrenheit(74);

동작은 변경되지 않습니다. 하지만 이 접근 방식에는 단점이 있습니다. 이러한 변수의 이름을 변경하려면 다소 장황해집니다.

const {
    temperature: tempF,
    setTemperature: setTempF
} = useFahrenheit(74);

각 방법에는 장단점이 있습니다. 배열을 사용하면 구조 분해할 때 조금 더 유연하게 사용할 수 있습니다. 그러나 객체를 사용하면 더 의미 있는 이름을 부여하고 순서를 변경할 수 있습니다. 두 가지 접근 방식 모두 나름의 용도가 있습니다.

하지만 배열을 선택하든 객체를 선택하든 이제 반환 값에 대한 해결책이 생겼습니다. 여러 값을 복합 데이터 구조에 밀어넣어 반환할 수 있습니다. 간단하죠. 하지만 여러 개의 매개 변수가 필요한 함수가 있다면 어떨까요?

여기에서도 복합 데이터 구조를 사용할 수 있습니다. 다중 매개변수 함수를 단항 함수로 래핑합니다. 예를 들어 HTML 요소를 문자열로 만드는 함수 el()이 있다고 가정해 보겠습니다.

const el = (tag, contents) => `<${tag}>${contents}</${tag}>`;

이 함수는 여러 매개변수를 받으므로 다른 함수와 함께 합성할 수 없습니다. 하지만 배열을 받는 다른 함수로 감싸는 것은 가능합니다.

const elComposable = ([tag, contents]) => el(tag, contents);

이렇게 사용할 수도 있습니다.

// compose()를 사용하면 함수는 아래에서 위로 합성된다는 것을 기억하세요
const wrapListItem = compose(
    elComposable,
    item => ['li', item],
);

const wrapList = compose(
    elComposable,
    list => ['ul', list.join('')],
    list => list.map(wrapListItem),
);

const characterList = ['Holmes', 'Watson', 'Mrs. Hudson'];
const characterListHTML = wrapList(characterList);
characterListHTML
// ← "<ul><li>Holmes</li><li>Watson</li><li>Mrs. Hudson</li></ul>"

더 멋지게 만들고 싶다면 유틸리티 함수를 만들 수도 있습니다. 이 함수는 임의의 함수를 인수를 배열로 기대하는 함수로 변환합니다. 다시 한번 말하지만, 인수 구조 분해는 함수를 매우 간결하게 만듭니다.

const arrayifyArgs = (fn) => (args) => fn(...args);

// elComposable()를 만드는 다른 방법
const elComposable = arrayifyArgs(el);

복합 데이터 구조는 여러 매개변수가 포함된 거의 모든 합성 문제를 해결할 수 있습니다. 하지만 이것이 유일한 해결책은 아닙니다.

부분 적용(partial application)

앞서 useFahrenheit() 함수가 쓸모없다고 했습니다. 화씨만 사용한다면 섭씨를 저장할 필요가 없습니다. 하지만 공유 상태를 사용하면 더 흥미로워집니다.

useState()useLocalStorage()로 대체한다고 가정해 보겠습니다. (기성 훅usehooks.com에서 찾을 수 있습니다). 이 훅에는 멋진 API가 있습니다. useState()와 비슷해 보이지만 key 매개변수가 추가로 필요하다는 점이 다릅니다. 다음과 같이 사용할 수 있습니다.

const PREFIX = 'my-clever-prefix-to-prevent-namespace-collisions-';
const [temperature, setTemperature] = useLocalStorage(23, `${PREFIX}temperature`);

반환되는 형태는 useState()의 모양과 일치합니다. 따라서 compose() 파이프라인에서 이를 사용할 수 있는 방법이 있어야 할 것 같습니다. 한 가지 방법은 key 매개변수를 부분적으로 적용한 새 함수를 만드는 것입니다.

const PREFIX = 'my-clever-prefix-to-prevent-namespace-collisions-';
const tempKey = `${PREFIX}temperature`;
const useDeconflictedLocalStorage =
    (initialVal) => useLocalStorage(initialVal, tempKey);

새로운 함수인 useDeconflictedLocalStorage()를 만들었습니다. 이 함수는 uselocalStorage()key를 특정 값으로 '고정'합니다. 이렇게 하면 이전처럼 함수 파이프라인에서 useDeconflictedLocalStorage()를 사용할 수 있습니다.

const useFahrenheit = compose(
    stateCelsiusToFahrenheitObj,
    useDeconflictedLocalStorage,
    fahrenheitToCelsius,
);

const { temperature, setTemperature } = useFahrenheit(74);

이제 온도를 로컬 스토리지에 섭씨로 저장하고 있습니다. 하지만 UI에서는 모든 것이 화씨로 표시되는 것처럼 작동합니다. 이것은 더 이상 쓸모없지 않습니다. 애플리케이션의 다른 부분에서 localStorage 값을 섭씨로 읽을 수 있습니다. 또는 나중에 UI에 원래의 섭씨 값을 표시하도록 전환할 수도 있습니다.

하지만 이것이 부분 적용을 할 수 있는 유일한 방법은 아닙니다. 모든 자바스크립트 함수에 내장된 .bind() 메서드를 사용할 수도 있습니다. 예를 들어 el() 함수로 돌아가 보겠습니다. 다양한 종류의 HTML 요소에 대한 함수를 만들 수 있습니다.

// .bind()의 첫 번째 매개변수는 함수를 호출할 때 `this`가 바인딩되는 대상을 결정합니다.
// el()의 경우 `this`와 무관하므로 `null`로 설정합니다.
const ul = el.bind(null, 'ul');
const li = el.bind(null, 'li');

위 코드에서는 두 개의 새 함수를 만듭니다. 각각은 el() 함수에 태그 이름을 부분적으로 적용합니다. 이렇게 하면 새로운 함수가 다시 생성됩니다. 그런 다음 해당 함수를 다음과 같이 사용할 수 있습니다.

const listify = compose(
    ul,
    list => list.join(''),
    list => list.map(li),
);

const characterList = ['Holmes', 'Watson', 'Mrs. Hudson'];
const characterListHTML = listify(characterList);
characterListHTML
// ← "<ul><li>Holmes</li><li>Watson</li><li>Mrs. Hudson</li></ul>"

다시 설명하자면, el()을 가져와서 '고정' 매개변수를 가진 두 개의 새 함수를 만들었습니다. 이 과정에서 이진 함수7를 두 개의 단항 함수로 변환했습니다.

그러나 이 기법에서는 매개변수의 순서가 중요합니다. el()에서 매개변수의 순서를 반대로 한다고 가정해 보겠습니다.

const elReversed = (contents, tag) => `<${tag}>${contents}</${tag}>`;

순서가 뒤집힌 매개변수에 .bind()를 사용하면 contents 매개변수만 고정할 수 있습니다. 그리고 이는 일반적으로 tag를 고정하는 것보다 덜 유용합니다.

함수를 만들 때 이 점을 염두에 두세요. .bind() 또는 커링을 사용하는 경우 변경이 가장 적은 데이터를 먼저 넣는 것이 도움이 됩니다. 그런 다음 변동성이 적은 매개변수를 고정하고 필요에 따라 언제든지 사용할 수 있는 새 함수를 만들 수 있습니다.

부분 적용은 생각보다 훨씬 강력합니다. 놀랍도록 다양한 도구와 기법을 활용할 수 있습니다. 이 글의 나머지 부분에서 그 중 세 가지를 살펴보겠습니다.

커링(currying)

우리는 부분 적용을 자주 사용하게 될 수 있습니다. 이런 경우, 함수를 호출하는 것만으로 매개변수를 고정할 수 있도록 만들면 더 쉽게 사용 할 수 있습니다. 예를 들어 앞서의 useLocalStorage() 함수를 생각해 봅시다. 이 함수를 다음과 같이 래핑한다고 가정해봅시다.

const useLocalStorageCurried =
    (key) => (initialVal) => useLocalStorage(initialVal, key);

새로운 함수인 useLocalStorageCurried()를 만들었습니다. 이 함수는 key라는 단일 매개변수를 받습니다. key를 사용하여 함수를 호출하면 단일 값인 initialVal을 취하는 또 다른 함수를 반환합니다. 이 함수를 호출하면 한 쌍의 값을 배열로 반환합니다. useLocalStorage()를 호출했을 때와 동일한 값입니다.

혼란스럽게 들리더라도 걱정하지 마세요. 사용 방법을 살펴보면 훨씬 더 쉽게 이해할 수 있습니다. useLocalStorageCurried()를 사용하면 다음과 같이 다양한 목적에 맞는 저장소를 만들 수 있습니다.

// 주어진 key로 로컬 스토리지에 데이터를 저장하는 훅 함수를 만듭니다.
const useTemperature = useLocalStorageCurried('temperature');
const useHeatingStatus = useLocalStorageCurried('heating');
const useCoolingStatus = useLocalStorageCurried('cooling');

// 훅 함수를 사용하여 상태를 관리합니다.
const [temp, setTemp] = useTemperature(23);
const [heatingOn, setHeatingStatus] = useHeatingStatus(false);
const [coolingOn, setCoolingStatus] = useCoolingStatus(false);

커링은 다중 인수 함수를 단항 함수로 변환하는 방법입니다. 서로 다른 함수를 중첩하여 커리 함수를 만듭니다. 바깥쪽 함수를 호출하면 매개변수 하나가 고정된 새 함수가 반환됩니다. 그 새 함수를 호출하면 다음 매개변수가 고정되는 식입니다. 필요한 인수를 모두 확보하고 결과를 반환할 때까지 이 과정을 계속합니다.

이 기법을 사용하면 el() 함수의 커링� 버전을 만들 수 있습니다.

const elCurried = (tag) => (contents) => `<${tag}>${contents}</${tag}>`;

이왕 하는 김에 몇 가지 다른 유틸리티 함수를 만들어 봅시다.(둘 다 커링을 활용했습니다)

const map = (func) => (list) => list.map(func);
const join = (joinStr) => (list) => list.join(joinStr);

다 작성한 뒤 compose()와 함께 사용할 수 있습니다.

const listify = compose(
    el('ul'),
    join(''),
    map(el('li')),
);

const characterList = ['Holmes', 'Watson', 'Mrs. Hudson'];
const characterListHTML = listify(characterList);
characterListHTML
// ← "<ul><li>Holmes</li><li>Watson</li><li>Mrs. Hudson</li></ul>"

가끔 이런 코드를 보면 사람들이 깜짝 놀라는 경우가 있습니다. 익숙한 코드와 너무 달라 보이기 때문입니다. 어떻게 작동하는지 자세히 살펴보겠습니다. compose()는 데이터를 아래에서 위로 파이프한다는 점을 기억하세요. 각 부분을 차례로 살펴보겠습니다.

  • 합성된 listify() 함수는 문자열 배열을 인수로 기대합니다.
  • listify()를 호출하면 compose()는 이 문자열 배열을 map(el('li'))로 전달합니다.
  • map(el('li'))는 목록의 각 항목을 <li></li>로 래핑합니다.
  • Compose는 해당 문자열 목록을 join('')에 전달합니다.
  • join('')은 목록의 모든 항목을 단일 문자열 값으로 합쳐 반환합니다.
  • 그런 다음 Compose는 해당 단일 문자열 값을 el('ul')로 전달합니다.
  • el('ul')은 단일 문자열 값을 <ul></ul>로 래핑하고 최종 문자열을 반환합니다.

저보다 더 똑똑한 사람들이 이 코딩 스타일을 연구해 왔습니다. 그들은 합성과 커링만으로 어떤 프로그램도 작성할 수 있다는 것을 증명했습니다. 일부 프로그래밍 언어에서는 이러한 코드 작성 방식을 권장하기도 합니다. 하지만 자바스크립트로 이 방식으로 코드를 작성하려고 하면 몇 가지 어려움에 직면할 수 있습니다. 그 중 하나가 바로 get/set 문제입니다.

ap()로 get/set 문제 해결하기

프런트엔드 웹 애플리케이션을 많이 작성하다 보면 같은 작업을 반복해서 수행하는 자신을 발견하게 됩니다. 다음은 이러한 패턴 중 하나입니다.

  1. 원격 서비스에서 일부 데이터를 가져옵니다.
  2. 해당 데이터를 로컬 상태와 결합합니다.
  3. 이 결합된 데이터를 UI에 적합한 형식으로 변환합니다.
  4. 해당 데이터로 UI를 렌더링합니다.

이렇게 하다 보면 또 다른 마이크로 패턴을 따르는 자신을 발견하게 될 때가 많습니다. 너무 자주 하다 보니 더 이상 알아차리지 못할 수도 있습니다. 마이크로 패턴은 다음과 같습니다.

  1. 객체에서 하나 이상의 값을 가져옵니다.
  2. 해당 값을 변환합니다.
  3. 그 다음 결과를 동일한 객체에 새 프로퍼티로 추가합니다.8

합성 함수를 사용해 값을 변환하는 것은 쉽게 할 수 있습니다. 하지만 변환된 값을 다시 시작 객체의 복사본으로 가져오는 것은 조금 더 까다롭습니다. 세 가지 정보가 필요하기 때문입니다.

  1. 입력 객체
  2. 변환된 값
  3. 새 객체에 할당할 프로퍼티 이름

합성 파이프라인에서 이러한 조각을 정렬하는 것은 까다로울 수 있습니다. 이 문제를 좀 더 구체적으로 설명하기 위해 온도 조절기의 예를 다시 한 번 살펴보겠습니다. 온도 관측값 배열을 반환하는 서비스라면 데이터는 다음과 같이 보일 수 있습니다.

const temps = [
    { time: 1715411010990, temp: 21.2, sensor: 'bakerst' },
    { time: 1715414610990, temp: 21.5, sensor: 'bakerst' },
    { time: 1715418210990, temp: 20.8, sensor: 'bakerst' },
];

이러한 값을 가져와 테이블에 표시하고 싶습니다. 하지만 시간은 읽기 쉬운 날짜 형식이어야 합니다. 이러한 요구 사항을 감안하여 다음과 같이 날짜 서식 지정 함수를 작성할 수 있습니다.

const DTFORMAT = {timeStyle: 'medium', dateStyle: 'short'}
const formatDateForLocale = (languages) => {
    const formatter = new Intl.DateTimeFormat(languages, DTFORMAT);
    return (timestamp) => formatter.format(new Date(timestamp));
}

const formatDate = formatDateForLocale(navigator.languages);

이렇게 하면 변환을 수행하는 formatDate() 함수가 제공됩니다. 또한 객체에서 이를 가져오고 내보내는 getter와 setter 함수를 만들 수 있습니다.

const getTimestamp = (tempObj) => tempObj.time;
const setReadableDate = (tempObj) => (value) => ({...tempObj, readableTime: value});

필요한 작업을 수행할 수 있는 모든 요소를 갖췄습니다. 하지만 여전히 이 모든 것을 하나로 연결할 방법이 필요합니다. ap()라는 유틸리티를 사용하여 이를 수행할 수 있습니다. 코드는 다음과 같습니다.

const ap = (binaryCurriedFn) => (unaryFn) => (value) => 
    binaryCurriedFn(value)(unaryFn(value));

이 함수는 어떤 동작을 수행할까요? 먼저 두 개의 함수 인수를 받아 단항 함수를 반환합니다. 이 단항 함수는 값을 binaryCurriedFn()unaryFn()에 모두 전달합니다. 그런 다음 unaryFn(value) 결과를 두 번째 인수로 binaryCurriedFn()에 전달합니다. 그리고 최종 호출의 결과를 반환합니다.

이를 사용해 온도 타임스탬프의 형식을 지정하는 방법은 다음과 같습니다.

const getTimestampAndFormat = compose(formatDate, getTimestamp);

const addFormattedDate = ap(setReadableDate)(getTimestampAndFormat);
const tempsWithFormattedDates = temps.map(addFormattedDate);

UI에 화씨 온도를 추가하고 싶을 수도 있습니다. 이를 위해 getTempC()setTempF()와 같은 몇 가지 getter 함수와 setter 함수를 만들 수 있습니다. 하지만 같은 일을 반복하게 될 것입니다. getter와 setter를 위한 유틸리티 함수를 만들면 어떨까요?

const get = (key) => (obj) => obj[key];
const set = (key) => (obj) => (val) => ({...obj, [key]: val});

또한 이들을 결합하는 getSet() 함수를 만들 수도 있습니다.

const getSet = (setter) => (getter) => (transform) => ap(setter)(compose(transform, getter));

getSet()에 대해 생각할 것은 세 개의 입력 매개 변수를 받아 함수를 반환한다는 것입니다. 다음과 같은 함수입니다.

  1. getter()를 사용하여 값을 추출합니다.
  2. transform()을 사용하여 값을 변환합니다.
  3. setter()를 사용하여 변환된 값을 객체에 삽입합니다.

그러면 파이프라인에서 다음과 같이 사용할 수 있습니다.

const transformTempReadings = compose(
    getSet(set('tempF'))(get('temp'))(celsiusToFahrenheit),
    getSet(set('readableTime'))(get('time'))(formatDate),
);

const readingsForUI = temps.map(transformTempReadings);
readingsForUI
// ← [ { time: 1715411010990, temp: 21.2, sensor: 'Baker St.', readableTime: '11/05/2024, 17:03:30', tempF: 70.16 }, { time: 1715414610990, temp: 21.5, sensor: 'Baker St.', readableTime: '11/05/2024, 18:03:30', tempF: 70.7 }, { time: 1715418210990, temp: 20.8, sensor: 'Baker St.', readableTime: '11/05/2024, 19:03:30', tempF: 69.44 } ]

getSet() 유틸리티를 사용하면 데이터를 변환하기 위한 파이프라인을 편리하게 만들 수 있습니다. 저도 이런 종류의 작업을 많이 하고 있습니다. 하지만 합성에는 환경 설정 문제라는 또 다른 문제가 있습니다.

flatMap()으로 환경 설정 문제 해결하기

다시 온도 조절기 예제로 돌아가 보겠습니다. 앱이 시작될 때마다 로드하는 앱에 대한 환경 설정이 있다고 가정해 보겠습니다. 예시입니다.

const config = {
    locale: 'en-GB',
    timezone: 'GMT',
    defaultTarget: 23,
    defaultUnits: 'Celsius',
    baseHeatingRate: 3,
    baseCoolingRate: 5,
    sensors: {
        bakerst: {name: 'Baker St.', host: 'bakerst.thermostat.example.com' },
        gorvesnorsq: {name: 'Grosvenor Sq.', host: 'grosvenor.thermostat.example.com' },
        bedlam: {name: 'Bedlam', host: 'bedlam.thermostat.example.com' },
    },
};

설정을 전역 변수로 노출하지 않고, 센서 판독값 목록을 다시 변환한다고 가정해 보겠습니다. 모두 동일한 구성 객체를 사용하는 세 개의 함수를 작성하려고 합니다.

이 과정에서는 매개변수 순서에 대한 조언을 잠시 무시하겠습니다. 가장 적게 변경되는 객체(환경 설정)를 마지막 매개변수로 만들겠습니다.

const formatDateForLocale = (timestamp) => (config) =>
    (new Intl.DateTimeFormat(config.local, {timeStyle: 'medium', dateStyle: 'short'}))
        .format(new Date(timestamp));

const addReadableDate = (obj) => (config) => ({
    ...obj, readableDate: formatDateForLocale(config.locale)(obj.time)
});

const addSensorName = (obj) => (config) => ({
    ...obj, sensorName: config.sensors[obj.sensor]?.name
});

const addTempDiff = (obj) => (config) => ({
    ...obj, tempDiff: obj.temp - config.defaultTarget
});

각 함수는

  1. 온도 객체를 가져온 다음
  2. 구성 객체를 가져오고,
  3. 수정된 온도 객체를 반환합니다.

이러한 함수를 함께 합성하여 원하는대로 설정을 변경 하고 싶습니다. 하지만 모두 동일한 환경 설정 객체를 갖도록 함께 연결하고 싶습니다. 이를 위해 flatMap()(chain()이라고도 함)이라는 유틸리티를 사용합니다.

const flatMap = (binaryCurriedFn) => (unaryFn) =>
    (x) => binaryCurriedFn(unaryFn(x))(x);

자세히 보면 ap()와 비슷합니다. 하지만 변환된 값을 두 번째가 아니라 binaryCurriedFunction()에 먼저 전달합니다.

flatMap()을 사용하면 합성 흐름에서 함수를 서로 연결할 수 있습니다.

const transformTempObjs = compose(
    flatMap(addTempDiff),
    flatMap(addSensorName),
    addReadableDate
);

이 파이프라인을 아래에서 위로 다시 살펴보면,

  1. 전달받은 객체에 읽기 쉬운 형식의 날짜를 추가하고,
  2. 항목의 센서 이름을 조회하여 객체에 추가하고,
  3. 실제 온도와 목표 온도의 차이를 객체에 추가합니다.

다른 합성 파이프라인과 크게 다르지 않습니다. 하지만 여기서 흥미로운 점은 단항이 아닌 이진 함수라는 점입니다. flatMap() 헬퍼는 환경 설정 옵션을 각 함수에 연결합니다. transformTempObjs()를 실행하면 세 가지 추가 프로퍼티가 모두 객체에 추가됩니다.

transformTempObjs(temps[0])(config);
// ← {"time":1715411010990,"temp":21.25,"sensor":"bakerst","readableDate":"11/05/2024, 17:03:30","sensorName":"Baker St.","tempDiff":-1.75}

깔끔하지만 배열 전체를 변환하고 싶습니다. 그리고 그 환경 설정 객체를 두 번째로 전달해야 하는 것은 불편합니다. 이를 뒤집어서 transformTempObjs()가 구성 객체를 먼저 가져가도록 하면 좋을 것입니다. 또 다른 깔끔한 작은 유틸리티인 flip()으로 이를 수행할 수 있습니다.

const flip = (binaryCurriedFn) => (b) => (a) => binaryCurriedFn(a)(b);

이를 사용하여 transformTempObjs()가 배열의 .map() 메서드와 잘 작동하도록 할 수 있습니다.

const flippedTransformTempObjs = flip(compose(
    flatMap(addTempDiff),
    flatMap(addSensorName),
    addReadableDate
));

const transformedReadings = temps.map(flippedTransformTempObjs(config));
console.log(transformedReadings);
// ⦘ [
//     {"time":1715411010990,"temp":21.25,"sensor":"bakerst","readableDate":"11/05/2024, 17:03:30","sensorName":"Baker St.","tempDiff":-1.75},
//     {"time":1715414610990,"temp":21.5,"sensor":"bakerst","readableDate":"11/05/2024, 18:03:30","sensorName":"Baker St.","tempDiff":-1.5},
//     {"time":1715418210990,"temp":20.75,"sensor":"bedlam","readableDate":"11/05/2024, 19:03:30","sensorName":"Bedlam","tempDiff":-2.25}
//   ]

이진 커링 함수를 합성하여 각각 동일한 환경 설정 객체가 전달되도록 했습니다. 마법같네요.

그래서 결론은 무엇인가요?

우리가 작성한 모든 함수를 커리 한다고 상상해 보세요. 모든 함수를 커링 함수로 만들면 매력적인 유틸리티 함수의 세계가 열립니다. 이 글에서는 이러한 유틸리티 중 세 가지를 사용했습니다.

  1. ap()
  2. flatMap()
  3. flip()

이 외에도 더 많은 유틸리티가 있습니다. 함수형 프로그래머는 이러한 작은 유틸리티를 '결합자(combinators)'라고 부릅니다. 결합자 합성을 통해 함수를 결합하는 데 도움이 되는 도구입니다. Avaq의 gist에서는 보다 포괄적인 목록을 제공합니다. 결합자를 가지고 노는 것은 매우 재미있을 수 있습니다. 유형과 함수가 있는 스도쿠 퍼즐을 푸는 것과 비슷합니다.

하지만 사실 결합자가 필요하지 않을 수도 있습니다. 배열이나 객체로 재배열하는 것만으로도 충분히 작업을 수행할 수 있습니다. 또한 코드 곳곳에 결합자를 뿌려두면 동료가 코드를 이해하기 어려울 수 있습니다. 이는 정말 큰 문제가 될 수 있습니다.

그렇다면 왜 결합자에 대해 배우려고 애쓰는 걸까요? 동료들을 혼란스럽게 할 뿐이고, 어차피 사용하려면 모든 함수를 커리 해야 하죠. 대체 무슨 소용이 있을까요?

자바스크립트 개발자는 함수를 자주 작성하게 됩니다. 의식하든 의식하지 않든 이는 사실입니다. 물론 복합 데이터 구조만 사용하면 다른 도구 없이도 원하는 결과를 얻을 수 있습니다. 하지만 이는 다른 도구 없이 맥가이버 칼만 사용하도록 제한하는 것과 비슷합니다. 물론 대부분의 상황에서는 효과가 있을 것입니다. 하지만 이것이 최선이 아닐 수도 있으며 다른 전문화된 도구를 사용할 수 있습니다. 부분 적용, 커리 및 결합자에 대해 알면 선택의 폭이 넓어집니다.

🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!

Footnotes

  1. 한국어번역

  2. '단항' 함수는 하나의 인수를 받는 함수입니다.

  3. 그리고 더 적어서도 안 됩니다. 하지만 이는 완전히 다른 논제입니다.

  4. 어떤 똑똑한 사람은 이렇게 생각할 것입니다: "하지만 제임스, 배열은 객체에요. 맵도 마찬가지고요. 그리고 세트(Set)도 마찬가지입니다." 그리고 이 영리한 사람의 말이 맞습니다. 기술적으로 자바스크립트에서 값을 결합하는 방법은 단 한 가지뿐입니다. 실무에선 다른 데이터 구조보다 객체 리터럴과 배열을 더 자주 사용합니다.

  5.  여기서는 'JavaScript function composition:What's the big deal?'compose() 함수를 사용하고 있습니다.

  6.  반환되는 값의 순서는 조금 중요합니다. 하지만 구조 분해 할 때만 그렇습니다. 그리고 두 번째 값이 항상 필요하지는 않다고 가정하는 경우에만 해당됩니다. 예를 들어, useLocalStorage()를 사용한다고 가정해 보겠습니다. 때로는 setter가 아닌 값만 필요할 수도 있습니다. 이 경우 값이 첫 번째 슬롯에 있으면 더 편리합니다. 쉼표를 하나 더 입력하지 않아도 되니까요.

  7. '이진' 함수는 두 개의 인수를 받는 함수입니다.

  8. 불변 데이터 구조를 사용하면 기술적으로 동일한 객체가 아닙니다. 하지만 패턴은 여전히 동일합니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment