기술 글

Scala 3 Capture Checking이 ZIO와 Cats Effect를 대체할 수 있을까?

0 0

Scala 3의 capture checking을 살펴보다 보면 자연스럽게 기존 effect library가 떠오른다.

Scala에서는 이미 ZIO와 Cats Effect를 통해 side effect와 resource를 상당히 정교하게 다룰 수 있다. ZIO는 ZIO[R, E, A]라는 타입을 통해 computation이 어떤 environment를 필요로 하고 어떻게 실패하며 어떤 값을 반환하는지를 표현한다. Cats Effect는 IOResource를 중심으로 effectful computation과 resource lifecycle을 관리한다.

그런데 capture checking은 이런 정보 중 일부를 Scala 언어 자체의 타입 시스템으로 가져오려 한다.

함수가 어떤 capability를 사용하고 있는지, 어떤 resource에 대한 reference를 capture하고 있는지, 그 reference가 허용된 scope 밖으로 빠져나가는지까지 compiler가 검사한다.

그렇다면 capture checking이 정식 Scala 기능으로 자리 잡으면 ZIO나 Cats Effect 같은 effect system은 필요 없어지는 걸까.

처음에는 그렇게 생각할 수도 있다.

하지만 두 기술이 해결하는 문제를 조금 더 자세히 보면 관계는 대체보다는 역할의 재조정에 가까워 보인다.

ZIO의 R과 Capture Checking은 꽤 닮아 있다

ZIO의 기본 타입은 다음과 같다.

ZIO[R, E, A]

여기서 R은 이 computation을 실행하기 위해 필요한 environment, E는 실패할 경우의 error, A는 성공할 경우의 결과다.

예를 들어 다음과 같은 함수가 있다고 하자.

def findUser(id: UserId): ZIO[Database, DbError, User]

이 타입만 봐도 findUser를 실행하려면 Database가 필요하다는 것을 알 수 있다.

여기에 logging까지 필요하다면:

def findUser(
  id: UserId
): ZIO[Database & Logger, DbError, User]

처럼 표현할 수 있다.

이런 방식은 단순한 dependency injection보다 의미가 크다. 함수가 어떤 외부 환경에 의존하는지가 effect의 타입에 나타나기 때문이다.

Capture checking도 비슷한 질문을 한다.

def findUser(using db: Database^): User =
  db.find(...)

여기서는 Database capability가 별도의 R 타입에 들어가 있는 것이 아니라 일반적인 Scala reference로 존재한다. 대신 compiler가 이 reference가 어디에서 사용되고 어떤 closure나 객체에 capture되는지를 추적한다.

둘 다 결국

이 computation이 실행되기 위해 무엇이 필요한가

를 타입 시스템에서 표현한다는 점에서는 상당히 비슷하다.

하지만 표현하는 방식에는 중요한 차이가 있다.

타입을 추적하는 것과 reference를 추적하는 것

ZIO의 environment는 기본적으로 타입을 기준으로 한다.

ZIO[Database, DbError, User]

라는 타입은 Database라는 service가 필요하다는 것을 나타낸다.

반면 capture checking의 capture set에는 실제 capability reference가 들어갈 수 있다.

예를 들어 다음 두 객체가 있다고 하자.

val userDb: Database
val adminDb: Database

둘 다 타입은 Database다.

하지만 capability의 관점에서는 같은 권한이 아니다.

userDb는 일반 사용자 데이터만 접근할 수 있고 adminDb는 더 높은 권한을 가지고 있을 수도 있다.

어떤 closure가 다음과 같이 만들어졌다면

val load =
  () => adminDb.findAll()

capture checking이 관심을 가지는 것은 단순히 이 함수가 Database를 사용한다는 사실만이 아니다.

이 함수가 실제로 adminDb라는 reference를 capture했다는 사실이다.

개념적으로는 다음과 같은 정보다.

() ->{adminDb} List[User]

이 차이는 effect의 종류뿐 아니라 capability의 identity가 중요한 상황에서 의미가 있다.

그래서 capture checking을 단순히 Scala에 들어온 또 하나의 effect system이라고만 보기는 어렵다.

그렇다면 ZIO의 R은 필요 없어질까

