함수형 프로그래밍과 Effect System을 이용한 의도가 명확한 코드 작성하기

함수형 프로그래밍과 Effect System을 이용한 의도가 명확한 코드 작성하기

요약: 이 글에서는 Effect System을 활용하여 사이드 이펙트를 명시적으로 관리하고 조합하는 방법을 다룹니다. 사이드 이펙트로 인해 코드의 예측 가능성이 떨어지고 유지보수가 어려워지는 문제를 해결하기 위해, Scala의 Cats Effect와 ZIO, Kotlin의 suspend 키워드를 활용한 접근법을 소개합니다. 또한, Algebraic Effects(Kyo, CanThrow 등)를 통해 보다 직관적이고 효율적인 함수형 프로그래밍 패턴을 적용하는 방법을 살펴봅니다.

💡 리뷰어 한줄평

tei.fxpark 자칫 어려울 수 있는 Effect System 개념을 예제와 같이 쉽게 풀어서 알려주고, Effect System을 만들기 위해서 과거부터 어떤 고민이 있었는지 알아볼 수 있었습니다. Side Effect가 없는 안전한 코드를 만들고 싶은 분들께 이 글을 추천합니다.

hyeoni.c Side Effect라는 불확실성을 Effect System을 활용하여 안정성과 유지보수성을 모두 잡을 수 있는 방법을 쉽게 알려줍니다. 더 나아가 Effect System의 미래까지 확인하고 싶다면 이 글을 추천합니다!

시작하며

// Java
client.sendEvent(event);

여러분은 위 코드를 보고 어떤 동작을 수행하는지 알 수 있나요?
메서드명만 보면 어딘가에 이벤트를 보내는 것 같습니다.
혹시 여러분도 저처럼 메서드의 이름만 보고 동작을 유추하고 있지는 않나요?

이해를 돕기 위해 실제로 서비스에 적용해보겠습니다.

// Java
public class NextService {

  private Client client = new Client();

  public void doAction(Request request) {
    // 기존에 있던 Business Logic
    ...

    // **** 추가로 작성한 코드 ****
    UserEvent event = UserEvent.from(request);
    client.sendEvent(request);
    // 의심이 많은 개발자라면 sendEvent 메서드를 디컴파일 하여 동작 과정을 살펴보거나 작성자에게 동작과 관련된 질문을 할 것입니다.
  }
}

만약 내가 담당하고 있는 서비스에 위 코드를 적용한다고 가정해 보겠습니다. 적용하려는 곳이(사용자 결제와 직접적으로 연관 있는) 크리티컬한 영역이라면 어떨까요? 아마 자연스럽게 의심부터 시작할 것입니다.

타 팀에서 sendEvent 메서드를 서비스에 적용하는 과정에서 아래와 같은 질문을 받았습니다.

  • sendEvent를 호출하면 이벤트는 어디로 어떻게 전송하는 것인가요?
  • sendEvent만 호출했다 하면 간헐적으로 알 수 없는 Exception이 터져서 이후에 있는 기존의 로직이 실행이 되질 않습니다. 매출에 영향이 있으니 얼른 고쳐주세요.
  • sendEvent는 얼마나 오래 걸리나요? 우리 서비스는 사용자 결제 UX와 직접적으로 연관이 있기 때문에 빠르게 처리돼야 합니다. 많은 시간을 소모하지 않았으면 합니다.

적용을 했는데 간헐적으로 NPE(NullPointerException)가 발생한다거나 CPU, 메모리 사용량이나 Latency가 증가하는 등 운영을 하기 전까지는 알 수 없는 문제가 발생할 수 있습니다. 특히 개발 환경에서는 문제가 없었는데 운영 환경에만 배포하면 문제가 발생할 경우 개발자를 야근의 늪으로 빠뜨리는 하나의 요소가 될 수 있습니다.

왜 우리는 코드만 보고도 숨은 의도나 이슈를 파악하지 못해 늘 문제가 터진 뒤에야 대응하게 되는 것일까요? 왜 매번 Null check를 빼먹어서 NPE를 맞이하는 걸까요? 문서화, 테스트 코드 작성 등의 활동으로 어느 정도 이슈를 커버할 수는 있지만 모든 불확실성을 커버하기란 쉽진 않습니다. 뭔가 더 나이스한 방법은 없는 것일까요?

이 문서에서는 Effect System을 이용하여 사이드 이펙트를 명시적으로 드러내고 조합하는 방법을 살펴보고자 합니다. Effect System을 처음 듣는 사람도 있고 경험해 본 독자도 있을 텐데요. Effect System이 뭔지는 뒤에 가서 좀 더 살펴보도록 하겠습니다.

이 글은 Effect System의 기법들과 개념들을 간략하게 살펴보는 입문용 글로서 주로 Scala 언어로 설명하지만 상황에 따라 Java나 Kotlin 언어를 사용하여 설명합니다. 따라서 Scala, Kotlin 언어를 다뤄봤으면서 좀 더 함수형 프로그래밍으로 내가 마주하고 있는 문제를 풀고 싶다고 느끼는 2~5년 백엔드 개발자들을 대상 독자라고 생각하며 글을 작성했습니다. Scala에 친숙하지 않더라도 최대한 따라올 수 있도록 쉽게 설명해 보도록 하겠습니다.

실제로 제가 속한 리스크관리플랫폼팀에서는 카카오페이의 이상거래탐지시스템(FDS)을 Scala 프로그래밍 언어 기반에서 Cats EffectZIO의 Effect System 라이브러리를 활용하여 수백 개의 룰을 동시에 실행하여 이상거래 여부를 실시간으로 판별하고 있습니다. 이러한 경험을 살려 이 글에서는 IO 모나드를 활용한 Effect System부터 시작하여 Direct Style Programming을 거쳐 Algebraic Effects 기법을 간략하게 살펴봄으로써 사이드 이펙트를 효율적으로 다루기 위한 노력들을 살펴보고자 합니다. 그럼 시작해 보겠습니다.

