Grace 프로그래밍 언어의 ‘타입 안전한 eval’ 기능을 소개하고, 이것이 단계적 타입 검사와 동적 import, 프라이버시를 보호하는 프롬프트 엔지니어링에 어떻게 기반이 되는지 설명한다.
이 글은 Grace 프로그래밍 언어의 기능 하나에 관한 이야기다. 저주이자 축복인 기능: 타입 안전한 eval.
이 기능이 저주 인 이유는 (어떤 eval 이든 그렇듯) 신뢰할 수 없는 입력을 코드로 승격시킬 잠재력이 있기 때문이고1, 축복 인 이유는 타입 안전하며 또 두 가지 다른 언어 기능의 토대가 되기 때문이다:
eval타입 안전한 eval 이라고 하면 무슨 뜻일까? 다음 Grace 프로그램이 유효하다는 뜻이다:
let increment x = import read "x + 1"
in increment 4 # Returns: 5
import read 는 Grace에서의 eval 에 해당한다. Grace 코드가 들어 있는 Text 인자를 실행 가능한 표현식으로 변환해, 같은 Grace 프로그램 안에서 사용할 수 있게 해준다. 위키백과의 표현을 빌리면:
eval은 evaluate의 줄임말로, 문자열을 마치 해당 언어의 표현식인 것처럼 평가하고 결과를 반환하는 함수이다.
즉, 앞선 Grace 예제는 우리가 다음과 같이 쓴 것과 동일하게 동작한다:
let increment x = x + 1
in increment 4
Grace의 import read 구현은 타입 안전 하다. 즉, Grace의 타입 안전성 보장을 깨뜨리지 않는다. 구체적으로, 그 보장 중 하나는 Grace가 타입 오류가 포함된 코드를 절대로 평가하지 않는다는 것이다.
무슨 뜻인지 보려면 다음 코드를 생각해 보자:
let not x = import read "x == false"
in not 4
이를 실행하면 다음과 같은 오류 메시지를 얻게 된다:
Not a subtype
The following type:
Natural
(input):3:9:
│
3 │ in not 4
│ ↑
… cannot be a subtype of:
Bool
(read):1:6:
│
1 │ x == false
│ ↑
… 요지는 4 와 false 를 동등 비교할 수 없다는 말이다. 더 중요한 점은, Text 안의 코드가 평가되기 전에 타입 검사가 실패한다는 것이다. 그리고 Grace가 이를 지원하는 방법은 사실 그렇게까지 복잡하지는 않지만, 실제로 구현한 언어는 매우 드물다2.
Grace의 타입 안전한 eval 은 언어가 양방향(bidirectional) 타입 검사된다는 사실을 이용한다. 즉, 타입 검사기가 코드가 사용되는 방식으로부터 거꾸로 입력의 타입을 추론할 수 있다는 뜻이다. 예를 들어 타입 검사기가 위 표현식을 처리할 때, 아직 해결되지 않은 타입을 위한 자리표시자(아래의 a?, b?)를 코드에 삽입한다:
let not (x : a?) = import read "x == false" : b?
in not 4 : b?
… 그리고 타입 검사가 진행되면서 더 많은 정보를 얻을수록 이 미해결 타입들을 점차 구체화한다.
예를 들어, 타입 검사기가 코드 끝의 not 4 를 분석하면, 이전에 미해결이던 타입 a? 는 Natural 이어야 함을 알게 된다(왜냐하면 4 는 Natural 숫자이고, 우리는 not 함수에 4 를 적용하기 때문이다).
let not (x : Natural) = import read "x == false" : b?
in not 4 : b?
… 이것이 첫 번째 타입 검사 단계가 끝났을 때의 “정교화(elaborated)”된 표현식이다(맞다, 곧 다른 타입 검사 단계가 한 번 더 있다). 이 시점에서 타입 검사기는 아직 타입 오류가 있다는 사실을 모른다. 왜냐하면 아직 어떤 평가도 수행하지 않았고(따라서 Text 를 eval 하지도 않았기) 때문이다.
이제 코드를 평가한다. 그리고 import read 표현식을 평가하기 전에 인터프리터는 Text 안에 저장된 코드를 타입 검사하는 두 번째 타입 검사 단계를 수행한다. 첫 번째 타입 검사 단계에서 x 의 타입이 Natural 이어야 한다는 사실을 이미 알고 있다(그렇지 않으면 not 4 가 타입 오류가 되기 때문이다). 그러면 타입 검사기는 Natural 숫자(x)와 Bool(false)을 동등 비교할 수 없음을 알아차리고3, 표현식이 eval 되기 전에 두 번째 타입 검사 단계가 실패한다.
이는 단계적(staged) 타입 검사라고 부르며, 인터프리터가 코드를 eval 할 때마다 적절하게 타입 검사와 평가를 교차(interleave)한다는 뜻이다. 타입 검사와 평가를 교차하더라도, 타입 검사를 먼저 통과하지 않은 코드는 절대로 평가되지 않는다.
여기까지 오면 아마 이런 의문이 들 것이다: Grace는 대체 왜 타입 안전한 eval 을 지원할까?
사실 타입 안전한 eval 은 더 일반적인 Grace 기능의 특수한 경우다. 그 일반 기능은 타입 안전한 동적(dynamic) import다. 이러한 동적 import는 import되는 코드를 해석(결정)할 때, 스코프에 있는 값들에 의존할 수 있다.
import read 는 Grace가 동적으로 코드를 import할 수 있는 여러 방법 중 하나일 뿐이다:
import github 로 GitHub에서 동적으로 코드를 import할 수 있다import http 로 HTTP 요청을 통해 동적으로 코드를 import할 수 있다import prompt 로 LLM이 생성한 코드를 동적으로 import할 수 있다import read 로 문자열(Text)에서 코드를 동적으로 읽어올 수 있다동적 import는 내가 이전에 만들었던 언어(Dhall)에서 계속 반복해서 또 또 또다시 요청받았던 기능이다. Dhall은 끝내 동적 import를 지원하지 않았지만4, Grace에서는 반드시 지원하겠다고 마음먹었다.
“동적 import”가 무슨 뜻인지 보여주기 위해 다음 Grace 코드를 보자:
let prelude{ name } =
import github
{ owner: "Gabriella439"
, repository: "grace"
, path: "prelude/${name}.ffg"
}
in prelude
위의 prelude 함수는 GitHub 저장소 내의 하위 경로(subpath)로 Grace의 prelude 코드를 가져오는 유틸리티다. 이 import가 동적 인 이유는, 어떤 코드를 가져올지가 아직 모르는 값(name)에 따라 달라지기 때문이다.
즉, 다음처럼 name 에 값을 정하면:
let prelude{ name } =
import github
{ owner: "Gabriella439"
, repository: "grace"
, path: "prelude/${name}.ffg"
}
let mystery = prelude{ name: "text/concat" }
in mystery [ "Hello, ", "world!" ] # Returns: "Hello, world!"
… 이 프로그램이 동작하려면(앞서와 같이) 인터프리터는 반드시 단계적으로 타입 검사를 수행해야 한다.
첫 번째 타입 검사 패스에서, 인터프리터는 mystery 함수를 어떻게 사용하는지로부터 거꾸로 추론하여 다음과 같은 타입들을 코드에 덧붙여 정교화한다:
let prelude{ name: Text } =
import github
{ owner: "Gabriella439"
, repository: "grace"
, path: "prelude/${name}.ffg"
} : List Text -> a?
let mystery : List Text -> a? = prelude{ name: "text/concat" }
in mystery [ "Hello, ", "world!" ] : a?
첫 번째 타입 검사 패스가 끝나면, 인터프리터는 표현식을 평가한다. 그 과정에서 import github 표현식이 다음 코드를 반환한다:
# Concatenate a `List` of `Text`
let concat
: List Text -> Text
= fold { cons: \x y -> (x + y) : Text, nil: "" }
in concat
그 다음 인터프리터는 그 코드에 대해 두 번째 타입 검사 패스를 수행하고, 추론된 타입(List Text -> Text)이 mystery 의 기대 타입(List Text -> a?)과 일치하는지 확인한다. 이 둘은 미해결 타입 a? 의 모든 발생을 Text 로 치환해야만 일치할 수 있다. 그러면 두 번째 타입 검사 패스가 끝난 뒤 최종적으로 정교화된 프로그램은 다음과 같다:
let mystery : List Text -> Text =
( let concat
: List Text -> Text
= fold { cons: \x y -> (x + y) : Text, nil: "" }
in concat
)
in mystery [ "Hello, ", "world!" ] : Text
… 그리고 나서 평가는 문제없이 계속 진행된다.
프롬프트 엔지니어링에서 흔한 우려는 민감하거나 독점적인 데이터를 대규모 언어 모델에 입력하는 것이다. 예를 들어 내 직장(Mercury)에서는 OpenAI 같은 제공자가 엔터프라이즈 계정에 대해 데이터 프라이버시 보장을 제공하더라도, 민감한 고객 데이터를 LLM에 넣는 것을 피한다.
Grace는 민감한 데이터가 실제로 모델에 전달되지 않아도(사실 민감한 데이터는 인터프리터 밖으로 절대로 나가지 않는다) LLM이 그 데이터에 대해 작업할 수 있게 해주는 깔끔한(다만 매우 제한적인) 기능을 제공한다. 이 기능은 일종의 신기한 장난감에 가깝지만, 아이디어를 떠올릴 수 있게 언급해 두고 싶다.
이게 어떻게 동작하는지 보여주기 위해, 우리가 피하고 싶은 다음의 꾸며낸 예시로 시작하겠다:
let private = { firstName: "Mary", lastName: "Sue" }
in prompt
{ key: ./openai.key
, text:
"Combine ${private.firstName} and ${private.lastName}"
} : { fullName: Text } # Returns: { fullName: "Mary Sue" }
여기서 프롬프트에 집어넣은 모든 것은 반드시 네트워크를 통해 OpenAI로 전송된다. 하지만 위 프로그램을 작은 변경 두 가지로 프라이버시를 보호하는 방식으로 동일한 일을 하도록 바꿀 수 있다:
let private = { firstName: "Mary", lastName: "Sue" }
in import prompt
{ key: ./openai.key
, text:
"Combine private.firstName and private.lastName"
} : { fullName: Text } # Same result
차이를 찾았는가? 변경점은 두 가지다:
prompt 키워드 앞에 import 를 붙였다prompt 키워드만으로는 JSON만 반환할 수 있고(임의의 코드는 불가), 또한 스코프 내 값에 접근할 수 없다. import 를 붙이면 prompt 키워드는 임의의 Grace 코드를 반환할 수 있고, 그 Grace 코드는 스코프 내 값들을 참조할 수 있다5.
private.firstName 과 private.lastName 을 프롬프트에 보간(interpolate)하지 않는다LLM이 보는 문자열은 문자 그대로 다음과 같다:
Combine private.firstName and private.lastName
그런데 잠깐… 이게 어떻게 가능한가? 민감한 데이터를 프롬프트에 넣지 않았는데 LLM이 어떻게 올바른 답을 내놓을 수 있을까?
사실 약간 거짓말을 했다. LLM이 실제로 보는 프롬프트는 (본질적으로) 다음과 같다:
다음 값들이 스코프에 있다:
private : { firstName: Text, lastName: Text }위 스코프 내 값을 사용하여, 다음 타입의 값을 만들어내는 Grace 프로그램을 작성하라:
{ fullName: Text }… 그리고 아래 지시를 따르라:Combine private.firstName and private.lastName
… LLM은 이 지시를 보고 다음과 같은 Grace 코드로 응답한다:
"${private.firstName} ${private.lastName}"
… 그리고 여기서부터는 Grace의 타입 안전한 eval 지원이 이어받는다. 이 코드는 타입 안전성을 검사한 뒤, 우리가 다음처럼 직접 작성한 것처럼 프로그램에 다시 주입된다:
let private = { firstName: "Mary", lastName: "Sue" }
in "${private.firstName} ${private.lastName}"
… 그리고 여전히 최종적으로 (올바른) 결과를 만들어낸다.
이 과정 전체에서 LLM은 데이터를 보지 못한다. LLM은 데이터의 형태 만 본다(firstName 과 lastName 이 모두 Text 라는 것만 안다). 그리고 LLM은 데이터로 무엇을 할지에 대한 지시를(Grace 코드 형태로) 돌려주지만, 그 지시를 실행하는 것은 LLM이 아니라 Grace 인터프리터 다.
즉, 이 트릭으로 민감한 데이터를 보호하며 할 수 있는 일에는 한계가 있다. LLM은 Grace 코드로 데이터를 변환할 수 있을 뿐이므로, Grace가 원래 할 수 없었던 일을 데이터에 대해 수행할 수는 없다6.
가장 멋진 점은, 이 모든 기능이 타입 추론이 있는 정적 타입 프로그래밍 언어에서 구현되었다는 것이다. 대부분의 프로그래머에게 이런 기능을 어떻게 구현할지 물으면, 아마 이런 저주스러운 일을 하려면 비정적(타입 없는) 언어가 필요하다고 생각하겠지만, 그들은 틀렸다. 엔지니어링 노력을 기울이기만 하면, 이런 기능은 정적 타입 언어에서도 모두 동작한다.
나는 이 기능들이 타입과 함께 동작하도록 꽤 강하게 밀어붙였는데, 그 이유는 스택 트레이스가 타입 검사의 적절한 대체재라고 생각하지 않기 때문이다. 이 주제는 다른 글인 동적 타입 오류는 관련성이 부족하다에서 더 자세히 다룬다.
Grace에 대해 더 알아보고 싶다면 Grace의 GitHub 저장소를 방문하거나, 브라우저에서 Grace를 사용해 볼 수 있다.
요즘은 바이브 코딩 덕분에 신뢰할 수 없는 코드를 생성하는 것 따위에 신경 쓰는 사람은 거의 없지만. ↩
내가 보기엔, 실제로 타입 안전한 eval 을 잘 볼 수 없는 이유는 eval 을 찾는 부류의 사람들이 대체로 타입 없는 언어도 함께 찾는 경향이 있기 때문일 것이다. 하지만 나는 정적 타입 언어에서도 eval 에는 정당한 사용처가 있다고 생각했고, 그래서 이 글을 썼다. ↩
… 적어도 Grace에서는 그렇다. 어떤 언어는 서로 다른 타입의 값을 동등 비교할 수 있게 해주기도 하는데, 내 생각에는 그건 역겹고 실수다. ↩
Dhall이 동적 import를 지원하지 않았던 큰 이유는 두 가지가 있었다. 하나는 Dhall이 강력한 언어 보안 보장을 지원하도록 명시적으로 설계되었기 때문인데(동적 import는 그 보장의 일부를 무력화한다). 또 다른 이유는 Dhall이 여러 언어로 포팅될 수 있도록 어느 정도 미니멀하게 설계되었고, (내 기준에서는) 동적 import가 구현 복잡도를 너무 많이 올렸기 때문이다. Grace는 Dhall과 설계 제약이 다르므로, Grace에서는 동적 import를 더 적극적으로, 기꺼이 지원하고자 했다. ↩
궁금해할까 봐 덧붙이면, import {github,http,prompt,read} 네 가지 모두 import 접두사 없이도 동작한다. 다만 import 키워드를 빼면, 모든 경우에 Grace 코드가 아니라 JSON 을 생성한다. 예를 들어 read 단독으로는 Text 에서 JSON 을 디코딩하는 함수다. ↩
예리한 독자라면 이런 생각을 할 수도 있다: “잠깐, LLM이 Grace 코드를 반환할 수 있다면, 민감한 데이터를 prompt 에 쑤셔 넣어서 프라이버시 보장을 깨는 Grace 코드를 만들어낼 수도 있는 거 아냐?” 답은: 그렇다. 하지만 오직 스코프 안에 OpenAI API 키가 있을 때만 가능하다(우리 예시에는 스코프 안에 API 키가 없다). 다만 이것은 때때로 의도적으로 하기도 한다. 우리는 때때로 import prompt 로 호출된 LLM이, 그 작업의 일부를 다른 LLM 호출에 위임하기를 원하기도 한다. Grace 튜토리얼의 “Coding” 예시는 LLM이 사용할 수 있도록 API 키를 일부러 스코프에 남겨 두는 방식으로 이 트릭을 매우 효과적으로 활용한다. ↩
Copyright © 2026 Gabriella Gonzalez. This work is licensed under CC BY-SA 4.0