CanThrow 역량
CanThrow 역량 (CanThrow Capabilities)
이 페이지는 Scala 3에서 예외 검사(exception checking)를 실험적으로 지원하는 내용을 다뤄요. 이 기능은 언어 import로 켤 수 있어요.
본문
import language.experimental.saferExceptions
지금 이 확장을 공개하는 이유는 그 사용성에 대한 피드백을 받기 위해서예요. 우리는 이 확장이 제시하는 일반적인 아이디어 위에 구축되는 더 발전된 타입 시스템을 연구 중이에요. 그 타입 시스템들은 검사된 예외(checked exceptions) 너머의 응용 분야를 갖고 있어요. 예외 검사는 유용한 출발점인데, 예외는 모든 Scala 프로그래머에게 익숙하고 현재의 처리 방식에도 개선의 여지가 있기 때문이에요.
왜 예외인가?
예외는 많은 상황에서 오류 처리를 위한 이상적인 메커니즘이에요. 오류 조건을 최소한의 보일러플레이트로 전파하려는 의도된 목적을 잘 수행해요. "행복한 경로"(happy path)에서는 오버헤드가 전혀 없어서, 오류가 드물게 발생하는 한 매우 효율적이에요. 또한 예외는 디버깅에도 친숙한데, 처리 지점에서 조사할 수 있는 스택 트레이스를 만들어 내기 때문이에요. 그래서 오류 조건이 어디서 시작됐는지 추측할 필요가 없어요.
왜 예외가 아닌가?
하지만 현재 Scala와 많은 다른 언어의 예외는 타입 시스템에 반영되지 않아요. 이는 함수 계약의 필수적인 부분——즉 "이 함수가 어떤 예외를 만들 수 있는가?"——이 정적으로 검사되지 않는다는 뜻이에요. 대부분의 사람이 이것이 문제라는 것은 인정하지만, 지금까지 검사된 예외(checked exceptions)라는 대안은 너무 고통스러워서 고려될 수 없었어요. 좋은 예가 Java의 검사된 예외인데, 원칙적으로는 올바른 일을 하지만 다루기가 너무 어려워서 실수였다는 평가를 널리 받아요. 지금까지 Java를 본뜬 후속 언어들이나 JVM 위에 구축된 언어 중 어느 것도 이 기능을 복사하지 않았어요. 예를 들어 Anders Hejlsberg가 C#에 검사된 예외가 없는 이유에 대해 한 말을 참고하세요.
Java 검사된 예외의 문제
Java의 검사된 예외 모델의 주된 문제는 유연성 부족인데, 이는 다형성(polymorphism)이 없기 때문이에요. List[A]에 다음과 같이 선언된 map 함수를 생각해볼게요.
def map[B](f: A => B): List[B]
Java 모델에서는 함수 f가 검사된 예외를 던질 수 없어요. 그래서 다음 호출은 유효하지 않아요.
xs.map(x => if x < limit then x * x else throw LimitExceeded())
이를 우회하는 유일한 방법은 검사된 예외 LimitExceeded를 검사되지 않은 java.lang.RuntimeException으로 감싸서, 호출 지점에서 잡아 다시 풀어내는 거예요. 대략 이렇게요.
try
xs.map(x => if x < limit then x * x else throw Wrapper(LimitExceeded()))
catch case Wrapper(ex) => throw ex
어휴! Java의 검사된 예외가 인기가 없는 것도 무리가 아니에요.
모나딕 효과 (Monadic Effects)
그렇다면 딜레마는 이거예요. 예외는 정적 타입 검사를 잊어버리는 한에서만 쉽게 사용할 수 있다는 것. 이 때문에 Scala를 쓰는 많은 사람이 아예 예외를 버리고 Either 같은 오류 모나드를 사용하게 됐어요. 이는 많은 상황에서 작동하지만 단점도 있어요. 코드가 훨씬 복잡해지고 리팩터링하기 어려워져요. 여러 모나드를 함께 다뤄야 하는 문제에 곧 부딪히게 되죠. 일반적으로 Scala에서 모나드 하나를 다루는 것은 간단하지만, 모나드는 합성되지 않으므로 여러 모나드를 함께 다루는 것은 훨씬 덜 즐거워요. 모나드 트랜스포머에서 자유 모나드, tagless final에 이르기까지 이를 다루기 위한 수많은 기법이 제안되고 구현되고 홍보되었어요. 하지만 이 기법들 중 어느 것도 보편적으로 사랑받지 못해요. 각각은 비전문가가 이해하기 어려운 복잡한 DSL을 도입하고, 런타임 오버헤드를 만들며, 디버깅을 어렵게 해요. 결국 꽤 많은 개발자들은 ZIO 같은 단일 "슈퍼 모나드"를 쓰는 것을 선호하는데, 거기에는 오류 전파가 다른 측면들과 함께 내장되어 있어요. 이 만능(one-size-fits-all) 방식은 아주 잘 작동할 수 있어요. (그것이 전방위 프레임워크라는 이유 때문에? 아니면 그럼에도 불구하고?)
하지만 프로그래밍 언어는 프레임워크가 아니에요. 언어는 프레임워크의 사용 사례에 맞지 않는 응용 프로그램들도 수용해야 해요. 그래서 여전히 예외 검사를 제대로 만드는 강력한 동기가 남아 있어요.
효과에서 역량으로
왜 map은 Java의 검사된 예외 모델과 그렇게 안 맞을까요? map의 시그니처가 함수 인자를 검사된 예외를 던지지 못하도록 제한하기 때문이에요. map의 더 다형적인 공식을 생각해볼 수 있어요. 예를 들어 이렇게요.
def map[B, E](f: A => B throws E): List[B] throws E
이는 타입 A의 계산으로, 타입 E의 예외를 던질 수 있는 것을 나타내는 A throws E 타입을 가정해요. 하지만 실제로 추가 타입 파라미터의 오버헤드가 이 접근을 매력적이지 않게 만들어요. 특히 함수 인자를 받는 모든 메서드를 그렇게 파라미터화해야 하므로, 이 모든 예외 타입을 선언하는 추가 오버헤드는 우리가 피하고 싶은 일종의 의례(ceremony)처럼 보여요.
하지만 그 의례를 피할 방법이 있어요. "이 코드가 예외를 던질지도 모른다" 같은 가능한 효과에 집중하는 대신, "이 코드는 예외를 던질 수 있는 역량이 필요하다" 같은 역량(capability)에 집중하는 거예요. 표현력 측면에서는 꽤 비슷해요. 하지만 역량은 파라미터로 표현되는 반면, 전통적으로 효과는 결과 값에 대한 어떤 추가로 표현돼요. 이것이 큰 차이를 만들 수 있다는 게 드러나요!
CanThrow 역량
효과-as-역량(effects as capabilities) 모델에서 효과는 특정 타입의 (암시적) 파라미터로 표현돼요. 예외에 대해서는 던질 수 있는 예외를 나타내는 E에 대한 CanThrow[E] 타입의 파라미터를 기대하게 돼요. CanThrow의 정의는 다음과 같아요.
erased class CanThrow[-E <: Exception]
이는 또 다른 실험적 Scala 기능을 보여줘요: 삭제(erased) 정의. 대략 말하면, 삭제된 클래스의 값은 런타임 코드를 생성하지 않아요. 코드 생성 전에 삭제되죠. 이는 모든 CanThrow 역량이 컴파일 타임 전용 산물이고, 런타임 발자국이 없다는 뜻이에요.
이제 컴파일러가 Exc가 검사된 예외인 throw Exc() 구문을 보면, CanThrow[Exc] 타입의 역량이 given으로 소환될 수 있는지 검사해요. 그렇지 않으면 컴파일 타임 오류예요.
이 역량은 어떻게 만들어질까요? 몇 가지 가능성이 있어요.
가장 흔한 경우는 어떤 감싸는 스코프에 using 절 (using CanThrow[Exc])이 있어서 역량이 생기는 거예요. 이는 대략 Java의 throws 절에 대응돼요. 게다가 CanThrow와 함께 scala 패키지에 정의된 다음 타입 별칭이 있어서 유사성은 더 강해져요.
infix type $throws[R, +E <: Exception] = CanThrow[E] ?=> R
즉 R $throws E는 암시적 CanThrow[E] 파라미터를 받고 타입 R의 값을 돌려주는 컨텍스트 함수 타입이에요. 게다가 컴파일러는 연산자로 throws를 가진 infix 타입을 다음 규칙에 따라 $throws 적용으로 번역해요.
A throws E --> A $throws E
A throws E₁ | ... | Eᵢ --> A $throws E₁ ... $throws Eᵢ
그래서 다음과 같이 작성된 메서드는
def m(x: T)(using CanThrow[E]): U
이렇게도 표현할 수 있어요.
def m(x: T): U throws E
여러 타입의 예외를 던지는 역량도 아래 예시처럼 몇 가지 방법으로 표현할 수 있어요.
def m(x: T): U throws E1 | E2
def m(x: T): U throws E1 throws E2
def m(x: T)(using CanThrow[E1], CanThrow[E2]): U
def m(x: T)(using CanThrow[E1])(using CanThrow[E2]): U
def m(x: T)(using CanThrow[E1]): U throws E2
참고 1: 다음과 같은 시그니처도
def m(x: T)(using CanThrow[E1 | E2]): U
메서드 본문 안에서 E1이나 E2를 던지는 것을 허용하지만, 누군가 앞선 예시들처럼 자신의 CanThrow 역량을 선언하는 다른 메서드에서 이 메서드를 호출하려 할 때 문제를 일으킬 수 있어요. CanThrow는 반변(contravariant) 타입 파라미터를 가지므로 CanThrow[E1 | E2]는 CanThrow[E1]과 CanThrow[E2] 둘 다의 하위 타입이기 때문이에요. 따라서 스코프에 CanThrow[E1 | E2]의 given 인스턴스가 있으면 CanThrow[E1]과 CanThrow[E2]의 요구를 충족하지만, CanThrow[E1]과 CanThrow[E2]의 given 인스턴스를 합쳐서 CanThrow[E1 | E2] 인스턴스를 제공할 수는 없어요.
참고 2: |는 자신의 왼쪽·오른쪽 인자를 throws보다 더 단단하게 묶는다는 점을 기억해야 해요. 그래서 A | B throws E1 | E2는 (A | B) throws (Ex1 | Ex2)를 뜻하지, A | (B throws E1) | E2를 뜻하지 않아요.
CanThrow/throws 조합은 본질적으로 CanThrow 요구를 바깥으로 전파해요. 하지만 이 역량들은 최초로 어디서 만들어질까요? 바로 try 표현식이에요. 다음과 같은 try가 주어지면
try
body
catch
case ex1: Ex1 => handler1
...
case exN: ExN => handlerN
컴파일러는 body의 스코프에서 given으로 사용 가능한 CanThrow[Ex1 | ... | Ex2] 타입의 누적 역량을 생성해요. 대략 다음과 같이 try를 확장해서 이뤄져요.
try
erased given CanThrow[Ex1 | ... | ExN] = compiletime.erasedValue
body
catch ...
합성된 given의 오른쪽이 compiletime.erasedValue임을 주목하세요. 이 given은 삭제되므로 괜찮아요. 런타임에 실행되지 않아요.
참고 1: saferExceptions 기능은 검사된 예외에서만 작동하도록 설계됐어요. 예외 타입은 Exception의 하위 타입이면서 RuntimeException의 하위 타입이 아닐 때 검사된 예외로 취급돼요. CanThrow의 시그니처는 여전히 RuntimeException을 허용하는데, RuntimeException이 그 바운드인 Exception의 진짜 하위 타입이기 때문이에요. 하지만 RuntimeException에 대해서는 역량이 생성되지 않아요. 게다가 throws 절도 RuntimeException을 참조할 수 없어요.
참고 2: 간단하게 유지하기 위해 컴파일러는 현재 다음 형태의 catch 절에 대해서만 역량을 생성해요.
case ex: Ex =>
여기서 ex는 임의의 변수 이름(_도 허용)이고, Ex는 임의의 검사된 예외 타입이에요. Ex(...) 같은 생성자 패턴이나 가드를 가진 패턴은 허용되지 않아요. saferExceptions가 켜진 상태에서 이런 것 중 하나로 검사된 예외를 잡으려 하면 컴파일러는 오류를 내요.
예시
그게 전부예요. 예시에서 실제로 어떻게 동작하는지 볼게요. 먼저 import를 추가해서
import language.experimental.saferExceptions
예외 검사를 켜요. 이제 예외 LimitExceeded와 함수 f를 이렇게 정의해요.
val limit = 10e9
class LimitExceeded extends Exception
def f(x: Double): Double =
if x < limit then x * x else throw LimitExceeded()
이런 오류 메시지를 받을 거예요.
if x < limit then x * x else throw LimitExceeded()
^^^^^^^^^^^^^^^^^^^^^
The capability to throw exception LimitExceeded is missing.
그 역량은 다음 중 하나로 제공할 수 있어요.
- 감싸는 메서드의 정의에 using 절
(using CanThrow[LimitExceeded])추가하기 - 감싸는 메서드의 결과 타입 뒤에
throws LimitExceeded절 추가하기 LimitExceeded를 잡는try블록으로 이 코드 조각 감싸기
다음 import가 문제를 해결할 수도 있어요.
import unsafeExceptions.canThrowAny
오류 메시지가 암시하듯, f가 LimitExceeded 예외를 던질 역량이 필요함을 선언해야 해요. 가장 간결한 방법은 throws 절을 추가하는 거예요.
def f(x: Double): Double throws LimitExceeded =
if x < limit then x * x else throw LimitExceeded()
이제 LimitExceeded를 잡는 try 안에 f에 대한 호출을 넣어요.
@main def test(xs: Double*) =
try println(xs.map(f).sum)
catch case ex: LimitExceeded => println("too large")
몇 가지 입력으로 프로그램을 실행해요.
> scala test 1 2 3
14.0
> scala test
0.0
> scala test 1 2 3 100000000000
too large
모든 것이 타입검사를 통과하고 기대대로 작동해요. 잠깐——우리는 map을 아무 의례 없이 호출했어요! 어떻게 된 걸까요? 컴파일러가 test 함수를 이렇게 확장해요.
// compiler-generated code
@main def test(xs: Double*) =
try
erased given ctl: CanThrow[LimitExceeded] = compiletime.erasedValue
println(xs.map(x => f(x)(using ctl)).sum)
catch case ex: LimitExceeded => println("too large")
CanThrow[LimitExceeded] 역량은 f가 요구하므로 합성된 using 절로 f에 전달돼요. 그런 다음 결과 클로저가 map에 전달돼요. map의 시그니처는 효과를 고려할 필요가 없어요. 평소처럼 클로저를 받지만, 그 클로저는 자유 변수에서 역량을 참조할 수 있어요. 즉 map은 시그니처를 전혀 바꾸지 않았는데도 이미 효과-다형적(effect polymorphic)이라는 뜻이에요. 요점은 효과-as-역량 모델이 자연스럽게 효과 다형성을 제공한다는 것인데, 다른 접근들은 이것에 애를 먹어요.
import를 통한 점진적 타이핑 (Gradual Typing)
또 다른 장점은 이 모델이 현재의 검사되지 않은 예외에서 더 안전한 예외로 점진적 이주를 허용한다는 거예요. 잠시 experimental.saferExceptions가 어디에서나 켜져 있다고 상상해볼게요. 함수들이 아직 throws로 제대로 어노테이션되지 않았으므로 깨지는 코드가 많아질 거예요. 하지만 깨짐을 잠시 무시할 수 있는 탈출구를 만드는 건 쉬워요. 그냥 다음 import를 추가하면 돼요.
import scala.unsafeExceptions.canThrowAny
이것은 어떤 예외에 대해서도 CanThrow 역량을 제공해서, throws 선언의 현재 상태가 어떠하든 모든 throw와 다른 모든 호출을 허용해요. canThrowAny의 정의는 다음과 같아요.
package scala
object unsafeExceptions:
given canThrowAny: CanThrow[Exception] = ???
물론 이렇게 전역 역량을 정의하는 것은 반칙에 가까워요. 하지만 그 반칙은 점진적 타이핑에 유용해요. 이 import는 기존 코드를 이주하거나, 완전한 예외 안전을 고려하지 않고 코드를 더 유연하게 탐색하는 데 쓸 수 있어요. 이주나 탐색이 끝나면 import를 제거해야 해요.
확장의 범위
정리하면 더 안전한 예외 검사를 위한 확장은 다음 요소들로 이루어져요.
- 표준 라이브러리에 클래스
scala.CanThrow, 타입scala.$throws, 객체scala.unsafeExceptions를 위에서 설명한 대로 추가해요. throws타입을 계단식$throws타입으로 다시 쓰는 몇 가지 탈-설탕 규칙을 추가해요.- 던져진 예외에 대해
CanThrow역량을 요구하도록throw의 타입 검사를 강화해요. - 잡힌 각 예외에 대해
CanThrow역량을 제공하도록try의 타입 검사를 강화해요.
그게 전부예요. 타입 시스템에 특별한 추가 없이 이런 방식으로 예외 검사를 할 수 있다는 건 꽤 놀랍지 않나요? 그저 일반적인 given과 컨텍스트 함수만 있으면 돼요. 런타임 오버헤드는 erased로 제거돼요.
주의사항 (Caveats)
우리의 역량 모델은 일급(first-order) 코드의 던져진 예외를 선언하고 검사할 수 있게 해줘요. 하지만 현재 형태로는 고차 함수의 인자에 대해 역량이 없음을 강제할 충분한 메커니즘을 제공하지 못해요. map의 변형인 pureMap을 생각해볼게요. 이 함수는 자신의 인자가 예외를 던지거나 다른 효과를 갖지 않도록 강제해야 해요(어쩌면 계산을 투명하게 재배열하고 싶기 때문일 거예요). 지금은 그걸 강제할 수 없어요. pureMap의 함수 인자가 자유 변수에서 임의의 역량을 캡처할 수 있고, 그것이 타입에 드러나지 않기 때문이에요. 이를 다루는 한 가지 가능한 방법은 순수 함수 타입(아마 A -> B로 표기)을 도입하는 거예요. 순수 함수는 역량을 닫아두는(closing over) 것이 허용되지 않아요. 그러면 pureMap을 이렇게 작성할 수 있어요.
def pureMap(f: A -> B): List[B]
순수성 요구가 없을 때 드러나는 또 다른 영역은 역량이 바운드된 스코프를 벗어나 도피할 때예요. 다음 함수를 생각해볼게요.
def escaped(xs: Double*): () => Int =
try () => xs.map(f).sum
catch case ex: LimitExceeded => -1
여기 제시된 시스템을 쓰면 이 함수는 다음 확장으로 타입검사를 통과해요.
// compiler-generated code
def escaped(xs: Double*): () => Int =
try
given ctl: CanThrow[LimitExceeded] = ???
() => xs.map(x => f(x)(using ctl)).sum
catch case ex: LimitExceeded => -1
하지만 escaped를 이렇게 호출하려 하면
val g = escaped(1, 2, 1000000000)
g()
결과는 g가 호출되는 두 번째 줄에서 던져진 LimitExceeded 예외가 돼요. 부족한 것은 try가 자신이 생성한 역량이 본문 결과의 자유 변수로 도피하지 않도록 강제해야 한다는 점이에요. 그런 스코프된 효과를 일시적 역량(ephemeral capabilities)으로 기술하는 것이 타당해요——그것들은 람다 안의 지연 코드로 확장될 수 없는 수명을 가져요.
전망 (Outlook)
우리는 값의 자유 변수를 추적함으로써 일시적 역량을 지원하는 새로운 종류의 타입 시스템을 연구 중이에요. 그 연구가 성숙하면, 부족한 속성들을 강제할 수 있도록 Scala 언어를 확장하는 것이 가능해지길 바라요.
그리고 그 밖에도 많은 응용이 있을 거예요. 예외는 대수적 효과(algebraic effects)의 특수한 경우인데, 지난 20년 동안 매우 활발한 연구 분야였고 프로그래밍 언어로 스며들고 있어요(예: Koka, Eff, Multicore OCaml, Unison). 실제로 대수적 효과는 추가적인 resume 연산을 가진 예외와 동등한 것으로 특징지어져 왔어요. 여기서 예외를 위해 개발된 기법들은 아마 다른 종류의 대수적 효과로 일반화될 수 있을 거예요.
하지만 그런 추가 메커니즘조차 없이도 예외 검사는 이미 그 자체로 유용해요. 예외를 사용하는 코드를 더 안전하게, 더 잘 문서화하게, 그리고 리팩터링하기 쉽게 만드는 명확한 길을 제시해요. 유일한 허점은 스코프된 역량에서 생겨요——여기서는 이 역량들이 도피하지 않는다는 것을 수동으로 검증해야 해요. 구체적으로 try는 항상 그것이 가능하게 하는 throws와 같은 계산 단계에 위치해야 해요.
다시 말하면: 현상 유지가 "100%는 너무 고통스러우니 0% 정적 검사"라면, 훌륭한 사용성을 가진 95% 정적 검사를 주는 대안은 승리처럼 보여요. 그리고 우리는 미래에 여전히 100%에 도달할지도 몰라요.
더 자세한 내용은 ACM Scala Symposium 2021에서의 우리 논문도 참고하세요.