본문 바로가기

프로그래밍 기술 노트/Functional Study

[Algebraic Effect] 내 멋대로 대수적 효과 이해하기 feat Continuation

[Effect 가 싫어요]

Functional Programming 에서는 효과(== 사이드 이펙트) 를 분리해서 Monad 로 순수하게 디자인하는 패턴이 아주 흔하다,

Haskell 의 IO 모나드가 가장 대표적인 예시 인데,

 

이런 "모나딕" 한 방식이 아니지만, 효과를 분리해서 처리하는 다른 디자인중에 하나가 Algebraic Effect [대수적 효과] 이다

 

이번에 Epic Games 에서 Verse 라는 언어를 발표했는데 이 언어는 Effect 를 Monadic 이 아닌 Effect system 으로 처리한다고 하는데, 정확한 내용은 안나왔지만 아마 Algebraic Effect 방식으로 추측된다.

Algebraic Effect 을 기반으로 설계된 언어는 

EffUnison 등이 있고 보면 알수있듯이 마이너중에 마이너중에 마이너중에 마이너... 이다.

다만 Algebraic Effect 자체는 직관적으로 모나드보다 이해하기 쉽고, 그럼에도 효과적으로 효과를 분리한다 

Or we can use algebraic effect handlers! This single mechanism generalizes all the control flow abstractions listed above and more, composes freely, has simple operational semantics, and
can be efficiently compiled, since there is just one mechanism that needs to be supported well.
 - Algebraic Effect Handlers go Mainstream -

 

 

[Algebraic Effect]

나도 방금 막 여러 문서를 간략하게 읽어본 참이라 정확하지는 않지만, Continuations[후속문] 과 연결되는 내용이라 간단히 설명해보도록 하자면..

 

기존의 에러 핸들링

거의 대부분의 메이저 언어는 Exception Handling [예외 처리] 이라는 기술을 지원한다.

흔하디 흔한 try - catch 이고,  사용하는 이유는 명확하다. "예외" 를  "처리" 하는것.

fun main() {
    try {
        val result = exceptionRaise()
    } catch (e: Exception) {
        println("Exception: $e")
    }
}

fun exceptionRaise(): Int {
    throw Exception("Exception raised")
}

위 예제는 흔하디 흔한 try-catch 구문이다.

try 내부에서 Exception 이 발생하면, catch 에서 exception 을 받아 처리한다.

 

try-catch 가 없는 exception 은 다음과 같다

fun main() {
    exceptionRaise()
}

결과는 

Catch 를 하라구~

요런식으로 콘솔에 에러를 뿜뿜 죽어버린다

 

try 내부에서 Exception 이 발생하면, catch 에서 exception 을 받아 처리한다. catch 를 안하면 에러 뿜뿜하고 펑 터진다

이를 달리 말하면 다음과 같다.

 

- try 내부에서 Exception 이 발생하면, catch 에서 exception 을 받아 처리한다. catch 를 안하면 에러 뿜뿜하고 펑 터진다
- try 내부에서 Exception 이 발생하면, catch 에서 exception type 에 따라 처리한다. catch 를 안하면 에러 뿜뿜하고 펑 터진다
- Exception 을  "Handling" 하는 영역인 try 내부에서 Exception 이 발생하면 Catch 에서 exception Type 에 따라 어떤 작업을 처리한다. 처리되지 않는 Excepton Type 은 에러 뿜뿜하고 펑 터지도록 되어있다.
-  Exception 을  "Handling" 하는 영역인 try 내부에서 Exception 이 발생하면 Catch 에서 exception Type 에 따라 어떤 작업을 Handle 한다. Handling 되지 않은 Exception Type 은 Console 에 Stack trace 를 출력하고 프로그램을 종료하는것이 기본(Default) 이다.

이 구문을 일반화 해보자,

Exception 도 Side Effect 의 일종이니까 이를 Effect 로 대체 하면 다음과 같다

- Effect 를  Handling 하는 영역에서, Effect 가 발생하면, Effect Type 에 따라서 작업을 수행하는 Handler 가 있다. 설정한 Handler 가 없다면, 기본으로 돌아가는 Default Handler 가 동작한다.

 

자 결국 Effect Type 과 이에 따른 Handler 가 Algebraic Effect 가 되는것이다.

이렇게 excpetion 뿐만 아니라 모든 효과를 일종의 타입으로 보고 이를 처리하는 Handler 를 처리할 수 가 있다.

모든 Side Effect 의 동작 원리를 이렇게 Type -> Handler 방식으로 일관성 있게 처리 한 다는것이다.

 

Effect 

Exception 의 경우, 기본적으로 함수에서 이를 Throw 하는 Someting 이다, 즉 모나드 처럼 함수의 "리턴 값" 에 대한 관점이 아니라 이 함수가 뿜뿜할수 있는 무엇인가 이다.