Effect System을 살펴보기 전에 알아야 할 것이 뭐가 있을까요?

액션, 계산, 데이터

먼저 안전한 코드 작성 방법에 대한 질문의 답을 하기 전에 앞에서 살펴본 client.sendEvent() 메서드를 한 번 살펴보도록 하겠습니다.

// Java
class Client {

  public void sendEvent(Request request) {
    // RPC를 이용한 Async Call 로직 & Circuit Breaker 패턴 로직
    // Prometheus Metric 출력 로직
  }
}

메서드 내용을 보면 알 수 있듯이 다른 서버와의 통신을 위한 RPC Call 로직과 실패 시 Retry 및 Circuit Breaker 패턴이 적용된 것이 확인됩니다. 모니터링을 위한 메트릭 출력 로직도 보이고요. RPC Call을 떠나서 보통 외부 시스템과 인터랙션을 위해 이런 식의 외부 서버 호출이나 DB 등을 참조하는 로직을 작성하곤 합니다. 이처럼 프로그램 외적으로 인터랙션 하는 코드를 가리켜 사이드 이펙트가 있는 코드라고 할 수 있습니다.

어떤 기준으로 코드의 사이드 이펙트 여부를 구분할 수 있을까요? 이를 알기 위해서는 사이드 이펙트를 나누는 기준을 아는 것이 중요합니다. 보통 사이드 이펙트는 다음과 같은 기준으로 나눌 수 있습니다.

사이드 이펙트의 예시

  • 네트워크를 이용한 타 서버에 HTTP RESTful API 호출
  • MySQL DB에서 데이터 조회
  • Kafka 토픽에 Producing
  • 파일이나 콘솔 화면에 로그 출력

사이드 이펙트들을 보면 뭔가 공통점이 보이지 않나요? 예시들을 살펴보면 다음과 같은 공통점을 발견할 수 있습니다.

  • 현재 프로세스에서 기대한 행동이 아닌 다른 행동을 할 가능성이 있음. 즉 예기치 않은 행동을 할 수 있음
  • 현재 모듈을 벗어나 다른 서버, 다른 프로세스/스레드에 영향을 끼침
  • 예시: throw exception, 오류 발생으로 인한 처리 중단, 네트워크 지연으로 인한 메서드 호출 처리 지연, File Descriptor 사용량 증가

쉽게 말해 1+1 같은 멱등성이 없는 계산 외에 시스템과 상호작용하며 데이터나 실행 흐름을 변경하는 코드를 사이드 이펙트라고 합니다. 함수형 프로그래머들은 리턴 타입이 void인 메서드는 무조건 사이드 이펙트를 포함하고 있다고 말하기도 합니다. 메서드 호출 시 반환값 없이 뭔가를 처리하고 있다는 것은 사이드 이펙트와 연관되어 있을 확률이 높기 때문이죠. 게다가 사이드 이펙트로 인해 발생하는 예상치 못한 동작과 오류는 개발자들의 주의력과 생산성을 저하시켜 실수를 유발할 수 있습니다. 결국 불필요한 야근까지 초래할 수도 있죠.

// java
public void calculateFundingFee(FundTransaction tx) {
  /*
    이 메서드의 구현 내용을 유추해 볼까요?

    calculateFundingFee 메서드 시그니처만 보면 tx에 있는 값을 뭔가 변경할 것 같은 느낌이 듭니다.
    메서드가 tx의 내용을 변경하지 않으리라는 확신을 할 수 있을까요?
  */
}

‘쏙쏙 들어오는 함수형 코딩(Grokking Simplicity, Eric Normand 저)’ 책에서는 이와 같이 동작을 예측하기 힘든 사이드 이펙트를 효율적으로 다루기 위해 사이드 이펙트를 액션, 계산, 데이터 3가지 요소로 구분하였습니다.

  • 액션: impure code, 부르는 시점에 의존하는 코드를 의미
  • 계산: pure code, 파라미터 입력값을 계산하여 반환하는 코드를 의미
  • 데이터: 액션과 계산에 필요한 재료

책에서는 이렇게 구분하는 것을 함수형 사고라고 부릅니다. 즉 함수형 프로그래밍의 첫걸음이라고 보는 거죠. 이 책에서는 Javascript를 사용하여 코드를 액션, 계산, 데이터 3가지 요소로 구분하는 것을 훈련합니다. 더 나아가 여러 아키텍처와 FP 개념을 설명하는 등 복잡한 수학적 수식 없이 함수형 프로그래밍 입문에 도움 되는 책입니다. 함수형 프로그래밍의 기초를 갈고닦고 싶으신 분들은 읽어보시면 좋을 것 같습니다.

하지만 위 기준에 따라 sendEvent 코드를 액션과 계산으로 구분해보려 하면 어디가 액션이고 어디가 계산인지 모호한 부분이 있습니다. 특정 메서드를 호출했을 때 오류가 발생하거나 원하는 동작을 하지 않고 스킵한다면, 이것은 액션일까요 계산일까요? 프로메테우스의 메트릭을 하나 더 추가하는 메서드가 있다고 했을 때 이 메서드는 계산일까요? 기존에 작성한 우리의 코드를 살펴보면 액션과 계산을 구분하는 것이 잘 와닿지 않을 수가 있습니다.

// Java
class Client {

  public void sendEvent(Request request) {
    // RPC를 이용한 Async Call 로직 => 액션?
    // Circuit Breaker 패턴 로직 작성 => 계산?
    // Prometheus Metric 출력 로직 => 액션? 계산?
  }
}

