테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

형식 검증

프로그램과 설계가 맞다는 것을 몇 가지 입력으로 시험하는 대신 수학으로 따지는 방법들 — 명세와 불변식, 모델 검사와 시간 논리, TLA+, SAT·SMT 솔버, 정리 증명기, 타입과 추상 해석 — 을 작은 예를 직접 계산한 그림으로 따라간다.

목차 (11)
  1. 왜 필요한가
  2. 명세: 전조건·후조건·불변식
  3. 모델 검사: 상태 공간을 모두 훑기
  4. 시간 논리: 안전성과 활성
  5. TLA+와 산업 사례
  6. SAT·SMT 솔버
  7. 정리 증명기: Rocq·Lean·Isabelle
  8. 타입 시스템이라는 가벼운 형식 검증
  9. 추상 해석과 정적 분석
  10. 비용과 한계, 어디에 쓰나
  11. 핵심 자료

왜 필요한가

테스트는 고른 입력에서 프로그램이 맞게 동작함을 보인다. 그러나 고르지 않은 입력에 대해서는 아무것도 말하지 않는다. 다익스트라의 말로 널리 알려진 대로, 테스트는 버그가 있음을 보일 수는 있어도 없음을 보일 수는 없다. 모든 입력을 다 해 보면 되지 않느냐고 할 수 있지만, 32비트 정수 인자 둘만 받는 함수도 입력이 2⁶⁴가지다. 초당 10억 번 시험해도 약 585년이 걸린다.

입력이 32, 48, 64비트일 때 모든 입력을 초당 10억 번 시험하는 데 걸리는 시간을 로그 막대로 비교한 그림초당 10억 번 시험할 때 모든 입력을 다 해 보는 시간(막대 길이는 로그)32비트int 인자 하나약 4.3초48비트int 하나 + short 하나약 3.3일64비트int 인자 둘약 585년
모든 입력을 시험하는 데 드는 시간. 막대 길이는 로그 눈금이다. 인자가 하나 늘 때마다 시간이 곱으로 늘어난다. 수치는 전수 시험 시간 함수로 계산했다.

동시성은 더 나쁘다. 스레드나 노드 사이의 실행 순서(인터리빙)는 입력과 별개로 폭발하고, 버그는 그중 드문 순서에서만 나타난다. 테스트를 수천 번 돌려도 안 보이던 경쟁 상태가 운영 환경에서 한 달에 한 번 터진다. 형식 검증은 프로그램이나 설계를 수학적 대상으로 보고, 원하는 성질이 모든 입력과 모든 실행 순서에서 성립함을 기계적으로 확인한다. 테스트를 대신한다기보다, 테스트가 닿지 못하는 곳을 맡는다. 테스트의 종류와 전략은 테스트 노트에 있다.

명세: 전조건·후조건·불변식

"맞다"를 따지려면 무엇이 맞는 것인지부터 적어야 한다. 그것이 명세다. 가장 오래된 틀은 Floyd(1967)의 흐름도 검증을 이어 Hoare가 1969년에 제안한 호어 삼중항 {P} C {Q}이다. "전조건 P가 참인 상태에서 C를 실행해 끝나면, 후조건 Q가 참이다." 이 형태는 끝난다는 것까지는 말하지 않아서 부분 정확성이라 하고, 끝남까지 증명하면 완전 정확성이다.

반복문에는 루프 불변식이 열쇠다. 루프에 들어가기 전에 참이고, 한 바퀴 돌 때마다 참으로 유지되는 식이다. 그러면 루프가 끝났을 때 "불변식 ∧ ¬조건"이 참이고, 이것이 후조건을 함의하면 증명이 끝난다. 아래는 1부터 n까지 더하는 루프에 불변식 s = i(i+1)/2 ∧ i ≤ n을 붙인 것이다. 끝나면 i ≤ n과 ¬(i < n)에서 i = n이 나오고, 불변식에 넣으면 후조건이 된다. 끝남은 n − i가 매번 1씩 줄어드는 0 이상의 정수라는 것으로 보인다.

