Skip to content

Instantly share code, notes, and snippets.

@guersam
Created February 26, 2026 04:40
Show Gist options
  • Select an option

  • Save guersam/41f8e82ede7e1192585fc68f4ba02ea0 to your computer and use it in GitHub Desktop.

Select an option

Save guersam/41f8e82ede7e1192585fc68f4ba02ea0 to your computer and use it in GitHub Desktop.
스칼라 3의 실험적인 Capture Checking 기능 해설

Scala 3 Capture Checking 완전 가이드

주니어 스칼라 개발자를 위한 실험적 기능 해설 ZIO Scope, Rust Lifetime과의 비교를 곁들여


1. 이 기능이 해결하려는 문제

1.1 자원 누수라는 오래된 문제

프로그래밍에서 "자원(resource)"이란 파일 핸들, 데이터베이스 커넥션, 네트워크 소켓처럼 열었으면 반드시 닫아야 하는 것을 말합니다. 문제는 이런 자원이 자기가 살아 있어야 할 범위를 넘어서 바깥으로 빠져나갈 때 생깁니다.

공식 문서의 첫 번째 예제를 봅시다.

def usingLogFile[T](op: FileOutputStream => T): T =
  val logFile = FileOutputStream("log")
  val result = op(logFile)
  logFile.close()
  result

이 메서드는 로그 파일을 열고, 전달받은 함수 op를 실행한 뒤, 파일을 닫습니다. 자바나 스칼라에서 흔히 볼 수 있는 try-with-resources 패턴입니다.

그런데 다음과 같이 쓰면 문제가 생깁니다.

val later = usingLogFile { file => () => file.write(0) }
later() // 이미 닫힌 파일에 쓰기 시도 → 런타임 크래시

여기서 () => file.write(0)는 클로저(closure)입니다. 이 클로저는 file을 "캡처"해서 자기 안에 가두어 놓습니다. usingLogFile 메서드가 끝나면 logFile.close()가 호출되지만, 클로저는 여전히 file을 들고 있습니다. 나중에 later()를 호출하면, 이미 닫힌 파일에 쓰기를 시도하게 되어 IOException이 터집니다.

핵심은 이것입니다. 자원의 참조가 자기가 유효한 범위(scope)를 탈출(escape)하면 use-after-close 버그가 발생합니다. 이 버그는 런타임에야 발견됩니다.

1.2 컴파일 타임에 잡을 수는 없을까?

Capture checking의 목표는 이러한 자원 탈출을 컴파일 타임에 감지하는 것입니다. 타입 시스템에 "이 값이 어떤 능력(capability)을 잡고(capture) 있는지"에 대한 정보를 추가하여, 잡고 있으면 안 되는 것을 잡고 있는 코드를 컴파일러가 거부하도록 합니다.

이 아이디어는 스칼라 3의 창시자인 Martin Odersky가 이끄는 연구 프로젝트에서 출발했으며, 현재 language.experimental.captureChecking 임포트로 활성화할 수 있는 실험적 기능입니다.


2. 기존에 익숙한 것과의 비교

Capture checking을 본격적으로 들어가기 전에, 이미 알고 있는 ZIO의 개념과 Rust의 라이프타임이 같은 문제를 어떻게 다루는지 비교해 보겠습니다.

2.1 ZIO의 Scope, R 타입과의 관계

ZIO를 써 보셨다면 ZIO[R, E, A]에서 R 타입 파라미터를 알고 계실 겁니다. R은 이 이펙트를 실행하기 위해 필요한 "환경(environment)"을 나타냅니다. Scope도 이 R에 들어가는 타입인데, "이 이펙트에는 정리(finalize)해야 할 자원이 있다"는 사실을 나타냅니다.

// ZIO에서의 자원 관리
def openFile(name: String): ZIO[Scope, IOException, FileInputStream] =
  ZIO.acquireRelease(acquire)(release)

// Scope를 소비하여 자원의 수명을 한정
def contents(name: String): ZIO[Any, IOException, Chunk[String]] =
  ZIO.scoped {
    openFile(name).flatMap { source =>
      ZIO.attemptBlockingIO(Chunk.fromIterator(source.getLines()))
    }
  }

ZIO.scoped 블록이 끝나면 Scope에 등록된 모든 finalizer가 실행됩니다. 파일이 블록 바깥으로 나갈 수 없게 하는 것은 R 타입에 Scope가 남아 있으면 컴파일이 안 되도록 하는 것으로 강제합니다.