만약 내가 기대하는 흐름을 벗어나는 행동을 할 우려가 있으면 이를 액션이라고 보기도 합니다. 예를 들어 어떤 메서드를 호출했는데 Exception이 터지면 그다음으로 넘어가지 않고 Exception을 호출한 곳으로 던지게 됩니다. 이렇게 액션은 실행 흐름을 변경하는 힘을 가지고 있는데요. 코드가 유지보수하기 쉬우려면 이러한 액션 코드를 최대한 줄여야 합니다. 만약 어떤 코드를 실행했을 때 간헐적으로 기대하지 않는 동작을 한다면 그 코드의 동작에 대해 신뢰를 할 수 없고 이해하는데 걸림돌이 될 것입니다.

그렇다고 액션을 아예 없앨 수는 없습니다. 액션은 프로그램의 핵심이니까요. 그럼 액션을 줄이려면 어떻게 해야 할까요?

Local Reasoning

그보다 액션 코드를 줄이면 왜 유지보수가 쉬워질까요? 제어의 흐름이 넘어가지 않는 방향으로 프로그래밍을 한다면 쉽게 예측할 수 있다는 단순한 논리로 설명할 수 있을 텐데요. 다른 말로 하자면 코드가 Local Reasoning 능력을 갖추게 된다는 것을 의미합니다. 이는 코드의 지역적인 부분만 보고도 그 코드 동작의 추론을 확신할 수 있게 만드는 능력을 말합니다. 그럼 어떻게 해야 Local Reasoning 능력을 갖게 되는지 한 번 살펴보도록 하겠습니다.

그전에 먼저 참조 투명성(Referential Transparency) 개념을 살펴봅시다. 함수형 프로그래밍을 하다 보면 가장 많이 듣게 되는 단어인데요. 이는 내가 작성한 메서드를 값과 치환할 수 있다는 것을 의미합니다. 즉 메서드만 보고도 그 결과를 추론할 수 있는 능력을 갖게 해주는 중요한 개념입니다.

참조 투명성을 위해서는 메서드 호출의 결과는 항상 동일해야 한다는 명제가 성립해야 합니다. 이는 동작의 일관성을 의미하는 것인데요. 안타깝게도 사이드 이펙트는 참조 투명성을 지키지 않는 경우가 태반입니다. RPC 코드만 봐도 이걸 두 번 호출하면 어떻게 될까요? 두 번 다 동일한 결과를 반환했다고 해서 참조 투명하다고 보장할 수 있나요? RPC 서버에서 데이터가 변경되면 값을 다르게 줄 수도 있기 때문에 그렇지 않다는 것을 알 수 있습니다. 게다가 첫 번째 호출에서는 정상적으로 반환했는데 두 번째 호출에서는 네트워크 이슈로 값을 반환하지 않거나 예외가 발생할 수도 있습니다. 이런 것을 두고 참조 투명하지 않다고 할 수 있습니다.

이게 Local Reasoning과 무슨 연관이 있는 것일까요? 코드 예시를 들어서 설명해 보겠습니다.

// scala
def printList(list: List[String]) =
  list.foreach(it => println(it)) // 출력 시 색을 지정하는 코드가 없었으니 기본색으로 출력할 것으로 기대

@main
def main(): Unit = {
  println("\u001b[92m") // Hello, Effect!만 녹색으로 출력하길 희망
  println("Hello, Effect!")

  val numberLetters = List("one", "two", "three")

  printList(numberLetters) // 호출 시 콘솔에 즉시 출력
}

위와 같이 printList 함수는 콘솔에 출력하는 내용을 담고 있습니다. 이 함수를 호출하게 되면 호출과 동시에 화면에 출력하라는 동작을 프로그램 외부에 영향을 미치는 예상치 못한 상호작용을 일으킬 수 있습니다. 만약 어딘가에서 println("\u001b[92m") 콘솔의 글씨를 녹색으로 바꾸는 코드를 호출한다면 이후 printList 함수를 호출할 때 출력하는 내용에도 영향을 받게 됩니다. 이는 예상치 못한 상호작용을 일으키는 예시라고 볼 수 있습니다.

그렇다면 이 문제를 어떻게 해결할 수 있을까요? 바로 함수 안에서 외부 상태에 의존하지 않는 코드를 작성하는 것입니다. 콘솔의 글자색 역시 우리에게는 드러나지 않은 전역변수로 볼 수 있는데요. Local Reasoning한 함수를 작성하기 위해서는 전역변수나 외부 상호작용을 일으키는 코드는 최대한 격리하고 함수는 최대한 참조 투명하게 작성하는 방식을 방법을 취할 수 있습니다. 그러한 예시는 다음과 같습니다.

// scala
def formatArgs(args: List[String]): String = 
  args.mkString("\n")

@main def main(args: String[]) {
  val numberLetters = List("one", "two", "three")

  val formatted = formatArgs(numberLetters) // formatArgs 함수는 Local Reasoning 능력을 갖춤

  println(formatted) // Side Effect는 여기에서만 발생
}

이렇게 하면 formatArgs 함수는 더 이상 외부 상태에 의존하지 않는 순수 함수가 되고 인터랙션 코드(여기서는 콘솔 출력)는 최대한 국소적인 부분에만 쓸 수 있게 됩니다. 즉 함수는 참조 투명하게 되어 지역적으로 추론할 수 있게 되는 이점을 누릴 수 있습니다. 우리가 작성하는 복잡한 프로그램도 이러한 Local Reasoning이 가능한 코드로 작성하고 상호작용이 국소화된 형식으로 작성하게 되면 코드를 예상하고 검증하는 능력이 올라갈 것입니다.