합 루프의 전조건, 불변식, 후조건과 n이 4일 때 반복마다 i, s, 불변식 값을 적은 표{ n ≥ 0 }i := 0; s := 0{ I: s = i(i+1)/2 ∧ i ≤ n }while i < n: i := i + 1 s := s + i{ I ∧ ¬(i < n) ⇒ s = n(n+1)/2 }isi(i+1)/2I조건000참참111참참233참참366참참41010참거짓n = 4: 마지막 줄에서 조건이 거짓이 되어 끝난다
루프 불변식. 왼쪽 색 글씨가 호어 논리의 단언이다. 오른쪽 표는 n = 4로 실제로 돌린 상태로, 매 줄에서 불변식이 참이다. 표 하나가 증명은 아니지만, 불변식을 찾을 때 이렇게 돌려 보며 짐작한다.

어려운 부분은 불변식을 찾는 일이다. 후조건을 조금 약하게 만든 꼴이 대개 답이지만, 늘 기계적으로 찾을 수는 없다. 그래서 Dafny·Frama-C 같은 도구는 사람이 불변식을 적게 하고, 나머지 검증 조건을 만들어 아래에 나올 SMT 솔버에 넘긴다.

같은 틀을 증명 대신 실행 중에 검사하는 가벼운 방법이 계약에 의한 설계(design by contract)다. Bertrand Meyer가 Eiffel 언어와 함께 1986년부터 내놓은 생각으로, 메서드마다 전조건·후조건·클래스 불변식을 적고 실행 중에 단언(assertion)으로 확인한다. 계약 하나는 호어 삼중항과 같은 뜻이지만, 증명하는 대신 어기는 순간 그 자리에서 실패하게 만든다. 보통 개발·테스트 때 켜고 배포판에서는 성능 때문에 끈다.

모델 검사: 상태 공간을 모두 훑기

시스템의 상태가 유한하다면 증명 대신 모두 훑어볼 수 있다. 시스템을 상태와 전이로 적고, 처음 상태에서 도달 가능한 모든 상태를 만들어 성질이 깨지는 곳이 있는지 본다. 깨지는 곳이 있으면 거기까지 가는 반례 경로를 내놓는다. 이것이 모델 검사이고, Clarke·Emerson·Sifakis가 이를 세우고 발전시킨 공로로 2007년 튜링상을 함께 받았다.

두 프로세스의 상호 배제를 깃발 두 개로 지키려는 순진한 프로토콜을 보자. 들어가고 싶으면 상대 깃발을 보고, 내려가 있으면 내 깃발을 올리고 임계 구역에 들어간다. 그럴듯하지만 "보기"와 "올리기"가 따로 일어난다. 너비 우선 탐색으로 상태를 훑으면 상태 16개 가운데 둘이 함께 임계 구역에 있는 상태가 나오고, 가장 짧은 경로는 6걸음이다.

두 프로세스의 위치와 깃발이 반례 경로를 따라 바뀌어 결국 둘 다 임계 구역에 들어가는 단계 그림프로세스 0쉼상대 깃발 확인내 깃발 올림임계 구역프로세스 1쉼상대 깃발 확인내 깃발 올림임계 구역쉼깃발 ▽쉼깃발 ▽경로 0 / 6걸음상대 깃발 확인깃발 ▽쉼깃발 ▽경로 1 / 6걸음내 깃발 올림깃발 ▽쉼깃발 ▽경로 2 / 6걸음내 깃발 올림깃발 ▽상대 깃발 확인깃발 ▽경로 3 / 6걸음내 깃발 올림깃발 ▽내 깃발 올림깃발 ▽경로 4 / 6걸음임계 구역깃발 ▲내 깃발 올림깃발 ▽경로 5 / 6걸음임계 구역깃발 ▲임계 구역깃발 ▲경로 6 / 6걸음
1 / 7

처음 상태. 두 프로세스 모두 쉬고 깃발은 내려가 있다. 검사기는 여기서 너비 우선으로 모든 다음 상태를 만든다.

모델 검사기가 찾은 반례 경로. 확인과 깃발 올리기 사이의 틈에 상대가 끼어들면 둘 다 들어간다. 같은 검사기로 피터슨 알고리즘을 돌리면 상태 20개를 모두 훑고도 위반이 없다.

먼저 깃발을 올리고 차례 변수를 상대에게 양보하는 피터슨 알고리즘은 같은 검사기로 상태 20개를 모두 훑어도 위반이 없다. 이 결론은 테스트와 다르다. 이 모델 안에서는 어떤 실행 순서에서도 깨지지 않는다는 뜻이다. 현실 CPU는 메모리 쓰기 순서를 바꿀 수 있어 그대로 옮기면 깨지는데, 그 이야기는 운영체제 노트의 동기화 절과 이어진다.