Capture checking도 근본적으로 같은 아이디어입니다. 다만 ZIO는 이펙트 시스템이라는 "라이브러리 수준"의 래퍼를 통해 이를 달성하고, capture checking은 "언어의 타입 시스템 수준"에서 달성합니다. 따라서 ZIO[R, E, A] 같은 래퍼 없이도, 일반 함수와 일반 값에 대해 자원 추적이 가능해집니다.

2.2 ZIO Blocks의 Scope: 라이브러리 수준의 캡처 방지

ZIO Blocks는 아직 개발 중인 새로운 라이브러리인데, 이펙트 시스템 없이도 컴파일 타임에 자원 누수를 방지하려는 시도입니다. 핵심 아이디어는 타입 태깅(type tagging)입니다.

import zio.blocks.scope._

Scope.global.scoped { scope =>
  import scope._
  val db: Database @@ ScopeTag = allocate(Resource(openDatabase()))
  // db의 타입에 ScopeTag가 붙어 있어서 바깥으로 반환할 수 없음
  // db.query()를 직접 호출할 수도 없음 → $(db)(_.query("SELECT 1"))로 접근
  val result = $(db)(_.query("SELECT 1"))
  result // 순수 데이터만 탈출 가능
}
// 여기서 db.close() 자동 호출

Database @@ ScopeTag라는 타입이 핵심입니다. @@는 "태그를 붙인다"는 의미이고, ScopeTag는 이름을 알 수 없는(unnameable) 타입입니다. 이 스코프 블록 바깥에서는 ScopeTag의 정체를 알 수 없으므로, 태그가 붙은 값을 반환하려 하면 타입 에러가 납니다.

이것은 capture checking이 하려는 것과 매우 유사합니다. capture checking은 캡처 집합(capture set)이라는 메커니즘으로 같은 목표를 달성하고, ZIO Blocks는 타입 태깅과 접근자 패턴으로 달성합니다. 차이점은 capture checking이 언어 내장이라 별도의 $() 같은 래퍼 없이 자연스럽게 동작한다는 점입니다.

2.3 Rust의 라이프타임과의 비교

Rust에서는 모든 참조(reference)에 라이프타임이 있습니다.

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

여기서 'a는 "이 참조가 유효한 기간"을 나타내는 라이프타임 파라미터입니다. 반환값의 라이프타임이 두 인자의 라이프타임 중 짧은 쪽으로 제한된다는 것을 컴파일러에게 알려주는 역할을 합니다.

Rust의 borrow checker가 하는 일과 Scala의 capture checker가 하는 일은 철학적으로 같습니다. 둘 다 "참조가 자기가 유효한 범위를 넘어서 살아남지 못하게" 하는 것이 목표입니다. 다만 접근 방식이 다릅니다.

관점 Rust 라이프타임 Scala Capture Checking
추적 대상 참조(reference)의 유효 기간 값이 캡처한 능력(capability)의 집합
표기법 'a, 'b 같은 라이프타임 파라미터 ^{fs}, ^{cap} 같은 캡처 집합
강제 시점 컴파일 타임 (borrow checker) 컴파일 타임 (capture checker)
주된 용도 메모리 안전성 (use-after-free 방지) 자원 안전성 (use-after-close 방지) + 이펙트 추적
GC 여부 GC 없음 (소유권 기반) GC 있음 (JVM 위)

Rust는 GC가 없기 때문에 메모리 자체의 해제 시점을 추적해야 하므로 라이프타임이 필수적입니다. Scala는 JVM 위에서 돌아가므로 메모리는 GC가 관리하지만, 파일 핸들이나 네트워크 커넥션 같은 자원의 수명은 GC가 관리해 주지 않습니다. Capture checking은 바로 이런 자원의 수명을 추적합니다.


3. 공식 문서 단락별 상세 해설

이제 공식 문서의 각 섹션을 하나씩 뜯어보겠습니다.

3.1 캡처 집합과 캡처 타입 (Capabilities and Capturing Types)

공식 문서에서 가장 먼저 등장하는 핵심 개념은 캡처 타입(capturing type)입니다.

T^{c₁, ..., cₙ}

이것은 "타입 T인데, 능력(capability) c₁부터 cₙ까지를 잡고(capture) 있다"는 뜻입니다. 캡처 집합은 중괄호 {} 안에 나열됩니다.

