제9장: 모나딕 양파 이론
뾰족한 펑터 팩토리
더 진행하기 전에 실토할 것이 있습니다: 지금까지 우리가 사용한 타입에 추가한 of 메서드에 대해 완전히 정직하게 설명하지 않았습니다. 사실 이 메서드는 단순히 new 키워드를 피하기 위한 것이 아니라, '값을 기본 환경에 자연스럽게 배치하는(default minimal context)' 작업을 위한 것입니다. of는 실제로 생성자를 대체하지 않습니다. 이는 *포인티드(Pointed)*라고 부르는 중요한 인터페이스의 일부입니다.
포인티드 펑터는
of메서드를 가진 펑터입니다
핵심은 어떤 값이든 타입에 담은 후 즉시 매핑 작업을 시작할 수 있는 능력입니다.
IO.of('tetris').map(concat(' master'));
// IO('tetris master')
Maybe.of(1336).map(add(1));
// Maybe(1337)
Task.of([{ id: 2 }, { id: 3 }]).map(map(prop('id')));
// Task([2,3])
Either.of('The past, present and future walk into a bar...').map(concat('it was tense.'));
// Right('The past, present and future walk into a bar...it was tense.')IO와 Task 생성자는 함수를 인자로 기대하지만, Maybe와 Either는 그렇지 않습니다. 이 인터페이스는 생성자의 복잡성 없이 일관된 방식으로 펑터에 값을 넣을 수 있게 합니다. '기본 환경 배치'라는 개념은 생성자를 우회해 값이 타입 시스템 내에서 자연스럽게 작동할 수 있도록 설계되었습니다. 우리는 값을 타입으로 '들여 올려(lift)' 펑터의 기대 동작대로 일반적인 방식으로 map을 적용할 수 있습니다.
중요한 수정 사항은 Left.of는 논리적으로 의미가 없습니다. Either에서 값을 넣는 유일한 방법은 new Right(x)입니다. Right을 사용해 of를 정의하는 이유는 맵핑 가능한 타입이 반드시 맵핑을 수행해야 하기 때문입니다. Left는 이러한 패턴을 따르지 않습니다.
pure, point, unit, return과 같은 함수를 들어본 적이 있을 겁니다. 이 모두 신비로운 of 메서드의 국제적 별칭입니다. 모나드 사용 시 of는 값을 직접 타입에 재배치해야 하는 책임이 있기 때문에 중요해집니다.
new 키워드 회피를 위해 JavaScript 트릭이나 라이브러리를 사용하고, folktale, ramda, fantasy-land의 펑터 인스턴스를 추천합니다. 이들은 new 없이 올바른 of 메서드를 제공합니다.
은유 뒤섞기