대가는 상태 폭발이다. 프로세스나 변수가 하나 늘 때마다 상태 수가 곱으로 늘어난다. 같은 상태를 한 번만 보는 해시, 대칭인 상태를 하나로 묶기, 순서가 상관없는 전이를 하나만 보기(부분 순서 축약), 상태 집합을 논리식으로 한꺼번에 다루는 기호 모델 검사(BDD·SAT 기반) 같은 기법이 이 폭발과 싸운다. 그래도 실무에서는 "서버 3대, 메시지 2개"처럼 작은 모델을 검사한다. 버그 대부분은 작은 반례를 가진다는 경험에 기댄 것이다.

시간 논리: 안전성과 활성

동시성 시스템의 성질은 "어느 시점에"가 아니라 "실행 내내"에 관한 것이 많다. Pnueli가 1977년 프로그램 검증에 들여온 선형 시간 논리(LTL)는 무한히 이어지는 실행 위에서 참·거짓을 따진다. 연산자 넷이면 대부분을 적는다. G p(언제나 p), F p(언젠가 p), X p(다음 상태에서 p), p U q(q가 될 때까지 계속 p, 그리고 q는 반드시 온다).

  • 안전성(safety): "나쁜 일은 결코 일어나지 않는다." G ¬(crit0 ∧ crit1)처럼 적는다. 깨지면 유한한 반례 경로가 있다. 앞 절의 검사가 이것이다.
  • 활성(liveness): "좋은 일은 언젠가 일어난다." G(wait0 → F crit0) — 기다리는 프로세스는 언젠가 들어간다. 유한한 접두부만으로는 깨졌다고 말할 수 없다. 반례는 같은 상태를 영원히 되풀이하는 올가미 모양 실행이다.
상태가 되풀이되는 두 무한 실행에서 상호 배제 안전성과 기아 없음 활성을 판정한 결과안전성G ¬(crit0 ∧ crit1)활성G(wait0 → F crit0)굶는 실행wait0wait0crit1wait0참거짓공정한 실행wait0wait0crit1crit0참참
두 올가미 실행의 판정. 화살표 고리는 그 구간이 영원히 되풀이된다는 뜻이다. 위 실행은 프로세스 1만 계속 들어가 0이 굶는다. 상호 배제는 지키지만 활성은 어긴다. 판정은 LTL 판정 함수가 했다.

활성을 검사할 때는 공정성 가정을 함께 적는다. "할 수 있는 걸음은 언젠가 한다"는 가정이 없으면 스케줄러가 한 프로세스만 영원히 돌리는 실행 때문에 거의 모든 활성 성질이 깨진다. 아래 AWS 사례 표에서 활성을 검사하지 않아 활성 버그를 놓친 경우가 나오는데, 안전성만 보는 검사가 흔한 이유는 활성 검사가 그만큼 비싸고 까다롭기 때문이다.

TLA+와 산업 사례

TLA+는 Leslie Lamport가 만든 명세 언어다. 시스템을 "처음 상태(Init)"와 "한 걸음에 상태가 어떻게 바뀔 수 있는가(Next)"라는 두 논리식으로 적고, 불변식과 시간 논리 성질을 함께 적는다. 코드가 아니라 설계를 적는다는 점이 중요하다. 프로토콜이 서버 몇 대, 메시지 몇 개로 어떻게 엇갈릴 수 있는지를 실행 가능한 수준보다 높게 적고, 모델 검사기 TLC가 작은 모델의 상태를 모두 훑는다. 의사 코드에 가까운 문법의 PlusCal로 쓰면 TLA+로 번역해 준다.

설계를 TLA+ 명세로 옮기고 모델 크기를 정해 TLC로 검사한 뒤, 위반이 없거나 반례 경로가 나오는 흐름설계문서·화이트보드의프로토콜TLA+ 명세Init: 처음 상태Next: 한 걸음의 관계모델 고정서버 3대·메시지 2개처럼 작게 정함TLC 실행도달 가능한모든 상태를 훑음위반 없음이 크기 안에서는 안전반례 경로고쳐서 다시
TLA+로 설계를 검사하는 흐름. 위반이 없다는 것은 그 크기의 모델 안에서만의 결론이다. 반례 경로가 나오면 설계를 고쳐 다시 돌린다.