능력(capability)이란 메서드 파라미터, 클래스 파라미터, 지역 변수, 또는 둘러싼 클래스의 this 중 하나인데, 그 타입의 캡처 집합이 비어 있지 않은 것을 말합니다. 쉽게 말해, 뭔가 외부 자원에 대한 참조를 가지고 있는 값입니다.

모든 능력의 최상위에는 cap이라는 보편 능력(universal capability)이 있습니다. T^T^{cap}의 축약형으로, "아무 능력이나 다 캡처할 수 있는 T"를 뜻합니다.

공식 문서의 예제를 보겠습니다.

class FileSystem

class Logger(fs: FileSystem^):
  def log(s: String): Unit = ... // fs를 이용해 로그 파일에 기록

def test(fs: FileSystem^) =
  val l: Logger^{fs} = Logger(fs)
  l.log("hello world!")
  val xs: LazyList[Int]^{l} =
    LazyList.from(1)
      .map { i =>
        l.log(s"computing elem # $i")
        i * i
      }
  xs

한 줄씩 따라가 봅시다.

fs: FileSystem^에서 ^^{cap}의 축약입니다. 즉 fs는 "어떤 능력이든 캡처할 수 있는 FileSystem"이며, 이것 자체가 하나의 능력이 됩니다.

val l: Logger^{fs} = Logger(fs)는 "이 Logger 인스턴스는 fs라는 능력을 캡처하고 있다"는 뜻입니다. Loggerfs를 필드로 가지고 있으니까요.

val xs: LazyList[Int]^{l}은 "이 LazyList는 l이라는 능력을 캡처하고 있다"는 뜻입니다. 게으른 리스트이므로 원소를 계산할 때 l.log를 호출해야 하니까, l에 대한 참조를 유지해야 합니다. 한편, xsfs를 직접 캡처하지 않습니다. fsl을 통해 간접적으로만 접근됩니다.

이 캡처 집합 정보가 타입 시스템에 들어감으로써, 컴파일러는 "이 값이 어떤 외부 자원에 의존하는지"를 정확히 알 수 있게 됩니다.

3.2 함수 타입 (Function Types)

Capture checking은 함수 타입에 중요한 변화를 줍니다.

표기 의미
A => B 아무 능력이나 캡처할 수 있는 함수 (불순, impure)
A -> B 아무것도 캡처하지 않는 함수 (순수, pure)
A ->{c, d} B 능력 cd만 캡처할 수 있는 함수

기존 Scala에서 A => B는 그냥 "A를 받아서 B를 돌려주는 함수"였습니다. Capture checking이 활성화되면, A => BA ->{cap} B의 별칭이 됩니다. 즉, "아무 능력이나 캡처할 수 있는 함수"입니다.

새로 도입된 -> 연산자는 순수 함수를 나타냅니다. 아무런 외부 능력도 캡처하지 않는 함수입니다. 그 중간에 해당하는 것이 A ->{c} B로, 특정 능력만 캡처할 수 있는 함수입니다.

한 가지 주의할 점은 메서드(method)에는 이 구분이 적용되지 않는다는 것입니다. 메서드는 값(value)이 아니므로 자체적으로 아무것도 캡처하지 않습니다. 메서드가 참조하는 능력은 그 메서드를 감싸고 있는 객체의 캡처 집합에 포함됩니다.

ZIO에 비유하자면, A => BZIO[Any, Nothing, B]처럼 아무 이펙트나 가질 수 있는 것에 해당하고, A -> B는 순수 함수에 해당하고, A ->{c} BZIO[SomeCapability, Nothing, B]처럼 특정 이펙트만 가지는 것에 해당합니다.

3.3 이름 전달 파라미터 (By-Name Parameter Types)

같은 규칙이 이름 전달 파라미터에도 적용됩니다.

def f(x: => Int): Int      // x는 아무 능력이나 캡처 가능 (기존 문법)
def f(x: -> Int): Int      // x는 순수해야 함 (새 문법)
def f(x: ->{c} Int): Int   // x는 능력 c만 캡처 가능

예를 들어 f(if p(y) then throw Ex() else 1)에서 throwCanThrow 능력을 사용합니다. => Int로 선언된 파라미터는 이를 허용하고, -> Int로 선언된 파라미터는 이를 거부합니다.