Effect System과 함께하는 코딩 변천사

IO를 이용한 Effect System

앞서 우리는 사이드 이펙트 함수 sendEvent를 살펴봤습니다. 우리가 앞서 배운 개념을 가지고 이 함수를 고치려면 어떻게 해야 할까요? 액션, 계산, 데이터로 나눠서 정리하는 방법을 취할 수도 있지만 그러기엔 이미 작성한 코드를 고치는데 많은 부담이 될 수도 있습니다. 또 다른 방법으론 Local Reasoning이 가능한 코드로 만드는 방법이 있습니다. 이는 묘사와 실행으로 구분하는 방법인데요. 함수형 프로그래밍에서 핵심 가치라고 불리는 추론과 조합을 하기 위한 첫걸음이라고 보면 됩니다.

이게 뭘 말하는 걸까요? 코드를 통해 살펴보겠습니다. 아래 코드를 실행하면 평가와 동시에 실행되는 것을 볼 수가 있습니다. 이는 우리가 전통적으로 해오던 프로그래밍 방식입니다.

// Java
client.sendEvent(event);

하지만 아래와 같이 IO로 감싸면 실행을 잠시 유보하고 평가만 할 수 있도록 할 수 있습니다. 즉 실행과 평가를 분리하는 거죠.

// Scala
IO { () =>
  client.sendEvent(event)
}

이렇게 기존에 사이드 이펙트가 있는 코드를 IO라는 실행 단위로 감싸는 것인데요. 위 코드를 실행하면 예상했다시피 sendEvent는 호출되지 않고 잠시 유보됩니다. Effect System에서는 이렇게 이펙트를 IO로 묶는 것을 시작으로 볼 수 있습니다.

In computing, an effect system is a formal system that describes the computational effects of computer programs, such as side effects. An effect system can be used to provide a compile-time check of the possible effects of the program.

Effect System은 프로그램의 Side Effect를 추적하고 제어하기 위해 타입 시스템을 확장한 것입니다.

