시작 · 04 / 35

개념 개요

TLA+의 가치를 납득시키기 위해, TLA+가 어떻게 유용한지, 그리고 프로그래밍 언어와 무엇이 다른지 이야기해 보자.

은행의 송금 서비스를 만든다고 상상해 보자. 사용자는 다른 사용자에게 송금할 수 있다. 요구사항으로, 사용자의 계좌를 초과 인출(overdraft)하게 만드는, 즉 잔액을 0달러 아래로 떨어뜨리는 송금은 일절 허용하지 않는다. 개략적으로 보면 코드는 다음과 같을 것이다.

def transfer(from, to, amount)
  if (from.balance >= amount) # guard
    from.balance -= amount;   # withdraw
    to.balance += amount;     # deposit

이 코드는 요구사항을 충족한다. 가진 돈보다 많이 송금하려고 하면 가드(guard)가 막아 준다.

이제 두 가지 변경을 생각해 보자.

  1. 사용자는 한 번에 둘 이상의 송금을 시작할 수 있다.

  2. 송금 단계는 원자적이지 않다(nonatomic). 한 송금이 진행 중인 동안 다른 송금이 시작되고 (어쩌면) 끝날 수도 있다.

어느 변경도 그것만으로는 문제를 일으키지 않는다. 하지만 둘이 함께 있으면 경쟁 조건(race condition)이 생길 수 있다.

  1. 앨리스의 계좌에 6달러가 있고, 앨리스가 밥에게 송금을 두 번 한다. 송금 X는 3달러, 송금 Y는 4달러다.

  2. Guard(X)가 실행된다. 3 < 6이므로 Withdraw(X)로 넘어간다.

  3. Withdraw(X)가 일어나기 전에 Guard(Y)가 실행된다. 4 < 6이므로 Withdraw(Y)로 넘어간다.

  4. 두 인출이 모두 실행되어 앨리스의 계좌에서 7달러가 빠져나가고, 잔액은 -1이 된다.

Withdraw(X)가 Guard(Y)보다 먼저 일어나면 아무 문제가 없다. 송금 Y가 그냥 실패할 뿐이다. 이런 경쟁 조건은 본질적으로 드물다. 대부분의 경우 프로그램은 예상대로 동작하고 우리가 정한 속성(property)을 지킨다. 버그가 생기는 것은 이벤트가 아주 특정한 순서로 일어날 때뿐이다. 동시성(concurrency) 오류를 찾기가 그토록 어려운 이유가 바로 이것이다.

고치기 어려운 이유도 마찬가지다. 버그를 고치려고 세 번째 기능, 이를테면 적절한 위치에 락(lock)을 추가했다고 해 보자. 문제가 사라진 것은 정말 해결했기 때문일까, 아니면 더 드물게 만들었을 뿐일까? 설계가 실제로 어떤 결과를 낳는지 탐색할 수 없다면, 무언가를 해결했다고 보장할 수 없다.

그렇다면 TLA+의 목적은 이런 설계 문제를 프로그램으로 탐색하는 것이다. 우리가 원하는 것은 도구에 시스템과 요구사항을 건네면 그 요구사항이 깨질 수 있는지 없는지를 도구가 알려 주는 것이다. 깨질 수 있다면 설계를 바꿔야 한다는 것을 알게 된다. 깨질 수 없다면 우리가 옳다고 더 확신할 수 있다.

구조

TLA+의 개념적 틀은 세 부분으로 이루어져 있다.

첫째, 시스템과 그 시스템이 할 수 있는 일을 기술해야 한다. 이것을 명세(specification), 줄여서 스펙이라고 한다. 우리의 설계는 이런 모습일 것이다.

  • 계좌들의 집합(set)이 있다. 각 계좌에는 잔액을 나타내는 숫자가 하나 있다.

  • 어느 계좌든 다른 어느 계좌로든 얼마든지 송금을 시도할 수 있다.

  • 송금은 먼저 잔액이 충분한지 확인한다. 충분하면 그 금액을 첫 번째 계좌에서 빼서 두 번째 계좌에 더한다.

  • 송금은 원자적이지 않다. 여러 송금이 동시에 일어날 수 있다.