가장 많이 인용되는 사례는 AWS다. 2014년 9월 자 보고서 「Use of Formal Methods at Amazon Web Services」(Newcombe 외)에 따르면 AWS 엔지니어들은 2011년부터 형식 명세와 모델 검사를 써 왔고, 보고서 시점에 7개 팀이 TLA+를 쓰고 있었다. 신입부터 수석 엔지니어까지 2~3주 만에 혼자 배워 쓸모 있는 결과를 냈다고 한다. 보고서의 표 일부를 옮기면 다음과 같다.

시스템구성 요소명세 줄 수(주석 제외)얻은 것
S3장애 허용 저수준 네트워크 알고리즘804 (PlusCal)버그 2개, 제안된 최적화에서 추가 버그
S3데이터 백그라운드 재분배645 (PlusCal)버그 1개, 첫 수정안에서도 버그 1개
DynamoDB복제·그룹 멤버십 시스템939 (TLA+)버그 3개, 일부는 35걸음짜리 반례
EBS볼륨 관리102 (PlusCal)버그 3개
내부 분산 락 관리자락 없는 자료 구조223 (PlusCal)확신이 늘었지만, 활성을 검사하지 않아 활성 버그를 놓쳤다

DynamoDB 쪽에서 찾은 버그 하나는 특정한 장애와 복구 순서가 다른 처리와 엇갈릴 때 데이터를 잃을 수 있는 것이었고, 그것을 보여 주는 가장 짧은 반례가 고수준 걸음으로 35걸음이었다. 이런 겹친 사건은 드물어 보이지만, 큰 규모에서는 드문 일이 실제로 일어난다는 것이 보고서의 요지다. 분산 합의·복제 프로토콜 자체는 분산 시스템 노트에 있다.

SAT·SMT 솔버

많은 검증 문제는 결국 "이 논리식을 참으로 만드는 값이 있는가"로 바뀐다. 변수가 참·거짓뿐이고 식이 절(리터럴의 ∨)들의 ∧인 꼴(CNF)이면 SAT 문제다. SAT는 NP-완전이라 최악에는 지수 시간이 들 수 있지만, 현대 솔버는 실무에서 나오는 변수 수백만 개짜리 식도 자주 푼다.

기본 알고리즘은 Davis·Logemann·Loveland가 1961년에 내놓은 DPLL(Davis·Putnam의 1960년 방법을 다듬은 것)이다. 변수 하나를 정해 보고(결정), 그 결과 미정 리터럴이 하나만 남은 절이 생기면 그 값을 강제로 정하고(단위 전파), 모든 리터럴이 거짓인 절이 생기면(충돌) 마지막 결정을 뒤집는다. 한쪽 부호로만 나오는 변수를 그 부호로 정하는 순수 리터럴 제거도 있지만 아래 예에서는 쓰이지 않는다.

다섯 개 절의 상태와 네 변수의 할당이 결정, 단위 전파, 충돌, 역추적을 거쳐 바뀌는 단계 그림C1(a ∨ b)C2(¬a ∨ c)C3(¬c ∨ d)C4(¬a ∨ ¬d)C5(¬b ∨ c)미정미정미정미정미정a?b?c?d?시작참단위미정단위미정a참b?c?d?결정참참단위단위참a참b?c참d?단위 전파참참참거짓참a참b?c참d참단위 전파참참참거짓참a참b?c참d참충돌단위참미정참미정a거짓b?c?d?역추적참참미정참단위a거짓b참c?d?단위 전파참참단위참참a거짓b참c참d?단위 전파참참참참참a거짓b참c참d참단위 전파참참참참참a거짓b참c참d참만족할당결정 → 전파 → 충돌이면 되돌림현대 솔버(CDCL)는 충돌 원인을 새 절로 배운다
1 / 10

시작. 아무 변수도 정하지 않았다. 단위 절(미정 리터럴이 하나뿐인 절)이 없으니 결정부터 한다.

DPLL이 작은 식 (a ∨ b) ∧ (¬a ∨ c) ∧ (¬c ∨ d) ∧ (¬a ∨ ¬d) ∧ (¬b ∨ c)를 푸는 과정. 걸음마다 왼쪽은 각 절의 상태, 오른쪽은 변수 할당이다. 걸음은 DPLL 함수가 기록했다.