출처: Wikipedia (https://en.wikipedia.org/wiki/Effect_system)

Effect System에서는 IO로 묶어 실행이 유보된 사이드 이펙트를 묘사(Description) 혹은 이펙트(Effect)라고 부릅니다. 묘사는 우리가 필요할 때 실행을 할 수 있습니다. 즉 우리가 실행을 통제할 수 있다는 것을 의미하죠. 왜냐면 내가 호출하고 싶을 때 실행할 수 있기 때문입니다. 이는 아래와 같은 형태로 실행을 평가할 수 있습니다.

// Scala
val effect = IO { client.sendEvent(event) } // effect는 success or finish가 담긴 값으로 표현 (마치 슈뢰딩거의 고양이와 같음)

run(effect) // 내가 원하는 시점에 이펙트를 실행

그런데 왜 이렇게 복잡하게 묘사와 실행을 분리해야 할까요? 이렇게 할 경우 하나의 이점을 얻게 되는데요. 바로 이펙트에 여러 가지 행위를 조합할 수 있는 능력을 얻게 된다는 것입니다. 예를 들어 아래와 같이 이펙트가 실행되었을 때 그 실행 시간을 제한한다거나 오류가 발생했을 때 try-catch가 아닌 좀 더 명시적으로 정의를 할 수 있는 것을 보실 수 있는데요. 이는 사람에게 읽히는 코드를 작성하면서 큰 규모의 프로그램이 이해하기 쉽게 만들어주는 이점을 가져갈 수 있습니다.

// Scala
object Main extends IOApp.Simple
  val effect = IO { client.sendEvent(event) }

  effect
    .timeout(10.millis)
    .handleError {
      case e: IOException => ???
      case e: Throwable => ???
    }

// 실제로 Effect를 실행하는 코드는 IOApp.Simple에 숨겨져 있음 (개발자가 직접 호출할 필요 없음)

추가로 또 다른 이펙트를 호출하고 싶은데 병렬로 실행할 때는 손쉽게 이펙트들을 조합하기만 하면 나중에 실행하는 부분에서 이펙트의 내용을 병렬로 실행할 수도 있습니다.

// Scala
object Main extends IOApp.Simple
  val newEffect = IO { client.sendEvent(event) }
                    .timeout(10.millis)
                    .handleError {
                      case e: IOException => ???
                      case e: Throwable => ???
                    }

  val nextEffect = IO { client.nextFunc(event) }

  Parallel.parTraverse(
    List(newEffect, nextEffect)
  )(identity)

그런데 어떻게 위와 같은 조합된 이펙트가 병렬로 실행된다는 것일까요?

그 이면에는 Thread 보다 더 경량화된 비동기 실행 단위인 Fiber가 있습니다. 우리가 앞서 조합한 timeout이 적용된 이펙트나 병렬처리가 조합된 이펙트나 결국 Fiber로 변환되어 특정 상황에서 비동기 실행이 종료되거나 실행 흐름을 넘길 수도 있습니다. 마치 JDK 21의 Project Loom과 비슷한 개념인데요. Scala에서는 이미 Project Loom이 나오기 오래전에 이러한 Structured Concurrency 개념을 탑재한 Effect System 프레임워크가 나온 바 있습니다. 바로 ZIO와 Cats Effect인데요. 두 프레임워크의 이펙트 시스템은 비슷하면서도 굉장히 다른 철학을 갖고 있는데 관심 있으면 살펴보는 것도 좋을 것 같습니다.

카카오페이 이상거래탐지시스템(FDS)에는 위와 같이 IO를 활용한 Effect System으로 구현되어, 아래와 같이 수백 개의 룰을 단 몇십 Millisecond 내에 실행할 수 있는 능력을 갖추고 있습니다. 기존 방식대로 스레드 풀을 관리하고 다양한 예외 상황을 처리하려면 개발에 많은 시간이 소요되지만, Effect System 덕분에 Fiber 기반의 경량화된 동시성 처리를 위임할 수 있어서 개발 시간을 크게 단축할 수 있었습니다.

// Scala
class RuleEngine[F[_]: Parallel: Logger: ResponseInterpretor] {

  def executeRules(
    request: ApiRequestParam,
    rules: Seq[RuleSet[F]]
  ): F[RuleExecuteResponseJson] =
    for {
      result: Seq[RuleResult] <- rules.parTraverse { rule =>
        executeWith(request, rule)
      }
      ruleCheckResponse: Response <- ResponseInterpretor[F].createResponse(
        request, result
      )
    } yield {
      ruleCheckResponse
    }
}

위 코드는 카카오페이의 FDS에서 사용하는 RuleEngine 클래스의 일부분인데요. 이 RuleEngine은 여러 룰을 동시에 실행하고 그 결과를 모아서 최종적으로 응답을 생성하는 역할을 합니다. 중간에 보면 parTraverse 메서드에서 여러 이펙트를 병렬로 실행하고 그 결과를 모아주는 이펙트를 생성하고 그 결과를 모아서 응답을 생성하는 것을 ResponseInterpretor 타입클래스에서 처리하는 것을 볼 수가 있습니다. 여기에는 Tagless Final과 Higher Kinded Type 기법을 사용하였는데 이 부분은 뒤에 가서 다시 한번 더 설명하도록 하겠습니다.

IO의 한계와 극복

하지만 이러한 IO를 이용한 Effect System에도 한계가 있습니다. 막상 실제 서비스에 Cats Effect의 IO나 ZIO의 Task, UIO 등을 이용하여 코딩하다 보면 생각보다 이펙트를 많이 합성하는 경우가 많습니다. 위에 예시로 든 코드들은 이해를 돕기 위해 간단하게 작성했지만 실제 서비스에서는 이보다 더 많은 이펙트 합성을 위해 flatMap과 for-comprehension으로 점철된 코드를 보게 될 것입니다. IO를 이용한 Effect System의 한계를 나열하자면 다음과 같습니다.

  1. 디버깅과 추론이 어려움
  • IO는 실행을 지연(lazy)시켜 실행하므로 디버깅이 어려울 수 있습니다. 특히 Effect System의 이해 없이 처음 보는 사람은 일반적인 함수처럼 디버깅하는 것이 어렵다는 것을 느끼게 될 것입니다.
  • Local Reasoning 만으로는 앞서 우리가 정의한 이펙트가 비동기적으로 실행되는지 확신할 수가 없습니다. 이펙트를 합성하는 과정이 한 곳에서 진행된다면 한눈에 알기 쉽겠지만 그 과정을 함수로 쪼개거나 깊이가 깊어질수록 한눈에 추론하기가 어려울 수가 있습니다. 즉 프로그램 전체를 분석해야만 실행 흐름을 이해할 수 있습니다. (사실 이 부분은 굳이 Effect System이 아니어도 규모가 큰 프로그램들이 가지고 있는 숙명이라 생각합니다.)
  1. 실행 흐름의 복잡성
  • IO는 실행 시점까지 실행되지 않으므로 프로그램이 거대해질수록 오히려 코드만 보고 실행 순서를 예측하기 어려울 수가 있습니다.
  • 너무 많은 IO 합성은 오히려 실행 흐름이 복잡해져 실행 순서에 문제가 발생할 수 있습니다.
  • 게다가 IO 모나드는 대수학의 카테고리 이론을 기반으로 만들어졌기 때문에 가끔 *>, >> 같은 기호를 사용하여 이펙트 합성 코드를 작성하는 것을 볼 수 있습니다. 이는 처음 보는 사람에겐 오히려 가독성을 떨어뜨리는 요소가 될 수 있습니다.
  1. 복잡한 비동기 처리
  • Effect System은 이펙트를 실행하는 런타임 환경을 따로 갖고 있는데요. 이러한 런타임 환경은 WorkStealingThreadPool 기반으로 이펙트를 Fiber로 변환하여 실행 스케줄링, 취소, 병렬 처리와 같은 복잡한 작업을 처리합니다. 실제 서비스를 운영하다 보면 오히려 진보된 비동기 처리에 대한 Effect System 자체에서 내부적인 오류가 발생할 경우 분석이 쉽지 않다는 것을 알 수 있습니다.

개인적으론 위와 같은 이유로 Effect System은 점차 외면받고 있는 추세라고 생각합니다. 하지만 이를 극복하기 위한 방법도 존재합니다. 바로 Tagless Final 패턴을 사용하는 방법인데요. Tagless Final을 쉽게 말하자면 IO를 직접 변형하거나 합성하지 않고 타입 클래스를 이용하여 타입에 대해 동작을 주입받도록 하는 방법입니다. 이를 이해하려면 제네릭을 이해해야 하므로 따라오기 조금 벅찰 수도 있을 텐데요. 예제 코드를 사용하여 설명해 보겠습니다.

앞서 우리는 아래와 같은 IO로 감싼 sendEvent 이펙트를 생성했습니다. sendEvent 내부에는 RPC 호출이라는 사이드 이펙트를 내포하고 있고요. 여기서 RPC 통신이라는 사이드 이펙트를 타입 클래스로 분리하여 원래 이펙트 내부를 좀 더 Local Reasoning 하게 변모할 수가 있습니다. 이는 아래와 같은 모습이 될 것입니다.

// Scala
trait RPC {
  def call(event: Event): IO[Unit]
}

class MyClient {

  def sendEvent(event: Event)(using rpc: RPC): IO[Unit] = {
    rpc.call(event)
  }
}

------------------------------------------------------------

given rpc = ??? // 사용자는 RPC 호출만 하는 타입 클래스를 작성해줘야 함
val effect = client.sendEvent(event)

run(effect) // 내가 원하는 시점에 이펙트를 실행

하지만 스칼라의 제네릭과 Context Bound 문법을 사용하면 타입 클래스를 암시적으로 넘기는 구문을 숨길 수가 있습니다. 이는 좀 더 Local Reasoning을 쉽게 할 수 있는 요소로 작용하게 됩니다.

trait RPC[F[_]] {
  def call(event: Event): F[Unit]
}

class MyClient[F[_]: RPC] {

  def sendEvent(event: Event): F[Unit] = {
    RPC[F].call(event)
  }
}

암시적으로 넘기는 구문만 사라졌지 RPC에 대한 동작을 담당하는 타입 클래스는 여전히 존재해야 됩니다. 여기서 또 하나의 장점을 얻게 되는데요. 이러한 타입 클래스는 테스트 코드 상에서는 Fake 동작을 하는 타입 클래스로 작성하여 동작에 대한 테스트를 보장하게 할 수도 있습니다. 이는 Mockito 없이도 Mock과 가짜 동작을 직접 만들 수 있는 것을 볼 수 있습니다.

object RPC {
  def apply[F[_]: RPC]: RPC[F] = implicitly[RPC[F]]

  given ioRPC: RPC[IO] =
    (event: Event) => IO(println(s"[FAKE] Sending event: $event"))
}

갑자기 F라는 제네릭(정확하게는 Higher Kinded Type)과 타입 클래스 용어가 튀어나와서 당황하실 수도 있는데요. Tagless Final를 자세하게 설명하기엔 시간과 지면 관계상 한계가 있어서 최대한 코드로 쉽게 퀀텀 점프(?) 하듯이 설명한 점을 양해 부탁드립니다. Tagless Final 패턴을 좀 더 알고 싶으신 분들은 하단에 관련 자료를 첨부할 테니 참고 부탁드립니다.

Effect System은 너무 어렵다. Direct Style Programming!

반면 코틀린(Kotlin)의 Arrow에서는 Effect System의 복잡함과 높은 러닝커브를 지적하며 IO를 버린 이유를 설명한 글을 하나 내놓게 됩니다. 이 글에 대놓고 Scala Cats Effect의 부족함과 이를 개선한 방법을 소개하고 있는데요. 그 내용을 한 번 간단하게 살펴보겠습니다.

코틀린에는 코틀린 언어적 차원에서 실행을 격리하는(lazy evaluation) 장치인 suspend를 가지고 있습니다. 이는 IO 모나드 없이 suspend 키워드로 일반 함수 작성하듯 Local Reasoning을 달성하는 것을 볼 수 있습니다. 한 번 IO를 활용한 모습과 suspend를 사용한 모습을 비교해 보시죠.

// scala
def number(): IO[Int] = IO.pure(1)

def triple(): IO[(Int, Int, Int)] =
  number().flatMap { a =>
    number().flatMap { b =>
      number().map { c =>
        (a, b, c)
      }
    }
  }
  /* 위 코드는 아래와 같이 for-comprehension을 사용하여 변경할 수 있음
  for {
    a <- number()
    b <- number()
    c <- number()
  } yield (a, b, c)
  */

object Main extends IOSimpleApp:
    triple()
// kotlin
suspend fun number(): Int = 1

suspend fun triple(): Triple<Int, Int, Int> = 
  Triple(number(), number(), number())

suspend fun main() {
  triple()
}

얼핏봐도 suspend 키워드를 사용한 곳이 코드량이 더 줄어든 것을 볼 수 있습니다. 아무리 Scala의 for-comprehension을 사용해도 일반 함수 호출하듯이 호출하고 있으면서 실행조차 개발자에게 노출하지 않는 모습을 보고 있노라면 이래도 되나 싶기까지 한데요. 줄어든 코드 이면에는 IO를 Effect System에 전달하면 그 실행을 Effect System 런타임이 알아서 처리해주는 것처럼 suspend 함수 호출의 결과물인 suspend () -> A는 코루틴 빌더를 통해 그 실행을 위임받아 처리하는 모습은 코드만 다를뿐 동작 방식은 거의 유사하다고 볼 수 있습니다.

fun main() {
  runBlocking {
    triple()
  }
}

// runBlocking 예시
// 출처: https://github.com/Kotlin/coroutines-examples/blob/master/examples/run/runBlocking.kt
fun <T> runBlocking(context: CoroutineContext, block: suspend () -> T): T =
    BlockingCoroutine<T>(context).also { block.startCoroutine(it) }.getValue()

private class BlockingCoroutine<T>(override val context: CoroutineContext) : Continuation<T> {
    private val lock = ReentrantLock()
    private val done = lock.newCondition()
    private var result: Result<T>? = null

    private inline fun <T> locked(block: () -> T): T {
        lock.lock()
        return try {
            block()
        } finally {
            lock.unlock()
        }
    }

    private inline fun loop(block: () -> Unit): Nothing {
        while (true) {
            block()
        }
    }

    override fun resumeWith(result: Result<T>) = locked {
        this.result = result
        done.signal()
    }

    fun getValue(): T = locked<T> {
        loop {
            val result = this.result
            if (result == null) {
                done.awaitUninterruptibly()
            } else {
                return@locked result.getOrThrow()
            }
        }
    }
}

게다가 IO보다 좀 더 직관적이면서 코드량이 적은 오류 처리를 구현할 수 있는 것을 확인할 수 있습니다.

// kotlin
import arrow.core.raise.either

suspend fun suspendProgram(): Either<PersistenceError, ProcessedUser> =
  either {
    val user = fetchUser().bind()
    val processed = user.process().bind()
    processed
  }

  // try-catch를 안쓰고 either 빌더를 통해 오류 처리를 하는 모습이 바로 Direct Style Programming

그 외 코틀린에서 Reader 모나드 없이 함수형 디펜던시 인젝션 구현한 사례던가 IO에 비해 메모리 사용량이 적은 이점에 대해서는 지면 관계상 생략하도록 하겠습니다. 나중에 다른 기회에 이 내용에 대해 같이 얘기 나눠봤으면 좋겠네요.

Effect System의 미래. Algebraic Effect

한편 Scala 진영에도 실험적 기능인 CanThrow가 Scala 3 언어에 추가되었습니다. 또한 2023년도에 혜성처럼 등장한 Kyo 라이브러리가 다시 한 번 Scala 프로그래머들을 열광시키게 만들었고요. 이 두 가지는 앞서 봤던 IO 모나드를 이용한 Effect System을 개선하기 위한 노력의 일환이라고 볼 수 있는데요. 이러한 개선을 Algebraic Effects 기법으로 풀어낸 것을 볼 수 있습니다. 어떻게 개선되었는지 간단하게 살펴보도록 하겠습니다.

Algebraic Effects란?

그보다 Algebraic Effects란 뭘까요? 이는 말그대로 Effect를 대수적으로(Algebraic) 다루는 기법을 말하는데요. 그럼 또 대수적이라는게 뭘까요? 우선 Algebra의 사전적 의미부터 살펴보도록 하겠습니다.

대수학(代數學, 영어: algebra)은 일련의 공리들을 만족하는 수학적 구조들의 일반적인 성질을 연구하는 수학의 한 분야이다.

(중략)

‘대수(代數)’는 말은 ‘수를 대신한다’는 뜻으로, 수 대신 문자를 쓴다는 것을 의미한다.

출처: Wikipedia (https://ko.wikipedia.org/wiki/%EB%8C%80%EC%88%98%ED%95%99)

대수학 관점에서 보면 방정식에서 구해야 하는 수를 x나 y 같은 변수로 나타내는 것과 그 방정식에 들어가는 덧셈과 곱셈이라는 연산도 대수적 요소라고 볼 수 있습니다. Algebraic Effects는 이러한 대수학 개념을 Effect System에 적용한 것인데요. 대수적 관점에서 이펙트를 연산(Operations)과 이 연산을 핸들링하는 핸들러(Handler)로 구성하여 이를 실행하는 것을 나중에 결정하는 기법을 말합니다. Handling Algebraic Effects 논문에 따르면 이러한 대수적인 방법을 통해 프로그램을 방정식 형태로 표현할 수 있고 이를 통해 프로그램의 의미를 더 명확하게 이해할 수 있고 심지어 수식만으로 성능에 병목이 되는 부분까지 찾아낼 수도 있다고 합니다.

어떤 값을 반환하는데 예외가 발생할 수도 있는 함수를 방정식으로 풀어낸 형태
출처: https://arxiv.org/abs/1312.1399

하지만 다른 함수형 프로그래밍 언어를 접하신 분들은 아시겠지만 Algebraic Effects 기법은 Scala 때문에 생겨난 것은 아니고 이미 10년도 더 전에 다른 함수형 프로그래밍 언어에서 생겨난 개념입니다. Scala 진영에서는 이제서야 경각심(?)을 가지고 도입을 고려하고 있고요. Scala 언어의 창시자 Martin Odersky 또한 Algebraic Effects를 Monadic Effect System을 대체할 것이라 예견하기도 했습니다. 실제로 Scala 3에서 그런 노력들을 하고 있고요.

Scala 3의 CanThrow

Scala 3에 추가된 실험적인 기능 중 하나인 CanThrow가 바로 이러한 Algebraic Effects를 지원하기 위한 기능 중 하나입니다. 이는 익명 함수도 예외를 던질 수 있다는 능력(Capability)을 갖추게 된 것을 의미합니다. 예시 코드와 함께 살펴보겠습니다.

// scala
@main def main(xs: Double*): Unit = {
  println(
    xs.map(x => f(x).sum) // <- map에 넘기는 함수에서 CheckedException을 던질 수 없음
  )
}

class LimitExceeded extends Exception

def f(x: Double): Double = {
  val limit = 10e9

  if x < limit then x * x
  else throw LimitExceeded
}

위와 같이 Double Array의 map 함수에 넘기는 함수에서는 CheckedException을 외부로 전파를 할 수 없기 때문에 이 익명 함수 안에 try-catch를 작성하던가 RuntimeException을 던지도록 하여 어딘가에서 예외를 해야했습니다. 이러한 문제를 해결하기 위해 Scala 3에는 CanThrow 능력이 추가되었습니다. 이를 통해 함수도 예외를 던질 수 있다는 것을 표현할 수 있게 되었습니다.

@main def main(xs: Double*): Unit = {
  try
    println(xs.map(x => f(x).sum)) // CanThrow 덕분에 CheckedException을 익명함수 외부에서도 처리할 수 있게 됨
  catch
    case e: LimitExceeded => println("too large")
}

def f(x: Double)(using CanThrow[LimitExceeded]): Double throws LimitExceeded = {
  val limit = 10e9

  if x < limit then x * x
  else throw LimitExceeded
}

xs.map(x => f(x).sum) 코드를 호출함에 있어 함수에서 던지는 예외를 명시적으로 처리할 수 있게 되었습니다. 덤으로 위와 같이 함수에서 던지는 CheckedException을 처리하지 않으면 컴파일 타임에 오류가 발생하게 되고요. 이는 Scala 3의 CanThrow 라는 Capability 덕분인데요. 이는 앞서 언급했던 Algebraic Effects의 연산과 핸들러로 구성된 이펙트 시스템을 구현하는데 필요한 단초가 될 것입니다.

// 함수에서 던지는 LimitExceeded 예외를 처리하지 않으면 SBT에서 컴파일 했을 때 CanThrow 능력이 없다는 오류가 발생함

[error]   | println(xs.map(x => Exercise.f(x)).sum)
[error]   |                                  ^
[error]   |The capability to throw exception capabilities.Exercise.LimitExceeded is missing.
[error]   |The capability can be provided by one of the following:
[error]   | - Adding a using clause (using CanThrow[capabilities.Exercise.LimitExceeded]) to the definition of the enclosing method
[error]   | - Adding throws capabilities.Exercise.LimitExceeded clause after the result type of the enclosing method
[error]   | - Wrapping this piece of code with a try block that catches capabilities.Exercise.LimitExceeded
[error]   |

Kyo와 함께하는 Algebraic Effects 프로그래밍

Kyo도 역시 Scala 3의 언어적 특성을 활용하여 Algebraic Effects를 직관적으로 사용할 수 있도록 지원하는 라이브러리입니다. 이를 통해 Effect System을 직관적으로 사용할 수 있도록 지원하는데요. 특히 Cats Effect나 다른 함수형 프로그래밍 언어에서 대수학 관점에서 표현한 수식이나 어려운 카테고리 이론(Category Theory)을 알지 못하여도 손쉽게 함수형 프로그래밍과 Effect System을 개발하는 것을 추구합니다. 즉 Kyo는 앞서 봤던 IO 모나드를 이용한 Effect System의 러닝커브와 복잡한 합성을 줄이고자 하는데 목표로 삼고 있습니다.

// scala
object Main extends KyoApp:
  run {

    defer {

      val result1 = Abort.catching(IO {
        1 + 2
      }).now

      val result2: Int = Abort.catching(IO {
        result1 - "3".toInt
      }).now

      val result3 = Abort.recover[Throwable](handleError)(IO {
        100 / result2
      }).now

      result3
    }

  }

  private def handleError(e: Throwable): String < IO =
    "Not divided"

end Main

위 코드는 Kyo를 이용한 Algebraic Effects를 구현한 모습입니다. 콘솔 화면에 시간과 랜덤 변수를 출력하는 코드인데요. 여기서는 이펙트를 구성하는 코드(연산)만 보이고 핸들러는 보이질 않습니다. 그 이유는 run 블록에서 핸들러를 넘기는 코드를 암묵적으로 숨겼기 때문입니다. 게다가 KyoApp에는 이렇게 이펙트와 핸들러로 버무러진 Kyo 이펙트를 실행하는 로직이 구현되어 있어 필요에 따라 Structured Concurrency 하게 실행되게 됩니다.

import kyo.*

object MyApp extends KyoApp:
    run {
        val create: UserEvent < IO = IO(UserEvent.from(request))
        val send: Unit < IO        = IO(sendEvent(event))

        Debug {
            for
                event <- create
                _     <- send
            yield ()
        }
    }
end MyApp

Kyo를 사용하게 되면 이런식으로 IO를 사용하지 않고도 Algebraic Effects를 사용할 수 있게 되어 프로그래밍을 더 직관적으로 할 수 있게 됩니다. 여기에는 run이나 Debug 빌더 메서드를 통해 필요한 핸들러를 암묵적으로 제공하여 이펙트를 안전하게 처리할 수 있게 해줍니다. 특히 Effect System의 고질적인 문제였던 Effect를 디버깅하기 어려웠던 문제도 Kyo 라이브러리 차원에서 지원해주는 기능이 있어서 좀 더 쉬운 이펙트 디버깅을 할 수 있게 되었습니다.

Kyo의 Algebraic Effects 기법과 Tagless Final 기법은 타입에 해당하는 행위를 처리하는 철학은 비슷하다고 보여집니다. Kyo는 핸들러를 이용하지만 Tagless Final은 Typeclass를 활용하는 것이 바로 그 점인데요. 다만 차이점은 Typeclass를 전달하기 위한 boilerplate 코드를 작성하지 않아도 된다는 점이 복잡한 러닝커브를 줄여주는 장점을 가지고 있다고 생각합니다. 그 외에도 IO 모나드를 이용한 Effect System 보다 더 성능을 이끌어낼 수 있는 노력이나 Direct Style 방식으로 프로그래밍을 하는 점 등 여러가지 장점이 있는데 추후 Kyo에 대해 얘기 나눠보는 기회가 있으면 좋겠습니다.

마무리

어떠셨나요? 요즘은 GPT나 LLM에 밀려 함수형 프로그래밍이 예전만큼의 인기를 구사하고 있는지 의문이지만 나름 함수형 프로그래밍 진영에서도 더 나은 방법을 찾고자 노력하는 모습이 기대가 되지 않으신가요? 요즘은 코파일럿에 의해 복잡해 보였던 Effect System도 일반 개발자가 더 다가가기 쉬운 시대가 되었다고 생각합니다. 개인적으론 지금이 함수형 프로그래밍을 시작하기에 적기가 아닌가 싶습니다.

이 글이 기존의 코딩 방식에서 생각의 전환을 일으키는 계기가 되기를 작게나마 소망해봅니다.

참고 자료

will.109
will.109

카카오페이를 안심하고 사용할 수 있게 만드는 이상거래탐지 시스템(FDS)을 개발하고 있는 이완근입니다. 실용주의를 추구합니다.