Java 에서는 이를 명시적으로  표현하니 다음과 같이 쓴다. (대부분 언어에서는 생략하지만)

 public A test() throws SomeException
 {
 	// something
    // return A
 }

 

이걸 Monadic 하게 (== 값 에 대한 것) 하게 처리하면 이렇게 된다.

 public Optional<A> test(String a, String b) {
   // return Optional A
 }

 

즉 Effect 는 함수에 붙어 있는 속성이라는 의미이다.

 

다른 Effect 

맨날 쓰는 try-catch 만 보니까 "그래서 뭐 어쩌라고" 소리가 절로 나온다. 다른 Effect 를 확인해보자

예제는 Eff 언어의 공식 Print 예제에서 가져왔다. Eff 를 몰라도 된다, 나도 이 언어 2시간 전에 처음 봣다

(* 1. 핸들 안함 *)
perform (Print "A") ;;

(* 2. 핸들함 *)
handle
    perform (Print "A")
with
| effect (Print msg) k ->
    perform (Print ("핸들한  " ^ msg ^ ".\n"))
;;

결과는 다음과 같다

A
- : unit = ()
핸들한 A.
- : unit = ()

1번의 Print 와 2번의 Print 는 동일하다, 동일한 Console 에 뭔가를 출력하기를 원하는 Effect 이다.

1번과 2번의 리턴값은 그냥 Unit 이다, 즉 Monad Type이 아니다

1번과 2번이 출력하는 값은 다르다, 이유는 2번은 "핸들한" <- 이 문장을 추가하라고 핸들링이 되었기 때문이다.

1번 아무것도 핸들링 하지 않으면 -> 디폴트 핸들러 -> Print 의 디폴트 핸들러는 콘솔에 출력 일테니까

그냥 그대로 출력이 되는거고, 

2번 핸들에서는 "핸들한" <- 이걸 앞에 추가하고 다시 Print Effect 를 사용했으므로, 이게 다시 디폴트 핸들러에서 출력된것이다. (아마?)

 

이렇게 Side Effect 를 Handler 에서 처리하면 위와 같이 똑같은 순수한 코드에 대하여 처리를 다르게 가져갈수있다.

Effect 에 대한 DI 가 처리되는 것이다.

단 이로 인해서 Effect 에 대한 referential transparency 이 보장되지는 않는다. (코드 자체는 순수함수이지만, Effect 동작이 보장되지 않음)

 

[후속문과 Effect System]

fun main() {
    try {
        val result = exceptionRaise()
        println("result: $result")
    } catch (e: Exception) {
        println("Exception: $e")
    }
}

fun exceptionRaise(): Int {
    throw Exception("Exception raised")
}

위 코틀린 코드는 result 를 출력하지 않는다.

"Exception: java.lang.Exception: Exception raised" 을 출력할 뿐이다.

이유는 너무 뻔하다, exception 이 발생했으니까 아래 문장은 씹힌거다.

즉 Exeption 발생 -> Catch 에 Type 있음 -> Catch 안에 문장이 "후속문장" 이 됨 -> 나머지 문장은 "후속문장" 이 아님

이다.

그렇기 떄문에 우리는 "result" 를 영영 볼수 없다.

exception 이 [Algebraic Effect] 의 예시중 하나라면

당연히 Effect 가 하나라도 Raise 되서 Handler 로 넘어가면 문장의 나머지는 영영 보지 못하는것이다.

handle
    perform (Print "A");
    perform (Print "B");
    perform (Print "C");
    perform (Print "D")
with
| effect (Print msg) k ->
    perform (Print ("어디까지 가나요 " ^ msg ^ ".\n"))
;;

EFF 의 결과는?

엥 B,C,D는 어디갓남?

 

그렇다. Effect 가 Handler 에서 처리되서 나머지 후속 문장등 B,C,D의 핸들러 수행이 씹힌것이다.

 

그렇다 [Algebraic Effect] 은 이렇게 처리 하나밖에 못하고, 기존의 Try - Catch 마냥 에러나면 컨텍스트 날라서 머리꽁꽁 싸매고 코딩해야되는 별 도움도 안되는 시스템인것이다

"한정 후속문이 없다면 말이지!"

 

한정 후속문 [Delimited Continuation] 과 Algebraic Effect

한정 후속문이 지원(구현) 된다면, Context를 기억하고 이것으로 되돌아 가는 것이 가능하다.

한정 후속문이 무엇인지를 다루는 포스팅은 아니므로 간단하게 설명하자면, handle 내부와 handler 내부의 각각 코드가 있는 상황을 다음과 같이 표시해보자