Capture checking이 충분히 발전하면 이 질문은 꽤 흥미로워진다.

현재 ZIO에서는 여러 dependency를 environment에 올리는 것이 일반적이다.

ZIO[Database & Logger & Config, Error, Result]

하지만 Scala 언어 자체가 capability를 추적할 수 있다면 일부 dependency는 굳이 R에 표현하지 않고 일반적인 Scala parameter나 context parameter로 전달할 수도 있다.

def process(
  input: Input
)(using
  db: Database^,
  logger: Logger^
): Result =
  ...

그리고 compiler가 실제로 어떤 capability가 capture되는지 추적할 수 있다.

이런 방식이 충분히 강력해진다면 ZIO environment가 현재 담당하는 역할 중 일부와 겹칠 가능성은 있다.

하지만 그렇다고 ZIO[R, E, A]가 사라진다고 보기는 어렵다.

ZIO가 제공하는 것은 dependency tracking만이 아니기 때문이다.

ZIO effect는 computation 자체를 값으로 표현한다.

val program: ZIO[Any, IOException, String]

이 값은 아직 실행된 computation이 아니다.

ZIO runtime은 이 값을 실행하면서 asynchronous computation, fiber, interruption, cancellation, retry, scheduling, parallel execution 같은 동작을 관리한다.

Capture checking에는 이런 실행 모델이 없다.

Compiler는 어떤 capability가 capture됐는지를 확인할 수 있지만 그 computation을 언제 실행할지, 여러 computation을 어떻게 병렬로 실행할지, 실패했을 때 몇 번 다시 시도할지를 결정해주지는 않는다.

program.retry(policy)
effect1.zipPar(effect2)

같은 것은 capture checking이 해결하려는 문제가 아니다.

따라서 capture checking이 ZIO의 일부 역할과 겹칠 수는 있어도 ZIO runtime까지 대체하는 것은 전혀 다른 이야기다.

Cats Effect는 조금 더 명확하다

Cats Effect에서도 비슷한 구분을 할 수 있다.

Cats Effect의 중심에는 IO[A]가 있다.

val program: IO[String]

IO[String]은 지금 String을 가지고 있다는 뜻이 아니라 실행하면 side effect를 수행하고 결국 String을 만들 수 있는 computation을 값으로 표현한 것이다.

이 구조를 통해 side effect의 실행을 코드 작성 시점에서 분리하고, composition과 cancellation, concurrency 같은 실행 의미를 Cats Effect runtime이 관리할 수 있다.

Capture checking이 pure function과 capability를 사용하는 function을 구분할 수 있다고 해서 IO가 하는 일이 없어지는 것은 아니다.

둘은 서로 다른 질문에 답한다.

Capture checking은

이 함수가 무엇을 capture하고 있는가

를 추적한다.

Cats Effect의 IO

이 effectful computation을 어떻게 값으로 만들고 조합하고 실행할 것인가

를 다룬다.

그래서 IO 자체는 capture checking과 직접 경쟁하는 영역이 생각보다 크지 않다.

Resource는 훨씬 더 흥미롭다

Cats Effect에서 capture checking과 가장 직접적으로 겹쳐 보이는 부분은 Resource다.

파일을 열어서 사용한 뒤 반드시 닫아야 한다고 하자.

Cats Effect에서는 보통 다음과 같은 방식으로 표현할 수 있다.

def openFile(path: String): Resource[IO, File] =
  Resource.make(
    acquireFile(path)
  )(
    file => closeFile(file)
  )

그리고 resource를 사용할 때는:

openFile("data.txt").use { file =>
  read(file)
}

처럼 use scope 안에서 사용한다.

Resource가 중요한 이유는 단순히 File을 반환하는 것이 아니라 acquisition과 release를 하나의 구조로 묶는다는 데 있다.

중간 computation이 실패하거나 cancellation이 발생하더라도 finalizer를 실행하는 것까지 Cats Effect runtime이 관리한다.

Capture checking도 resource lifetime 문제를 다룬다.

Scala 공식 문서에서 capture checking을 설명할 때 가장 먼저 등장하는 예제 중 하나가 FileOutputStream이다.

def usingLogFile[T](
  op: FileOutputStream^ => T
): T =
  ...