3.4 서브타이핑과 서브캡처링 (Subtyping and Subcapturing)

타입 간의 서브타이핑 관계가 캡처 집합으로 확장됩니다. 핵심 규칙은 두 가지입니다.

첫째, 순수 타입은 캡처 타입의 서브타입입니다. TT^{anything}의 서브타입입니다. "아무것도 캡처하지 않는 것"은 "뭔가를 캡처하는 것"보다 더 제한적이므로, 더 제한적인 타입이 서브타입이 되는 것은 자연스럽습니다.

둘째, 캡처 집합이 작을수록 서브타입입니다. T^{a}T^{a, b}의 서브타입입니다.

서브캡처링(subcapturing)은 하나의 캡처 집합이 다른 캡처 집합에 의해 "포괄(cover)"되는 관계입니다. 예를 들어 다음 선언이 있을 때,

fs: FileSystem^
ct: CanThrow[Exception]^
l : Logger^{fs}

다음과 같은 관계가 성립합니다.

{l} <: {fs}       // l의 캡처 집합이 {fs}이므로, {fs}가 {l}을 포괄
{fs} <: {fs, ct}  // fs는 {fs, ct}에 포함됨
{fs, ct} <: {cap} // cap은 모든 것을 포괄

{l} <: {fs}가 성립하는 이유가 중요합니다. l의 타입은 Logger^{fs}인데, 이는 l이 가진 모든 능력이 fs에서 나왔다는 뜻입니다. 따라서 {fs}{l}의 모든 것을 감당할 수 있습니다.

공식 문서에 나오는 회피(avoidance) 개념도 여기서 파생됩니다. 지역 변수 ltest 메서드 바깥에서 참조할 수 없으므로, 반환 타입에 l이 나타날 수 없습니다. 이때 컴파일러는 {l}{fs}로 넓히는데(widening), 이것이 가능한 이유가 바로 {l} <: {fs} 관계가 있기 때문입니다.

3.5 Capability 클래스

매번 파라미터에 ^를 붙이는 것이 번거로우므로, 클래스 자체를 "항상 능력인 클래스"로 선언할 수 있습니다.

import caps.Capability

class FileSystem extends Capability

class Logger(using FileSystem):
  def log(s: String): Unit = ???

def test(using fs: FileSystem) =
  val l: Logger^{fs} = Logger()
  ...

Capability를 확장한 클래스는 캡처 집합이 항상 {cap}으로 암시됩니다. 따라서 FileSystem 타입의 파라미터에 ^를 붙일 필요가 없습니다. 문서에서도 FileSystem^{cap}처럼 명시적으로 쓰면 "중복된 캡처"라는 경고가 나온다고 설명합니다.

또한 이 예제에서 using 키워드로 능력을 암시적 파라미터로 전달하는 것을 볼 수 있습니다. ZIO에서 R 타입의 환경을 ZLayer로 제공하는 것처럼, capture checking에서도 능력을 using으로 전달하면 배선(wiring) 코드를 크게 줄일 수 있습니다.

3.6 클로저의 캡처 검사 (Capture Checking of Closures)

클로저가 본문에서 능력을 참조하면, 그 능력은 클로저 타입의 캡처 집합에 나타납니다.

def test(fs: FileSystem): String ->{fs} Unit =
  (x: String) => Logger(fs).log(x)

이 람다는 fs를 참조하므로, 타입이 String ->{fs} Unit이 됩니다. 주목할 점은 함수값(function value)을 쓸 때는 항상 =>로 쓴다는 것입니다. ->=>의 구분은 타입 수준에서만 존재하고, 값 수준에서는 항상 =>입니다.

또한 간접적인 캡처도 추적됩니다.

def test(fs: FileSystem) =
  def f() = g()
  def g() = (x: String) => Logger(fs).log(x)
  f

f 자체는 fs를 직접 참조하지 않지만, g를 호출하고, gfs를 캡처하는 클로저를 반환합니다. 따라서 test의 결과 타입은 여전히 String ->{fs} Unit입니다.

3.7 클래스의 캡처 검사 (Capture Checking of Classes)

클래스도 클로저와 같은 원리가 적용됩니다. 클래스가 능력을 필드로 유지하면, 그 능력은 인스턴스의 캡처 집합에 포함됩니다.

class Logger(using fs: FileSystem):
  def log(s: String): Unit = ... summon[FileSystem] ...