현대 솔버의 바탕인 CDCL(충돌 기반 절 학습)은 충돌이 나면 그 원인을 새 절로 배워 같은 실수를 다시 하지 않고, 원인이 된 결정까지 한 번에 건너뛰어 되돌아간다. SMT 솔버는 여기에 정수·실수 산술, 비트 벡터, 배열, 자료형 같은 이론을 결합한다. SAT 쪽이 불리언 뼈대를 탐색하고, 이론 쪽이 "x > 3 ∧ x < 2는 모순"처럼 그 할당이 말이 되는지 판정한다. 대표가 Microsoft Research의 Z3(MIT 라이선스, 파이썬·C++·Java 등 바인딩)다.

고전적인 예가 이진 탐색의 mid = (lo + hi) / 2다. 수학으로는 늘 lo와 hi 사이지만, 고정 폭 정수에서는 lo + hi가 넘쳐 음수가 된다. 8비트라면 모든 쌍을 직접 확인할 수 있다.

lo와 hi 평면에서 중간값 계산이 넘치는 영역을 칠한 삼각형과 전수 확인 결과lo = 127127lo →himid = (lo + hi) / 2 를 8비트로 계산틀린 쌍 4,096 / 8,256 (49.6%)첫 반례: lo = 1, hi = 127lo + hi가 넘쳐 mid = -64고친 식: mid = lo + (hi − lo) / 232비트면 쌍이 약 2.3 × 10¹⁸개 — 전수는 불가능,SMT 솔버는 기호로 풀어 반례를 바로 낸다
8비트 부호 있는 정수에서 0 ≤ lo ≤ hi ≤ 127인 모든 쌍을 확인한 결과. 빨간 삼각형(lo + hi > 127)에서 mid가 범위를 벗어난다. 점은 첫 반례다. 수치는 전수 확인 함수로 계산했다.

32비트에서는 전수 확인이 불가능하지만, SMT 솔버에는 같은 질문을 그대로 적으면 된다. 솔버는 비트 벡터 산술을 기호로 다뤄 곧바로 반례를 낸다.

from z3 import BitVecs, Solver

lo, hi = BitVecs('lo hi', 32)   # 32비트 부호 있는 정수
mid = (lo + hi) / 2              # 넘칠 수 있는 덧셈

solver = Solver()
solver.add(lo >= 0, hi >= lo, lo > mid)   # mid가 lo보다 작아지는 경우가 있나?
print(solver.check())            # sat — 있다
print(solver.model())            # 그런 lo, hi 한 쌍

심볼릭 실행 도구(KLEE 등), 경계 모델 검사기(CBMC 등), 프로그램 검증기(Dafny 등)가 모두 이렇게 코드에서 식을 만들어 SMT 솔버에 넘긴다. 문제 크기가 커지면 솔버가 끝나지 않을 수 있다는 점이 한계다. SAT가 NP-완전이라는 것의 뜻은 계산 이론 노트에 있다.

정리 증명기: Rocq·Lean·Isabelle

모델 검사와 솔버는 자동이지만 다룰 수 있는 범위가 정해져 있다. 무한히 많은 상태, 임의 크기의 자료 구조, 수학 정리처럼 더 일반적인 성질은 사람이 증명의 뼈대를 짜고 기계가 한 걸음씩 검사하는 대화형 정리 증명기(증명 보조기)로 다룬다. 대표가 셋이다.

  • Rocq(옛 이름 Coq): 의존 타입 이론 위에 선 증명 보조기. 증명이 곧 프로그램이고 명제가 곧 타입이다.
  • Lean: 같은 계열의 의존 타입 언어이자 증명기로, 일반 프로그래밍 언어로도 쓰인다. 수학 정리를 형식화하는 공동체가 크다.
  • Isabelle: 고차 논리(Isabelle/HOL)를 주로 쓰고, 증명을 사람이 읽는 문서처럼 적는 Isar 언어와 강한 자동화를 갖췄다.