FileOutputStream^을 전달받은 함수가 이 reference를 밖으로 빼내려고 하면 compiler가 이를 알아낼 수 있다.

예를 들어 다음과 같은 코드는 문제가 된다.

val later =
  usingLogFile { file =>
    () => file.write(0)
  }

usingLogFile이 끝난 뒤에는 파일이 닫혀 있는데 반환된 closure가 계속 file을 가지고 있기 때문이다.

이 경우 Cats Effect의 Resource와 capture checking이 해결하려는 문제가 상당히 가까워 보인다.

하지만 여기에서도 차이는 남는다.

Compile time과 runtime의 역할

Capture checking은 다음을 검사할 수 있다.

이 file reference가 사용 가능한 scope 밖으로 escape하는가.

Cats Effect Resource는 다른 문제도 해결한다.

실제 파일을 언제 acquire하고 언제 release할 것인가.

그리고 그 사이에 error나 cancellation이 발생했을 때도 finalizer가 올바르게 실행되어야 한다.

두 기술을 함께 생각하면 오히려 역할이 더 명확해진다.

Capture Checking
resource reference가 잘못된 scope로 빠져나가지 않는지 compile time에 검사

Cats Effect Resource
resource의 acquire / release와 cancellation을 runtime에서 관리

이런 구조라면 한쪽이 다른 쪽을 없애기보다 서로 다른 층에서 같은 문제를 더 강하게 방어할 수도 있다.

Resource API가 capture-aware하게 설계된다면 compiler가 resource escape까지 잡아내면서 Cats Effect runtime은 실제 lifecycle을 관리하는 방식도 생각할 수 있다.

Effect 자체를 library에서 표현해야 했던 이유

여기에서 조금 더 큰 관점으로 볼 필요가 있다.

Scala effect library들이 발전한 이유 중 하나는 Scala 언어 자체가 표현하지 않는 정보를 library의 타입으로 가져와야 했기 때문이다.

ZIO는 다음 타입을 통해

ZIO[R, E, A]

environment와 error, result를 하나의 effect type에 표현한다.

Cats Effect는

IO[A]

를 통해 effectful computation 자체를 값으로 만든다.

Resource[F, A]는 resource lifecycle을 library abstraction으로 표현한다.

이런 방식은 Scala 타입 시스템의 표현력을 이용해 언어에서 직접 제공하지 않는 의미를 library level에서 만드는 것이다.

그런데 capture checking은 일부 정보를 반대 방향으로 이동시키고 있다.

함수가 외부 capability를 capture하는지,

resource reference가 scope 밖으로 escape하는지,

어떤 computation이 pure한지,

어떤 capability가 특정 closure에 남아 있는지를 Scala compiler 자체가 알 수 있게 하려 한다.

지금까지 library가 타입 encoding을 통해 표현하던 정보 중 일부가 language level로 내려오는 셈이다.

이런 변화가 실제로 일어난다면 effect library의 역할도 조금 달라질 수 있다.

Effect library가 더 얇아질 가능성

Capture checking이 정식 기능으로 자리 잡았다고 가정해보자.

그렇다고 앞으로 Scala application이 다음과 같이 단순해질 것이라고 기대하기는 어렵다.

def program(using db: Database^): Result =
  ...

현실적인 application에는 여전히 asynchronous execution, cancellation, timeout, retry, concurrency, streaming 같은 문제가 있다.

이런 문제는 capability를 추적한다고 없어지지 않는다.

따라서 ZIO나 Cats Effect가 사라지는 것보다는 지금보다 언어와 library 사이의 역할이 달라질 가능성이 더 크다.

지금은 대략 다음과 같은 구조라고 볼 수 있다.

Scala
  ↓
ZIO / Cats Effect
  ↓
effect
resource
concurrency
cancellation
runtime

Capture checking이 충분히 발전한다면 일부 영역은 아래처럼 나뉠 수 있다.

Scala type system
  ↓
capability
capture
purity
resource escape
일부 effect constraint

ZIO / Cats Effect
  ↓
effect composition
async runtime
fiber
cancellation
retry
scheduling
streaming
structured concurrency

Effect library가 없어지는 것이 아니라 language level에서 이미 보장되는 부분을 다시 구현할 필요가 줄어드는 것이다.