def test(xfs: FileSystem): Logger^{xfs} =
  Logger(xfs)

Loggerfs를 필드로 유지하므로, test의 반환 타입은 Logger^{xfs}입니다.

만약 파라미터를 생성자에서만 쓰고 필드로 유지하지 않는다면, @constructorOnly 어노테이션을 사용할 수 있습니다.

import annotation.constructorOnly

class NullLogger(using @constructorOnly fs: FileSystem):
  ...
def test2(using fs: FileSystem): NullLogger = NullLogger() // OK, 순수 타입

이 경우 NullLoggerfs를 유지하지 않으므로, 반환 타입에 캡처 집합이 없습니다 (순수 타입).

클래스의 캡처된 참조는 두 종류가 있습니다. 지역 능력(local capability)은 클래스 바깥에서 정의되었지만 클래스 본문에서 참조되는 것이고, 인자 능력(argument capability)은 생성자 파라미터로 전달되는 것입니다. 지역 능력은 상속됩니다. 슈퍼클래스의 지역 능력은 서브클래스의 지역 능력이기도 합니다.

3.8 캡처 터널링 (Capture Tunnelling)

이것은 capture checking을 실용적으로 만드는 핵심 메커니즘입니다. 타입 변수(type variable)가 캡처 타입으로 인스턴스화되면, 캡처 정보가 바깥으로 전파되지 않습니다.

def x: Int ->{ct} String
def y: Logger^{fs}
def p = Pair(x, y)
// p의 타입: Pair[Int ->{ct} String, Logger^{fs}]
// p 자체의 캡처 집합은 비어 있음!

Pair(x, y)는 분명히 ctfs 능력을 캡처하고 있지만, p의 바깥쪽 캡처 집합은 비어 있습니다. 캡처 정보가 제네릭 타입 인자 안에 "터널"처럼 숨어 들어간 것입니다.

하지만 타입 인자를 다시 꺼낼 때(접근할 때), 캡처 정보가 "팝"됩니다.

() => p.fst : () -> Int ->{ct} String

p.fst를 접근하면 ct 능력이 다시 나타납니다. 이것을 클로저 안에서 사용하면 클로저의 캡처 집합에 포함됩니다.

왜 이렇게 설계했을까요? 만약 터널링이 없으면, List, Option, Map 같은 모든 제네릭 컬렉션의 타입에 캡처 정보가 전파되어야 합니다. List[Logger^{fs}] 자체의 타입이 List[Logger^{fs}]^{fs}가 되어야 하는 식으로 모든 곳에 캡처 주석이 범람할 것입니다. 터널링 덕분에 제네릭 컬렉션은 기존처럼 자연스럽게 사용할 수 있습니다.

3.9 탈출 검사 (Escape Checking)

탈출 검사는 capture checking의 핵심 안전 장치입니다. 규칙은 명확합니다. 타입 변수의 인스턴스인 캡처 타입은 보편 능력 cap을 포함할 수 없습니다.

첫 번째 예제로 돌아가 봅시다.

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

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

컴파일러의 추론 과정은 다음과 같습니다.

  1. f의 타입은 FileOutputStream^이므로 f는 능력입니다.
  2. () => f.write(0)의 타입은 () ->{f} Unit입니다 (f를 캡처한 함수).
  3. 전체 클로저의 타입은 (f: FileOutputStream^) -> () ->{f} Unit이 됩니다 (의존 함수 타입).
  4. 기대 타입 FileOutputStream^ => T와 맞추기 위해, T를 추론해야 합니다.
  5. 의존 함수 타입에서 파라미터 f를 지우려면, {f}{cap}으로 넓혀야 합니다.
  6. 따라서 T() ->{cap} Unit, 즉 () => Unit으로 인스턴스화됩니다.
  7. 그런데 T는 타입 변수이므로, cap을 포함하면 안 됩니다 → 에러 발생.

에러 메시지가 이렇게 나옵니다.

The expression's type () => Unit is not allowed to capture the root capability `cap`.
This usually means that a capability persists longer than its allowed lifetime.

이 메커니즘은 가변 변수를 통한 탈출도 막습니다.

var loophole: () => Unit = () => ()
usingLogFile { f =>
  loophole = () => f.write(0)  // 에러: 가변 변수는 cap을 캡처하는 타입을 가질 수 없음
}

또한 제네릭 타입 안에 클로저를 숨겨서 탈출시키려는 시도도 막습니다.