사람이 쓰는 증명, 자동화 층, 작은 커널로 이어지는 정리 증명기의 신뢰 구조사람이 쓰는 것정의 · 정리 문장 · 증명 전술(induction, simp …)자동화 · 전술 엔진 · 라이브러리크고 복잡하지만 틀려도 위험하지 않다커널작은 타입 검사기 — 이것만 믿으면 된다통과하면 정리가 된다 — 거부하면 어디가 틀렸는지 알려 준다
정리 증명기의 신뢰 구조. 전술과 자동화가 아무리 복잡해도 결국 작은 커널이 증명 하나하나를 규칙대로 검사하므로, 믿어야 할 코드가 작다.

Lean 4로 쓴 아주 작은 증명은 이런 모양이다. 자연수 덧셈은 오른쪽 인자에 대한 재귀로 정의돼 있어 n + 0 = n은 계산으로 나오지만 0 + n = n은 귀납법이 필요하다.

-- 모든 자연수 n에 대해 0 + n = n (덧셈은 오른쪽 인자로 정의돼 있어 자명하지 않다)
theorem zero_add' (n : Nat) : 0 + n = n := by
  induction n with
  | zero => rfl           -- 0 + 0 = 0은 계산으로 참
  | succ k ih => omega    -- 귀납 가정 ih: 0 + k = k를 써서 선형 산술로 닫는다

굵직한 성과도 있다. Appel과 Haken이 1976년 컴퓨터를 써서 증명한 4색 정리는, 그 컴퓨터 계산을 믿을 수 있느냐는 논란이 이어지다 2005년 Gonthier와 Werner가 Coq 안에서 증명 전체를 형식화했다. CompCert는 ISO C 2011의 거의 전부를 받아 ARM·PowerPC·RISC-V·x86 코드를 만드는 C 컴파일러로, 만든 기계어가 원본 프로그램의 의미대로 동작한다는 기계 검사 증명이 붙어 있다. 컴파일러가 버그를 끼워 넣을 가능성을 증명으로 없앤 것이다. 마이크로커널 seL4는 고수준 명세에서 바이너리 코드까지 이어지는 기계 검사 증명으로 기능 정확성부터 정보 누출 없음까지를 보였고, 증명이 무엇을 가정하는지(어셈블리 코드, 부트 코드, 기계 인터페이스, 하드웨어, DMA)도 공개한다.

타입 시스템이라는 가벼운 형식 검증

가장 널리 쓰이는 형식 검증은 이미 매일 돌고 있다. 타입 검사다. 정적 타입 언어의 컴파일러는 "이 변수에는 문자열만 들어간다", "이 함수는 정수를 돌려준다" 같은 성질을 모든 실행에 대해 증명한 뒤에야 코드를 받아 준다. 덜 정밀하지만 완전히 자동이고 빠르며, 프로그래머가 적는 타입 표기가 곧 명세다.

타입 검사, 정적 분석, 추상 해석, 모델 검사, 정리 증명을 노력과 보장 축 위에 계단처럼 놓은 그림타입 검사컴파일러가 늘정적 분석린터 · Infer추상 해석Astrée모델 검사TLC · SPIN정리 증명Rocq · Lean자동 · 싸다 · 증명하는 성질이 얕다사람 손 · 비싸다 · 무엇이든 증명
형식 검증의 스펙트럼. 왼쪽은 자동이고 싸지만 보장하는 성질이 얕고, 오른쪽은 사람이 손을 많이 대야 하지만 거의 무엇이든 증명할 수 있다. 실무는 왼쪽을 늘 쓰고, 중요한 부분에만 오른쪽으로 간다.

타입은 갈수록 많은 것을 담는다. 널이 될 수 있는 값을 타입으로 구분하는 언어(Kotlin·Swift)는 널 참조 오류를, Rust의 소유권·빌림 검사는 데이터 경쟁과 해제 후 사용을 컴파일 시간에 막는다. 다른 상태의 값을 서로 다른 타입으로 두어 "검증 안 된 입력"을 "검증된 입력" 자리에 못 넣게 하는 설계도 같은 생각이다. 끝까지 가면 값에 기댄 타입(의존 타입)이 되는데, 그것이 위 정리 증명기들이다. 타입 검사와 추론이 컴파일러 안에서 어떻게 도는지는 컴파일러 노트에 있다.

추상 해석과 정적 분석