그 위에서 더 높은 수준의 abstraction과 runtime semantics에 집중할 수 있다.

기존 library가 capture checking을 사용할 수도 있다

더 흥미로운 가능성은 ZIO와 Cats Effect의 API 자체가 capture checking을 활용하는 경우다.

Capture checking은 기존 functional programming abstraction과 공존할 수 있도록 설계되고 있다.

대표적인 예가 capture polymorphism이다.

map, filter 같은 고차 함수에 전달되는 함수가 어떤 capability를 capture할지 library 작성자가 미리 모두 알 수 없다.

Capture checking에서는 compiler가 실제 전달된 함수의 capture set을 추론할 수 있다.

따라서 library가 모든 기존 API를 완전히 새로 만들지 않더라도 capture-aware한 타입을 점진적으로 적용할 가능성이 있다.

예를 들어 Cats Effect의 Resource가 resource handle에 대한 capture 정보를 compiler에 제공한다면 runtime의 finalization guarantee에 compile-time escape checking까지 추가할 수 있다.

ZIO에서도 특정 service나 scoped resource가 실제 어느 computation에 capture되는지를 compiler와 결합해 더 정교하게 검사하는 API를 생각해볼 수 있다.

이것이 실제 ZIO나 Cats Effect가 앞으로 선택할 방향이라는 의미는 아니다.

현재 capture checking 자체가 아직 experimental한 연구 기능이고, 기존 effect library들이 이를 중심으로 재설계하겠다고 발표한 것도 아니다.

하지만 기술적인 관계만 놓고 보면 대체보다는 이런 결합 쪽이 훨씬 자연스럽다.

ZIO가 가장 큰 영향을 받을까

개인적으로는 Cats Effect보다 ZIO가 더 흥미로운 위치에 있다고 생각한다.

ZIO[R, E, A]R이 capture checking과 개념적으로 겹치는 부분이 있기 때문이다.

Environment는 어떤 computation이 필요로 하는 service를 타입에 표현한다.

Capture checking은 어떤 computation이 실제로 capture하는 capability reference를 타입 시스템이 추적한다.

둘은 같지는 않지만 상당히 가까운 정보를 다룬다.

만약 Scala 자체에서 capability dependency를 충분히 잘 표현할 수 있게 된다면 ZIO application에서 모든 dependency를 R에 올리는 방식이 계속 최선인지 다시 생각해볼 수 있다.

일부 dependency는 Scala의 contextual abstraction과 tracked capability로 표현하고,

def load(
  id: UserId
)(using db: Database^): User

ZIO는 실제 effect와 runtime이 필요한 영역에 집중하는 형태도 가능하다.

반대로 ZIO의 environment model이 이미 ecosystem 전체에서 잘 작동하고 있기 때문에 굳이 capture checking으로 옮길 이유가 크지 않을 수도 있다.

Capture checking이 더 세밀한 정보를 표현한다고 해서 기존 abstraction이 자동으로 불필요해지는 것은 아니다.

언어 기능이 더 강력하다는 것과 application API로 더 사용하기 좋다는 것은 다른 문제이기 때문이다.

Error handling은 어떻게 될까

ZIO의 또 다른 중요한 축은 E다.

ZIO[R, E, A]

여기서 E는 typed error channel이다.

Capture checking에서도 checked exception을 capability로 표현하는 실험이 진행되고 있다.

예외를 던질 수 있는 권한을 CanThrow라는 capability로 만들고 compiler가 이를 추적하는 방식이다.

이 부분까지 발전한다면 ZIO의 error channel과도 어느 정도 비교할 수 있게 된다.

하지만 여기서도 둘은 같은 문제가 아니다.

ZIO의 E는 error를 effect의 값으로 다루고 mapError, catchAll, retry 같은 combinator로 조합하는 programming model의 일부다.

Checked exception capability는 어떤 computation이 특정 exception을 던질 수 있는지 compile time에 제한하는 것이 중심이다.

따라서 capture checking이 typed error의 일부 표현력을 언어에 제공한다고 해도 ZIO의 error model 전체를 대체한다고 보기는 어렵다.

결국 어디까지가 언어의 역할일까

이 문제는 capture checking만의 이야기는 아니다.

