제11장: 또다시 변환, 자연스럽게
본 장에서는 일상적인 코드에서 실용적인 유용성을 가지는 *자연 변환(Natural Transformation)*에 대해 논의합니다. 이 개념은 범주론의 기둥이며 코드 논리화와 리팩토링에 수학을 적용할 때 절대적으로 필수적인 요소입니다. 따라서, 제 한계로 인해 여러분이 목격할 유감스러운 현실을 미리 알려드리는 것이 의무라고 생각합니다. 시작해 보겠습니다.
중첩의 저주
중첩(nesting) 문제를 다루고자 합니다. 이는 예비 부모가 강박적으로 정리하고 재배치하려는 본능적 충동이 아니라... 사실 생각해 보면 앞으로 다룰 내용과 크게 다르지 않습니다... 어쨌든 '중첩'이란 두 개 이상의 서로 다른 타입이 값 주변에 아기 보듯이 모여 있는 상황을 의미합니다.
Right(Maybe('b'));
IO(Task(IO(1000)));
[Identity('bee thousand')];지금까지 신중하게 설계된 예제로 이런 상황을 피해왔지만, 현실적으로 코딩할 때 타입은 퇴마 의식 속 이어폰 코드처럼 스스로 뒤얽히는 경향이 있습니다. 타입 체계를 체계적으로 유지하지 않으면 코드는 고양이 카페의 비트족보다 더 복잡해질 것입니다.
코미디 상황
// getValue :: Selector -> Task Error (Maybe String)
// postComment :: String -> Task Error Comment
// validate :: String -> Either ValidationError String
// saveComment :: () -> Task Error (Maybe (Either ValidationError (Task Error Comment)))
const saveComment = compose(
map(map(map(postComment))),
map(map(validate)),
getValue('#comment'),
);타입 서명이 경악할 만큼 복잡한 구조가 되었습니다. 간단히 설명하면: getValue('#comment')로 사용자 입력을 가져오는데 이는 요소의 텍스트를 가져오는 액션입니다. 요소 검색 실패 또는 문자열 미존재 가능성으로 Task Error (Maybe String)을 반환합니다. 이후 validate에 전달하기 위해 Task와 Maybe를 모두 map 처리하여 Either ValidationError String을 반환합니다. 최종적으로 Task Error (Maybe (Either ValidationError String)) 구조를 postComment에 전달하는 과정입니다.
끔찍한 복잡성입니다. 추상 타입 콜라주, 아마추어식 타입 표현주의, 다형성 폴록(추상화 화가 잭슨 폴록), 단일형 몬드리안(기하학적 화가 피트 몬드리안) 같습니다. 이 문제를 해결하는 여러 방법이 있지만, 본 장에서는 자연 변환을 통한 동질화 방법에 집중합니다.
모두 자연스러운
자연 변환은 '펑터 간 모피즘'으로, 컨테이너 자체를 변환하는 함수입니다. 타입 관점에서는 (Functor f, Functor g) => f a -> g a 함수입니다. 이 변환의 핵심은 펑터 내용물을 들여다볼 수 없다는 점입니다. 이는 기밀 문서봉투를 교환하는 것과 같습니다. 다음 조건을 만족해야 공식적으로 자연 변환이 됩니다:

코드로 표현하면:
// nt :: (Functor f, Functor g) => f a -> g a
compose(map(f), nt) === compose(nt, map(f));도식과 코드는 동일한 의미를 전달합니다: 자연 변환 후 map 하거나 map 후 자연 변환을 수행해도 동일한 결과를 얻습니다. 이는 자유 정리에서 유도되며, 자연 변환은 타입 함수에만 제한되지 않습니다.
원칙적 타입 변환
프로그래머에게 타입 변환은 익숙한 개념입니다. 여기서 차이점은 대수적 컨테이너를 다루며 이론적 기반을 활용한다는 점입니다.
예시를 살펴보겠습니다:
// idToMaybe :: Identity a -> Maybe a
const idToMaybe = x => Maybe.of(x.$value);
// idToIO :: Identity a -> IO a
const idToIO = x => IO.of(x.$value);
// eitherToTask :: Either a b -> Task a b
const eitherToTask = either(Task.rejected, Task.of);
// ioToTask :: IO a -> Task () a
const ioToTask = x => new Task((reject, resolve) => resolve(x.unsafePerform()));
// maybeToTask :: Maybe a -> Task () a
const maybeToTask = x => (x.isNothing ? Task.rejected() : Task.of(x.$value));
// arrayToMaybe :: [a] -> Maybe a
const arrayToMaybe = x => Maybe.of(x[0]);단순히 하나의 펑터를 다른 펑터로 변경하는 작업입니다. 가치 손실 없이 map이 가능해야 함을 의미합니다. 이는 변환 후에도 map이 정의에 따라 작동해야 함을 의미합니다.
효과 변환 관점에서 ioToTask는 동기를 비동기로, arrayToMaybe는 비결정론적 상황을 실패 가능성으로 변환합니다. 자바스크립트에서는 비동기를 동기로 변환할 수 없으므로 taskToIO 작성은 불가능합니다.
기능 선호
List의 sortBy 같은 기능 사용 시 자연 변환을 통해 타입 변환 후 안전하게 map 적용이 가능합니다.
// arrayToList :: [a] -> List a
const arrayToList = List.of;
const doListyThings = compose(sortBy(h), filter(g), arrayToList, map(f));
const doListyThings_ = compose(sortBy(h), filter(g), map(f), arrayToList); // law appliedarrayToList 적용만으로 [a]를 List a로 변환하여 sortBy 사용이 가능해집니다.
doListyThings_에서 보듯 자연 변환 왼쪽에 map(f)을 배치하여 연산 최적화/퓨전이 용이해집니다.
동형 자바스크립트
정보 손실 없는 양방향 변환이 가능하면 *동형(Isomorphism)*이라고 합니다. 이는 다음 자연 변환 쌍으로 증명합니다:
// promiseToTask :: Promise a b -> Task a b
const promiseToTask = x => new Task((reject, resolve) => x.then(resolve).catch(reject));
// taskToPromise :: Task a b -> Promise a b
const taskToPromise = x => new Promise((resolve, reject) => x.fork(reject, resolve));
const x = Promise.resolve('ring');
taskToPromise(promiseToTask(x)) === x;
const y = Task.of('rabbit');
promiseToTask(taskToPromise(y)) === y;Q.E.D. Promise와 Task는 동형입니다. arrayToMaybe는 정보 손실로 동형이 아닙니다.
// maybeToArray :: Maybe a -> [a]
const maybeToArray = x => (x.isNothing ? [] : [x.$value]);
// arrayToMaybe :: [a] -> Maybe a
const arrayToMaybe = x => Maybe.of(x[0]);
const x = ['elvis costello', 'the attractions'];
// not isomorphic
maybeToArray(arrayToMaybe(x)); // ['elvis costello']
// but is a natural transformation
compose(arrayToMaybe, map(replace('elvis', 'lou')))(x); // Just('lou costello')
// ==
compose(map(replace('elvis', 'lou')), arrayToMaybe)(x); // Just('lou costello')이들은 양측 map 적용 시 동일 결과를 보장하는 자연 변환이지만, 동형은 아닙니다. 동형 개념은 매우 강력하고 보편적입니다.
확장된 정의
이 구조 변환 함수들은 타입 변환에만 국한되지 않습니다.
다양한 예시:
reverse :: [a] -> [a]
join :: (Monad m) => m (m a) -> m a
head :: [a] -> a
of :: a -> f a자연 변환 법칙은 이들에도 적용됩니다. head :: [a] -> a를 Identity a로 보는 것도 가능하며, a와 Identity a가 동형임을 증명할 수 있습니다.
중첩 해결 방안
코미디 같은 타입 서명에 자연 변환을 적용하여 타입을 일관되게 만들고 join 가능하게 합니다.
// getValue :: Selector -> Task Error (Maybe String)
// postComment :: String -> Task Error Comment
// validate :: String -> Either ValidationError String
// saveComment :: () -> Task Error Comment
const saveComment = compose(
chain(postComment),
chain(eitherToTask),
map(validate),
chain(maybeToTask),
getValue('#comment'),
);chain(maybeToTask)와 chain(eitherToTask) 추가로 중첩을 원천 차단합니다. 창가의 비둘기 방지 철제 장식처럼 원천 차단하는 것으로, 파리 속담 "Mieux vaut prévenir que guérir"(예방이 치료보다 낫다)가 적용됩니다.
요약
자연 변환은 펑터 구조 변환 함수로 범주론의 핵심 개념입니다. 타입 변환을 통해 구성 안정성을 보장하며 중첩 타입 문제 해결에 유용합니다. 단, 과도한 동질화는 유연성 저하를 초래할 수 있습니다.
타입 정렬 작업은 구현에 따르는 대가입니다. 다음 장에서는 Traversable 개념을 통해 타입 재정렬을 다룰 예정입니다.
제12장: 돌을 가로지르며(Traversing the Stone)
연습문제
연습해 보세요!
Either b a를 Maybe a로 변환하는 자연 변환을 작성하세요
// eitherToMaybe :: Either b a -> Maybe a
const eitherToMaybe = undefined;
// eitherToTask :: Either a b -> Task a b
const eitherToTask = either(Task.rejected, Task.of);연습해 보세요!
eitherToTask를 사용해 findNameById의 중첩 Either를 제거하세요
// findNameById :: Number -> Task Error (Either Error User)
const findNameById = compose(map(map(prop('name'))), findUserById);
다음 함수들이 연습문제 컨텍스트에서 사용 가능합니다:
split :: String -> String -> [String]
intercalate :: String -> [String] -> String연습해 보세요!
String과 [Char] 간 동형을 작성하세요 // strToList :: String -> [Char] const strToList = undefined; // listToStr :: [Char] -> String const listToStr = undefined;