실행하지 않고 코드만 보고 성질을 따지는 것이 정적 분석이다. 그 이론적 바탕이 Cousot 부부가 1977년 POPL에서 내놓은 추상 해석이다. 실제 값 대신 값들의 요약(부호, 구간, 짝홀 같은 추상 도메인) 위에서 프로그램을 "실행"한다. 요약은 실제 값을 빠짐없이 덮도록(과대 근사) 잡으므로, 요약에서 오류가 안 보이면 실제로도 없다.

루프가 문제다. 요약을 한 바퀴씩 넓혀 가면 x = 0; while (x < 100) x++;에서 구간이 [0, 0], [0, 1], [0, 2] … 로 100번 넘게 반복해야 멈춘다. 반복 횟수가 정해지지 않은 루프라면 영영 안 멈춘다. 그래서 몇 번 돌린 뒤에도 커지는 끝은 무한대로 확 넓혀 버리고(확대, widening), 멈춘 뒤에 루프 조건으로 다시 좁힌다(축소, narrowing).

반복할 때마다 x의 구간이 넓어지다 확대로 무한대까지 열리고, 축소로 0부터 100까지로 좁혀지는 과정050100+∞처음[0, 0]반복 1[0, 1]반복 2[0, 2]반복 3 (확대)[0, +∞]축소[0, 100]루프 출구[100, 100]확대 없이 한 칸씩 늘리면 101번 반복해야 멈춘다 — 확대를 쓰면 4번
구간 추상 해석. 반복 2번 뒤 위쪽 끝이 계속 커지자 +∞로 넓혀 고정점에 닿고, 루프 조건(x가 100보다 작음)으로 축소해 [0, 100]을 되찾는다. 출구에서는 x = 100이라는 정확한 답이 나온다. 수치는 구간 해석 함수로 계산했다.

이런 분석이 늘 정확할 수는 없다. 라이스 정리에 따르면 프로그램의 의미에 관한 자명하지 않은 성질은 모두 결정 불가능하다. 즉 모든 프로그램에 대해 놓침도 오경보도 없이 끝나는 분석기는 만들 수 없다. 그 근거인 정지 문제와 결정 불가능성은 계산 이론 노트에 있다. 그래서 도구는 어느 쪽을 희생할지 고른다.

실제 버그인지와 경보를 냈는지로 나눈 네 칸과, 건전한 분석과 완전한 분석이 각각 어느 칸을 비우는지실제로 버그버그 아님경보를 냄경보 없음맞는 경보찾았다오경보사람이 확인하느라 지친다놓침건전한 분석은 0맞는 침묵라이스 정리: 의미에 관한 성질을 놓침도 오경보도 없이 늘 끝나며 판정하는 분석은 없다그래서 도구마다 어느 칸을 희생할지 고른다
정적 분석의 네 칸. 건전한(sound) 분석은 왼쪽 아래 놓침을 0으로 만드는 대신 오른쪽 위 오경보를 받아들이고, 버그 찾기 도구는 오경보를 줄이려고 놓침을 허용한다.

건전한 쪽의 대표가 Astrée다. C 프로그램에 런타임 오류(0으로 나누기, 배열 범위 밖 접근, 산술 오버플로 등)가 없음을 증명하는 것을 목표로 하며, 2003년 11월 에어버스 A340 플라이 바이 와이어 시스템의 주 비행 제어 소프트웨어(C 13만 2천 줄)에 런타임 오류가 없음을 증명했다. 2004년부터는 A380의 전기식 비행 제어 코드로 넓혔다. 반대쪽에는 Meta의 Infer처럼 코드 리뷰 단계에서 널 역참조·자원 누수 같은 버그를 빨리 찾아 주는 도구들이 있다. 개발자가 오경보에 지쳐 경보를 무시하기 시작하면 도구가 쓸모없어지므로, 이런 도구는 정밀도를 더 중시한다.

비용과 한계, 어디에 쓰나

형식 검증이 "버그 없음"을 보장한다는 말은 늘 단서를 단다. 증명되는 것은 명세와 구현 사이의 관계다. 명세가 원하는 것을 잘못 적었으면 증명은 틀린 것을 정확히 지킨다. 또 증명은 그 아래 층을 가정한다. 컴파일러, 운영체제, 하드웨어가 맞다는 가정이다. seL4가 가정 목록을 공개하고 CompCert가 컴파일러 층을 메우는 것도 이 틈을 줄이려는 일이다.

