Go의 사례를 통해 데이터 경쟁이 허용되는 언어에서 ‘메모리 안전’을 주장하기 어렵다는 점을 보여 주고, 우리가 진정으로 원하는 속성은 ‘정의되지 않은 동작(UB)의 부재’임을 논증한다.
요즘 메모리 안전성이 큰 화두다. 그런데 이 용어는 도대체 무엇을 뜻할까? 의외로 딱 잘라 말하기가 어렵다. 보통은 프로그램에 use-after-free나 경계 초과(out-of-bounds) 메모리 접근이 없도록 보장하는 언어를 가리키는 말로 쓴다. 그러면 이는 종종 스레드 안전성과 같은 다른 안전성 개념과 구분되는데, 스레드 안전성은 특정 종류의 동시성 버그가 없는 프로그램을 말한다. 하지만 이 글에서 나는 이런 구분이 그리 유용하지 않으며, 실제로 우리가 프로그램에 원하고 기대하는 속성은 바로 _정의되지 않은 동작(Undefined Behavior, UB)의 부재_라고 주장하려 한다.
내가 메모리 안전성과 스레드 안전성과 같이 안전성을 미세한 분류로 나누는 것에 문제를 제기하는 이유는, 스레드 안전하지 않은 언어가 메모리 안전을 제공한다는 말이 의미 있게 성립하지 않기 때문이다. 무슨 말인지 보기 위해, 위키피디아에 따르면(https://en.wikipedia.org/wiki/Go_(programming_language)) 메모리 안전하다고 하는 Go로 작성한 다음 프로그램을 보자.
package main
// 나중에 인터페이스 타입을 쓰기 위해 임의의 인터페이스.
type Thing interface {
get() int
}
// 서로 매우 다른 타입의 필드를 가진, 인터페이스를 구현하는 두 타입.
type Int struct {
val int
}
func (s *Int) get() int {
return s.val
}
type Ptr struct {
val *int
}
func (s *Ptr) get() int {
return *s.val
}
// 인터페이스 타입의 전역 변수. 나중에 `Int`와 `Ptr`를 번갈아 가리키게 만들 것이다.
var globalVar Thing = &Int { val: 42 }
// 전역 변수에 대해 인터페이스 메서드를 반복 호출.
func repeat_get() {
for {
x := globalVar
x.get()
}
}
// 전역 변수의 동적 타입을 반복해서 바꾸기.
func repeat_swap() {
var myval = 0
for {
globalVar = &Ptr { val: &myval }
globalVar = &Int { val: 42 }
}
}
func main() {
go repeat_get()
repeat_swap()
}
이 프로그램을 실행해 보면(예: Go playground https://go.dev/play/p/SC-o_Q8e-aK), 아주 금방 크래시 난다:
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x2a pc=0x468863]
이건 일반적인 Go 패닉이 아니라 세그폴트이므로, 뭔가 심각하게 잘못된 것이다. 세그폴트를 유발한 주소가 16진수 42의 표현인 0x2a라는 점에 주목하자. 무슨 일이 벌어지고 있는 걸까?
이 예제는 Go가 Thing 같은 인터페이스 타입의 값을 “데이터를 가리키는 포인터”와 “vtable(가상 함수 테이블)을 가리키는 포인터”의 쌍으로 저장한다는 점을 악용한다. repeat_swap이 globalVar에 새 값을 저장할 때마다, 이 두 포인터를 갱신하기 위해 별도의 두 저장을 수행한다. 따라서 repeat_get에서는 우리가 그 두 저장 사이에 globalVar를 읽으면, Int의 데이터 포인터와 Ptr의 vtable이 뒤섞인 값을 읽을 작은 가능성이 생긴다. 그렇게 되면 get의 Ptr 버전을 실행하게 되고, Int의 val 필드를 포인터처럼 역참조하려 한다. 그 결과 프로그램은 주소 42에 접근하여 크래시 난다.
이 예제를 조금만 변형하면 정수를 포인터로 캐스팅하는 함수를 만들고, 임의의 메모리 손상을 일으킬 수도 있다.
여기까지 읽고 “이건 많은 언어에서 생기는 문제 아닌가? 자바도 데이터 경쟁을 허용하지 않나?”라고 생각할 수 있다. 맞다, 자바도 데이터 경쟁을 허용한다. 하지만 자바 개발자들은 데이터 경쟁이 있어도 프로그램 전체가 잘 정의되도록 만들기 위해 엄청난 노력을 기울였다. 그 목적을 위해 C++11 메모리 모델보다도 수년 앞서 세계 최초로 산업에 배치된 동시성 메모리 모델을 개발하기도 했다. 그 결과 동시성 자바 프로그램에서 특정 변수에 대해 기대와 다른 오래된 값을 볼 수는 있다. 예컨대 제대로 초기화된 참조를 기대했지만 null 포인터를 볼 수도 있다. 하지만 언어 자체를 깨뜨려 잘못된 덩글링 포인터를 역참조하고 주소 0x2a에서 세그폴트가 나게 만들 수는 절대 없다. 그런 의미에서, 모든 자바 프로그램은 스레드 안전하다.1
일반적으로, 동시성이 언어의 기본 불변식을 깨뜨리지 않게 하려면 언어가 취할 수 있는 선택지는 두 가지다.
불행히도 Go는 이 둘 중 어느 것도 택하지 않았다. 이는 엄밀히 말해 Go가 메모리 안전한 언어가 아님을 뜻한다. 언어가 최선으로 약속할 수 있는 것은, 만약 프로그램에 데이터 경쟁이 없다면(좀 더 정확히는, 인터페이스, 슬라이스, 맵과 같은 문제적 값들에 대한 데이터 경쟁이 없다면) 메모리 접근이 잘못되지 않으리라는 정도다. 물론 공정하게 말해, Go에는 데이터 경쟁을 탐지하는 기본 제공 도구가 있고 내 예제의 문제도 금방 찾아낸다. 하지만 실제 프로그램에서는 테스트 스위트가 실전에서 프로그램이 마주칠 모든 상황을 포괄하길 바라는 수밖에 없다. 그런데 바로 이 점이 강한 타입 시스템과 정적 안전 보장이 의도한 대상을 정확히 빗겨간다. 그래서 Go에서 데이터 경쟁이 막대한 문제이며, 실제 메모리 안전 위반의 경험담도 있다는 사실이 놀랍지 않다. 숙련된 Go 프로그래머조차, unsafe 연산을 쓰거나 컴파일러/언어 버그를 이용하지 않고도 메모리 안전을 깨뜨릴 수 있다는 점을 항상 인지하지는 못한다. Go는 동시성 프로그래밍을 목표로 설계된 언어이기에, 이런 종류의 함정이 있으리라 기대하지 않는 것이다. 나는 이것이 문제적인 블라인드 스폿이라고 본다.
물론 언어 설계의 모든 것은 결국 트레이드오프이며, Go 진영도 이 문제를 잘 알고 있다.3 Go는 여기서 가능한 한 가장 단순한 선택을 했고, 이는 언어의 전반적 설계 철학과도 잘 들어맞는다. 본질적으로 잘못된 것은 아니다. 하지만 Go를 데이터 경쟁 문제를 해결하기 위해 실제로 _노력_을 기울인 언어들과 같은 묶음에 넣는 것은, 언어가 제공하는 안전성의 약속을 잘못 전달하는 일이다. Go 메모리 모델 문서도 이 점을 명확히 드러내지 않는다. “비공식 개요”는 “대부분의 경쟁은 제한된 수의 결과만을 가진다”고 강조하면서, Go는 “경쟁이 있는 어떤 프로그램의 의미도 완전히 정의되지 않은” C/C++과 다르다고 말한다. 여기서 “대부분”이라는 단어는 복선을 깔았다고 볼 수도 있겠지만, 이 절에서는 결과의 수가 무제한인 경우를 하나도 열거하지 않아 쉽게 놓치기 쉽다. 심지어 Go가 “자바나 자바스크립트에 더 가깝다”고까지 주장하는데, 해당 언어들이 스레드 안전성을 확보하기 위해 들인 노력을 생각하면 이는 꽤 불공평하다고 본다. 후반의 하위 절에 가서야 비로소 Go에서 일부 경쟁은 정말로 완전히 정의되지 않은 동작을 갖는다는 사실을 분명히 인정한다(이는 자바나 자바스크립트와는 매우 다르다).
사람들이 메모리 안전성을 말할 때 실제로 중요하게 여기는 속성은, _프로그램이 언어 자체를 깨뜨릴 수 없다는 것_이라고 나는 주장한다. 메모리 안전 위반으로 인한 보안 취약점의 전형적인 사례는, 코드가 언어 명세상 불가능한 일을 해버리는 것이다. 예컨대 사용자에게서 받은 배열로 점프해 _그걸 기계어로 실행_해 버리는 식이다. 이렇게 언어를 깨뜨리는 프로그램을 우리는 보통 _정의되지 않은 동작(Undefined Behavior, UB)_이라고 부른다. 프로그램에 UB가 나타나는 순간 모든 보장은 사라진다. 그 다음 공격자가 이 UB가 구체적으로 어떻게 드러나는지를 얼마나 통제하고 자신에게 유리하게 악용할 수 있는지는 대체로 구현 세부사항일 뿐이다.4
내 관점에서, 프로그램에 UB가 생길 수 없는 “안전한” 언어와 그럴 수 있는 “비안전한” 언어 사이에는 뚜렷한 경계가 있다. 이를 메모리 안전, 스레드 안전, 타입 안전 등으로 더 잘게 나눌 만한 의미 있는 기준은 없다. 프로그램에 UB가 있는 _이유_가 무엇인지는 중요하지 않다. 중요한 것은 UB가 있는 프로그램은 언어의 기본 추상화를 거스르며, 그 자체가 취약점이 자라나기 좋은 온상이라는 점이다. 따라서 언어가 체계적으로 UB를 예방하지 못한다면 그 언어를 “메모리 안전”하다고 불러서는 안 된다.
물론 실제로는 안전이 이분법적이지 않고 스펙트럼이며, 그 스펙트럼에서 Go는 C보다는 전형적인 안전 언어에 훨씬 가깝다. 데이터 경쟁으로 인한 UB는, 범위 초과나 use-after-free 같은 직접적인 메모리 접근 오류로 인한 UB에 비해 공격자에게 덜 유용할 가능성도 있다. 하지만 동시에, 언어가 신뢰성 있게 제공하는 안전 보장이 어디까지이고, 트레이드오프의 흐릿한 경계가 어디서 시작되는지를 이해하는 것이 중요하다고 생각한다. 나는 언어의 안전성 주장을 증명하는 일을 하고 있고, Go에 대해서는 실제로 증명할 수 있는 것이 많지 않다. 이 글이 다양한 언어들이 선택한 방향이 어떤 비자명한 결과를 낳는지 이해하는 데 도움이 되기를 바란다.5 :)
자바 프로그래머들은 때때로 “스레드 안전”과 “메모리 안전”이라는 용어를 C++이나 Rust 프로그래머들과 다르게 사용한다. Rust의 관점에서 보면 자바 프로그램은 구조적으로 메모리·스레드 안전하다. 자바 프로그래머들은 이를 너무나 당연하게 여겨서, 같은 용어를 더 강한 속성—예컨대 “의도치 않은” 데이터 경쟁이 없다거나 null 포인터 예외가 없다—을 가리키는 데 쓰곤 한다. 그러나 그런 버그는 잘못된 포인터 사용으로 인한 세그폴트를 유발할 수 없으므로, 내 Go 예제의 메모리 안전 위반과는 질적으로 전혀 다른 문제다. 이 글에서는 해당 용어들을 저수준의 Rust와 C++ 맥락에서의 의미로 사용한다.↩
일부 하드웨어는 포인터보다 큰 크기의 원자적 접근을 지원하며, 이를 통해 멀티워드 값의 일관성을 보장할 수도 있다. 하지만 Go의 슬라이스는 포인터 3개 크기이고, 내가 아는 한 그 정도로 큰 원자적 접근을 지원하는 하드웨어는 없다.↩
Go 개발자들이 스스로 자국 언어를 메모리 안전하다고 보는지 알아보려 했지만 확답을 얻지 못했다. Go 웹사이트는 이 문제에 입장을 밝히지 않는다. 2009년 발표에서 Rob Pike는 메모리 안전이 Go의 목표라고 말하지만, 2012년 슬라이드에서는 “공유가 허용되기 때문에” 이 언어가 “순수하게 메모리 안전하지는 않다”고 부른다.↩
이 “구현 세부사항”으로 _할 수 있는 일이 아주 많다_는 점은 잘 알고 있다. 기본적인 비실행 스택부터 고급 제어 흐름 무결성까지, 각종 완화 기법이 바로 그것을 다루는 것이다. 그러나 원칙적이고 형식적인 관점에서 보면, 그것들은 모두 UB가 드러나는 방식을 제한하는 여러 형태에 불과하다. 우리는 그런 시도를 당연히 계속해야 한다. 하지만 동시에 UB가 애초에 발생하지 않도록 할 수 있는 모든 일도 해야 한다.↩
왜 Go에 이렇게 초점을 맞추느냐 궁금할 수 있다. 솔직히 말해, 메모리 안전을 표방하면서도 데이터 경쟁으로 메모리 안전이 깨질 수 있는 다른 언어를 알지 못하기 때문이다. 원래는 수년 전에 이 글을 쓰려 했는데, 그때의 Swift는 이 점에서 Go와 거의 같은 부류였다. 하지만 그 사이 Swift가 “strict concurrency”를 도입해, 동시성 문제를 타입 시스템 기법으로 다루는 소수 언어(=Rust)의 대열에 합류했다. 정말 멋진 일이다! 불행히도 Go에게는, 그래서 내가 여기서 논지를 펼치는 데 쓸 수 있는 유일한 언어로 남게 되었다. 이 글은 Go를 깎아내리려는 것이 아니라, 언어의 잘 알려지지 않은 약점을 조명하려는 것이다. 교육적인 약점이라고 생각하기 때문이다.↩