에러 처리에서 출발해 이펙트 시스템, 코루틴, async/await, 그리고 의존성 주입을 ‘제어 흐름 점프’라는 관점으로 연결하며 Effekt와 Koka를 예로 들어 설명한다.
제가 정말로 하고 싶었던 건 서로 다른 에러 처리 모델을 조금 더 알아보는 것이었는데, 그러다 계속 “이펙트(effect)”와 “이펙트 시스템(effect systems)”이 언급되는 걸 보게 됐습니다. Koka와 Effekt에 대해 읽고 나니 저는 개종한 것 같습니다. 이제 저는 이펙트를 원합니다. 그래서 이 글은 몇 주 전의 제가 읽고 싶었던 내용입니다.
시작하기 전에, 함수는 존재하지 않는다는 걸 기억해야 합니다. 함수는 꾸며낸 겁니다. 사회적 구성물이에요.
당신의 CPU는 함수가 무엇인지 알지도, 신경 쓰지도 않습니다.1 함수는 순전히 장부 정리용 추상화로, 당신이 코드에 대해 더 쉽게 추론하도록, 그리고 컴파일러가 코드에 대해 유용한 피드백을 주도록 돕습니다. 이것이 구조적 프로그래밍(structured programming)의 핵심 아이디어 전체입니다. 몇 가지 추상화를 만들고, 컴파일러가 그에 대한 보장을 제공하게 만드는 것이죠.
저는 어셈블리를 많이 해본 적이 없어서 이 문제를 크게 겪어본 적은 없지만, 함수가 흥미로운 이유는 고정된 진입점과 동적인 반환점 때문입니다. C 프로그램으로 제가 무슨 말인지 보여드리겠습니다.
int first_function() {
// ...
return 10;
}
int some_function() {
// ...
int number = first_function();
return 4 + number;
}
void main() {
first_function();
some_function();
}
이 프로그램이 컴파일되면, 컴파일러는 first_function과 some_function으로 가기 위해 명령 포인터가 어디로 점프해야 하는지 정확히 압니다. 실행 파일 안에서 이 함수들을 어디에 배치했는지 정확히 알고 있기 때문이죠. 어셈블리를 들여다보면 각 함수는 아마도 보기 좋게 고정된 오프셋으로 점프하는 단 하나의 명령일 가능성이 큽니다.
그럼 return 문에 도달하면 무슨 일이 일어날까요? first_function은 some_function과 main에서 모두 호출됩니다. 즉 되돌아갈 위치가 하나로 고정돼 있지 않습니다. 컴파일러는 first_function의 코드를 생성할 때 누가 이 함수를 호출할지 모릅니다.
이것이 동작하는 방식은, 어떤 함수 인자들과 함께 보이지 않는 인자2가 하나 전달된다는 것입니다. 그 인자에는 함수의 시작점으로 점프를 만들었던 명령의 위치가 들어 있습니다. 컴파일러는 그 명령 주소가 무엇인지 압니다(그걸 그 자리에 둔 게 컴파일러니까요). 그래서 각 함수 호출 지점마다 그건 그냥 전달되는 정적인 정보 조각이 됩니다. 각 함수의 끝에서 컴파일러는 그 인자(대개 CPU 레지스터 어딘가에 저장되지만 반드시 그럴 필요는 없습니다)를 읽고, 그 위치로 점프해서, 실행을 계속하는 코드를 생성하기만 하면 됩니다.
당신이 이 복잡성을 생각하지 않는 이유는, 이 추상화가 매우 견고하면서도 복잡한 프로그램을 작성하는 데 엄청난 유연성을 주기 때문입니다.
어떤 함수를 호출할지 결정하는 과정은 함수 이름만이 아니라 인자의 개수와 타입을 고려하면서 더 복잡해질 수 있습니다.
가장 단순한 경우는 컴파일 타임에 알려진 정적 디스패치(static dispatch)지만, 더 고수준 언어들은 동적 디스패치(dynamic dispatch)를 도입합니다. 여기서는 함수 호출이 여러 위치 중 하나로 점프할 수 있습니다. 좋은 예는 Java입니다.
class MyClass {
@Override
public String toString() {
return "my class";
}
}
Object someObject = new MyClass();
someObject.toString();
호출되는 toString 메서드는 리시버 객체의 타입에 따라 달라집니다. 이는 컴파일 타임에 결정되지 않고, 런타임에 수행되는 조회(lookup)로 결정됩니다. 컴파일러는 사실상 getClass의 결과를 보고 그 다음에 올바른 메서드를 호출하는 switch 문을 생성합니다. 성능을 위해 실제로는 더 똑똑하게 하겠지만, 개념적으로는 그런 일을 하고 있는 겁니다.
이 추상화는 여전히 매우 잘 동작합니다. Java(또는 이 동작을 공유하는 수많은 언어)로 개발해본 적이 있다면, 메서드 해석 알고리즘의 동작을 빠르게 내면화하게 되고, 어떤 코드가 실행될지 거의 놀랄 일이 없습니다. 컴파일러는 확인을 위해 런타임 조회가 필요할 수 있지만, 당신은 큰 인간 두뇌로 코드를 쓰는 동안 연역적으로 그 결론을 내릴 수 있습니다.
그래서 Java(그리고 사실상 대부분의 객체지향 언어)에서는 동적 함수 디스패치와, 각 함수 끝에서의 동적 반환 점프를 모두 갖습니다.
C에서는 함수 내부에 동적 조회가 없습니다. 모든 동적 점프는 명시적인 조건문에서 나오죠. 하지만 Java와 다른 고수준 언어에서는 객체를 함수에 전달하고 그 객체의 메서드를 호출할 수 있습니다. 수신 함수는 컴파일 타임에 객체 타입을 모르기 때문에, 그 객체에 대한 메서드 호출은 완전히 동적으로 이뤄집니다.
String someMethod(Object object) {
return "This could be anything: " + object.toString();
}
someMethod는 정적으로 디스패치될 수 있지만, toString 호출은 object의 타입에 따라 동적으로 해석되어야 합니다.
someMethod 안에서 toString 호출은, 인자로 전달된 객체가 전적으로 통제하는 코드로 점프하게 됩니다. CPU(또는 이 경우 JVM)는 어떤 타입의 객체인지에 따라 그 타입의 toString 위치를 조회하고 그곳으로 점프합니다.
함수 해석 알고리즘과 마찬가지로, 이 복잡성은 함수 호출 추상화(제어가 다른 함수로 점프했다가 다시 우리 함수로 돌아온다는 것을 안다)와 타입 안전성(반환 타입이 String임을 안다) 덕분에 관리 가능한 수준입니다. 그래서 어떻게 얻었는지는 걱정할 필요가 없습니다.
이 점이 저는 Rust에서 흥미롭습니다. 기본적으로 런타임 동적 디스패치가 없기 때문에 Box<dyn MyTrait>로 감싸는 식으로 매우 명시적으로 해야 합니다. 아니면 컴파일 타임의 다형성을 원하면 impl MyTrait를 사용할 수 있죠.
이제 임의의 코드 조각으로 점프할 거라면, 그 코드 조각을 호출 지점에 두면 어떨까요? 익명 서브클래스를 만들면 바로 그 일이 일어납니다.
someMethod(new Object() {
@Override
public String toString() {
return "heh a new string";
}
});
소스 파일에서의 실제 위치는 그다지 중요하지 않습니다. 컴파일러는 마음대로 어딘가에 배치할 테니까요. 하지만 문법 관점에서는 이제 제어 흐름이 someMethod로 점프했다가, 다시 우리의 toString 메서드로 돌아오고, someMethod로 복귀한 다음, 마지막으로 호출 지점으로 돌아갑니다.
이 패턴은 너무 유용해서 대부분의 언어가 이를 위한 전용 문법을 갖습니다. 클로저(closure)죠! 저는 클로저를 너무 사랑해서 여러 클로저 문법들을 리뷰하는 글도 썼습니다. 이제 JVM을 잠시 벗어나 이 사랑스러운 Swift 클로저를 감상해봅시다.
[1, 2, 3].map { number in
number * 3
}
새 객체를 만들기 위한 그 모든 보일러플레이트 대신, 우리는 호출하는 함수가 사용할 코드 블록을 사실상 그냥 작성합니다. 여기서 흥미로운 점은, 그 코드 블록이 언제 실행되는지에 대해 우리가 완전히 통제하지 못한다는 것입니다. 그렇게 보일 수는 있지만, 우리는 함수에 값을 돌려주는 것 외에는 할 수 있는 게 없습니다.
이로 인해 언어에 내장된 제어 흐름과 통합되는 커스텀 제어 흐름을 만들 수 없다는 제약이 생깁니다. 클로저는 값을 제공할 수 있고 부작용도 가질 수 있지만, 자신을 호출한 함수가 계속 실행되는 것을 멈추게 만드는 능력은 제한적입니다.
Ruby와 Crystal은 이 제약을 흥미로운 방식으로 우회하지만, 지금은 조금 앞서 나가고 있습니다.
잠시 클로저는 잊고 에러 처리를 이야기해봅시다. 약속하건대, 다 말이 될 겁니다.
가장 기본적인 에러 처리는 Go에서 볼 수 있는 방식입니다. 뭔가가 안 됐으면, 안 됐다고 말하는 값을 반환하는 겁니다. 관례적으로 호출자는 그 값을 검사하고, 보통은 “내가 하려던 일도 역시 안 됐다”고 말하기 위해 그 값을 그대로 반환합니다.
func getConfigPath() (string, error) {
path, set := os.LookupEnv("CONFIG_PATH")
if !set {
// The variable isn't set, report an error
return "", fmt.Errorf("CONFIG_PATH not set")
}
return path, nil
}
개념적으로 매우 단순합니다. 여러 반환값을 허용함으로써 함수 추상화 위에 약간을 더 얹은 것뿐이며, 그 외에는 거의 없습니다. 함수가 실패할 수 있다면 위 getConfigPath처럼 함수 시그니처만 봐도 에러를 반환한다는 것을 알 수 있습니다.
이 모델에서는 각 함수 호출 뒤에 return nil, err를 매번 써야 하지만, 의미적으로는 우리가 에러를 즉시 반환하는 대신 다른 일을 하는 지점으로 제어 흐름이 “점프”한다고 생각할 수 있습니다.
func getConfig() (*conf.Config, error) {
path, err := getConfigPath()
if err != nil {
return nil, err
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
config, err := configFromFile(f)
if err != nil {
return nil, err
}
return config, nil
}
config, err := getConfig()
if err != nil {
panic(err)
}
이 예시에서 경로 로딩, 파일 읽기, 설정 파싱 중 어떤 에러든 제어 흐름을 최상위 코드로 되돌려 panic 호출로 향하게 합니다.
에러를 더 간결하게 반환하게 해주는 매크로들을 건너뛰면, 이 패턴의 다음 단계는 Java의 체크드 예외(checked exceptions)입니다. 실패할 수 있는 모든 함수는 사실상 두 번째 반환값을 무엇인지로 주석이 달립니다. 차이점은 호출 지점에서 이 값을 반환하기 위한 코드가 필요 없다는 것입니다. 예외는 catch 블록을 만날 때까지 호출 스택을 따라(각각 동적으로 해석되는 걸 기억하세요) 암묵적으로 위로 전달됩니다. catch 블록은 그 반환값을 받아 뭔가를 하는 코드 조각으로, 위 Go 예시와 크게 다르지 않습니다.
Java의 예외가 타입을 가진다는 사실을 무시하면, 실제로 일어나는 일은 이렇습니다. try 블록에 들어갈 때마다 컴파일러는 메모리에 catch 블록 시작에 해당하는 명령의 위치를 기록합니다. 더 많은 함수를 호출하면서 그들 중 일부는 자체 try 블록을 가질 수 있고, 이것들은 스택에 쌓입니다. (모든 함수가 try/catch를 가지는 건 아니므로 실제 호출 스택보다 짧은 스택입니다.) 예외가 던져지면, 함수가 원래 돌아가야 할 위치를 조회하는 대신, 그 스택을 확인해 가장 위의 catch 블록을 찾고 곧바로 그곳으로 점프합니다. 여러 함수를 한 번에 건너뛰는 return을 해버린 셈입니다.
물론 실제 동작은 finally 블록, 타입 등등을 신경 써야 해서 훨씬 복잡하지만, 핵심 아이디어는 같습니다.
여기까지 따라오셨나요? 여기서부터 이상해집니다.
예외가 던져질 때, 컴파일러가 명령 포인터를 잡아 어딘가에 저장해둔 뒤 catch 블록으로 점프한다면 어떨까요? 그러면 catch 내부에서 원한다면, 여러 겹의 함수 호출을 거슬러 올라가, 마치 아무 일도 없었던 것처럼 실패한 코드로 다시 점프할 수 있게 됩니다.
컴파일러는 명령을 생성하는 주체이니 코드의 각 줄에 대한 현재 명령 포인터 위치를 알고 있을 것입니다. 그 위치를 특별한 변수 __instruction__으로 잡을 수 있다고 해봅시다.
C에 catch… 또는 throw가 있었다면 이런 식이었을 겁니다:3
int some_function() {
print("At the start...")
throw __location__;
print("I'm back!");
}
try {
some_function();
} catch(error_location) {
print("Caught an exception!");
goto error_location;
}
print("Finished.");
some_function에서 throw를 하여 호출 스택에서 가장 가까운 try로 점프하며, 현재 명령 위치를 되돌려 보냅니다. 호출 스택 위쪽의 코드에서는 약간의 코드를 실행한 뒤, throw가 일어났던 곳으로 goto하여, 중단된 지점부터 함수를 재개할 수 있습니다.
출력은 이렇게 보일 겁니다.
At the start...
Caught an exception!
I'm back!
Finished.
자, 이게 이펙트입니다. 거의요.
이펙트와 비슷한 또 다른 기능으로 “코루틴(coroutines)”이 있습니다. 이건 혼란스러운 용어인데, 사람들이 흔히 경량 스레드(lightweight threads)를 코루틴이라고 부르기 때문입니다. 실제로는 많은 경량 스레드가 코루틴의 어떤 변형으로 구현되긴 하지만, 언어에서 다른 용도로 코루틴을 쓸 수 없는 경우도 많습니다. 코루틴은 함수 실행을 멈췄다가 나중에 재개할 수 있게 해주며, 그 과정에서 보통 값들을 주고받습니다.
저는 Wren 프로그래밍 언어에서 처음 코루틴을 접했습니다. 기본적으로는 Go나 Crystal이 가진 “벽에 던져두면 대충 동시에 돌아간다” 모델이 아니라서, Wren의 동시성(concurrency) 문서를 읽고 매우 혼란스러웠습니다.
var fiber = Fiber.new {
System.print("Before yield")
Fiber.yield()
System.print("Resumed")
}
System.print("Before call")
fiber.call()
System.print("Calling again")
fiber.call()
System.print("All done")
이 코드는 이런 출력을 냅니다.
Before call
Before yield
Calling again
Resumed
All done
파이버가 백그라운드에서(또는 스케줄러에 따라 “백그라운드”) 실행되는 대신, Fiber.yield()에 도달할 때까지 실행되다가 멈추고, 누군가 다시 .call()을 호출하기를 기다립니다.
이건 복잡한 코드를 쓰거나, 다음 단계로 넘기기 전에 전체 중간 결과를 메모리에 저장하지 않고도 함께 작동하는 렉서와 파서를 작성하는 데 매우 강력합니다. 렉서는 계속 진행하다가 토큰 하나를 완성하면 그 값을 yield()할 수 있습니다. 파서는 처리할 새 토큰이 필요할 때마다 계속해서 .call()을 실행합니다. 이들은 단일 함수를 호출해 단일 결과를 돌려받는 것보다 더 복잡한 방식으로 서로에게 제어권을 넘깁니다. 렉서와 파서의 코드는, 값이 발견되거나 필요할 때 어떤 함수든 자유롭게 yield()하거나 call()할 수 있어서, 더 자유로운 구조를 가질 수 있습니다.
제가 동시성 프로그래밍에 대해 수천 단어를 썼던 것을 기억하시나요? 사실 async/await가 있는 모든 언어의 비밀은, 기본적으로 이 “catch로 점프했다가 나중에 다시 재개하기” 트릭을 할 수 있다는 것입니다.
catch가 보통 예외를 의미하고, 예외가 보통 실패를 의미한다는 사실은 무시합시다. 어떤 코드가 실행 중이고, 백그라운드에서 오래 걸릴 작업을 시작했습니다. 기다릴 필요가 없고, 프로그램은 백그라운드에서 일이 진행되는 동안 다른 유용한 일을 할 수 있습니다. 이 코드는 “예외를 던지고”, 호출 스택 위쪽 여러 겹에 있는 스케줄러가 그것을 “잡습니다”. 스케줄러는 반환 주소를 나중에 돌아올 대기 작업 목록에 저장한 다음, 진행 가능한 다른 일을 찾아갑니다. 결국 다른 일을 완료했고, 우리의 백그라운드 작업이 끝났다는 신호를 받습니다. 그러면 목록에서 반환 주소를 꺼내 그곳으로 점프해, 마치 아무 일도 없었던 것처럼 함수 호출을 중단 지점부터 정확히 계속합니다.
이 글에서 딱 하나만 가져간다면, async/await는 되돌릴 수 있는 이상한 예외일 뿐이라는 점만은 알아두세요.
이제 async/await와 예외가 함께 겪는 문제는, 보통 이들이 타입 시스템의 나머지와 통합되어 있지 않다는 것입니다. Java에서는 “예외를 던질지 여부에 대해 제네릭인” 타입이나 함수를 만들 수 없습니다.
String readFileOrFail(String path) throws IOException, FileNotFoundException {
File file = new File(path);
if (file.exists()) {
return FileUtils.read(path);
} else {
throw new FileNotFoundException("file doesn't exist");
}
}
List.of("one.txt", "two.txt", "three.txt")
.stream()
// doesn't work!
.map(name -> readFileOrFail(name))
.collect();
map 메서드는 체크드 예외를 던지지 않는 람다만 받기 때문에, readFileOrFail 메서드를 직접 호출할 수 없습니다. 이상적으로는 “내가 받는 람다가 던지는 것과 같은 예외를 던진다”고 제네릭하게 말할 수 있어야 하지만, Java 타입 시스템에서는 그게 불가능합니다.
게다가 Java는 체크드 예외를 대부분 포기하고, 컴파일 타임 보장을 제공하지 않는 순수한 언체크드 런타임 예외를 선택한 점도 이 문제를 악화시킵니다.
Swift는 조금 나아서, 클로저가 던지는 것과 같은 예외로 실패할 수 있음을 클로저와 함수에 표시할 수 있는 rethrows 키워드가 있습니다.
async 함수도 같은 이야기입니다. Swift에는 컬렉션에 대한 비동기 연산을 다루기 위한 완전히 별도의 라이브러리가 있습니다. 기존 컬렉션의 메서드들이 동기/비동기 버전을 모두 지원하도록 제네릭하게 만들 수 없기 때문이죠. re-async 같은 건 없습니다.
자, 이제 결론입니다. 이 모든 것—클로저, 예외, 중단 가능한 함수—은 서로 다른 위치로 앞으로/뒤로 점프하는 방식이고, 컴파일러가 그 점프가 구조적이고 안전하게 일어나도록 보장해주는 것입니다. 그리고 이펙트는 바로 그걸(그리고 그 이상을) 제공합니다.
Effekt는 이펙트 핸들러와 이펙트 다형성(effect polymorphism)을 가진 연구용 언어입니다(웹사이트에도 그렇게 적혀 있습니다!). Koka 문서도 읽었지만, 가장 많은 코드를 작성한 건 Effekt였습니다.
Effekt의 이펙트에 대한 언어 투어에서, 이펙트는 interface로 작성합니다.
interface Exception {
def throw(msg: String): Nothing
}
이 경우 String으로 throw하고, 이펙트 핸들러가 Nothing을 돌려줍니다. 여기서 Nothing은 다소 마법 같은 타입으로, 컴파일러에게 그 함수가 절대 반환하지 않는다는 것을 알려줍니다. 하지만 나중 예시에서 보겠지만 실제 값일 수도 있습니다.
그 다음 이 이펙트를 사용하는 함수가 있습니다.
def div(a: Double, b: Double) =
if (b == 0.0) { do throw("division by zero") }
else { a / b }
여기서 흥미로운 점은 throw가 div의 함수 시그니처를 어떻게 바꾸는가입니다. 이 예시에서는 컴파일러가 추론할 것이므로 생략되어 있습니다. 우리는 이를 Double / { Exception }이라고 쓸 수 있는데, 이는 Double을 반환하고 Exception 이펙트를 사용한다는 뜻입니다. 즉 다음처럼 Exception 이펙트 핸들러가 있는 곳에서만 호출할 수 있습니다.
try {
div(4, 0)
} with Exception {
def throw(msg) = {
println("oh no the div failed: " ++ msg)
}
}
println("finished")
제어 흐름은 try에서 시작해 div로 점프하고, b 인자가 0이므로 div는 throw 이펙트를 호출합니다. 이펙트는 제어 흐름을 def throw 블록으로 점프시키고, 우리는 에러를 출력합니다. resume()를 호출하지 않았기 때문에 제어 흐름은 try 블록 이후로 진행되어 마지막 println을 실행합니다.
Effekt 이펙트는 resume 키워드를 통해 힘을 얻습니다. 이것은 이펙트를 예외처럼 동작하는 것에서 async/await처럼 동작하도록 바꿉니다. 제어 흐름이 이펙트 핸들러로 점프하고, 핸들러가 일을 좀 한 뒤 resume을 호출해 이펙트를 트리거한 지점부터 계속 진행하게 합니다.
Exception 예시를 계속 이어가되, 에러로부터 복구할 수 있게 만들어봅시다. 이펙트는 조금 더 복잡해질 겁니다.
interface Exception[T] {
def throw(msg: String): [T]
}
이제 우리는 메시지와 함께 예외를 던질 수 있고, 예외 핸들러는 대신 사용할 값을 돌려줄 수 있습니다.
val result = try {
div(4, 0)
} with Exception {
def throw(msg): Double = {
println("oh no the div failed: " ++ msg)
resume(42.0)
}
}
println("finished: " ++ result)
div가 호출되고, 다시 예외 핸들러로 throw합니다. 이번에는 에러를 출력한 다음 값을 가지고 resume합니다. div에서는 이것이 do throw 표현식의 결과로 사용됩니다.
Koka에서는 여기서 resume을 한 번 이상 호출할 수 있는 더 미친 일도 일어납니다. 이는 원래 함수를 포크해서 두 인스턴스를 만들고, 각각 다른 결과로 진행하게 합니다. 정말 말이 안 되게 대단하죠.
여기서 핵심은 resume을 즉시 호출할 필요가 없다는 것입니다. 어떤 결과를 나중에 계산하기 위해 클로저를 저장할 수 있는 것처럼, resume을 클로저로 감싸서 다른 시점에 호출하도록 기다릴 수 있습니다. 이펙트를 트리거한 함수의 상태는 다른 데이터와 마찬가지로 클로저와 함께 저장됩니다. 이는 Effekt async 예시에서 실제로 볼 수 있습니다.
yield이건 이펙트를 제어 흐름에 사용할 수 있는 방법의 극히 일부에 불과합니다. 읽으면서 흥미로웠던 건, Crystal의 yield 키워드가 아주 작은 아기 이펙트 시스템 같다는 걸 깨달은 점입니다.
Crystal은 Ruby의 다소 복잡한 블록 시맨틱을 물려받았습니다. 이는 Proc 타입과 yield 키워드를 통해 노출됩니다. 문서의 간단한 예시는 이렇습니다.
def twice(&)
yield
yield
end
twice do
puts "Hello!"
end
yield 키워드는 호출한 함수로 제어를 넘깁니다(아아!). 이 예시에서는 twice에 “전달된” 코드 블록이 두 번 실행됩니다. 이는 Java 메서드에 Runnable을 전달하는 것과 크게 다르지 않습니다.
void twice(Runnable block) {
block.run();
block.run();
}
twice(() -> {
System.out.println("Hello!")
});
하지만 Crystal의 yield는 더 강력합니다. 호출자가 블록을 받는 함수의 제어 흐름을 바꿀 수 있기 때문입니다. 블록 안에서 break하여 조기 반환을 일으킬 수도 있고, 블록 안에서 return하여 블록이 있는 메서드에서(호출한 메서드가 아니라) 반환할 수도 있습니다.
def find_mod_2(items)
items.each do |i|
if i % 2 == 0
return i
end
end
end
이 return 문은 each의 실행을 멈추고 그리고 find_mod_2에서 반환합니다. 다른 언어였다면, 또는 each가 yield가 아니라 Proc로 구현되어 있었다면, 멈추고 싶다는 뜻을 나타내는 특별한 값을 반환하거나 예외를 던져야 했을 것입니다. 이것이 Crystal이 언어에 for 루프가 없어도 되는 이유입니다.4 그렇지 않으면 블록은 단순히 자신을 호출한 메서드에 제어를 양도했을 테니까요.
혼란스러운 점은 Proc를 만드는 데도 같은 문법을 사용한다는 것입니다. Proc는 자신을 호출한 함수의 제어 흐름에 영향을 줄 수 없고, Java 같은 다른 언어들의 동일한 제한을 갖습니다. 구현을 생각해보면 말이 되는데, yield는 자신이 속한 메서드의 실행 밖으로 저장했다가 나중에 실행할 수 없지만, Proc는 인스턴스 변수로 저장해 훨씬 나중에 실행할 수 있기 때문입니다. 예를 들어, 이건 어떻게 동작해야 할까요?
class Thingie
getter block : Proc(Nil)? = nil
def do_thing(&block : Proc(Nil))
puts "setting thing"
@block = block
end
end
def use_thingie(th : Thingie)
th.do_thing do
return "this is a value!"
end
puts "Am I unreachable?"
end
th = Thingie.new
use_thingie
th.block.call # what should happen here?
return 문이 Proc 안에 있는데 어떻게 use_thingie가 끝날 수 있을까요? Proc가 호출될 때 무슨 일이 일어나야 할까요? 호출 시점에는 use_thingie가 이미 끝났을 테니, use_thingie에서 반환할 수도 없습니다.
Crystal 컴파일러는 이게 안 된다는 걸 알고 있으며, 프로그램은 컴파일에 실패합니다.
In test.cr:12:5
12 | return "this is a value!"
^
Error: can't return from captured block, use next
이는 Swift의 @escaping 클로저와 정확히 같은 구분입니다. 다만 Swift는 애초에 non-escaping 클로저에서도 그런 제어 흐름을 허용하지 않습니다.
Crystal의 yield는 이펙트의 매우 단순한 버전입니다. 호출 스택에서 위로 한 층만 점프할 수 있고, 블록을 전달(forward)하고 싶다면 다른 함수를 호출할 때 다시 yield해야 합니다. 가능한 수신자도 하나뿐이라, 함수에 전달된 단일 블록이 모든 yield 문에 사용됩니다.
어떤 이펙트든, 호출 스택 위 어딘가에 그 이펙트를 처리(handle)하는 장소가 있어야만 사용할 수 있습니다. Java에서는 런타임 예외라면 약간 피해갈 수 있긴 해도, 모든 throw 주위에 catch가 필요합니다. async/await가 있는 언어에서는 async 함수를 호출할 때 반드시 await로 감싸야 하고, 그 함수를 호출하는 쪽 함수도 async여야 합니다. 결국 호출 스택 위쪽으로 올라가다 보면 비동기 작업을 태스크 큐나 executor에 넣거나, 완료될 때까지 블록하는 호출에 도달합니다. 이들은 모두 비동기 프로그래밍을 위한 이펙트 핸들러의 예입니다. 비동기 코드가 실행되기 위해 필요한 스케줄링 이펙트를 제공하죠.
이것은 렉시컬 스코프를 정의할 수 있습니다. 어떤 이펙트 핸들러가 설치된 장소 바깥의 코드는 그 이펙트를 사용할 수 없습니다. 제 정신이 적절히 깨진 저는, 이를 깨달았을 때 “그거 그냥 의존성 주입이잖아”라고 생각했습니다.
(Dagger 스타일) 의존성 주입의 핵심은, 애플리케이션의 특정 부분에서만 특정 의존성에 접근할 수 있고, 그 의존성이 어떻게 구성되는지는 실제 사용과 분리된다는 것입니다. 저는 이게 너무 좋아서 Crystal 매크로로 구현까지 했습니다.
이펙트는 위로 전파되기 때문에, 자연스럽게 중첩 스코프를 지원합니다. 더 넓은 스코프가 제공하는 의존성에 대한 이펙트가 트리거되면, 내부 스코프의 핸들러를 건너뛰고 곧바로 외부 핸들러로 점프해 의존성을 받습니다.
아래 코드는 여전히 꽤 장황합니다. 이를 덜 고통스럽게 만들려면 코드 생성이나 매크로로 정리하고 싶을 겁니다. 주입 이펙트부터 시작해봅시다.
interface Inject[A] {
def get(): A
}
올바른 타입 주석만 있다면, do get()으로 이펙트 핸들러에 위임해 값을 제공받을 수 있습니다.
def functionWithDeps(): Unit / { Inject[Logger], Inject[Config] } = {
val logger: Logger = do get()
val config: Config = do get()
logger.log("Doing stuff, this config: " ++ show(config))
doImportantStuff(config)
}
이 함수는 Logger와 Config를 모두 주입할 수 있는 컨텍스트에서만 호출할 수 있습니다. 스코프 루트에서의 함수 호출은 이렇게 보일 겁니다.
def doWithInjection() = {
val config = buildConfig()
val logger = getLogger()
try {
functionWithDeps()
} with Inject[Config] {
def get() = resume(config)
} with Inject[Logger] {
def get() = resume(logger)
}
}
의존성이 많아지면 당연히 다루기 힘들겠지만, 이는 영리한 타입 시스템 트릭, 매크로, 또는 코드 생성으로 처리할 수 있습니다. 또한 객체를 지연 생성(lazy)하고 싶겠지만, 여기서는 생략했습니다.
이 방식의 좋은 점은 객체가 아니라 함수에 적용된다는 것입니다. 그래서 원하지 않는다면, 여러 클래스들로 간접 계층을 억지로 만들 필요가 없습니다.
많은 언어가 이미 예외를 던지고 잡는 이펙트에 인접한 방식을 갖고 있기 때문에, 임의의 이펙트를 지원하기 위한 문법 변경은 실제로 꽤 최소화될 수 있습니다. 이펙트는 Swift 문법에 꽤 자연스럽게 들어맞을 수 있습니다(제가 떠올릴 수 있는 부분들에서는요). 기존 throws와 async 키워드에 최대한 가깝게 두고 싶으니, 인자 리스트와 반환 타입 앞의 -> 사이에 이펙트를 나열하는 걸 제안하겠습니다. 이런 식으로요.
// No effects
func foo() -> String {}
// One effect
func foo() async -> String {}
// Two effects, one with a type parameter
func foo() throws<Error>, async -> String {}
Swift의 다른 타입들과는 잘 맞지 않지만, 이펙트 이름은 “타입”이라기보다 “태그”처럼 보이도록 소문자여야 한다고 생각합니다. 다만 일관성을 위해 대문자로 해야 한다고 설득당할 수도 있습니다. 이펙트는 임의의 개수의 제네릭 파라미터를 가질 수 있으므로, 꺾쇠괄호 안에 그것들을 지정해야 합니다. 조금 못생겼지만 아주 끔찍하진 않습니다.
이펙트 정의는 enum 정의와 비슷할 수 있습니다.
effect async {
case suspend
case cancel
}
이는 특정 case로 양보(yield)한다는 점에서 잘 어울리고, enum에서 하듯 각 case에 연관 데이터를 추가할 수도 있습니다.
effect throws<T: Error> {
case throw(T)
}
Effekt처럼, 이펙트를 이용해 뭔가를 한다는 걸 표시하는 do 키워드는 좋다고 생각합니다. 이는 이펙트를 가진 모든 함수 호출에서 요구되어야 할 것 같습니다. 예를 들면:
func fetchUserInfo(id: Int) async -> User {
let info = do userInfoLoader.load(id)
return User(from: info)
}
이펙트를 처리하고 싶은 지점에서는, do 표현식에서 사용된 이펙트에 매칭되는 블록을 둘 것입니다. Swift에서 현재 이것은 catch 키워드지만, 더 일반적이어야 하므로 when이 더 잘 맞는다고 생각합니다. “그걸 하되, 이런 일이 일어나면, 이 다른 일을 해라”처럼 읽힙니다.
do {
do userInfoLoader.load(id)
} when throws(error) {
Log.error("Unable to load user \(id): \(error)")
return nil
}
여러 이펙트를 처리해야 한다면, 오늘날 catch 블록을 여러 개 덧붙일 수 있는 것처럼 추가 when 블록을 붙이면 됩니다.
throws의 경우는 Effekt처럼 단일 타입을 가진 이펙트에 대한 특수 케이스가 될 것입니다. 여러 case를 가진 이펙트 핸들러에서는 when 블록 본문이 switch 문과 동등해질 겁니다.
do {
do asyncScheduler.doSomeWork()
} when async {
case suspend: {
self.pendingTasks.append {
resume
}
}
case cancel: {
self.onTaskCancelled()
}
}
이 연습에서 흥미로운 점은, 문법 관점에서 실제로 바꿔야 할 게 그렇게 많지 않다는 것입니다. 함수는 이미 고정된 이펙트 집합으로 태그될 수 있고, 이를 처리하는 문법 구조도 이미 존재합니다.
저는 에러를 처리하는 방식과 비동기 프로그래밍을 처리하는 방식에 대해 읽다가 이 난장판에 빠졌습니다.
제네릭과 마찬가지로, 대부분의 코드는 직접 이펙트나 이펙트 핸들러를 정의할 필요는 없을 거라고 생각합니다. 예외와 async/await가 언어에 내장된 것이 아니라, 언어 위에서 만들어지는 것이라면 정말 멋질 겁니다. 언어는 비동기 코드가 어떻게 작성되어야 하는지에 대해 덜 규정적일 수 있고, 예컨대 어떤 코드 경로에서는 파이버가 어떻게 취소될 수 있는지에 대한 엄격한 보장을 제공할 수도 있겠죠.
이것이, 구조적 동시성(structured concurrency)을 언어에 넣되 그 부담을 전적으로 언어 자체에 지우지 않는 방법일지도 모릅니다. 라이브러리 작성자가 특정 함수가 호출될 수 있는 컨텍스트를 규정하여 구조와 정합성을 강제할 수 있게 해줄 테니까요. 대부분의 가비지 컬렉션 언어에서는 많은 계약이 “이 객체에 대한 참조를 붙잡고 있지 마라” 같은 문서로만 강제되는데, 이에 대해 저는 이전에 글을 쓴 적도 있습니다.
이펙트는 데드라인이나 다른 스코프 기반 데이터(보통은 의존성 주입, 스레드 로컬 변수, 또는 수동으로 함수 호출을 통해 전달되는 것)를 다루는 코드에서도 가치가 있을 것입니다. 데드라인을 계속 확인하는 대신, 기존의 중단(suspension) 이펙트를 확장해서 데드라인이 끝났다면 어떤 중단 지점에서든 실패하도록 만들 수 있습니다. 데드라인이 필요한 어떤 코드든, 데드라인이 없는 컨텍스트에서는 호출될 수 없게 됩니다.
저는 이펙트가 예외와 비동기 코드와 어떻게 연결되는지에 주로 초점을 맞췄습니다. 이것들이 제가(그리고 아마 당신도) 가장 익숙한 제어 흐름 구성물이기 때문입니다. 모든 I/O가 이펙트를 통해 처리되는 코드를 작성하는 게 어떤 느낌일지는 크게 생각해보지 않았습니다. I/O를 하려는 모든 함수와 모든 함수 호출에 주석을 달아야 한다면 꽤 지루해질 것 같습니다. 하지만 언어가 요구되는 이펙트에 대해 좋은 타입 추론을 제공한다면, 그렇게 나쁘진 않을 수도 있겠죠.
제가 동시성에 대해 길게 썼을 때, 저는 모든 코드가 Go나 Crystal처럼 기본적으로 async여야 한다고 주장했습니다. 일반적인 프로그램에는 이미 너무 많은 암묵적 동작이 있으니, 그럴 바엔 저비용 I/O도 얻는 게 낫다는 이유였죠. 저는 UI 핸들러처럼 임의로 긴 시간 동안 중단되지 않을 것을 아는 게 유용한 컨텍스트가 있다고 생각합니다. 이펙트가 전혀 필요 없는 API를 작성할 수 있다면, 이런 종류의 보장을 가능하게 해줄 겁니다.
그러니, 여기까지입니다. 저는 에러 처리를 이해하고 싶어서 시작했는데, 결국 프로그래밍에 대해 제가 아는 모든 것이 어떻게든 하나의 언어 기능으로 연결될 수 있다는 걸 배우고 말았습니다. 더 읽고 싶다면 Effekt와 Koka 언어 투어에서 시작하길 권합니다(저는 좋은 부분으로 바로 건너뛰었습니다).
여기서 제가 설명한 함수 호출과 예외의 동작 방식은 문자 그대로라기보다 설명을 위한 예시로 받아들여 주세요. 실제로 컴퓨터가 어떻게 하는지에 너무 깊게 빠지지 않고, 컴퓨터가 어떤 종류의 일을 하고 있는지의 예시를 보여주고 싶었습니다. 목표는 실제 구현 방법보다는, 서로 다른 언어에서 제어 흐름이 어떻게 동작하는지를 더 생각해보자는 것입니다.
좋아요, 저는 제 CPU에게 물어본 적이 없습니다.↑
아마도 아키텍처에 따라 조금씩 다르겠죠? 저는 CPU 명령어 집합 전문가가 아니지만, 대략 이런 개념입니다.↑
스택 프레임 같은 것들과 goto가 함수 사이를 점프할 수 없다는 사실을 무시하고 말이죠.↑
물론 매크로 언어는 제외인데, 그건 좀 별개죠.↑
피드백은 Mastodon 또는 이메일로 보내주세요.
Permalink • 2026년 3월 2일
© Will Richardson 2014–2026
여기에 표현된 모든 견해와 의견은 전적으로 저 개인의 것입니다.