명세에는 “행동(behavior)”들, 즉 서로 구별되는 가능한 실행들의 집합이 있다. 스펙이 올바르려면 모든 행동이 우리의 시스템 요구사항, 즉 속성을 전부 만족해야 한다. “어떤 계좌도 초과 인출될 수 없다”가 속성의 한 예이며, 스펙의 어떤 행동에 계좌 잔액이 음수인 상태(state)가 하나라도 있으면 이 속성은 위반된다. 다른 가능한 행동들에 초과 인출이 전혀 없다는 것은 상관없다. 우리는 드문 설계 오류를 찾고 있으므로 위반 하나면 충분하다.

노트

“어떤 계좌도 초과 인출될 수 없다”는 불변식(invariant) 속성, 즉 모든 행동의 모든 상태 하나하나에서 참이어야 하는 속성이다. 이 밖에도 라이브니스(liveness) 속성과 액션(action) 속성처럼 더 고급 속성들이 있는데, 때가 되면 다룬다.

스펙과 속성을 작성했으면 이것을 “모델 체커(model checker)”에 넣는다. 모델 체커는 스펙을 받아 가능한 모든 행동을 생성하고, 그 행동들이 모두 우리의 속성을 전부 만족하는지 확인한다. 만족하지 않는 행동이 하나라도 있으면, 그 위반을 재현하는 방법을 보여 주는 “에러 트레이스(error trace)”를 돌려준다. TLA+용 모델 체커는 몇 가지가 있지만, 가장 널리 쓰이는 것은 툴박스에 번들로 들어 있는 TLC다. 따로 말하지 않는 한, 내가 모델 체커라고 하면 TLC를 가리킨다.

하지만 모든 가능한 행동을 검사할 수는 없다. 사실 행동은 무한히 많다. 시스템에 계좌와 송금을 얼마든지 더 추가할 수 있기 때문이다. 그래서 대신 “계좌 세 개에 각 계좌 최대 10달러, 송금 두 건에 각 송금 최대 10달러인 모든 행동”처럼 특정 제약 아래의 모든 행동을 검사한다. 이런 런타임 매개변수들의 집합을, 그 밖에 우리가 하는 모든 모델 체커 설정과 함께 모델(model)이라고 부른다.

노트

따라서 모델이 통과했다고 해서 스펙이 올바르다고 보장되지는 않는다. 더 큰 매개변수에서만 나타나는 오류가 있을지도 모른다. 하지만 경험적으로, 명세 분야에서 우리는 대부분의 오류가 아주 작은 범위에서 나타난다는 것을 알게 되었다. 워커 3개로 동작하는 시스템이라면 아마 워커 25개로도 동작할 것이다.

스펙

그렇다면 실제로는 이 모든 것이 어떤 모습일까? 송금 스펙을 먼저 매개변수를 하드코딩한 형태로, 그다음 모델에서 매개변수를 정할 수 있는 형태로 보여 주겠다.

---- MODULE wire ----
EXTENDS TLC, Integers

People == {"alice", "bob"}
Money == 1..10
NumTransfers == 2

(* --algorithm wire
variables
  acct \in [People -> Money];

define
  NoOverdrafts ==
    \A p \in People:
      acct[p] >= 0
end define;

process wire \in 1..NumTransfers
variable
  amnt \in 1..5;
  from \in People;
  to \in People
begin
  Check:
    if acct[from] >= amnt then
      Withdraw:
        acct[from] := acct[from] - amnt;
      Deposit:
        acct[to] := acct[to] + amnt;
    end if;
end process;
end algorithm; *)

====
(실패) spec