요구 사항, 명세, 소스 코드, 기계어, 하드웨어 층 사이에서 증명되는 연결과 가정으로 남는 연결사람이 원하는 것명세가 원하는 것을 제대로 적었나? — 증명할 수 없다, 검토할 뿐형식 명세코드가 명세를 지키나? — 정리 증명·모델 검사소스 코드컴파일러가 의미를 지키나? — CompCert는 증명기계어하드웨어·부트 코드·DMA는 대개 가정으로 남는다하드웨어
증명이 덮는 범위. 실선은 증명으로 이을 수 있는 층, 빨간 점선은 검토나 가정으로 남는 층이다. 맨 위 '원하는 것 ↔ 명세'는 어떤 도구로도 증명할 수 없다.
  • 비용: 정리 증명은 코드보다 증명이 몇 배 길어지기 쉽고, 코드를 바꾸면 증명도 고쳐야 한다. 모델 검사는 자동이지만 모델을 작게 만드는 기술이 필요하다.
  • 규모: 상태 폭발과 솔버 시간 초과 때문에 시스템 전체보다 핵심 프로토콜이나 핵심 모듈을 골라 검증한다.
  • 사람: 명세를 쓰는 능력은 따로 배워야 한다. AWS 보고서는 엔지니어들이 2~3주 만에 익혔다고 하지만, 무엇을 명세할지 고르는 감각은 경험에서 온다.

테스트와 증명 사이에는 속성 기반 테스트(property-based testing)가 있다. 입력 예를 손으로 고르는 대신 "정렬한 결과는 입력과 길이가 같고 오름차순이다"처럼 모든 입력에서 성립해야 할 성질을 적으면, 도구가 무작위 입력을 만들어 그 성질을 깨 보려 하고, 깨는 입력을 찾으면 군더더기를 덜어 작은 반례로 줄여 보여 준다. 1999년 Claessen과 Hughes가 Haskell로 만든 QuickCheck이 시작이고, 파이썬에는 Hypothesis가 있다. 증명은 아니지만 "무엇이 맞는가"를 명세로 적는 습관을 테스트 비용으로 들일 수 있어, 형식 검증으로 가는 첫걸음으로 좋다.

그래서 어디에 쓸지는 "버그 하나의 값"으로 정한다. 비행 제어·철도·의료 기기처럼 버그가 사람을 다치게 하는 곳, 운영체제 커널·컴파일러·암호 라이브러리처럼 그 위의 모든 것이 기대는 곳, 분산 합의·복제처럼 테스트로는 드문 순서를 재현하기 어려운 곳, 하드웨어 설계처럼 한 번 만들면 고칠 수 없는 곳이 대표다. 그 밖의 대부분은 타입 검사와 정적 분석으로 넓게 깔고, 위험한 설계 몇 개만 TLA+나 모델 검사로 확인하는 조합이 가장 흔하다.

핵심 자료

결정 불가능성과 복잡도 이론은 계산 이론 노트, 테스트 전략은 테스트 노트, 형식 검증을 많이 쓰는 분산 프로토콜은 분산 시스템 노트에 있다.

자료종류요지링크
Use of Formal Methods at Amazon Web Services보고서AWS 엔지니어들이 S3·DynamoDB·EBS의 설계를 TLA+로 검사하며 찾은 버그와 배운 점. 산업에서 형식 명세를 설득한 대표 사례.lamport.azurewebsites.net
Learn TLA+튜토리얼PlusCal과 TLA+를 예제로 익히는 무료 안내서. 모델 검사기 TLC로 동시성 버그를 찾는 법부터 시작한다.learntla.com
Software Foundations무료 교재Rocq(옛 Coq)로 논리·함수형 프로그래밍·호어 논리·타입 시스템을 직접 증명하며 배우는 연작.UPenn
Programming Z3안내서SMT 솔버 Z3의 사용법과 내부(이론 결합, 탐색)를 파이썬 예제로 설명한다.z3prover.github.io
CompCert프로젝트생성한 기계어가 원본 C 프로그램의 의미대로 동작함을 기계로 검사한 증명과 함께 내놓는 C 컴파일러.compcert.org
seL4 Verification프로젝트마이크로커널 seL4의 증명이 무엇을 보장하고 무엇을 가정하는지(어셈블리·부트 코드·하드웨어·DMA) 정리한 문서.sel4.systems