Programming language와 library 사이에는 오래전부터 비슷한 관계가 있었다.

어떤 기능은 처음에는 library pattern으로 만들어졌다가 충분히 일반적이라고 판단되면 언어의 기능으로 들어오기도 한다.

반대로 언어가 제공할 수 있는 기능이라도 application에서 사용하기 좋은 abstraction은 library가 만드는 편이 나을 때도 많다.

Capture checking은 이 경계를 다시 움직일 가능성이 있다.

지금까지 library가 타입으로 표현했던 capability와 resource의 일부 성질을 compiler가 직접 이해할 수 있게 되기 때문이다.

하지만 compiler가 더 많은 것을 안다고 해서 library가 필요 없어지는 것은 아니다.

Compiler는 FileOutputStream이 잘못된 scope로 빠져나가는 것을 막을 수 있다.

그 파일을 여러 asynchronous computation 사이에서 어떻게 사용하고, cancellation이 발생했을 때 어떻게 정리하며, 실패했을 때 어떤 전략으로 다시 실행할지는 여전히 다른 문제다.

결국 두 영역은 서로 경쟁하기보다 층이 다르다고 보는 편이 더 정확하다.

대체가 아니라 새로운 기반이 될 가능성

현재 capture checking은 아직 highly experimental하고 빠르게 변하고 있다. Scala 공식 문서에서도 production에서 일반적으로 사용할 수 있는 완성된 기능으로 다루고 있지 않다.

따라서 ZIO와 Cats Effect의 미래를 지금 단정하는 것은 이르다.

Capture checking이 현재 모습 그대로 정식 Scala 기능이 될지도 아직 알 수 없고, 실제 application 개발에서 어느 정도까지 사용될지도 검증되지 않았다.

그래도 이 기능이 안정적으로 자리 잡는다면 기존 effect library와의 관계는 꽤 흥미로울 것 같다.

ZIO나 Cats Effect가 해결해온 문제를 Scala compiler가 전부 가져가는 방향보다는, 지금까지 library가 떠안고 있던 일부 정적 보장을 언어가 담당하는 방향이 더 현실적이다.

그렇게 되면 effect library는 줄어들기보다는 오히려 더 높은 수준의 문제에 집중할 수 있다.

Capture checking이 resource의 escape와 capability dependency를 확인하고,

ZIO와 Cats Effect가 effect composition, concurrency와 runtime을 담당하는 식이다.

어쩌면 capture checking의 의미는 새로운 effect system 하나가 Scala ecosystem에 추가되는 것이 아닐지도 모른다.

기존 effect system이 서 있을 수 있는 언어적 기반 자체가 조금 더 강해지는 것에 가까울 수 있다.

그리고 그 위에서 ZIO와 Cats Effect가 지금과는 다른 형태로 발전한다면, Scala에서 effect를 다루는 방식도 다시 한번 크게 달라질 수 있다.

참고

Scala 3 Reference, Capture Checking
https://docs.scala-lang.org/scala3/reference/experimental/capture-checking/

Scala 3 Reference, Capability Polymorphism
https://docs.scala-lang.org/scala3/reference/experimental/capture-checking/polymorphism.html

Scala 3 Reference, Scoped Capabilities
https://docs.scala-lang.org/scala3/reference/experimental/capture-checking/scoped-capabilities.html

Scala 3 Reference, Checked Exceptions
https://docs.scala-lang.org/scala3/reference/experimental/capture-checking/checked-exceptions.html

ZIO Documentation, ZIO Data Type
https://zio.dev/reference/core/zio/

ZIO Documentation, Contextual Data Types
https://zio.dev/reference/contextual/

Cats Effect Documentation, IO
https://typelevel.org/cats-effect/docs/datatypes/io

Cats Effect Documentation, Resource
https://typelevel.org/cats-effect/docs/std/resource

이 글은 Scala 3, ZIO, Cats Effect의 공식 문서를 바탕으로 현재의 기술적 관계와 향후 가능성을 정리했으며, 미래의 library 설계에 관한 부분은 현재 공개된 기능을 바탕으로 한 전망입니다. 글을 작성하고 다듬는 과정에서 ChatGPT를 활용했습니다.

댓글 0

댓글을 불러오는 중입니다.