class Cell[+A](x: A)
val sneaky = usingLogFile { f => Cell(() => f.write(0)) }
sneaky.x()  // 에러: 접근 시 cap이 튀어나옴

Cell이 만들어질 때는 괜찮지만, sneaky.x()로 접근할 때 터널링이 풀리면서 cap이 나타나고, 이것이 탈출 검사에 걸립니다.

3.10 검사된 예외 (Checked Exceptions)

Capture checking의 강력한 응용 중 하나가 검사된 예외입니다. Scala 3에는 이미 CanThrow라는 능력 기반 예외 시스템이 실험적으로 있는데, capture checking과 결합하면 완전한 안전성을 얻습니다.

import language.experimental.saferExceptions

class LimitExceeded extends Exception

def f(x: Double): Double throws LimitExceeded =
  if x < limit then x * x else throw LimitExceeded()

throws LimitExceeded(using CanThrow[LimitExceeded]) 암시적 파라미터로 확장됩니다. CanThrowCapability를 확장하므로 자동으로 추적됩니다.

try 표현식은 CanThrow 능력을 생성하고, 그 범위 안에서만 유효합니다. 이를 통해 다음과 같은 위험한 코드가 거부됩니다.

def escaped(xs: Double*): (() => Double) throws LimitExceeded =
  try () => xs.map(f).sum  // 에러: 클로저가 try 바깥으로 CanThrow를 가지고 탈출
  catch case ex: LimitExceeded => () => -1

try 블록이 생성한 CanThrow 능력을 클로저가 캡처한 채로 try 밖으로 나가려 하므로, 탈출 검사에 걸립니다. ZIO에서 ZIO.scoped 블록 바깥으로 Scope가 있는 이펙트를 반환할 수 없는 것과 같은 원리입니다.

3.11 게으른 리스트 전체 예제 (A Larger Example)

공식 문서는 게으른 리스트(lazy list)를 통해 capture checking의 실전 사용을 보여줍니다. 이것은 단순한 예제가 아니라, 캡처 검사가 어떻게 정밀하게 작동하는지를 보여주는 중요한 예제입니다.

게으른 리스트 트레이트의 핵심은 tail의 반환 타입입니다.

trait LzyList[+A]:
  def isEmpty: Boolean
  def head: A
  def tail: LzyList[A]^{this}

tail의 타입이 LzyList[A]^{this}인 것에 주목하세요. 이것은 "꼬리 리스트가 현재 리스트와 같은 능력을 캡처할 수 있다"는 뜻입니다. 게으른 리스트는 꼬리를 나중에 계산하므로, 계산에 필요한 능력을 꼬리가 계속 유지해야 합니다.

map, filter 같은 연산의 캡처 주석도 직관적입니다.

extension [A](xs: LzyList[A]^)
  def map[B](f: A => B): LzyList[B]^{xs, f} =
    if xs.isEmpty then LzyNil
    else f(xs.head) #: xs.tail.map(f)

map의 반환 타입이 LzyList[B]^{xs, f}인 이유는, 결과 리스트를 순회할 때 원본 리스트 xs에서 원소를 가져와야 하고, 매핑 함수 f를 적용해야 하기 때문입니다. 만약 f가 순수 함수라면 (A -> B 타입이라면), f는 캡처 집합에서 빠지고 LzyList[B]^{xs}만 남습니다.

문서가 강조하는 핵심 통찰은 이것입니다. 엄격한(strict) 리스트와 순수 게으른 리스트에는 캡처 주석이 전혀 필요 없습니다. 엄격한 리스트는 부수효과를 결과에 보관하지 않으므로 캡처할 것이 없고, 순수 게으른 리스트는 캡처 가능한 능력이 없으므로 모든 것이 순수합니다. 캡처 주석이 필요한 것은 불순한 게으른 리스트처럼 "지연된 부수효과"가 있는 경우뿐입니다. 이 성질은 capture checking이 기존 코드에 주는 부담을 최소화합니다.

3.12 존재적 능력 (Existential Capabilities)

cap은 맥락에 따라 다른 능력을 의미할 수 있습니다. 함수 결과 타입에서의 cap은 존재적(existential) 변수로 해석됩니다.

() -> Iterator[T]^
// 실제 의미: () -> Exists x. Iterator[T]^{x}

