모나드, 어렵게 배우지 말고 코드로 이해하는 엔도펑터와 flatMap 결국 '중첩된 세계'

궁금하면 500원·2025년 5월 27일

1. "무엇인가"보다 "어디에 쓰는가"

모나드는 공통된 인터페이스 규약을 가진 데이터 처리의 표준 모델입니다.
각 모나드(IO, List, Promise 등)는 용도가 완전히 다르지만, 내부 로직을 처리하는 방식은 동일한 메커니즘을 공유합니다.

  • 구조적 특징:
  • 래퍼: 실체 값을 감싼 컨텍스트를 가집니다.
  • 연산 보존: flatMap 등의 연산을 수행해도 여전히 래퍼 상태를 유지합니다.
  • 부수 효과 격리: 예외, null, 비동기 제어 등 복잡한 부가 로직을 래퍼 내부에서 은닉하여 처리합니다.
  • 기대 효과:
  • 비즈니스 로직에서 if-null이나 try-catch 같은 노이즈가 사라집니다.
  • 지연 실행: 종단 연산이 호출될 때까지 실행을 미루며 연산을 최적화하거나 제어할 수 있습니다.

2. 합성을 가능하게 하는 조건

함수형 프로그래밍에서 우리가 다루는 대부분의 펑터는 사실 엔도펑터입니다.

  • 일반 펑터: 타입 를 로 바꾸면서, 컨텍스트 자체도 에서 로 바꿀 수 있는 변환입니다.

  • 엔도펑터: 컨텍스트 를 그대로 유지한 채 내부 타입만 로 바꿉니다.
    (예: List<T> -> List<R>)

  • 이 '유지성' 덕분에 함수를 무한히 이어 붙이는 합성이 가능해집니다.

  • 모나드는 엔도펑터의 성질을 포함하며, 여기에 중첩된 컨텍스트를 평평하게 펴주는 능력이 추가된 구조입니다.


3. 중첩된 세계의 해결사

모나드의 핵심 연산인 flatMap은 "세계 속에 중첩된 세계"를 해결하는 알고리즘입니다.

  1. 문제 상황: 모나드 에 어떤 연산을 적용했는데, 그 연산의 결과가 또 다른 모나드 이라면? 결과는 Monad<Monad<R>>이라는 중첩 구조가 됩니다.
  2. 해결 로직: 모나드는 이 중첩된 차원을 단일 차원으로 Flatten/Merge 정책을 내장하고 있습니다.
  3. 다양한 정책: 이 "끌어올리는 방법"이 곧 각 모나드의 개성입니다.
  • List: 중첩된 리스트의 원소들을 꺼내 하나의 리스트로 합침.
  • Either: 중간에 Left가 발생하면 이후의 모든 차원 연산을 무시하고 에러를 유지.
  • Promise: 비동기 작업이 완료될 때까지 기다렸다가 다음 비동기 컨텍스트로 연결.

4. 실전 구현 예시

① 실패 가능성 기술

Left는 흡수원 역할을 하여, 한번 발생하면 이후의 모든 flatMap 연산을 무효화합니다.

sealed interface Either<L, R> {
    class Left<L>(val v: L) : Either<L, Nothing>
    class Right<R>(val v: R) : Either<Nothing, R>

    fun <R2> flatMap(block: (R) -> Either<L, R2>): Either<L, R2> = when (this) {
        is Left -> this // 에러 발생 시 이후 연산 스킵
        is Right -> block(v)
    }
}

② 비동기 순차 제어

then 사실상의 flatMap은 비동기 상태를 관리하며, 이전 비동기가 끝나야 다음 비동기 차원으로 진입하도록 동기화 정책을 내포합니다.


5. 도메인 함수와 기능 함수의 분리

모나드를 사용하는 궁극적인 이유는 순수 비즈니스 로직구동 메커니즘을 분리하기 위함입니다.

  • 도메인 함수: 입력과 출력이 도메인 개념에 충실함. 비순수 요소를 배제하려 노력함.
  • 기능 함수: 예외 처리, 로깅, 트랜잭션 등 프로그램 구동에 필요한 코드.
  • 연결 고리: 도메인 함수가 모나드를 반환하게 함으로써, 부수 효과를 안전하게 래퍼에 담아 전달하고 파이프라이닝합니다.

6. DDD 관점의 Either 파이프라이닝

현실적인 마이크로서비스나 대규모 시스템에서는 에러 타입이 제각각입니다. 이를 합타입 계층 구조로 묶어 해결합니다.

// 1. 공통 터미널 정의
sealed interface BC1 { interface Terminal : BC1 }
sealed interface CommonFail : BC1.Terminal { object Timeout : CommonFail; object Network : CommonFail }

// 2. 도메인 전용 실패 정의
sealed interface Agg1Rej : BC1.Terminal { class InvalidEmail(val msg: String) : Agg1Rej }

// 3. 파이프라인 구성
aggregate1Entry(email)
    .flatMap { validate(it) } // Either<BC1.Terminal, Email>
    .flatMap { createOrder(it) } // 에러가 발생해도 BC1.Terminal이라는 공통 분모로 유지됨
    .effect { result -> 
        when(result) {
            is Right -> handleSuccess(result.v)
            is Left -> when(val err = result.v) {
                is CommonFail -> ...
                is Agg1Rej -> ...
            }
        }
    }

7. 인자 전달 문제의 해결책

파이프라인이 길어질수록 앞 단계의 데이터가 뒷 단계에서 필요할 때가 있습니다.
이를 도메인 모델에 억지로 넣으면 모델이 오염됩니다.

  1. 자유변수 캡처: 클로저를 활용해 외부 스코프의 변수를 직접 참조.
  2. 어댑터 활용: map { it to extraInfo }를 통해 데이터를 Pair나 Data Class로 묶어서 전달.
  3. 리더 모나드: 컨텍스트 공유 층을 하나 더 쌓아, 모든 파이프라인 단계에서 공통 설정이나 환경 변수에 접근 가능하게 함.
profile
그냥 코딩할래요 재미있어요

0개의 댓글