handle  : { //code 1 //code 2 //A effect 발생 //code 3  }

handler : { A Effect 면 : Print A } 

위 코드의 순서와 실행되는 Context 를 표기하면 다음과 같을 것이다 ([컨텍스트] 실행 코드)

[Handle 내부] code 1 수행 -> [Handle 내부] code 2 수행 -> [Handle 내부] A effect 발생 -> [Handler 처리] Print A

 

한정 후속문은 [컨텍스트] 정보를 가지고 있다가, 어디에서 다시 [컨텍스트 1] 에서 수행하도록! 명령하면 이후 [컨텍스트 1] 부터 수행하는것이다 (후속 문장이 [컨텍스트1 이후] 문장이 됨 : 후속문이 한정 [컨텍스트 1 이후] 되어 있음 )

 

따라서 한정 후속문이 있다면 handle 내부와 handler 내부의 각각 코드를 다음과 같이 짤수있다.

handle  : { //code 1 //code 2 //A effect 발생 //code 3  }

handler : { A Effect 면 : Print A 하고,  handle 나머지 다시 수행 } 

위 코드의 순서와 실행되는 Context 를 표기하면 다음과 같다.

[Handle 내부] code 1 수행 -> [Handle 내부] code 2 수행 -> [Handle 내부] A effect 발생 -> [Handler 처리] Print A -> [Handler 처리] 나머지 Handle 내부 수행 -> [Handle 내부] code 3

즉, Resume 이 가능하다

 

실제 EFF 코드를 보면 다음과 같다

handle
    perform (Print "A");
    perform (Print "B");
    perform (Print "C");
    perform (Print "D")
with
| effect (Print msg) k ->
    perform (Print ("어디까지 가나요" ^ msg ^ ".\n"));
    continue k ()

k <- 이게 한정후속문 (Continuation)자체를 파라미터로 받은것이다

그리고 continue k() 로 handle의 나머지 코드를 수행한다. 따라서 A,B,C,D 가 전부 출력된다.

 

이렇게 한정 후속문이 지원되면 

Algebraic Effect 를 통하여 효과를 정말 효과적으로 다룰수있다.

 

다시 한정 후속문 [Delimited Continuation] 

이렇게, 한정 후속문은 Algebraic Effect 을 사용하는데 아주 중요하다.

In fact, Algebraic Effects can be seen as a more structured alternative to delimited continuations.

 

즉 한정 후속문을 이용한 정형화된 디자인이 Algebraic  Effect 가 되는것이다.

(Type System 을 이용한 정형화된 디자인 이 Monad 인것과 비슷한가..?)

 

Algebraic Effect 의 강점

한정 후속문을 효율적으로, 정형화된 흐름으로 관리할수가 있다.

그리고 오히려 이를 통하여 우리는 정말로 코드를 작성할 수 있다. 아주 direct 하게

예를 들어서 Exception 도 Effect 이다.

Algebraic Effect  시스템에서는 에러를 기존처럼 처리할수도, 후 처리 이후 다음에 나머지 구문을 수행시킬수도 있다.

Handle (==try) 내부의 코드는 아무런 변화가 없는데도!

exception 에 따른 롤백후 재실행 <- 이 작업을 위해서 try 내에 특정함수 try 를 감싸고..  이럴 필요가 없다. 어차피 필요하면 handler 에서 실행시킬테니까

 

즉 Effect 와 Handler 식으로 문제를 해결하게되면

예외 상황이나 특수 구문을 추가히가 위해서 수정에 수정에 날코딩에 수정을 거듭해서 

로직만 깔쌈하게 나왔던 최초 코드에서 덕지덕지 누더기 코드가 되는걸 방지 할수있다.

 

Algebraic Effect 과 Monad

Monad 또한 코드를 직관적으로 작성하는것을 도와준다. 정확히는 Monad 로 모델링 된것은 각 언어에서 제공되는 Monad 용 문법을 통하여 아주 직관적이고 깔끔하게 관리된다. 

Monad 로 디자인된 코드가 종종 "functional imperative programming" 으로 불리는 이유다. 

다만 이는 모나드로 모델링이 되야함을 의미한다.

 

Algebraic Effect 는 이를 효율적으로 풀어냈다,

monad 만큼은 아닐지라도, 효과를 분리해내고, 실제 비즈니스 로직은 순수하고 Direct 한 문장을 만들어낸다. Handler 는 발생한 효과를 해석할뿐이다. 따라서 해석을 다르게해서 Side Effect 를 분리해 낼 수 잇다. 개념적으로 보면 free monad 구문과 이를 해석하는 interpreter 가 있는것과 비슷하다.

이 또한 functional imperative programming 으로 볼수 있을 듯 하다.

 

참조

모두를 위한 대수적 효과 — Overreacted

Algebraic Effect Handlers go Mainstream

Trying out Unison, part 3: effects through abilities

🤖 Introduction to Abilities: A Mental Model  

Algebraic Effects(-ish Things) in Clojure

728x90