IO 모나드
IO 모나드는 부수 효과를 값으로 표현하는 패턴입니다. “나중에 실행될 부수 효과”를 데이터로 다룰 수 있게 합니다.
문제: 순수 함수와 부수 효과
순수 함수형 프로그래밍에서는 부수 효과가 없어야 합니다. 그런데 실제 프로그램은 파일을 읽고, 네트워크를 호출하고, 콘솔에 출력해야 합니다.
어떻게 순수성을 유지하면서 부수 효과를 다룰까요?
// 부수 효과를 직접 실행하면 순수하지 않음
function getUsername(): string {
console.log('이름을 가져옵니다'); // 부수 효과 실행
return localStorage.getItem('username') ?? 'guest'; // 부수 효과 실행
}IO 모나드의 아이디어
부수 효과를 즉시 실행하지 않고, “나중에 실행할 것”을 기술합니다.
// IO<T>는 "T를 반환하는 부수 효과"를 나타내는 타입
type IO<T> = () => T;
// IO를 만드는 함수들
const readUsername: IO<string> = () => localStorage.getItem('username') ?? 'guest';
const printLine = (msg: string): IO<void> => () => console.log(msg);
const getCurrentTime: IO<Date> = () => new Date();IO<T>는 함수(() => T)일 뿐입니다. 실제 실행은 나중에 합니다.
map과 flatMap
IO를 조합할 수 있어야 합니다.
class IOClass<T> {
constructor(private readonly effect: () => T) {}
// IO를 실행하지 않고 변환을 등록
map<U>(fn: (value: T) => U): IOClass<U> {
return new IOClass(() => fn(this.effect()));
}
// IO를 반환하는 함수를 연결
flatMap<U>(fn: (value: T) => IOClass<U>): IOClass<U> {
return new IOClass(() => fn(this.effect()).run());
}
// 실제로 실행 (프로그램의 최외곽에서만 호출)
run(): T {
return this.effect();
}
}
const io = <T>(effect: () => T): IOClass<T> => new IOClass(effect);
// IO 생성 — 아직 실행 안 됨
const readName = io(() => localStorage.getItem('name') ?? 'guest');
const getTime = io(() => new Date().toISOString());
// 변환 조합 — 아직 실행 안 됨
const greeting = readName.map(name => `Hello, ${name}!`);
const log = (msg: string) => io(() => { console.log(msg); });
// 연쇄 — 아직 실행 안 됨
const program = readName
.flatMap(name => io(() => `Hello, ${name}!`))
.flatMap(msg => log(msg));
// 여기서 비로소 실행
program.run();부수 효과를 프로그램 끝으로 밀어내기
IO 모나드의 핵심 아이디어는 부수 효과 실행을 프로그램의 가장 바깥으로 미루는 것입니다.
// 순수 로직 — IO를 조합만 하고 실행하지 않음
function buildGreeting(name: string): string {
return `Hello, ${name}! 오늘도 좋은 하루 되세요.`;
}
function createProgram(): IOClass<void> {
const readName = io(() => process.env.USER ?? 'World');
const getTime = io(() => new Date().getHours());
return readName.flatMap(name =>
getTime.flatMap(hour => {
const timeGreeting = hour < 12 ? '좋은 아침' : hour < 18 ? '좋은 오후' : '좋은 저녁';
const message = `${timeGreeting}이에요, ${name}!`;
return io(() => console.log(message));
}),
);
}
// main: 프로그램 최외곽에서 실행
const main = createProgram();
main.run(); // 여기서만 부수 효과 발생비동기 IO
비동기 효과는 IO<Promise<T>> 또는 별도의 Task<T> 타입으로 다룹니다.
// Task<T> = IO<Promise<T>> — 비동기 부수 효과
type Task<T> = () => Promise<T>;
const fetchUser = (id: string): Task<User> =>
() => fetch(`/api/users/${id}`).then(r => r.json());
const saveUser = (user: User): Task<void> =>
() => fetch('/api/users', { method: 'POST', body: JSON.stringify(user) }).then(() => {});
// 조합
async function updateUsername(id: string, newName: string): Task<void> {
return async () => {
const user = await fetchUser(id)(); // Task 실행
const updated = { ...user, name: newName };
await saveUser(updated)(); // Task 실행
};
}
// 실행
updateUsername('123', '홍길동')();실무에서의 IO 모나드
순수 Haskell과 달리 TypeScript에서는 IO 모나드를 엄격하게 사용하지 않습니다. 대신 핵심 아이디어를 차용합니다.
효과를 나중에 실행하기 패턴
// 테스트하기 어려운 버전
function processOrder(orderId: string): void {
const order = db.find(orderId); // 실행
const result = calculateTotal(order); // 실행
db.save({ ...order, total: result }); // 실행
emailService.send(order.userId, '주문 처리 완료'); // 실행
}
// IO 아이디어를 차용한 버전 — 효과 기술과 실행 분리
type Effect =
| { type: 'saveOrder'; order: Order }
| { type: 'sendEmail'; userId: string; message: string };
function processOrder(order: Order): Effect[] {
// 순수 함수 — 무엇을 할지만 기술, 실제 실행은 안 함
const total = calculateTotal(order);
return [
{ type: 'saveOrder', order: { ...order, total } },
{ type: 'sendEmail', userId: order.userId, message: '주문 처리 완료' },
];
}
// 실행 레이어 — 여기서만 실제 I/O 발생
async function runEffects(effects: Effect[]): Promise<void> {
for (const effect of effects) {
switch (effect.type) {
case 'saveOrder': await db.save(effect.order); break;
case 'sendEmail': await emailService.send(effect.userId, effect.message); break;
}
}
}
// 사용
const order = await db.find(orderId);
const effects = processOrder(order); // 순수, 테스트 쉬움
await runEffects(effects); // 실제 I/O이 패턴의 장점:
processOrder는 순수 함수 — DB/이메일 없이 테스트 가능- 어떤 효과가 발생할지 데이터로 검사 가능
- 효과의 실행 방식을 쉽게 교체 가능 (테스트용 mock)
fp-ts의 IO
실무에서 IO 모나드를 사용하려면 fp-ts를 활용합니다.
import { IO, map, chain } from 'fp-ts/IO';
import { pipe } from 'fp-ts/function';
const readLine: IO<string> = () => 'user input'; // 실제로는 readline
const program: IO<void> = pipe(
readLine,
map(input => input.toUpperCase()),
chain(upper => () => console.log(upper)),
);
program(); // 실행정리
IO 모나드의 핵심 아이디어:
- 부수 효과를 값으로 표현 —
() => T형태의 지연 실행 - 조합 가능 — map, flatMap으로 효과를 연결
- 실행은 바깥에서 — 순수 코어와 효과 실행 레이어를 분리
TypeScript에서는 엄격한 IO 모나드보다 “효과를 데이터로 표현하고 마지막에 실행한다”는 아이디어를 실용적으로 적용하는 경우가 많습니다. 이 아이디어는 Redux의 action, React의 event handler, 도메인 이벤트 패턴 등 이미 많은 곳에 녹아 있습니다.