효과 시스템의 주장된 이점과 범용 언어에서 이를 지원하는 장단점을 둘러싼 가상 대화.
의도된 독자: 효과 시스템에 대해 어느 정도 접해 본 적이 있는 프로그래밍 언어 설계자와 애호가.
다음은 프로그래밍 언어 설계자 Emmett와 Pratik이 효과 시스템의 주장된 이점, 그리고 범용 언어에서 이를 지원하는 것의 장단점에 대해 나누는 가상의 대화를 담고 있다.
Emmett: 안녕, 나는 요즘 효과 시스템에 대해 많이 읽고 있었어. 이런 것들은 Unison, Koka, Flix 같은 비교적 새로운 언어들에서 지원돼. 함수형 프로그래밍 커뮤니티에서 나온 연구와도 관련이 있고, 일부는 모나드와 모나드 트랜스포머를 사용할 때의 어려움 때문에 추진되기도 했지.
예를 들어 Environment 효과는 대략 이런 식일 수 있어:
// Allows access to information about the running system etc.
effect Environment {
// Get command-line arguments
getArgs() -> List[String]
// Get the value for a specific env var
getVar(name: String) -> Option[String]
// ... other methods
}
그래서 환경 변수를 읽는 코드를 작성할 때는 대략 이렇게 하게 돼:
fun getLogLevel() ->{Environment} LogLevel {
match getVar("LOG_LEVEL") {
Some("error") => return LogLevel.Error
Some("warn") => return LogLevel.Warn
Some("info") => return LogLevel.Info
Some("debug") => return LogLevel.Debug
_ => return LogLevel.Info
}
}
Pratik: 흠, 미리 정해진 문자열들 중 하나가 아닌 문자열이 들어오면 그 함수는 단정 실패를 해야 하지 않을까…?
Emmett: 화제를 바꾸지는 말자, 지금 우리는 효과 시스템에 대해 이야기하고 있잖아.
Pratik: 알겠어.
내가 보는 효과 시스템은 기본적으로 두 부분이 있어:
효과 핸들러: 이것들은 본질적으로 continuation을 조작할 수 있게 해 주어서 사용자 정의 제어 흐름 원시 요소를 만들 수 있게 해 줘. 정확한 언어 의미론에 따라 continuation은 최대 한 번, 정확히 한 번, 또는 여러 번 호출될 수 있고, 그 밖의 조합도 가능해. 이런 것은 현재 OCaml에서 지원되고 있어.
타입 및 효과 시스템: 이것은 함수 타입에 그 함수가 어떤 효과를 수행하는지, 더 정확히는 어떤 효과를 수행하도록 허용되는지에 대한 정보를 부여하는 것을 말해. 흔한 예로 List.map 연산은 다음과 같이 더 정밀한 타입을 가질 수 있어:
forall a b e. (List[a], (a) ->{e} b) ->{e} List[b]
여기서 e는 kind가 Effect이고, 이는 a의 kind인 Type과는 달라. 이 예시는 또한 효과 다형성 을 활용하는데, Exception 효과 같은 서로 다른 효과로 구체화될 수 있는 타입 매개변수 e가 있기 때문이야.
Swift 같은 언어에서는 Sequence.map 연산의 시그니처가 다음과 같아:
func map<T, E>(_ transform: (Self.Element) throws(E) -> T) throws(E) -> [T] where E : Error
그리고 AsyncSequence.map 연산은 다음 시그니처를 가져:
func map<T>(_ transform: @escaping (Self.Element) async -> T) -> AsyncMapSequence<Self, T>
어떤 의미에서는 효과 다형적 시그니처가 이것들을 일반화한다고 볼 수 있어. 함수 인자가 escape하는 것이 허용되는지 여부에 관한 세부 사항, 반환 타입들이 이상화된 List.map 시그니처만큼 깔끔하게 맞아떨어지지 않는다는 사실 같은 것은 잠시 무시한다면 말이야…
Emmett: 맞아, 맞아. 세부 사항으로 들어가기 전에 몇 가지 더 중요한 점이 있어.
함수는 하나보다 많은 효과를 가질 수 있어. 다음 타입을 보자:
() ->{Network, Console} Unit
이것은 네트워크에 접근할 수 있고, 잠재적으로 stdin/stdout/stderr에도 접근할 수 있는 함수에 해당해.
또한 효과는 종종 결합, 병합, 합집합, 심지어는 뺄셈까지 같은 타입 수준 연산을 지원하는 이른바 row type과 함께 사용돼.
Flix에서는 표준 라이브러리의 모든 효과가 다음과 같은 함수들과 함께 제공돼:https://doc.flix.dev/library-effects.html
/// Runs `f` handling the `Clock` effect using `IO`.
def runWithIO(f: Unit -> a \ ef): a \ (ef - {Clock} + IO)
여기서 -는 뺄셈 연산을, +는 병합을 나타내.
그래서 다음과 같은 코드를 쓸 수 있어:
def main(): Unit \ IO =
run {
let timestamp = Clock.currentTime(TimeUnit.Milliseconds);
println("${timestamp} ms since the epoch")
} with Clock.runWithIO
여기서 Flix 런타임은 IO 효과를 처리할 수 있지만, 일반 사용자 코드는 그것을 처리할 수 없어.
Emmett: 그래, 좋은 개요였어. 알고 보니 효과에는 많은 이점이 있어. 블로그 글 Algebraic Effects in Practice with Flix에서는 이렇게 말해:
효과는 코드를 테스트 가능하게 만든다
효과는 자신이 작성한 코드와 서드파티 코드가 무엇을 하는지 즉각적인 가시성을 제공한다
효과는 사용자 정의 제어 흐름 추상화를 가능하게 한다
Pratik: 흠, 나는 그 점에 그렇게 확신이 서지는 않네. 테스트 가능하다는 부분부터 이야기해 보자. 구체적으로는 그 주장에 대해 단계별로 분석해 보자고.
Emmett: 그 말도 일리가 있네. 소프트웨어를 테스트하기 어려운 이유는 정말 많다고 생각해:
더 있을 테지만, 지금 당장 떠오르는 것은 이 정도야.
Pratik: 좋아, 꽤 괜찮은 목록이네. 그럼 이 문제들에 대해 사람들은 어떻게 대응하지?
Emmett: 내가 아는 해결책은 다음과 같아:
리소스를 직접 획득하는 대신 의존성 주입을 사용한다.
점점 더 철저하고 정교한 형태의 테스트를 사용한다: * 속성 기반 테스트 * 차분 테스트 * 정확성을 감사하기 더 쉬운 참조 구현 작성 * 결정적 시뮬레이션 테스트 * 커버리지 유도 여부와 관계없는 퍼징 * 카오스 테스트 * 변이 테스트
테스트 대상 시스템에 테스트 전용 또는 항상 켜져 있는 단정을 추가한다.
서드파티 의존성을 목킹이나 스터빙으로 대체하는 대신 실제 버전을 상대로 테스트한다. 예를 들어 일부 API 제공자는 테스트 환경까지 제공하기도 해.
테스트를 실행할 때 타입을 대체할 수 있도록 구체 타입 대신 인터페이스나 유사한 것을 사용한다.
전역 변수를 피하거나 금지한다.
Pratik: 좋은 목록이네. 그럼 이 목록을 볼 때, 대수적 효과가 제공한다고 말하는 이점은 의존성 주입의 이점과 비슷하다고 생각해?
내가 보기에는 다른 모든 항목에 대해서는 대수적 효과가 다른 테스트 기법들을 사용하는 데 특별히 도움을 주지는 않는 것 같아.
Emmett: 왜 그렇게 생각해?
Pratik: 의존성 주입을 할 때는 본질적으로 일급 함수들의 구조체나 레코드를 인자로 넘기는 거야. 그게 “mock” 하위 클래스의 형태이든, Go의 인터페이스이든, Haskell의 타입 클래스 딕셔너리이든, 다른 무엇이든 말이지.
이것은 효과 핸들러의 사용과 비슷하고, 효과 핸들러에 대한 이른바 ‘capability-passing style’은 이 점을 더 구체적으로 보여 줘.
어떤 사람들은 비꼬는 투로 의존성 주입이 5센트짜리 개념에 붙인 10달러짜리 단어라고 말하곤 해. 어느 정도는 그 말도 이해가 돼. 여기서 흥미로운 점 하나는 의존성 주입이라는 용어가 객체지향 커뮤니티에서 더 흔히 사용된다는 거야. 반면 대수적 효과는 함수형 프로그래밍 커뮤니티에서 나왔지.
그렇긴 해도, 테스트의 관점에서는 요구 사항, 이점, 단점 모두 서로 대응되는 부분이 있어. 더 구체적으로 말하면:
IO 효과를 직접 사용하는 코드를 작성할 수 있어. 반면 사용자 정의 핸들러를 작성할 수 있게 해 주는 효과를 사용할 수도 있지.반대로 Zig처럼 더 절제된 언어에서도 함수 포인터들의 구조체를 사용해 IO “인터페이스”를 정의하고, 그것을 이리저리 전달할 수 있어. (그리고 이것이 정확히 Zig 0.16+의 계획이기도 해.)
그 시점에서는 문제는 언어가 대수적 효과를 지원하느냐가 아니라 API 설계, 특히 표준 라이브러리의 API 설계로 귀결돼.
이점은 동일해: 대체를 허용한다는 것.
단점도 비슷해: 함수 시그니처에 사용하는 모든 효과를 주석처럼 달아야 한다는 것…
Emmett: 하지만! 하지만! 효과를 쓰면 일반적으로 그 효과들을 모든 함수에 명시적으로 전달할 필요는 없잖아. 문맥에서 사용 가능한 효과를 바탕으로(그 타입에 따라) 암묵적으로 전달되니까.
예를 들어 Go에서는 취소 가능한 함수들에 context.Context 값을 계속 아래로 전달하지. 그래서 이것도 효과처럼 “전염성”이 있지만, 수동으로 배관 작업을 해야 해. 마찬가지로 Logger를 전달하고 싶다면 또 하나의 추가 매개변수가 생기지. 그래서 매개변수를 명시적으로 전달하거나 의존성 주입을 하다 보면 보일러플레이트가 많아질 수 있어.
Pratik: 맞아, 바로 그 이야기를 하려던 참이었어. 언어 기능으로서의 효과 시스템은 대략 다음과 같은 서로 구별되는 구성 요소들로 나눌 수 있어:
여기에 추가로 “전역 변수 없음”이라는 제약이 따라올 수도 있지만, 그건 별도의 고려 사항이야.
Emmett: 그건 너무 세세하게 보는 방식처럼 느껴지는데, 마치 효과를 어떻게 컴파일할지 생각하는 것 같아. 프로그래머의 관점에서 효과 시스템은 “하나의” 것이지.
Pratik: 내가 이렇게 분해해서 보는 이유는 다른 언어들과 더 가까운 비교를 할 수 있게 해 주기 때문이야.
예를 들어 Go에는 인터페이스를 정의하는 방법이 있고(본질적으로 함수들의 레코드지), Rust에는 연관 타입까지 가질 수 있는 trait를 정의하는 방법이 있어. 그러니까 언어가 이미 이런 종류의 기능을 갖고 있다면, 이렇게 분해해서 보는 것이 효과 시스템이 정확히 무엇을 추가로 사 주는지를 더 정밀하게 표현하게 해 줘.
또 다른 예로 GHC Haskell은 꽤 오래전부터 ImplicitParameters 확장을 가지고 있었고, Scala와 Kotlin도 호출 체인을 따라 인자를 암묵적으로 전달하는 어떤 형태를 지원해. 하지만 이런 기능이 있다는 사실만으로 그 언어들에서의 코드가 마법처럼 더 테스트 가능해지지는 않아.
row type, union type, intersection type, 집합론적 타입에 대해서는 이미 데이터 타입(즉 kind가 Type인 타입)에서 이를 지원하는 언어들이 있어. 이것들이 꼭 효과에 대해서만 지원되어야 하는 것은 아니지.
내가 보기에 Flix에서 더 눈에 띄는 점은 전역 변수를 허용하지 않는다는 고집이야. 그래서 데이터를 반드시 매개변수로 넘기도록 강제 되지. 레코드로 묶어서 넘기든, 여러 개의 개별 매개변수로 펼쳐서 넘기든, 메서드의 receiver에 넣든 말이야.
하지만 이것도 효과 지원이 꼭 필요하지는 않아. 전역 상태를 금지하는 스타일 가이드(선택적으로는 린터와 함께)와 전역 상태를 피하는 기본 API 집합으로도 할 수 있어. 예를 들어 Rust 생태계에는 capability를 명시적으로 드러내는 표준 라이브러리 대안 API를 제공하는 cap-std 크레이트가 있어.
Emmett: 그러니까 네 말은 Flix 코드가 더 테스트 가능해 보이는 것은 효과 시스템 때문이 아니라 전역 변수를 금지하기 때문일 수도 있다는 거네?
Pratik: 정확히 그래. 더 테스트 가능한 코드라는 이점을 효과 시스템의 공으로 돌리는 것은 잘못된 귀속이야. 명령형 언어에서는 예를 들어 컴파일러 코드베이스가 더 쉬운 테스트를 위해 “가상 파일시스템”을 갖는 일이 드물지 않아. 이것은 전역 기반 API 대신 매개변수 전달을 선택하는 아키텍처적 규율이 작동하는 사례야.
Emmett: 흠, 무슨 말인지 알 것 같아. 매개변수 전달은 단순하고, 새로운 레코드 타입을 정의하는 것도 쉽지. 그러니까 더 테스트 가능한 코드의 이점을 원한다면, 효과 없이도 더 “정교한” 타입 시스템이 없는 다른 언어들에서 얻을 수 있겠네.
그리고 효과 시스템이 있는 언어로의 전환을 강제할 정도의 사회적 자본이 있다면, 분명 지금 이미 쓰고 있는 언어에서 특정 린터나 코드 스타일을 도입하자고 주장할 충분한 사회적 자본도 있겠지.
Pratik: 맞아. 그리고 내가 언급한 가상 파일시스템 예로 돌아가면, 그걸 사용해서 더 테스트 가능 한 코드의 이점은 얻을 수 있어도, 자동으로 더 잘 테스트된 코드의 이점이 생기는 것은 아니야. 실제로 그렇게 되려면 그 가상 파일시스템이 운영 환경에서 나올 수 있는 오류를 실제로 반환하고, 심볼릭 링크를 모델링하는 등, 다양한 코드 경로를 제대로 실행해 볼 수 있어야 해.
비정상적인 특성을 가진 테스트 케이스를 직접 작성하거나(또는 테스트 생성기가 생성하게 하거나), 그런 상황에서도 시스템이 무너지지 않는지 확인해야 하지.
여기서 효과 시스템이나 의존성 주입은 사실 별로 도움을 주지 않아. 여전히 다양한 상황에서 시스템이 어떻게 동작해야 하는지를 정의하는 어려운 작업과, 그 시스템이 실제로 기대한 대로 동작하는지 테스트하는 어려운 작업을 해야 해.
Pratik: 네가 꺼낸 다른 논점 중 하나는 보안이었지.
Emmett: 응, 공급망 공격은 계속 일어나고 있잖아. 예를 들어 최근 npm 패키지에 대한 Shai-hulud 웜 공격 같은 것 말이야.
Pratik: 그 공격이 어떻게 일어났는지 알아?
Emmett: 음…
Pratik: 그 공격은 계정 탈취가 먼저 일어나고, 그 뒤에 post-install 스크립트(일부 언어에서는 build script라고 부르지)에서 코드가 실행되는 방식으로 이루어졌어.
Emmett: 그렇다면 build script에는 네트워크 접근을 허용하면 안 되지! 아니면 실행 전에 그런 스크립트를 승인하도록 해야 하고.
Pratik: 맞아, 그러니까 네가 제안하는 해결책은 샌드박싱이나 권한 통제, 그리고 수동 감사에 의존해. 효과와는 아무 관련이 없어.
Emmett: 하지만! 하지만! 함수가 네트워크가 필요한지 선언하도록 요구하는 건 보안 조치로 유용해 보이는데… 동의하지 않아?
Pratik: 그 논점의 핵심은 효과 추적이 아니라 전역 변수 금지와 빌림 의미론 또는 escape 추적에 더 가까워.
전역 변수가 허용되고, 네트워크를 사용하는 capability가 escape하는 것도 허용된다면, 한 함수가 그 capability를 전역 변수에 몰래 저장할 수 있고, 그러면 그 함수 시그니처가 “순수”하더라도 나중에 다른 로직이 그것을 사용할 수 있어.
위와 같은 일이 일어날 수 없다고 보장할 수 있을 때에만, 매개변수의 필드 타입 중 어느 것이 네트워크 요청 전송을 허용하는지를 검사해서 함수를 “감사”하는 것이 의미가 있어. (예를 들어 매개변수로부터 전이적으로 도달 가능한 필드 타입 집합 어딘가에 Network 값이 있는지 보는 식으로.)
더 일반적으로 보안 관점에서 공격자는 흔히 임의 코드 실행 권한을 얻고 싶어 해. 이를 막기 위해 효과가 있는 방법은 다음과 같아:
예를 들어 /proc/self/mem 같은 인터페이스를 고려하면, 안전한 Rust조차 메모리 안전성 문제로부터 완벽한 자유를 보장하지는 못해. Flix 식으로 말하자면, 파일 읽기 및 쓰기 권한이 필요한 API를 볼 수는 있겠지만, 그런 타입 시그니처로부터 “이것은 메모리를 임의로 손상시키지 않을 것이다”라고 추론하는 것은 타당하지 않아.
FFI가 전용 효과 없이 허용된다면, FFI는 또 다른 공격 벡터를 열어 줘. 반면 FFI에 대해 처리할 수 없는 전용 효과를 요구하면(예를 들어 Flix는 FFI에 IO 효과를 요구하지), 기술적으로는 순수 함수인 암호화 API나 압축 API가 여전히 어떤 효과를 갖는 식의 다소 이상해 보이는 API로 이어질 수 있어.
오늘날의 실제 공격들을 보면, 공격자들은 흔히 다음을 악용해:
이 두 상황 모두에서 샌드박싱과 감사는 도움이 될 수 있어. 효과 시스템이 있다고 해서 도움이 되는 정도는 논쟁의 여지가 있지만, 대체로 매우 적어 보여.
Emmett: 네 말은 설득력이 있네. 그러니까 “효과 시스템은 보안에 유용하다”는 주장은 결국 공격자가 “규칙을 지킬 것”이라는 가정에 기대고 있는 셈인데, 물론 공격자는 규칙을 지키려는 게 아니라 가능한 어떤 방식으로든 이득을 얻으려 하잖아.
샌드박싱과 코드 감사는 특정 부류의 공격을 방해하고 탐지하는 데 도움이 된다는 것이 입증된 접근법들이지.
Pratik: 네가 언급한 효과 시스템의 또 다른 잠재적 장점은 사용자 정의 제어 흐름을 가질 수 있다는 것이었어. 예를 들어 async/await, 제너레이터, 백트래킹, 예외 같은 것을 효과 위에 구축할 수 있다는 거지.
Emmett: 맞아, 그건 정말 유용해 보여. 효과 지원을 추가하면 이런 메커니즘들을 모두 “공짜로” 얻는 셈이잖아. 다른 언어들에서는 보통 이런 것들을 하나씩 따로 추가하니까.
Pratik: 그걸 언어 구현자에게 주는 이점으로 말하는 거야, 아니면 언어 사용자에게 주는 이점으로 말하는 거야?
Emmett: 흠, 구현자에게도 유용하겠지만, 언어 사용자에게도 유용하지. 예를 들어 async/await에 사용자 정의 스케줄러를 둘 수 있을 테니까.
Pratik: async/await에 대한 사용자 정의 스케줄러는 효과 시스템 없이도 둘 수 있어. 예를 들어 Rust에서는 언어가 특정 “async 런타임” 하나를 내장하고 있지 않아. 이런 것들은 라이브러리에서 정의돼. 그리고 이것은 특정 사용 사례를 위한 특화 런타임을 사용할 수 있다는 식으로 어떤 상황에서는 유용하지만, 반대로 일부 라이브러리가 특정 런타임에 결합되는 단점도 가져와.
Emmett: 응, 그건 사실이야. async를 사용하는 많은 Rust 라이브러리들이 Tokio 런타임에서만 작동하니까, 유연성이 단점도 분명히 있지.
Pratik: 네가 든 또 다른 예는 예외였지. 예외의 유용성은 예외가 기록될 때 얻는 스택 트레이스에서 나와. 하지만 효과 지원을 구현한다고 해서 스택 트레이스를 “공짜로” 얻는 것은 아니고, 스택 트레이스 지원은 별도로 추가해야 해.
Emmett: 응, 그건 타당한 지적이네.
Pratik: 더 일반적으로 말하면, 나는 사용자 정의 제어 흐름이 대체로 좋은 생각인지 잘 확신이 안 서. 예를 들어 Haskell에서 Tardis monad를 써 본 적이 있다면, 자신이 무엇을 하는지 조심하지 않으면 무한 루프나 멈춤 상태에 빠지기 쉬운데, 그런 것은 디버깅하기가 어려울 수 있어.
마찬가지로 백트래킹 알고리즘을 구현할 때 bookkeeping을 스택과 visited set으로 하면 매 단계마다 상태를 기록하는 것만으로도 문제를 비교적 쉽게 디버깅할 수 있어. 하지만 이 상태가 호출 스택의 일부가 되어 버리면, 일이 잘못되었을 때 디버깅이 더 어려워질 수 있어 보여. 일반적으로 호출 스택을 동적으로 들여다보는 것은 더 어렵고 비용도 크기 때문이지.
Emmett: 그 말은 이해가 돼. 인터프리터를 작성할 때 재귀적인 스타일로 쓰고 싶은 유혹이 있지. 하지만 문제가 생기면 여러 다른 위치에서 데이터를 기록해야 할 수도 있어서 디버깅이 더 어려운 경우가 많아.
반면 인터프리터를 명령어를 하나씩 분기 처리하는 루프로 작성하면, 단지 각 루프 반복의 시작 부분에 로깅을 추가하는 것만으로도 충분한 경우가 많지.
Pratik: 전반적으로 보면, 사용자 정의 효과는 PL 연구를 할 때는 의미가 있을 수 있다고 생각해. 그곳의 목표는 연구를 하고 새로운 무언가를 만들어 보려는 것이지, 반드시 최고의 개발자 경험이나 실전 배포 가능한 무언가를 갖는 것은 아니니까.
또 하나 이 장점이 가치 있을 수 있는 곳은 다른 것들이 구축되는 “코어 언어” 또는 “코어 IR”일 가능성이 있어. 코어에서는 유연하게 조합되는 원시 요소 수가 더 적은 편이 종종 유용하거든.
하지만 실전용 언어에서는 사용자 정의 제어 흐름이 새 사람을 온보딩하기 어렵게 만들 수 있고, 라이브러리들 간 상호운용성도 어렵게 만들 가능성이 있어.
Pratik: 내가 보기에 효과의 사용이 훼손하는 또 다른 것은 런타임 단정의 사용이야.
단정과 계약 프로그래밍은 SQLite, FoundationDB, 그리고 최근에는 ‘Tiger Style’의 일부로 TigerBeetle에 의해 대중화된 것처럼, 가장 철저하게 테스트된 오픈 소스 소프트웨어들 중 일부에서 널리 사용되어 왔어.
Emmett: 단정 밀도가 높거나 계약 프로그래밍을 쓰면 결함률이 낮아진다는 연구가 있나?
Pratik: Microsoft Research의 Assessing the Relationship between Software Assertions and Code Quality: An Empirical Investigation라는 논문이 있어. 그 논문은 과거 버그와 단정 사용을 살펴봤고, 단정 밀도와 결함 밀도 사이에 통계적으로 유의하지만 크지는 않은 음의 상관관계를 발견했어.
이 분야에서 더 나은 연구가 있는지는 나는 잘 몰라.
Emmett: 그럼 단정 사용이 더 낮은 결함률과 연결되어 있다는 어느 정도의 증거는 있네. 그런데 단정과 효과의 문제는 뭐지?
Pratik: assert 함수나 매크로가 있다고 해 보자. 거기에는 어떤 효과를 주석처럼 달아야 할까?
Emmett: 흠, Exception/Throws 효과일까? AssertionError를 던질 수 있으니까.
Pratik: 만약 컴파일 타임 스위치로, 단정이 발동했을 때 프로그램을 종료하고 싶다면? 예를 들어 가용성보다 안전성을 더 중시한다면, 프로그램을 종료하고 supervisor 프로세스가 다시 시작하게 하는 편이 더 타당할 수도 있잖아.
Emmett: 그 경우에는 기본적으로 assert가 Exit 효과를 가져야 할까?
Pratik: SaaS 애플리케이션의 웹서버를 작성 중이라서 가용성이 더 높은 우선순위이고, 서버 프로세스를 통째로 종료하는 대신 요청만 중단하는 편이 더 타당하다면 어떡하지?
Emmett: 으, 그래, 서로 다른 사람들의 서로 다른 우선순위를 모두 만족시키기는 어렵네. 아마 assert를 디버그 로깅 함수와 비슷한 특별 함수로 만들어서, 어떤 효과는 수행할 수 있지만 오류를 잡거나 종료하는 것은 일반 효과 시스템을 거치지 않게 하는 식으로 해야 할지도 모르겠어?
Pratik: 좋아, 그러니까 단정을 컴파일러 내장 기능으로 만들자는 제안이네. 그런데 내가 그것을 감싸는 래퍼를 두고, 타이밍 정보를 수집해서 다른 메트릭과 함께 모으고 싶다면? 아마 시계와 타이밍을 기록하기 위한 전역 상태에 접근할 수 있어야 할 텐데, 그러면 계측된 단정 함수는 효과를 명시적으로 사용해야 하니까 표준 단정의 드롭인 대체물이 될 수 없겠지.
Emmett: 네가 무엇을 말하려는지 알겠어. 단정 같은 원시적 연산에서는 어느 정도의 유연성이 유용해. 하지만 그 때문에 호출 코드를 바꿔야 한다면, 실험 자체가 방해받게 되지.
비슷한 반론이 원시 수치 연산에도 적용되겠네. 언어들이 기본 동작을 wrapping에서 trapping으로 바꾸는 기능을 제공하는 경우가 있으니까. 하지만 프로그램 종료가 타입 시스템에서 효과로 모델링된다면, 그건 선택지에서 배제되어 버리지.
Pratik: 마지막으로 꺼내고 싶은 것은 전역 변수 금지야.
Emmett: 설마 네가 전역 변수 사용을 찬성하는 건 아니겠지!
Pratik: 내가 꼭 전역 변수 사용을 찬성하는 것은 아니야. 전역 변수에 반대하고 명시적 매개변수 전달(또는 효과 사용)을 지지하는 이유는 테스트를 병렬로 실행하면서도 값을 대체할 수 있게 해 주기 때문이야.
하지만 때로는 테스트에 관한 논점의 우선순위가 다른 논점보다 낮을 수도 있어. 예를 들어 Leaving Rust gamedev after 3 years에서 LogLog Games는 이렇게 말해:
[전형적인 2D 플랫포머, 탑다운 슈터, 또는 복셀 기반 워킹 시뮬레이터 같은 게임]의 경우에는 오디오 시스템도 하나, 입력 시스템도 하나, 물리 월드도 하나, deltaTime도 하나, 렌더러도 하나, 에셋 로더도 하나뿐이다. 어떤 극단 사례에서는 몇몇 것들이 전역이 아니면 조금 더 편할 수도 있겠지만, 물리 기반 MMO를 만드는 경우라면 요구 사항이 다를 수도 있다.
게임 개발은 자동화된 테스트보다 기능 테스트와 수동 QA에 더 큰 비중을 두는 경우가 많아.
마찬가지로 다른 맥락에서 프로토타입을 만들거나 작은 스크립트나 도구를 만들 때도 전역 변수를 사용할 수 있는 능력이 유용할 수 있어.
Emmett: 흠, 그런 종류의 예는 전에는 생각해 본 적이 없었네. 어떤 경우에는 전역 변수를 신중하게 사용하는 것이 도움이 될 수 있다는 점은 이해가 가.
Pratik: LogLog Games의 그 글 전체는 읽어 볼 만한 가치가 충분해. 언어 설계에 대한 내 예전 생각들 중 많은 것을 다시 생각하게 만들었거든.
전역 변수와 관련된 또 하나의 복잡한 점은, 언어 차원에서 그것을 금지하더라도 언어가 FFI를 가진다면 FFI 반대편의 언어는 거의 확실히 전역 변수를 허용한다는 거야. 그래서 전역 상태에 대한 의존성이 그 경로로 슬그머니 들어올 수 있어.
Emmett: 흠, 이 모든 대화를 거치고 나니 효과 시스템에 대한 내 열의가 꽤 많이 식었다고 말해야겠네.
Pratik: 오해하지는 마. 나는 라이브러리에서 사용자 정의 제어 흐름 연산을 정의할 수 있다는 점 때문에 효과 시스템이 멋지다고 생각해.
하지만 흔히 주장되는 다양한 이점들은 자세히 들여다보면 그리 잘 버티지 못하는 것 같아.