- Published on
Rust에서 HKT 없이 Functor·Monad 패턴 구현
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 컨테이너 타입(예: Option, Result, Vec)에 대해 map/flat_map 같은 추상 연산을 하나의 인터페이스로 묶고 싶을 때, 함수형 언어라면 Functor·Monad 타입클래스를 떠올리게 됩니다. 문제는 Rust가 HKT를 제공하지 않는다는 점입니다. 즉, F<_> 같은 “타입 생성자”를 trait의 매개변수로 직접 받을 수 없습니다.
그럼에도 Rust에는 강력한 대안들이 있습니다. 연관 타입(associated type), 제네릭, 그리고 최근 안정화된 GAT(제네릭 연관 타입)를 조합하면, 완전한 HKT는 아니지만 실무에서 유용한 수준으로 Functor·Monad 패턴을 “근사”할 수 있습니다.
이 글에서는 다음을 다룹니다.
- 왜 Rust에서 전통적인 Functor·Monad 추상이 어려운지
Option/Result에 대해 “모나드스러운” 인터페이스를 만드는 방법- GAT 기반으로 “컨테이너 재구성”을 표현하는 방법
- 한계(코히어런스, 타입 추론, orphan rule)와 실전 타협점
또한 재시도·보상 같은 흐름 제어를 모나드로 모델링하는 관점은 분산 시스템에서도 자주 등장합니다. 예를 들어 사가 패턴에서 보상 트랜잭션을 체이닝하는 사고방식은 모나딕한 합성과 닮아 있습니다. 관련해서는 MSA 사가(Saga) 패턴 - 중복 실행·보상처리 버그 해결도 함께 보면 좋습니다.
Rust에 HKT가 없다는 말의 의미
함수형 언어의 Functor는 보통 이런 형태입니다.
map :: (a -> b) -> f a -> f b
여기서 핵심은 f가 타입 생성자라는 점입니다. Rust에서는 trait가 f 같은 “타입 생성자”를 직접 추상화할 수 없습니다. 즉, 아래 같은 인터페이스를 자연스럽게 표현할 수 없습니다.
trait Functor<F<_>> { ... }같은 형태
대신 Rust는 다음의 도구로 우회합니다.
- 연관 타입: trait 내부에서 결과 타입을
type Output;처럼 표현 - GAT:
type Wrapped<T>;처럼 “타입 매개변수를 받는 연관 타입”을 표현 - 구체 타입별 trait 구현:
Option<T>용,Result<T, E>용으로 각각 구현
접근 1: “값 중심” 인터페이스로 Functor를 근사하기
가장 단순한 방법은 “컨테이너 전체를 추상화”하기보다, 해당 컨테이너가 제공하는 연산을 trait로 통일하는 것입니다. 예를 들어 map을 통일하고 싶다면 이렇게 시작할 수 있습니다.
pub trait FunctorMap {
type Item;
fn fmap<B>(self, f: impl FnOnce(Self::Item) -> B) -> Self::Mapped<B>
where
Self: Sized;
type Mapped<B>;
}
impl<T> FunctorMap for Option<T> {
type Item = T;
type Mapped<B> = Option<B>;
fn fmap<B>(self, f: impl FnOnce(T) -> B) -> Option<B> {
self.map(f)
}
}
impl<T, E> FunctorMap for Result<T, E> {
type Item = T;
type Mapped<B> = Result<B, E>;
fn fmap<B>(self, f: impl FnOnce(T) -> B) -> Result<B, E> {
self.map(f)
}
}
여기서 type Mapped<B>가 사실상 “F<B>” 역할을 합니다. 즉, HKT의 “컨테이너 재구성”을 연관 타입 제네릭으로 흉내 내는 방식입니다.
장점
Option/Result같은 대표 타입에 대해 일관된fmap인터페이스를 제공 가능- 구현이 직관적이고 러스트다운(기존
map위임)
단점
- 각 타입마다 구현을 만들어야 함
Vec<T>나Iterator처럼 “아이템 타입”이 있는 구조를 모두 통일하려면 설계가 복잡해짐Self::Mapped<B>를 사용하는 순간, 타입 추론이 엮이면 에러 메시지가 길어질 수 있음
접근 2: Monad(= flat_map/and_then)를 연관 타입으로 표현하기
모나드는 보통 다음 연산을 핵심으로 봅니다.
pure(또는return)flat_map(또는bind,and_then)
Rust에서 Option과 Result는 이미 and_then을 제공합니다. 이를 trait로 묶어보겠습니다.
pub trait Monad {
type Item;
type Wrapped<B>;
fn pure(x: Self::Item) -> Self
where
Self: Sized;
fn flat_map<B>(self, f: impl FnOnce(Self::Item) -> Self::Wrapped<B>) -> Self::Wrapped<B>
where
Self: Sized;
}
impl<T> Monad for Option<T> {
type Item = T;
type Wrapped<B> = Option<B>;
fn pure(x: T) -> Self {
Some(x)
}
fn flat_map<B>(self, f: impl FnOnce(T) -> Option<B>) -> Option<B> {
self.and_then(f)
}
}
impl<T, E> Monad for Result<T, E> {
type Item = T;
type Wrapped<B> = Result<B, E>;
fn pure(x: T) -> Self {
Ok(x)
}
fn flat_map<B>(self, f: impl FnOnce(T) -> Result<B, E>) -> Result<B, E> {
self.and_then(f)
}
}
하지만 위 코드는 pure에서 문제가 생깁니다. pure는 “B를 받아 Wrapped<B>를 만들기”가 자연스러운데, 현재 pure 시그니처는 Self::Item에 고정되어 있습니다. 즉, Self 자체가 이미 Option<T>처럼 T가 박혀 있기 때문에, pure를 일반화하기가 어렵습니다.
이 지점이 바로 HKT 부재의 핵심 통증입니다. pure :: a -> f a를 Rust에서 그대로 옮기려면, f를 “타입 생성자”로 추상화해야 하기 때문입니다.
따라서 Rust에서는 보통 다음 타협 중 하나를 선택합니다.
pure를 trait에서 빼고flat_map만 통일한다pure를 “별도의 생성 trait”로 분리하고, 구체 타입별로 사용한다- GAT 기반으로 “컨테이너 패밀리”를 표현한다
접근 3: GAT로 F<T> 패밀리를 모델링하기
GAT를 쓰면, trait 내부에 type Wrapped<T>; 같은 형태를 둘 수 있습니다. 이를 이용하면 “컨테이너 패밀리”를 어느 정도 표현할 수 있습니다.
아래는 Option이라는 “컨테이너 패밀리”를 나타내는 타입 수준의 태그를 만들고, 그 태그에 대해 wrap/map/and_then을 정의하는 방식입니다.
use core::marker::PhantomData;
pub trait TypeApp {
type Wrapped<T>;
fn pure<T>(x: T) -> Self::Wrapped<T>;
fn map<A, B>(fa: Self::Wrapped<A>, f: impl FnOnce(A) -> B) -> Self::Wrapped<B>;
fn flat_map<A, B>(fa: Self::Wrapped<A>, f: impl FnOnce(A) -> Self::Wrapped<B>) -> Self::Wrapped<B>;
}
pub struct OptionK;
impl TypeApp for OptionK {
type Wrapped<T> = Option<T>;
fn pure<T>(x: T) -> Option<T> {
Some(x)
}
fn map<A, B>(fa: Option<A>, f: impl FnOnce(A) -> B) -> Option<B> {
fa.map(f)
}
fn flat_map<A, B>(fa: Option<A>, f: impl FnOnce(A) -> Option<B>) -> Option<B> {
fa.and_then(f)
}
}
pub struct ResultK<E>(PhantomData<E>);
impl<E: Clone> TypeApp for ResultK<E> {
type Wrapped<T> = Result<T, E>;
fn pure<T>(x: T) -> Result<T, E> {
Ok(x)
}
fn map<A, B>(fa: Result<A, E>, f: impl FnOnce(A) -> B) -> Result<B, E> {
fa.map(f)
}
fn flat_map<A, B>(fa: Result<A, E>, f: impl FnOnce(A) -> Result<B, E>) -> Result<B, E> {
fa.and_then(f)
}
}
이 패턴의 핵심은 다음과 같습니다.
OptionK,ResultK<E>는 “타입 생성자”를 흉내 내는 태그 타입- 실제 컨테이너는
TypeApp::Wrapped<T>로 표현 pure가 드디어T에 대해 일반화됨
사용 예시
fn parse_port(s: &str) -> Result<u16, String> {
s.parse::<u16>().map_err(|e| e.to_string())
}
fn validate_port(p: u16) -> Result<u16, String> {
if p == 0 { Err("port must be non-zero".to_string()) } else { Ok(p) }
}
fn main() {
type R = ResultK<String>;
let r = R::flat_map(parse_port("8080"), validate_port);
let r2 = R::map(r, |p| p + 1);
assert_eq!(r2, Ok(8081));
}
이렇게 하면 Functor·Monad에서 기대하는 “컨테이너 독립적인 합성”을 꽤 비슷하게 구현할 수 있습니다.
장점
pure/map/flat_map을 HKT처럼 일반화 가능Result처럼 추가 타입 파라미터가 있는 경우도ResultK<E>로 표현 가능
단점과 주의점
- 태그 타입을 새로 도입해야 해서 코드가 장황해짐
ResultK<E>에서E를 어떻게 다룰지(복제 가능성, 변환 등) 정책이 필요- Rust의 trait coherence 및 orphan rule 때문에, 외부 타입에 대한 확장에 제약이 있을 수 있음
실전 패턴: 에러 처리 파이프라인을 모나딕하게 합성하기
Result 기반 서비스 로직은 사실상 모나드 체이닝과 동일한 형태로 자주 작성됩니다. 예를 들어 “파싱 → 검증 → 저장”을 합성하면 다음처럼 됩니다.
fn parse_id(s: &str) -> Result<i64, String> {
s.parse::<i64>().map_err(|e| e.to_string())
}
fn validate_id(id: i64) -> Result<i64, String> {
if id <= 0 { Err("id must be positive".to_string()) } else { Ok(id) }
}
fn to_shard(id: i64) -> Result<u32, String> {
Ok((id as u32) % 16)
}
fn pipeline(s: &str) -> Result<u32, String> {
parse_id(s)
.and_then(validate_id)
.and_then(to_shard)
}
여기서 and_then은 flat_map이고, map_err는 “에러 채널에 대한 functor map”입니다. 이런 식의 합성은 DB 재시도나 데드락 처리 같은 곳에서도 자주 보입니다. 예를 들어 데드락 감지 후 재시도 정책을 Result 체인으로 감싸면 “실패를 값으로 다루는” 구조가 됩니다. 관련해서는 MySQL 8 Deadlock 1213 원인추적·재시도 패턴 글의 재시도 사고방식도 비슷한 맥락에서 참고할 수 있습니다.
Iterator는 왜 더 까다로운가
Iterator는 map/flat_map을 제공하지만, Self가 제네릭하게 “다시 같은 종류의 iterator”로 돌아오지 않습니다. map의 결과 타입은 Map<Self, F>처럼 새로운 어댑터 타입이 됩니다.
즉, Functor 관점에서 보면 Iterator는 F<T>가 Iterator<Item = T>인 “컨테이너”지만, map 결과가 같은 F<_> 형태로 단순히 표현되지 않습니다. Rust에서는 이를 연산자 합성(어댑터 체인) 으로 모델링하고, 타입은 컴파일러가 추론하게 둡니다.
따라서 Iterator까지 포함해 “모든 컨테이너를 하나의 Monad trait로” 묶으려는 시도는 보통 이득보다 복잡성이 커집니다. 실무에서는 다음처럼 경계를 나누는 편이 낫습니다.
Option/Result는 “제어 흐름(early return)과 실패” 모델Iterator는 “지연 평가 스트림” 모델Future는 “비동기 계산” 모델
각각의 map/and_then/then은 이름은 비슷하지만 의미론과 타입 변화가 달라, 하나로 통일하면 오히려 가독성이 떨어질 수 있습니다.
결론: Rust에서 Functor·Monad는 “패턴”으로 가져온다
Rust에서 HKT 없이 Functor·Monad를 구현하는 현실적인 결론은 다음 중 하나입니다.
- 가장 러스트다운 선택:
Option/Result/Iterator/Future가 제공하는 표준map/and_then/flat_map을 그대로 쓰고, 공통 trait로 과도하게 묶지 않는다. - 인터페이스 통일이 필요할 때: 연관 타입 기반
fmap/flat_maptrait를 도입하되,pure일반화는 포기하거나 제한적으로 제공한다. - 라이브러리/프레임워크 레벨 추상화: GAT + 태그 타입(
OptionK,ResultK<E>)로 타입 생성자를 흉내 내어pure까지 포함한 “타입클래스 유사” 계층을 만든다.
특히 3번은 학습 비용이 있지만, 도메인 로직에서 “효과(실패, 컨텍스트, 환경)를 조합”해야 하는 라이브러리를 만든다면 충분히 시도해볼 가치가 있습니다. 다만 팀 합의 없이 도입하면 난이도가 급상승하므로, 적용 범위를 작게 시작하는 것이 좋습니다.
추가로 타입 시스템의 미묘한 추론/제약을 다루는 감각은 다른 언어에서도 도움이 됩니다. 타입 추론을 다루는 사례로는 TS 5.x satisfies vs as const 타입추론 실전 글도 함께 참고할 만합니다.