모나드는 양파와 같은 구조적 특성을 지닙니다. 일반적인 사례로 설명드리겠습니다:
const fs = require('fs');
// readFile :: String -> IO String
const readFile = filename => new IO(() => fs.readFileSync(filename, 'utf-8'));
// print :: String -> IO String
const print = x => new IO(() => {
console.log(x);
return x;
});
// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);
cat('.git/config');
// IO(IO('[core]\nrepositoryformatversion = 0\n'))map 과정에서 print가 두 번째 IO를 도입하여 IO 안에 또 다른 IO가 갇힌 상황입니다. 문자열 작업을 계속하려면 map(map(f))을, 효과 관측에는 unsafePerformIO().unsafePerformIO()가 필요합니다.
// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);
// catFirstChar :: String -> IO (IO String)
const catFirstChar = compose(map(map(head)), cat);
catFirstChar('.git/config');
// IO(IO('['))두 개의 효과가 패키징된 것은 좋지만, 두 개의 방호복을 입는 것처럼 불편함이 있습니다. 또 다른 사례를 살펴보겠습니다:
// safeProp :: Key -> {Key: a} -> Maybe a
const safeProp = curry((x, obj) => Maybe.of(obj[x]));
// safeHead :: [a] -> Maybe a
const safeHead = safeProp(0);
// firstAddressStreet :: User -> Maybe (Maybe (Maybe Street))
const firstAddressStreet = compose(
map(map(safeProp('street'))),
map(safeHead),
safeProp('addresses'),
);
firstAddressStreet({
addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
});
// Maybe(Maybe(Maybe({name: 'Mulburry', number: 8402})))중첩 펑터 상황에서 호출자가 값을 얻기 위해 세 번의 map을 호출해야 하는 것은 비현실적입니다. 이 문제 해결에 모나드가 필요합니다.
모나드는 양파의 겹겹이 껍질을 벗기듯 복잡한 구조를 가집니다. map으로 중첩 펑터 레이어를 벗길 때 복잡도가 증가하는 특성이 있습니다. join 메서드로 이 문제를 해결할 수 있습니다.
const mmo = Maybe.of(Maybe.of('nunchucks'));
// Maybe(Maybe('nunchucks'))
mmo.join();
// Maybe('nunchucks')
const ioio = IO.of(IO.of('pizza'));
// IO(IO('pizza'))
ioio.join();
// IO('pizza')
const ttt = Task.of(Task.of(Task.of('sewers')));
// Task(Task(Task('sewers')));
ttt.join();
// Task(Task('sewers'))동일 타입의 두 레이어를 join으로 결합하여 중첩을 제거할 수 있습니다. 이 결합능력이 모나드를 모나드로 만듭니다. 더 정확한 정의로 접근해 보겠습니다:
모나드는 중첩을 제거할 수 있는 포인티드 펑터입니다
join 메서드를 정의하고 of 메서드를 가지며 몇 가지 법칙을 준수하는 펑터가 모나드입니다. Maybe에 join을 구현해 보겠습니다:
Maybe.prototype.join = function join() {
return this.isNothing() ? Maybe.of(null) : this.$value;
};Maybe(Maybe(x))의 경우 .$value로 불필요한 레이어를 제거합니다. 그렇지 않으면 원래 Maybe가 유지됩니다.
이제 join 메서드로 firstAddressStreet 예제를 개선해 보겠습니다:
// join :: Monad m => m (m a) -> m a
const join = mma => mma.join();
// firstAddressStreet :: User -> Maybe Street
const firstAddressStreet = compose(
join,
map(safeProp('street')),
join,
map(safeHead), safeProp('addresses'),
);
firstAddressStreet({
addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
});
// Maybe({name: 'Mulburry', number: 8402})중첩된 Maybe에 join을 적용해 제어합니다. IO에도 동일하게 적용합니다.
IO.prototype.join = function() {
const $ = this;
return new IO(() => $.unsafePerformIO().unsafePerformIO());
};두 IO 레이어를 순차적으로 실행하며 순수성(purity)을 훼손하지 않았습니다. 과도한 포장을 단일 패키지로 재구성했습니다.
// log :: a -> IO a
const log = x => new IO(() => {
console.log(x);
return x;
});
// setStyle :: Selector -> CSSProps -> IO DOM
const setStyle =
curry((sel, props) => new IO(() => jQuery(sel).css(props)));
// getItem :: String -> IO String
const getItem = key => new IO(() => localStorage.getItem(key));
// applyPreferences :: String -> IO DOM
const applyPreferences = compose(
join,
map(setStyle('#main')),
join,
map(log),
map(JSON.parse),
getItem,
);
applyPreferences('preferences').unsafePerformIO();
// Object {backgroundColor: "green"}
// <div style="background-color: 'green'"/>getItem이 IO String을 반환하므로 파싱에 map을 사용합니다. log와 setStyle의 IO 중첩은 join으로 관리합니다.
연쇄의 충격

map 후 join 호출 패턴을 추상화해 chain 함수를 만듭니다.
// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = curry((f, m) => m.map(f).join());
// or
// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = f => compose(join, map(f));평탄화 맵핑(flatMap) 또는 >>=(바인드)로도 알려진 chain으로 예제를 리팩토링합니다. JavaScript에선 chain이 표준 용어입니다.
// map/join
const firstAddressStreet = compose(
join,
map(safeProp('street')),
join,
map(safeHead),
safeProp('addresses'),
);
// chain
const firstAddressStreet = compose(
chain(safeProp('street')),
chain(safeHead),
safeProp('addresses'),
);
// map/join
const applyPreferences = compose(
join,
map(setStyle('#main')),
join,
map(log),
map(JSON.parse),
getItem,
);
// chain
const applyPreferences = compose(
chain(setStyle('#main')),
chain(log),
map(JSON.parse),
getItem,
);chain은 효과 중첩을 통해 순차 실행과 변수 할당을 함수형으로 캡처합니다. 명시적인 변수 할당보다 간결합니다.
// getJSON :: Url -> Params -> Task JSON
getJSON('/authenticate', { username: 'stale', password: 'crackers' })
.chain(user => getJSON('/friends', { user_id: user.id }));
// Task([{name: 'Seimith', id: 14}, {name: 'Ric', id: 39}]);
// querySelector :: Selector -> IO DOM
querySelector('input.username')
.chain(({ value: uname }) =>
querySelector('input.email')
.chain(({ value: email }) => IO.of(`Welcome ${uname} prepare for spam at ${email}`))
);
// IO('Welcome Olivia prepare for spam at olivia@tremorcontrol.net');
Maybe.of(3)
.chain(three => Maybe.of(2).map(add(three)));
// Maybe(5);
Maybe.of(null)
.chain(safeProp('address'))
.chain(safeProp('street'));
// Maybe(null);컴포즈 대신 chain 중위 표기를 사용해 가독성을 높입니다. 체인은 map과 join의 조합으로 자동 유도 가능하며, chain(id)로 join을 정의할 수 있습니다. fantasyland 사양서에서 더 많은 유도 사례를 확인할 수 있습니다.
첫 예제에선 두 비동기 작업(user 조회 후 친구 찾기)을 체인으로 순차 처리합니다. Task(Task([Friend])) 상황을 방지합니다.
querySelector로 입력값을 찾고 환영 메시지 생성 시 내부 함수에서 uname과 email에 접근합니다. IO.of로 값을 안전히 반환하며, 이는 모나드 인터페이스의 근간입니다.
querySelector('input.username').chain(({ value: uname }) =>
querySelector('input.email').map(({ value: email }) =>
`Welcome ${uname} prepare for spam at ${email}`));
// IO('Welcome Olivia prepare for spam at olivia@tremorcontrol.net');Maybe 사용 예제에선 null 발생 시 계산을 즉시 중단합니다.
초보자에게 어려울 수 있지만, 직접 실험해 볼 것을 권장합니다. 정규 값 반환엔 map을, 펑터 반환시엔 chain을 사용하세요. 다음 장에선 애플리커티브를 다룹니다.
서로 다른 중첩 타입엔 펑터 조합이나 모나드 트랜스포머가 필요합니다.
파워 트립
컨테이너 스타일 프로그래밍은 가끔 혼란스럽습니다. 값의 중첩 깊이나 map/chain 선택에 어려움을 겪을 수 있습니다. inspect 구현 등 디버깅 기법과 효과 스택 처리로 개선 가능합니다.
모나드 방식의 강력함을 보여드리겠습니다.
파일 읽기 후 업로드 예제:
// readFile :: Filename -> Either String (Task Error String)
// httpPost :: String -> String -> Task Error JSON
// upload :: Filename -> Either String (Task Error JSON)
const upload = compose(map(chain(httpPost('/uploads'))), readFile);Either로 입력 검증, Task로 파일 접근 오류 처리, httpPost 업로드 실패 처리 등 세 가지 오류를 방지합니다. 체인으로 중첩 비동기 작업을 수행합니다.
선언적 스타일의 순수 함수로 구성되며, 방정식 추론이 가능합니다. 특정 API가 아닌 일반 인터페이스를 사용합니다.
명령형 방식과 비교:
// upload :: Filename -> (String -> a) -> Void
const upload = (filename, callback) => {
if (!filename) {
throw new Error('You need a filename!');
} else {
readFile(filename, (errF, contents) => {
if (errF) throw errF;
httpPost('/uploads', contents, (errH, json) => {
if (errH) throw errH;
callback(json);
});
});
}
};변화무쌍한 미로 같은 구조입니다. 변이(mutation)가 추가되면 재앙이 될 수 있습니다.
이론
첫 번째 법칙은 (익숙한 방식과 다른) 결합법칙입니다.
// associativity
compose(join, map(join)) === compose(join, join);중첩 모나드 처리 시 내부/외부 타입을 먼저 조인해도 결과 동일:

바깥쪽 두 M(M(Ma))부터 조인하거나 안쪽을 먼저 조인(map(join))해도 최종 Ma는 같습니다. 중간 단계 값은 달라도 최종 조인 결과는 동일합니다.
두 번째 법칙: 항등법칙
// identity for all (M a)
compose(join, of) === compose(join, map(of)) === id;모나드 M에 대해 of 후 join은 id와 동등합니다. 삼각형 항등식으로 시각화 가능: M → M(Ma) → Ma 경로와 M → M(Ma) → Ma 경로 결과 동일

특정 모나드에 맞는 M.of를 사용해야 합니다.
항등/결합 법칙은 카테고리 법칙입니다.
모나드는 클라이슬리 카테고리를 형성합니다. 객체는 모나드, 사상(morphism)은 체인 함수입니다. 이론적 배경을 일부 소개하며 실제 활용에 초점을 맞춥니다.
const mcompose = (f, g) => compose(chain(f), g);
// left identity
mcompose(M, f) === f;
// right identity
mcompose(f, M) === f;
// associativity
mcompose(mcompose(f, g), h) === mcompose(f, mcompose(g, h));요약
모나드는 중첩 연산을 단순화합니다. 변수 할당, 순차 효과, 비동기 작업을 피라미드형 코드 없이 처리합니다. '포인티드'가 박스 해제된 값을 제공하고 작업 완료 후 재배치를 보장합니다.
여러 API 호출 병렬 실행이나 다중 검증 수집에는 모나드의 한계가 있습니다. 다음 장에선 애플리커티브 펑터를 소개합니다.
연습 문제
사용자 객체 예시:
const user = {
id: 1,
name: 'Albert',
address: {
street: {
number: 22,
name: 'Walnut St',
},
},
};연습해 보세요!
safeProp과 map/join 또는 chain을 사용해 주어진 사용자의 거리 이름을 안전하게 추출하세요
// getStreetName :: User -> Maybe String
const getStreetName = undefined;
다음 항목을 고려하세요:
// getFile :: IO String
const getFile = IO.of('/home/mostly-adequate/ch09.md');
// pureLog :: String -> IO ()
const pureLog = str => new IO(() => console.log(str));연습해 보세요!
getFile로 파일 경로 추출 → 디렉토리 제거 → 순수 로깅. 힌트: split과 last 사용
// logFilename :: IO ()
const logFilename = undefined;
다음 서명의 헬퍼 사용:
// validateEmail :: Email -> Either String Email
// addToMailingList :: Email -> IO([Email])
// emailBlast :: [Email] -> IO ()연습해 보세요!
validateEmail, addToMailingList, emailBlast을 조합해 유효한 이메일을 목록에 추가 후 전체 알림
// joinMailingList :: Email -> Either String (IO ())
const joinMailingList = undefined;