이것은 "이 함수를 호출할 때마다, 결과 Iterator가 캡처하는 능력이 있는데, 그게 정확히 뭔지는 각 호출마다 다를 수 있다"는 뜻입니다. 함수 파라미터의 cap은 "무엇이든 가능"이지만, 결과 타입의 cap은 "뭔가 있는데 정확히 뭔지는 모름"이라는 의미론적 차이가 있습니다.

내부적으로는 의존 함수 타입으로 표현됩니다. () -> (x: Exists) -> Iterator[T]^{x} 같은 형태입니다. 이것은 에러 메시지에 나타날 수 있지만, 소스 코드에서 직접 쓸 일은 거의 없습니다.

변환 규칙은 다음과 같습니다.

  • A => B(A -> B)^의 별칭이므로, () -> A => B() -> Exists c. A ->{c} B로 확장됩니다.
  • A -> B^A -> Exists c. B^{c}로 확장됩니다.
  • 타입 별칭을 통해 존재적 바인더의 범위를 조절할 수 있습니다. 예를 들어 type Fun[T] = A -> T로 정의하면, () -> Fun[B^]() -> Exists c. A -> B^{c}로 확장됩니다.

3.13 도달 능력 (Reach Capabilities)

불순한 함수를 담고 있는 리스트를 다룰 때 필요한 개념입니다.

def f(ops: List[A => B])
  var xs = ops
  var x: A ->{ops*} B = xs.head
  ...

ops는 순수합니다 (캡처 집합이 비어 있음). List 자체는 아무것도 캡처하지 않으니까요(터널링). 하지만 리스트의 원소인 A => B 함수들은 불순합니다. ops*(ops 별표)는 "ops를 통해 도달할 수 있는 모든 능력"을 나타냅니다.

x* 형태의 도달 능력은 x의 타입 T에서 공변(covariant) 위치에 나타나는 모든 능력을 대표합니다. 이를 통해 "리스트에 들어 있는 함수들이 캡처하는 모든 능력"이라는 개념을 캡처 집합으로 표현할 수 있습니다.

3.14 능력 다형성 (Capability Polymorphism)

캡처 집합 자체를 타입 파라미터로 받을 수 있습니다.

class Source[X^]:
  private var listeners: Set[Listener^{X}] = Set.empty
  def register(x: Listener^{X}): Unit =
    listeners += x
  def allListeners: Set[Listener^{X}] = listeners

X^는 캡처 집합을 나타내는 타입 변수입니다. 이것을 통해 "이 Source에 등록할 수 있는 Listener가 캡처할 수 있는 능력의 범위"를 파라미터로 받을 수 있습니다.

인스턴스화할 때는 구체적인 캡처 집합을 지정합니다.

def test1(async1: Async, others: List[Async]) =
  val src = Source[{async1, others*}]
  src.register(listener(async1))
  others.map(listener).foreach(src.register)

내부적으로 X^X >: CapSet <: CapSet^ 범위를 가진 일반 타입 변수로 표현됩니다. CapSetcaps 객체의 밀봉된(sealed) 트레이트로, 캡처 집합용 타입 변수를 식별하는 데 쓰입니다.

3.15 능력 멤버 (Capability Members)

타입 파라미터 대신 타입 멤버로 캡처 집합을 표현할 수도 있습니다.

class Source:
  type X^
  private var listeners: Set[Listener^{this.X}] = Set.empty
  ...

{this.X} 처럼 경로를 사용하여 캡처 집합에서 능력 멤버를 참조할 수 있습니다. 능력 멤버는 타입 멤버처럼 상한과 하한을 가질 수 있습니다.

trait GPUThread extends Thread:
  type Cap^ >: {cudaMalloc, cudaFree} <: {caps.cap}

이것은 "이 GPUThread의 Cap 능력 집합은 최소한 cudaMalloc과 cudaFree를 포함하며, 어떤 능력이든 포함할 수 있다"는 의미입니다.


4. 한눈에 보는 문법 요약

