모나드
감싼 값을 꺼내 다음 계산에 넘기는 bind로, 실패·부재·비동기 같은 문맥을 가진 계산을 이어 붙이는 함수형 패턴.
· 마지막 업데이트: 2026-10-25 · 16 min read
개요
사용자 번호로 사용자를 찾고, 그 사용자의 팀을 거쳐 팀 관리자의 이메일을 찾는다고 하자. 사용자나 팀이 없을 수도 있고 이메일이 등록되지 않았을 수도 있어서, 각 조회는 값이 있는 Some과 없는 None을 타입으로 구분한 Option<T>를 돌려준다.
여기에 펑터의 연산인 Map을 쓰면 곤란해진다. FindTeam이 이미 Option<Team>을 돌려주는데 Option<User>에 Map(FindTeam)을 적용하면 바깥을 한 번 더 감싸 Option<Option<Team>>이 되니, 단계를 이을수록 겹이 쌓여 매번 직접 풀어야 한다.
모나드(monad)는 이 연결을 bind에 맡긴다. 감싼 값이 있으면 꺼내 다음 함수에 넘기고 그 함수가 돌려준 Option을 그대로 쓰며, 값이 없으면 다음 함수도 부르지 않는다. 여기에 값을 문맥에 넣는 return(unit이라고도 한다)과 두 연산이 지킬 세 법칙을 함께 묶은 것이 모나드다. 문맥은 값과 함께 따라다니는 계산의 사정으로, 값이 없을 수 있다는 사실이나 실패, 여러 결과, 비동기 완료 같은 것을 가리킨다.
언어에 따라 bind의 이름도 달라서 Haskell은 >>=, JavaScript 배열과 Scala는 flatMap, C# LINQ는 SelectMany를 쓴다. 여기서는 직접 만든 Option<T>의 메서드를 Bind라고 이름 붙인다.
흐름
Map이 함수의 결과를 다시 감싸는 반면 Bind는 그 Option을 그대로 넘기니, 단계가 늘어도 한 겹으로 이어진다. 중간에 None이 나오면 남은 함수는 호출하지 않는다.
역사
Eugenio Moggi는 1991년 논문 "Notions of computation and monads"에서 값의 대상 A와 계산의 대상 TA를 구분하고, 타입 생성자 T를 계산 개념(notion of computation)이라고 불렀다. 예로 든 계산 개념은 부분성, 비결정성, 부수 효과, 예외, 연속(continuation), 대화식 입출력이다. 이 논문의 목적은 프로그램의 동치를 증명하는 의미론과 논리였다.1
Philip Wadler는 같은 기법을 함수형 프로그램의 구조를 짜는 데 가져왔다. 1992년 POPL 초청 강연 논문 "The essence of functional programming"은 간단한 인터프리터에 오류 메시지, 상태, 출력, 비결정적 선택을 모나드로 덧붙인다.2 같은 해 Marktoberdorf 여름학교 강의 노트로 처음 나온 "Monads for functional programming"은 세 법칙(왼쪽 단위, 오른쪽 단위, 결합)을 정리하고, join이 두 겹의 계산을 한 겹으로 편다는 점과 리스트가 모나드를 이룬다는 점을 보인다. 이 노트는 1995년 Advanced Functional Programming(LNCS 925)에 개정판으로 실렸다.3
Haskell은 1996년 5월의 1.3판에서 >>=, >>, return을 묶은 Monad 클래스를 언어의 중요한 부분으로 두고, 모나드 바인딩을 편하게 쓰는 do 표기와 모나드 기반 I/O를 표준으로 받아들였다.45
왜 사용하는가?
실패할 수 있는 단계를 이을 때마다 쓰던 "값이 없으면 멈춘다"는 규칙을 Bind 한 곳에 모으면, 각 단계에는 값이 있을 때의 처리만 적고 호출부에서도 같은 if를 되풀이하지 않아도 된다.
이 연결은 Option 밖에서도 반복된다. IEnumerable<T>의 SelectMany는 각 원소에서 나온 시퀀스를 이어 붙이고, 결과 타입의 bind는 첫 오류에서 멈추는데, 문맥마다 다음으로 넘길 값이 무엇을 뜻하는지는 달라도 단계를 잇는 모양은 같다.
세 법칙은 이 연결을 다시 묶어도 의미가 바뀌지 않게 하는 약속이다. 왼쪽 항등(left identity)은 Unit(a).Bind(f)가 f(a)와 같다는 것, 오른쪽 항등(right identity)은 m.Bind(Unit)이 m과 같다는 것, 결합(associativity)은 m.Bind(f).Bind(g)가 m.Bind(x => f(x).Bind(g))와 같다는 것이다. 결합 법칙이 있으므로 연결의 일부를 별도 함수로 뽑아내도 결과가 같다.6 C# 컴파일러는 이 법칙을 검사하지 않으므로, 직접 만든 타입이 법칙을 지키는지는 테스트로 확인해야 한다.
C# 쿼리 구문은 이 패턴을 문법으로 지원한다. 명세에 따르면 쿼리 식은 형식 바인딩과 오버로드 해석 전에 Select, SelectMany 같은 이름의 메서드 호출로 바뀌는 문법적 대응이고, 그 메서드는 인스턴스 메서드여도 확장 메서드여도 된다. 두 번째 from 절이 붙은 쿼리는 SelectMany(x1 => e2, (x1, x2) => v) 호출이 된다.7 그래서 직접 만든 Option<T>에 Select와 SelectMany만 정의하면 from ... from ... select 구문을 쓸 수 있다.
Option은 값이 없다는 사실만 남기고 이유는 버리므로, 어떤 실패를 구분하고 호출자에게 무엇을 알릴지는 설계에서 정해야 한다. 이유까지 필요하다면 오류 값을 담는 결과 타입을 쓴다.
서로 다른 모나드를 겹쳐 쓰기는 C#에서 번거롭다. 예를 들어 Task<Option<T>>를 이어 붙이려면 await로 한 겹을 풀고 Bind로 다른 겹을 풀어야 한다. Haskell은 모나드 변환자로 문맥을 쌓지만, C# 제네릭은 Option<>이나 Task<> 같은 타입 생성자 자체를 형식 매개변수로 받지 못해 모든 모나드에 공통인 Bind 인터페이스를 선언할 수 없다. C# 쿼리 식도 인터페이스가 아니라 메서드 이름과 시그니처의 규칙(쿼리 식 패턴)으로 정의되어 있다.
async/await도 앞 작업의 결과가 준비되면 다음 단계를 이어 간다는 점에서 bind와 닮았다. 하지만 C# 명세에서 await는 GetAwaiter, IsCompleted, GetResult로 정의되는 awaiter 패턴이며 SelectMany로 번역되지 않는다.8 표준 라이브러리의 Task<T>에는 Select와 SelectMany가 없어서 from x in task select x는 컴파일 오류 CS1936이 난다.
예제
문자열을 정수로 바꾸는 ParseInt와 짝수만 반으로 나누는 Half를 이어 보자. 둘 다 실패할 수 있어 Option<int>를 돌려주며, C#에는 표준 Option 타입이 없으니 Some과 None을 레코드로 만들고 switch 식의 패턴 매칭으로 나눈다. 레코드의 값 기반 동등성 덕분에 법칙을 확인할 때 ==도 바로 쓸 수 있다.
using System;
using System.Linq;
// 문자열을 정수로 바꾼다. 실패할 수 있으므로 Option을 돌려준다.
static Option<int> ParseInt(string s) =>
int.TryParse(s, out var n) ? new Some<int>(n) : new None<int>();
// 짝수만 반으로 나눈다. 이 단계도 실패할 수 있다.
static Option<int> Half(int n) =>
n % 2 == 0 ? new Some<int>(n / 2) : new None<int>();
// 값을 문맥에 넣기만 하는 연산(return, unit)이다.
static Option<int> Unit(int n) => new Some<int>(n);
Option<Option<int>> nested = ParseInt("8").Map(Half);
Console.WriteLine($"Map: {nested}");
Console.WriteLine($"Bind: {ParseInt("8").Bind(Half)}");
Console.WriteLine($"Bind: {ParseInt("7").Bind(Half)}");
Console.WriteLine($"Bind: {ParseInt("x").Bind(Half)}");
// 세 법칙을 몇 개의 값으로 확인한다. 증명이 아니라 예시 확인이다.
int[] values = [8, 7];
Option<int>[] ms = [ParseInt("8"), ParseInt("6"), ParseInt("x")];
Console.WriteLine("left identity: " + values.All(a => Unit(a).Bind(Half) == Half(a)));
Console.WriteLine("right identity: " + ms.All(m => m.Bind(Unit) == m));
Console.WriteLine("associativity: " + ms.All(m =>
m.Bind(Half).Bind(Half) == m.Bind(x => Half(x).Bind(Half))));
abstract record Option<T>;
sealed record Some<T>(T Value) : Option<T>
{
public override string ToString() => $"Some({Value})";
}
sealed record None<T> : Option<T>
{
public override string ToString() => "None";
}
static class OptionExtensions
{
// Map: 값이 있으면 f를 적용하고 결과를 다시 Some으로 감싼다.
public static Option<U> Map<T, U>(this Option<T> option, Func<T, U> f) => option switch
{
Some<T>(var value) => new Some<U>(f(value)),
_ => new None<U>(),
};
// Bind: 값이 있으면 f에 넘기고, f가 돌려준 Option을 그대로 결과로 쓴다.
public static Option<U> Bind<T, U>(this Option<T> option, Func<T, Option<U>> f) => option switch
{
Some<T>(var value) => f(value),
_ => new None<U>(),
};
}Map: Some(Some(4))
Bind: Some(4)
Bind: None
Bind: None
left identity: True
right identity: True
associativity: TrueMap은 Some(Some(4))처럼 두 겹을 만들고, Bind는 "7"에서는 Half가 돌려준 None을, "x"에서는 Half를 부르지 않은 채 None을 낸다.
범위 넓힌 예제
개요의 조회 연결을 중첩 if, Bind 직접 호출, LINQ 쿼리 구문의 세 가지로 쓴다. 쿼리 구문을 쓰려고 Option<T>에 Select와 인자 두 개를 받는 SelectMany를 확장 메서드로 추가했다. 1번은 모든 단계가 성공하고, 2번은 팀이 없고, 3번은 관리자 이메일이 없고, 99번은 사용자가 없다. 마지막 쿼리는 같은 구문을 IEnumerable<T>에 쓴 것으로, 리스트에서 bind는 모든 조합을 이어 붙인다.
using System;
using System.Collections.Generic;
using System.Linq;
var users = new Dictionary<int, User>
{
[1] = new("Alice", "core"),
[2] = new("Bob", null),
[3] = new("Dave", "ops"),
};
var teams = new Dictionary<string, Team>
{
["core"] = new("Core", "carol"),
["ops"] = new("Ops", "erin"),
};
// erin은 이메일이 등록되어 있지 않다.
var emails = new Dictionary<string, string> { ["carol"] = "carol@example.com" };
Option<User> FindUser(int id) =>
users.TryGetValue(id, out var user) ? new Some<User>(user) : new None<User>();
Option<Team> FindTeam(User user) =>
user.TeamId is string teamId && teams.TryGetValue(teamId, out var team)
? new Some<Team>(team) : new None<Team>();
Option<string> FindEmail(Team team) =>
emails.TryGetValue(team.ManagerId, out var email) ? new Some<string>(email) : new None<string>();
// 1. 중첩 if: 단계가 늘 때마다 들여쓰기가 한 단계 깊어진다.
Option<string> WithIfs(int id)
{
if (users.TryGetValue(id, out var user))
{
if (user.TeamId is string teamId && teams.TryGetValue(teamId, out var team))
{
if (emails.TryGetValue(team.ManagerId, out var email))
{
return new Some<string>($"{user.Name} -> {email}");
}
}
}
return new None<string>();
}
// 2. Bind 직접 호출: 앞 단계의 user를 마지막에 쓰려면 람다가 안쪽으로 겹친다.
Option<string> WithBind(int id) =>
FindUser(id).Bind(user =>
FindTeam(user).Bind(team =>
FindEmail(team).Map(email => $"{user.Name} -> {email}")));
// 3. 쿼리 구문: 컴파일러가 from 절을 SelectMany 호출로 바꾼다.
Option<string> WithQuery(int id) =>
from user in FindUser(id)
from team in FindTeam(user)
from email in FindEmail(team)
select $"{user.Name} -> {email}";
foreach (var id in new[] { 1, 2, 3, 99 })
{
var a = WithIfs(id);
var b = WithBind(id);
var c = WithQuery(id);
Console.WriteLine($"id {id}: {c}, all three equal: {a == b && b == c}");
}
// IEnumerable<T>의 SelectMany도 같은 구문을 쓴다. 각 원소마다 나온 시퀀스를 이어 붙인다.
var pairs =
from team in new[] { "Core", "Ops" }
from role in new[] { "dev", "qa" }
select $"{team}/{role}";
Console.WriteLine(string.Join(", ", pairs));
record User(string Name, string? TeamId);
record Team(string Name, string ManagerId);
abstract record Option<T>;
sealed record Some<T>(T Value) : Option<T>
{
public override string ToString() => $"Some({Value})";
}
sealed record None<T> : Option<T>
{
public override string ToString() => "None";
}
static class OptionExtensions
{
public static Option<U> Map<T, U>(this Option<T> option, Func<T, U> f) => option switch
{
Some<T>(var value) => new Some<U>(f(value)),
_ => new None<U>(),
};
public static Option<U> Bind<T, U>(this Option<T> option, Func<T, Option<U>> f) => option switch
{
Some<T>(var value) => f(value),
_ => new None<U>(),
};
// 쿼리 구문이 찾는 이름이다. 단일 from ... select는 Select로 번역된다.
public static Option<U> Select<T, U>(this Option<T> option, Func<T, U> f) => option.Map(f);
// 두 번째 from 절은 이 오버로드로 번역된다. Bind 뒤에 결과 선택자를 적용한다.
public static Option<V> SelectMany<T, U, V>(
this Option<T> option, Func<T, Option<U>> f, Func<T, U, V> resultSelector) =>
option.Bind(t => f(t).Map(u => resultSelector(t, u)));
}id 1: Some(Alice -> carol@example.com), all three equal: True
id 2: None, all three equal: True
id 3: None, all three equal: True
id 99: None, all three equal: True
Core/dev, Core/qa, Ops/dev, Ops/qa세 방식은 같은 값을 내지만 2번, 3번, 99번은 모두 None으로만 보여서 어느 단계에서 왜 멈췄는지 알 수 없다. 원인을 남기려면 결과 타입처럼 오류 값을 담는 문맥으로 바꾸고 어떤 실패를 구분할지 정해야 한다. Bind 직접 호출에서는 첫 단계의 user를 마지막에 쓰려고 람다가 안쪽으로 겹친다. 쿼리 구문은 앞의 범위 변수를 뒤에서도 쓸 수 있게 필요한 중간 단계에 함께 전달하며, 명세는 이를 투명 식별자로 설명한다. 익명 객체로 묶어 전달하는 방식이 흔하지만 명세가 그 구현만 요구하지는 않으므로, 실제 객체 할당을 모든 단계에 하나씩 대응시킬 수는 없다. 조회는 메모리 안의 동기 사전 조회이며 비동기 조회와 섞는 경우는 다루지 않는다.
TypeScript 예제
TypeScript에서는 kind 필드로 경우를 나누는 유니온으로 Option을 만들고 bind도 직접 정의한다. C#의 쿼리 구문이 없으므로 앞 단계 값을 마지막에 쓰려면 콜백이 겹치지만, 배열에서는 표준 flatMap으로 map과의 차이를 바로 볼 수 있다.
type Option<T> = { kind: "some"; value: T } | { kind: "none" };
const some = <T>(value: T): Option<T> => ({ kind: "some", value });
const none: Option<never> = { kind: "none" };
const show = <T>(o: Option<T>): string => (o.kind === "some" ? `Some(${o.value})` : "None");
// 값이 있으면 꺼내서 f에 넘기고, f가 돌려준 Option을 그대로 결과로 쓴다.
function bind<T, U>(o: Option<T>, f: (value: T) => Option<U>): Option<U> {
return o.kind === "some" ? f(o.value) : none;
}
type User = { name: string; teamId: string | null };
type Team = { name: string; managerId: string };
const users = new Map<number, User>([
[1, { name: "Alice", teamId: "core" }],
[2, { name: "Bob", teamId: null }],
[3, { name: "Dave", teamId: "ops" }],
]);
const teams = new Map<string, Team>([
["core", { name: "Core", managerId: "carol" }],
["ops", { name: "Ops", managerId: "erin" }],
]);
// erin은 이메일이 등록되어 있지 않다.
const emails = new Map<string, string>([["carol", "carol@example.com"]]);
const fromMap = <K, V>(map: Map<K, V>, key: K): Option<V> =>
map.has(key) ? some(map.get(key) as V) : none;
const findUser = (id: number): Option<User> => fromMap(users, id);
const findTeam = (user: User): Option<Team> =>
user.teamId === null ? none : fromMap(teams, user.teamId);
const findEmail = (team: Team): Option<string> => fromMap(emails, team.managerId);
for (const id of [1, 2, 3, 99]) {
const result = bind(findUser(id), (user) =>
bind(findTeam(user), (team) =>
bind(findEmail(team), (email) => some(`${user.name} -> ${email}`))));
console.log(`id ${id}: ${show(result)}`);
}
// 배열에서는 map이 배열을 겹치고, flatMap이 한 겹으로 이어 붙인다.
const nested = ["Core", "Ops"].map((team) => ["dev", "qa"].map((role) => `${team}/${role}`));
const flat = ["Core", "Ops"].flatMap((team) => ["dev", "qa"].map((role) => `${team}/${role}`));
console.log(JSON.stringify(nested));
console.log(JSON.stringify(flat));id 1: Some(Alice -> carol@example.com)
id 2: None
id 3: None
id 99: None
[["Core/dev","Core/qa"],["Ops/dev","Ops/qa"]]
["Core/dev","Core/qa","Ops/dev","Ops/qa"]map은 배열 안에 배열을 만들고 flatMap은 한 겹으로 이어 붙인다. Option의 bind는 C# 예제와 같이 2번, 3번, 99번을 모두 None으로만 보고한다.
같이 보기
예제 실행 환경: C#은 독립된 .NET 10 콘솔 프로그램, TypeScript는 ES2022 이상으로 컴파일한 뒤 Node.js에서 실행한다.
각주
- Moggi, Eugenio. "Notions of computation and monads". Information and Computation 93(1): 55-92 (1991). Example 1.1. ↩
- Wadler, Philip. "The essence of functional programming". POPL '92, pp. 1-14 (1992). Wadler monads page. ↩
- Wadler, Philip. "Monads for functional programming". Advanced Functional Programming, LNCS 925, pp. 24-52 (1995). 1992년 Marktoberdorf 여름학교 판의 개정판. 3절 Monad laws, 5.1절 Lists. 저자 사본. ↩
- Changes from Haskell 1.2 to Haskell 1.3. Monads, Monadic I/O, do expressions. ↩
- The Haskell definition. "The Haskell version 1.3 report from May 1996". ↩
- base 4.22.0.0, Control.Monad, class Monad. Left identity, Right identity, Associativity. ↩
- C# language specification. §12.23.3 Query expression translation, §12.23.3.5, §12.23.4 The query-expression pattern. ↩
- C# language specification. §12.9.9 Await expressions, §12.9.9.2 Awaitable expressions. ↩