이 모든 것이 문법적으로 어떻게 동작하는지는 책의 나머지 부분에서 다룬다. 지금은 TLA+가 코드와 다르게 하는 여러 부분에 주의를 환기하고 싶을 뿐이다.

  • 정의에는 ==를 쓴다. 미안, 규칙은 내가 정한 게 아니다

  • People과 Money는 집합, 즉 중복 없고 순서 없는 값들의 모음이다. 프로그래밍 언어는 주로 배열과 키-값 맵(각각 시퀀스(sequence)와 구조체(structure)에 해당)을 쓰지만, 명세에서는 집합이 훨씬 더 근본적이다.

  • [People -> Money]도 집합이다(이 경우에는 함수 집합(function set)). 이것은 사람에게 금액을 배정하는 가능한 모든 방식을 나타낸다. 앨리스는 5달러에 밥은 1달러, 앨리스는 10달러에 밥은 6달러, 이런 식이다.

  • 변수 acct는 고정된 값이 아니라, [People -> Money]의 원소 하나하나에 대응하는 서로 다른 100개의 값 중 하나다. 이것을 모델 체킹하면, TLC는 이 100개의 가능한 초기값 하나하나에서 출발하는 모든 가능한 행동을 탐색한다.

  • NoOverdrafts는 한정자(quantifier)다. 모든 계좌가 >= 0이면 참이고, 그렇지 않으면 거짓이다. 파이썬이라면 대략 all([acct[p] >= 0 for p in People])에 해당할 것이다. 한정자는 TLA+의 극히 강력한 기능으로, 아주 복잡한 속성도 쉽게 작성할 수 있게 해 준다.

  • wire 프로세스(process)가 둘 이상 동시에 실행된다. NumTransfers == 2이면 스펙에 프로세스가 두 개 있다. 하지만 정말 원한다면 프로세스를 열 개, 백 개, 천 개로도 둘 수 있으며, 제약은 우리의 인내심과 RAM뿐이다.

  • 알고리즘의 각 스텝(step)은 별개의 레이블(label)에 속한다. 레이블은 무엇이 원자적으로 일어나고 무엇에 다른 프로세스가 끼어들 수 있는지를 결정한다. 이렇게 해서 경쟁 조건을 표현할 수 있다.

모델

설계가 준비되면 몇 가지 요구사항에 대해 모델 체킹할 수 있다. 모델을 만들고 NoOverdrafts가 불변식이라고 지정하면 된다. 그러면 모델을 실행할 때 시스템이 전개될 수 있는 가능한 모든 방식을 검사한다. 그 방식 중 하나라도 NoOverdrafts가 거짓인 상태에 이르면, 모델 체커가 에러를 낸다.

노트

직접 실행해 보고 싶다면 환경 설정을 보라.

우리는 송금 두 건으로 검사했다. 하지만 송금 네 건으로 검사하고 싶다면 어떨까? TLA+에서는 설계를 아주 쉽게 바꿀 수 있다. 어떤 값이든 매개변수화한 다음, 모델마다 다른 값으로 검사하게 하면 된다.

 ---- MODULE wire ----
 EXTENDS TLC, Integers
-
-People == {"alice", "bob"}
-Money == 1..10
-NumTransfers == 2
+CONSTANTS People, Money, NumTransfers
 
 (* --algorithm wire
 variables
(실패) spec

이제 불변식은 같되 동시 송금 수가 다른 모델들을 따로 만들 수 있다. 그러면 송금이 한 건일 때는 올바르게 동작하지만 두 건일 때는 그렇지 않다는 것을 확인할 수 있다.

논의

여기서 소개하지 않은 개념도 몇 가지 있다: 시간 속성(temporal property), 공정성(fairness), 스터터 불변성(stutter-invariance) 등. 이것들은 모두 나중에 다룬다. 그래도 TLA+를 배우기로 마음먹는다면 실제로 그것으로 무엇을 할 수 있게 될지, 이 정도면 감이 오기를 바란다. 계속할 생각이 있다면 핵심 과정과 환경 설정을 살펴보라.