문법 의미 비유
T^ T^{cap}의 축약. T가 아무 능력이나 캡처 가능 ZIO의 Any 환경
T^{a, b} T가 능력 a, b를 캡처함 ZIO의 R 타입에 a, b를 명시
A -> B 순수 함수 (아무것도 캡처하지 않음) Rust의 fn(A) -> B (클로저 아님)
A ->{c} B 능력 c만 캡처하는 함수 특정 환경만 가진 ZIO 이펙트
A => B A ->{cap} B의 별칭. 아무거나 캡처하는 함수 ZIO의 ZIO[Any, ?, ?]
cap 보편 능력 (최상위 캡처 집합) Rust의 'static 반대 (가장 넓은 범위)
extends Capability 항상 캡처 집합이 {cap}인 클래스 ZIO에서 항상 Scope가 필요한 서비스
x* x를 통해 도달 가능한 모든 능력 컬렉션 내부의 이펙트를 지칭
X^ (타입 파라미터) 캡처 집합을 매개변수화 어떤 이펙트 집합인지를 제네릭으로 받음
@constructorOnly 생성자에서만 쓰이고 필드로 유지 안 됨 일시적 의존성

5. 전체 그림: 개념들의 연결

지금까지 살펴본 개념들이 어떻게 연결되는지 정리하겠습니다.

캡처 타입(T^{c})은 기본 건축 블록입니다. 값이 어떤 외부 능력에 의존하는지를 타입에 기록합니다.

함수 타입의 분화(-> vs =>)는 캡처 타입의 자연스러운 결과입니다. 함수도 값이므로, 함수가 캡처하는 능력을 타입으로 표현해야 합니다. 순수 함수는 아무것도 캡처하지 않고, 불순 함수는 캡처합니다.

서브캡처링은 캡처 집합 간의 포괄 관계를 정의하여, 더 적게 캡처하는 것이 더 많이 캡처하는 것의 서브타입이 되게 합니다. 이것이 있어야 기존의 서브타이핑 기반 다형성이 캡처 정보와 자연스럽게 공존합니다.

터널링은 제네릭 타입 인자 안에 캡처 정보를 숨겨서, 모든 제네릭 코드에 캡처 주석이 범람하는 것을 방지합니다. 이것 없이는 List, Option, Map 등의 모든 사용에 캡처 주석이 필요해져서 실용적이지 않게 됩니다.

탈출 검사는 터널링의 보완책입니다. 터널링으로 숨겨진 캡처 정보가 타입 변수 인스턴스화를 통해 cap으로 확대되면, 이를 거부합니다. 이것이 자원 탈출을 실제로 막는 최종 방어선입니다.

검사된 예외, 게으른 리스트, 이벤트 소스 등은 모두 이 기본 메커니즘의 응용입니다. 이들이 보여주는 것은 capture checking이 단순히 파일 닫기 문제를 넘어서, 이펙트 다형성(effect polymorphism), 예외 추적, 비동기/동기 혼합 등 다양한 문제에 적용될 수 있다는 것입니다.

ZIO의 R 타입, ZIO Blocks의 @@ ScopeTag, Rust의 라이프타임은 모두 같은 근본 문제, 즉 "참조가 유효 범위를 벗어나는 것을 방지"를 다르는 계층에서 해결합니다. Capture checking은 이것을 Scala 타입 시스템에 직접 녹여넣어, 별도의 이펙트 래퍼 없이도 동일한 안전성을 제공하려는 시도입니다.


6. 실무적 함의

아직 실험적 기능이므로 프로덕션에 바로 쓸 수는 없지만, 알아두면 좋은 점들을 정리합니다.

첫째, 기존의 엄격한(strict) 코드에는 변경이 거의 없습니다. 엄격한 리스트를 사용하는 코드는 캡처 주석 없이 기존 그대로 컴파일됩니다. =>가 이미 "아무거나 캡처 가능"을 의미하고, 엄격한 연산은 부수효과를 결과에 보관하지 않으므로 별도 주석이 필요 없습니다.

둘째, 게으른(lazy) 연산이나 자원 관리 패턴에서 진가를 발휘합니다. LazyList, 스트림, 이터레이터 등 지연 평가가 관여하는 곳에서 캡처 주석이 필요하고, 이 주석을 통해 자원 누수를 컴파일 타임에 방지할 수 있습니다.

셋째, ZIO 등의 이펙트 시스템이 해결하던 문제 중 일부를 언어 수준에서 해결할 수 있게 됩니다. 이펙트 래핑 없이 일반 함수로 이펙트를 추적할 수 있으므로, 더 가볍고 성능 오버헤드가 없는 이펙트 관리가 가능해집니다. 물론 ZIO가 제공하는 동시성, 재시도, 타이머 등의 풍부한 기능까지 대체하는 것은 아닙니다.


참고 자료

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment