Reader, Writer, State 모나드

세 모나드는 각각 의존성 주입, 로깅, 상태 관리라는 흔한 문제를 순수하게 해결합니다.

Reader 모나드

의존성(환경)을 함수에 암묵적으로 전달하는 패턴입니다.

문제

// 매 함수마다 config, db, logger를 전달해야 함
function getUser(id: string, db: Database, logger: Logger): User {
  logger.info(`getUser: ${id}`);
  return db.find(id);
}
 
function processUser(id: string, db: Database, logger: Logger, config: Config): ProcessedUser {
  const user = getUser(id, db, logger); // 다시 전달
  return transform(user, config);
}

Reader 구현

// Reader<R, A> = "R 환경이 주어지면 A를 반환하는 함수"
class Reader<R, A> {
  constructor(private readonly run: (env: R) => A) {}
 
  static of<R, A>(value: A): Reader<R, A> {
    return new Reader(() => value);
  }
 
  static ask<R>(): Reader<R, R> {
    return new Reader(env => env); // 환경 자체를 반환
  }
 
  map<B>(fn: (a: A) => B): Reader<R, B> {
    return new Reader(env => fn(this.run(env)));
  }
 
  flatMap<B>(fn: (a: A) => Reader<R, B>): Reader<R, B> {
    return new Reader(env => fn(this.run(env)).run(env));
  }
 
  provide(env: R): A {
    return this.run(env); // 환경을 주입해 실행
  }
}

Reader로 의존성 주입

type AppEnv = { db: Database; logger: Logger; config: Config };
 
// 환경을 암묵적으로 사용하는 함수
const getUser = (id: string): Reader<AppEnv, User> =>
  Reader.ask<AppEnv>().flatMap(({ db, logger }) => {
    logger.info(`getUser: ${id}`);
    return Reader.of(db.find(id));
  });
 
const processUser = (id: string): Reader<AppEnv, ProcessedUser> =>
  getUser(id).flatMap(user =>
    Reader.ask<AppEnv>().map(({ config }) => transform(user, config)),
  );
 
// 최외곽에서 한 번만 환경 주입
const env: AppEnv = { db, logger, config };
const result = processUser('123').provide(env);

Reader의 핵심: 의존성을 함수 인자로 일일이 전달하지 않고 Reader.ask()로 필요할 때 꺼냅니다.


Writer 모나드

부수 효과 없이 로그를 쌓는 패턴입니다. 로그를 출력하는 대신 값과 함께 반환합니다.

// Writer<W, A> = 값 A와 로그 W를 함께 가짐
class Writer<W, A> {
  constructor(
    private readonly value: A,
    private readonly log: W[],
  ) {}
 
  static of<W, A>(value: A): Writer<W, A> {
    return new Writer(value, []);
  }
 
  static tell<W>(entry: W): Writer<W, void> {
    return new Writer(undefined as void, [entry]);
  }
 
  map<B>(fn: (a: A) => B): Writer<W, B> {
    return new Writer(fn(this.value), this.log);
  }
 
  flatMap<B>(fn: (a: A) => Writer<W, B>): Writer<W, B> {
    const next = fn(this.value);
    return new Writer(next.value, [...this.log, ...next.log]);
  }
 
  run(): [A, W[]] {
    return [this.value, this.log];
  }
}

Writer로 순수 로깅

type Log = string;
 
function validateAge(age: number): Writer<Log, number> {
  if (age < 0 || age > 150) {
    return Writer.tell<Log>(`유효하지 않은 나이: ${age}`).flatMap(() =>
      Writer.of(0),
    );
  }
  return Writer.of<Log, number>(age).flatMap(a =>
    Writer.tell<Log>(`나이 검증 통과: ${a}`).map(() => a),
  );
}
 
function applyDiscount(price: number, age: number): Writer<Log, number> {
  const discount = age >= 65 ? 0.2 : age < 18 ? 0.1 : 0;
  const discounted = price * (1 - discount);
  return Writer.of<Log, number>(discounted).flatMap(d =>
    Writer.tell<Log>(`할인 적용: ${(discount * 100).toFixed(0)}% → ${d}`).map(() => d),
  );
}
 
const [finalPrice, logs] = Writer.of<Log, number>(0)
  .flatMap(() => validateAge(70))
  .flatMap(age => applyDiscount(10000, age))
  .run();
 
console.log(finalPrice); // 8000
console.log(logs); // ['나이 검증 통과: 70', '할인 적용: 20% → 8000']
// console.log를 직접 호출한 적 없음 — 순수 함수

State 모나드

상태를 암묵적으로 전달하면서 변환하는 패턴입니다.

// State<S, A> = "상태 S를 받아 [결과 A, 새 상태 S]를 반환하는 함수"
class State<S, A> {
  constructor(private readonly run: (state: S) => [A, S]) {}
 
  static of<S, A>(value: A): State<S, A> {
    return new State(s => [value, s]);
  }
 
  static get<S>(): State<S, S> {
    return new State(s => [s, s]); // 현재 상태를 읽음
  }
 
  static put<S>(newState: S): State<S, void> {
    return new State(() => [undefined as void, newState]); // 상태를 교체
  }
 
  static modify<S>(fn: (s: S) => S): State<S, void> {
    return new State(s => [undefined as void, fn(s)]); // 상태를 변환
  }
 
  map<B>(fn: (a: A) => B): State<S, B> {
    return new State(s => {
      const [a, s2] = this.run(s);
      return [fn(a), s2];
    });
  }
 
  flatMap<B>(fn: (a: A) => State<S, B>): State<S, B> {
    return new State(s => {
      const [a, s2] = this.run(s);
      return fn(a).run(s2);
    });
  }
 
  execute(initialState: S): [A, S] {
    return this.run(initialState);
  }
}

State로 스택 구현

type Stack = number[];
 
const push = (n: number): State<Stack, void> =>
  State.modify<Stack>(stack => [n, ...stack]);
 
const pop: State<Stack, number> = new State(stack => {
  const [head, ...tail] = stack;
  return [head, tail];
});
 
const peek: State<Stack, number> = State.get<Stack>().map(s => s[0]);
 
// 상태 변이 없이 스택 연산 조합
const program = push(1)
  .flatMap(() => push(2))
  .flatMap(() => push(3))
  .flatMap(() => pop)
  .flatMap(top => peek.map(next => `꺼낸 값: ${top}, 다음: ${next}`));
 
const [result, finalStack] = program.execute([]);
console.log(result);      // "꺼낸 값: 3, 다음: 2"
console.log(finalStack);  // [2, 1]

State로 ID 생성기

type IdState = { nextId: number };
 
const generateId: State<IdState, number> = new State(s => [s.nextId, { nextId: s.nextId + 1 }]);
 
const createThreeUsers = generateId.flatMap(id1 =>
  generateId.flatMap(id2 =>
    generateId.map(id3 => [
      { id: id1, name: 'Alice' },
      { id: id2, name: 'Bob' },
      { id: id3, name: 'Carol' },
    ]),
  ),
);
 
const [users, finalState] = createThreeUsers.execute({ nextId: 1 });
// users: [{id:1, name:'Alice'}, {id:2, name:'Bob'}, {id:3, name:'Carol'}]
// finalState: { nextId: 4 }

세 모나드 비교

모나드해결하는 문제패턴
Reader의존성 주입환경을 암묵적으로 전달
Writer부수 효과 없는 로깅로그를 값과 함께 반환
State순수한 상태 변환상태를 인자/반환으로 명시

세 모나드 모두 부수 효과를 타입으로 표현해 순수 함수 안에서 다루는 방법입니다.

실무에서

TypeScript에서는 이 모나드들을 직접 구현하기보다 아이디어를 차용합니다.

  • Reader: 함수에 deps 객체를 첫 인자로 받거나, DI 컨테이너 사용
  • Writer: 함수가 { result, logs } 형태를 반환
  • State: reducer 패턴 ((state, action) => state)

Redux의 reducer가 State 모나드의 정신을 그대로 따릅니다. 엄격한 구현보다 핵심 아이디어를 상황에 맞게 적용하는 것이 중요합니다.