효과 시스템이 무엇인지, Rust와 Koka를 통해 살펴봅니다. 패닉·await·yield·오류 같은 제어 흐름 효과, 효과 다형성, 키워드 제네릭, 그리고 타입 서명으로 부수 효과를 명시하는 방법을 다룹니다.
2025년 10월 10일 - 읽는 데 8분
효과 시스템(effect system)이란 무엇일까요? 위키백과에 따르면, 프로그램의 계산적 효과(예: 부수 효과)를 기술하는 형식 체계입니다. 이는 타입 시스템의 확장이기도 하며, 프로그램이 효과 측면에서 건전함을 정적으로 검증할 수 있게 해줍니다.
이 개념을 완전히 이해하고 싶다면 Koka를 배우길 권합니다. Koka는 아름다운 문법과 강력하면서도 이해하기 쉬운 효과 시스템을 갖춘 언어입니다. 이 글에는 Koka 코드 조각이 포함되어 있지만, 사전 지식이 없어도 이해하기 쉽습니다.
가장 흔한 효과는 부수 효과(side effect)입니다. 함수가 반환값 이외의 방식으로 함수 외부에서 관찰 가능한 상태를 변경할 때를 말합니다. 예를 들어, stdout에 텍스트를 출력하는 것은 부수 효과이며, 전역 변수를 변경하는 것도 마찬가지입니다.
Rust는 본질적으로 명령형 언어이지만, 함수형 프로그래밍에서 많은 아이디어를 차용했습니다. 이는 FP가 ‘멋져서’가 아니라, Rust가 코드를 더 신뢰할 수 있고, 유지 보수 가능하며, 테스트하기 쉽고, 이해하기 쉬운 것으로 만들고자 하기 때문입니다. 특히 프로그래머들은 Rust가 다중 스레드 코드에서 데이터 레이스의 부재를 증명할 수 있다는 점을 높이 평가합니다. 이는 다른 명령형 언어에서는 악명 높게 어렵습니다. Rust는 소유권(ownership) 모델을 통해 이를 달성하는데, 데이터가 언제 어떻게 변경될 수 있는지를 제한하며, 이는 타입 시스템에 의해 인코딩됩니다:
fn report_items(items: Vec<&mut Item>) {
for item in items {
println!("{item}");
item.reported = true;
}
}
소유권 모델은 &mut가 변경에 의한 잠재적 부수 효과를 선언한다는 점에서(비록 불완전하긴 하지만) 효과 시스템이라고 주장할 수 있습니다. Rust의 타입 시스템이 추적하지 않는 부수 효과는 다음과 같습니다.
이는 의도된 설계입니다. Rust는 안전성에 대해서는 교조적일 수 있지만, 프로그래밍 관용구에 대해서는 훨씬 더 실용적입니다. 순수 함수형 언어가 아닙니다.
함수를 일시 중단하고 잠재적으로 재개할 수 있는 또 다른 종류의 효과가 있습니다. 여기에는 다음이 포함됩니다.
Rust에는 예외가 없지만, 패닉은 꽤 비슷합니다. 패닉은 콜 스택을 풀어내며(unwind) 그 과정에서 소멸자를 호출하면서 위로 전파되고, 잡히면 멈춥니다(단, panic=abort를 설정한 경우는 제외). 일반적으로 예외는 try/catch 구문으로 잡지만, Rust의 패닉은 catch_unwind 함수로 잡습니다.
예외와 패닉의 주된 차이는, 패닉이 오류 처리를 위한 기본 선택지가 아니라는 점입니다. 패닉은 프로그램이 복구할 수 없는 오류에만 사용하도록 의도되었으며, 이를 잡는 것은 로깅이나 보고 목적에 국한되어야 합니다. 대신 일반적인 오류 처리는 Result 타입으로 합니다.
이는 곧 Rust에서 오류가 부수 효과가 아님을 의미합니다. Result::Err(_)는 값으로 반환되므로, 타입 시스템의 눈에는 정상 반환과 구분되지 않습니다. 마찬가지로 ?도 부수 효과가 아닙니다. 단지 조기 return으로 디슈가링될 뿐입니다. 하지만 오류는 타입 시스템의 일부인 대수적 효과(algebraic effect)입니다. 이는 가능한 모든 오류가 처리되도록 보장하며, 그렇지 않으면 컴파일되지 않습니다.
이제 Koka에서 예외가 어떻게 구현되는지 살펴봅시다. Koka는 단순하지만 강력한 효과 시스템을 갖춘 함수형 언어입니다. 많은 형태의 제어 흐름을 내장하는 대신, 모든 종류의 제어 흐름을 구현할 수 있는 몇 가지 기본 빌딩 블록을 제공합니다:
effect throw
ctl throw(msg : string) : a
fun safe-divide(x : int, y : int) : throw int
if y == 0 then throw("div-by-zero")
else x / y
무슨 일이 일어나고 있을까요? 문자열을 받아 임의의 타입 a를 반환하는 효과 throw를 선언합니다(Rust라면 여기서 ! 타입을 쓸 것입니다). 이 효과는 safe-divide 함수에서 호출되며, 그 결과 함수의 타입 시그니처의 일부가 됩니다. throw int는 이 함수가 int를 반환하고 throw 효과를 가진다는 뜻입니다.
ctl 키워드는 이것이 제어 흐름 효과(control flow effect)임을 의미합니다. 예외처럼 실행을 중단시키는 종류입니다. 제어 흐름 효과는 나중에 실행을 재개할 수도 있습니다.
safe-divide가 호출되면, 그 효과는 처리되거나(propagate) 전파되어야 합니다:
fun propagate() : throw int
safe-divide(4, 0)
fun handle-effect() : int
with handler
ctl throw(msg) 42
propagate()
이제 흥미로운 부분입니다. handle-effect가 propagate를 호출하고, 이는 safe-divide를 호출하며, 그 안에서 throw 효과가 발생합니다. handler 식은 42를 반환함으로써 그 효과를 처리합니다. 이를 Rust로 쓰면 다음과 같습니다.
fn handle_effect() -> i32 {
std::panic::catch_unwind(propagate)
.unwrap_or_else(|_| 42)
}
Koka에서 효과가 호출되면, 현재 실행이 일시 중단됩니다. 효과 핸들러는 실행을 재개(resume)할 수 있지만, 반드시 그래야 하는 것은 아닙니다.
제너레이터는 반복자를 쉽게 작성하게 해주는 Rust의 불안정 기능입니다:
let generator = gen {
yield 5;
yield 3;
yield 1;
};
for n in generator {
println!("yielded {n}");
}
흥미로운 부분은 yield 키워드입니다. 값을 방출하고 제너레이터를 일시 정지했다가, 반복자의 next() 메서드가 호출되면 다시 재개합니다. Koka에서는 다음처럼 쓸 수 있습니다:
effect yield
ctl yield(item : t) : ()
fun generator() : yield ()
yield(5)
yield(3)
yield(1)
fun main()
with handler
ctl yield(item)
println("yielded " ++ item.show)
resume()
generator()
예외 처리 예제와 매우 비슷해 보이지만, 중요한 차이가 하나 있습니다. yield 효과를 처리한 뒤 resume()을 호출하여 제너레이터의 실행을 이어갑니다.
async/await도 비슷하지만 더 강력합니다. await 식은 실행을 일시 중단하지만, 동시에 새로운 값을 만들어 냅니다:
effect await
ctl await(fut : future<t>) : t
이 값은 await 효과를 처리할 때 resume() 함수로 제공될 수 있습니다. 여기서는 구현을 시도하진 않겠지만, 충분히 가능하다고 볼 수 있습니다.
효과 시스템을 강력하게 만드는 것은 효과에 대해 다형적(polymorphic)일 수 있다는 능력입니다. 다형성은 여러 형태를 가질 수 있음을 의미합니다. 예를 들어, Koka에서는 다음과 같은 함수를 정의할 수 있습니다.
fun map(xs : list<a>, f : a -> e b) : e list<b>
여기서 e는 제네릭 효과(혹은 여러 효과의 집합)입니다. 이 고차 함수는 임의의 효과를 가진 함수를 받아 그 효과를 그대로 전파합니다.
Rust에서는 같은 일을 할 수 없습니다. await를 지원하려면 함수가 async여야 하고, yield를 지원하려면 제너레이터를 반환해야 합니다. 그러나 일단 await를 지원하려고 함수를 async로 만들면, 실제로 await를 사용하지 않더라도 더 이상 동기적으로 사용할 수 없습니다. 이런 이유로 일부 라이브러리는 마지못해 동기 API와 비동기 API 두 가지를 제공합니다.
또한 Rust에 효과 다형성이 없어 문제가 되는 경우가 하나 더 있습니다. 고차 함수 안에서 오류를 내고 싶지만, 그 함수가 이를 지원하지 않을 때입니다. 이로 인해 try_map, try_find, try_fold, try_reduce 같은 함수들이 우후죽순 늘어났습니다. 앞서 언급했듯이, Rust에서 오류는 값으로 반환되므로 부수 효과가 아닙니다. 이는 오류를 타입 시스템의 관할로 두게 하므로 대체로 좋은 접근입니다. 하지만 가변성과 비가변성(실패 가능/불가능)에 대해 다형적일 수 없다는 점 때문에, 때로는 다루기 어렵습니다.
2022년에 Rust 프로젝트는 효과 다형성 지원을 목표로 하는 Keyword Generics Initiative를 발표했습니다(https://blog.rust-lang.org/inside-rust/2022/07/27/keyword-generics/). 특히 이 이니셔티브는 함수(와 트레이트)가 async 여부, const 여부, 실패 가능성(fallibility), 아마도 self의 가변성에 대해 제네릭이 되도록 하는 것을 목표로 합니다. 포괄적 제안이 만들어지지 않아 폐기된 줄 알았지만, 추적 이슈에는 여전히 산발적인 활동이 이어지고 있습니다.
그렇다면 const는 무엇일까요? const 자체는 부수 효과를 내지 않거나 제어 흐름을 바꾸지 않으므로 효과로 볼 수 없습니다. 사실 그 반대입니다. const의 부재, 이를 runtime이라 부르자면, 그것이 효과입니다. 이론상 순수 함수는 언제나 const로 만들 수 있지만, 패닉을 제외한 부수 효과를 가지는 함수는 항상 런타임 효과를 요구하므로 const가 될 수 없습니다.
runtime과 async 사이에는 멋진 대칭성이 있습니다. 두 효과 모두 “전염성”이 있어 async 함수는 동기 함수에서 호출할 수 없고, 마찬가지로 런타임 함수는 const 함수에서 호출할 수 없습니다. 반대 방향은 됩니다. 동기 함수는 async 함수에서 문제없이 호출할 수 있고, const 함수도 마찬가지입니다.
현재 Rust 프로젝트는 const 함수에서 트레이트를 사용할 수 있도록 열심히 작업하고 있습니다. 이는 어느 정도의 효과 다형성을 요구합니다:
// 현재 제안된 문법
[const] trait Default {
fn default() -> Self;
}
struct Thing<T>(T);
impl<T: [const] Default> const Default for Thing<T> {
fn default() -> Self { Self(T::default()) }
}
impl const Default for () {
fn default() {}
}
[const] 한정자는 “const일 수도 있고 아닐 수도 있다”는 뜻입니다. Koka에서는 이것이 어떤 모습일지 살펴봅시다. Koka에는 트레이트나 타입 클래스가 없으므로, 암시적 매개변수(물음표 ?로 표시)를 사용합니다:
struct thing<a>
value : a
fun thing/default(?default : () -> e a) : e thing<a>
Thing(default())
fun unit/default()
()
이는 더 강력합니다. runtime만이 아니라 어떤 효과에도 동작하기 때문입니다. 그 외에는 Rust 예제와 같은 일을 합니다. ?는 default 매개변수를 생략할 수 있으며, Koka가 자동으로 올바른 함수를 찾아준다는 뜻입니다.
이미 Rust로 표현할 수 있는 효과가 많습니다. 실행을 중단하지 않는 효과(즉, 즉시 재개되는 효과)는 함수 인자와 동등합니다.
fun count-lines(path : path) : <exn,fsys> int
read-text-file(path).sep/split("\n").length
이는 Rust로 다음처럼 쓸 수 있습니다.
fn count_lines(
path: &Path,
read_text_file: impl Fn(&Path) -> io::Result<String>,
) -> io::Result<usize> {
Ok(read_text_file(path)?.split("\n").count())
}
부수 효과(파일 I/O)가 이제 함수 시그니처에 인코딩되었습니다. 이는 프로그램을 추론하기 쉽게 하고 단위 테스트를 단순화하는 장점이 있습니다. 더 잘 확장되게 하려면 트레이트를 사용할 수 있습니다:
trait Fsys {
fn read_text_file(&self, path: &Path) -> io::Result<String>;
}
fn count_lines(path: &Path, fsys: impl Fsys) -> io::Result<usize> {
Ok(fsys.read_text_file(path)?.split("\n").count())
}
주된 단점은 이 매개변수를 파일 시스템에 접근하는 모든 함수에 main에서부터 일일이 내려보내야 한다는 점입니다. 함수에 효과가 여럿이면 번거로워질 수 있습니다. 그러나 부수 효과가 많은 함수가 너무 많아지는 것은 안티 패턴이며, 이를 명시적으로 만드는 것이 리팩터링과 코드 개선에 도움이 될 수 있습니다.
Effect.ts(https://effect.website/)는 TypeScript에서 효과 시스템을 포함한 함수형 프로그래밍을 가능하게 해주는 새롭고 인기 있는 프레임워크입니다. 이는 소프트웨어 업계에서 타입과 효과 안전성에 대한 수요가 증가하고 있음을 보여줍니다.
안타깝게도 Koka처럼 완전 일반적인 효과 시스템을 Rust에 추가하는 것은 큰 호환성 깨짐 없이 어렵습니다. 제게는 제안된 키워드 제네릭이 반짝이는 미래(shiny future)(https://github.com/rust-lang/rust-project-goals/blob/main/src/2024h2/const-traits.md#the-shiny-future-we-are-working-towards)라기보다는 타협처럼 느껴지지만, 현 상태보다는 큰 진전입니다. 그 사이에는 매개변수로 부수 효과를 명시적으로 표현하는 방법을 사용할 수 있습니다. 그리고 아직이라면, Koka를 한번 살펴보세요(https://koka-lang.github.io/koka/doc/index.html).
이 글에 대해 Reddit에서 토론하기: https://www.reddit.com/r/rust/comments/1o2lvez/effects_in_rust_and_koka/