IBM은 NASA 위성 제어 시스템을 천 줄당 0.1개 결함이라는 수준으로 출시했다. 당시 업계 평균은 10에서 50 사이였다. 그들이 이를 달성한 것은 더 똑똑한 엔지니어를 고용하거나 더 오래 일해서가 아니다. 개발자가 자신의 코드를 실행하지 못하게 함으로써 달성한 것이다.

이것이 Cleanroom 소프트웨어 공학이며, 1970년대 IBM의 Harlan Mills가 개발했다. 이름은 반도체 제조에서 유래했다. 그곳의 목표는 결함을 만들어내는 조건을 예방하는 것이다. 품질을 나중에 테스트로 집어넣는 것이 아니다. 설계 단계부터 집어넣는 것이다.

문제: 테스트는 결함을 발견하지만, 막지는 못한다

대부분의 소프트웨어 공학은 작성-테스트-수정 루프를 전제로 한다. 코드를 작성하고, 실행하고, 버그를 찾고, 수정한다. 생산적으로 느껴진다. 하지만 이는 수학적으로 결함을 남긴다는 것이 보장되어 있다.

테스트는 버그의 존재만 증명할 수 있고, 부재는 증명할 수 없다. Dijkstra가 1969년에 이렇게 말했다. 만약 코드에 천 가지 가능한 실행 경로가 있고 테스트 스위트가 그중 쉰 개를 커버한다면, 테스트되지 않은 경로는 950개 남아 있다. KLOC당 10~50개에 달하는 업계 평균 결함률은 테스트의 엄밀성 부족이 아니다. 결함을 존재하게 하는 프로세스에서 예측 가능하게 도출된 결과물이다.

Cleanroom은 이를 뒤집는다. 코드를 작성한 뒤 테스트하는 대신, 구축 시점부터 올바른 코드를 작성한다.

Cleanroom이 실제로 어떻게 작동하는가

이 방법에는 함께 작동하는 세 가지 엄격한 실천이 있다.

첫째, 통계적 공정 관리 하의 점진적 개발. 프로젝트는 작은 인크리먼트로 나뉘며, 각각은 명확하게 정의된 기능 부분집합을 추가한다. 각 인크리먼트는 하나의 완전한 단위로서 사양·설계·검증·테스트를 거친다. 핵심은 결함률을 인크리먼트별로 측정하는 것이다. 어떤 인크리먼트가 결함 목표를 초과하면 프로세스는 중단되고, 팀은 개인이 아닌 방법에 무엇이 잘못되었는지를 파악한다.

둘째, box structure를 사용한 함수이론적 설계. 모든 소프트웨어 컴포넌트는 세 단계로 정의된다:

  • Black box: 입력에서 출력으로의 순수한 수학적 함수로 정의된 외부 동작. 상태도, 구현 세부사항도 없다.
  • State box: 내부 상태 전이 함수. 컴포넌트가 어떤 상태를 유지하며, 그것은 어떻게 변하는가?
  • Clear box: 검증된 컴포넌트로부터 구축된 실제 구현.

각 단계는 다음 단계로 넘어가기 전에 상위 단계에 대해 검증된다. State box가 올바르다고 증명될 때까지 Clear box를 작성하지 않는다. Black box가 올바르다고 증명될 때까지 State box를 작성하지 않는다. 이는 느린 것처럼 들린다. 하지만 느리지 않다. 왜냐하면 디버깅을 하지 않기 때문이다. 디버거에서 오후를 보내지도 않는다. 코드는 처음 컴파일될 때부터 작동한다.

셋째――그리고 이것이 사람을 멍하게 만드는 부분이다――개발자에 의한 실행 테스트를 하지 않는다. 코드를 작성한 사람은 그것을 컴파일하거나, 실행하거나, 단위 테스트하지 않는다. 테스트는 별도의 팀이 통계적 방법을 사용해 수행한다. 그들은 소프트웨어를 Black box로 취급하고, 코드 구조가 아닌 사용 프로파일을 기반으로 테스트 케이스를 생성한다.

이 분리는 관료주의적 잔인함이 아니다. 구축에 의한 정확성을 강제하는 핵심 메커니즘이다. 자신의 코드를 “작동하는지 보려고” 실행할 수 없다는 것을 알면, 타이핑하기 전에 모든 경우의 수를 생각할 수밖에 없다. 컴파일러가 실수를 잡아주길 기대하거나, 재빨리 python script.py를 실행해 명백한 버그를 드러내길 기대할 수도 없다. 옳아야 한다.

중요한 수치

IBM 연방 시스템 사업부는 1980년대와 1990년대 여러 프로젝트에서 Cleanroom을 사용했다. 결과는 팀과 언어, 응용 분야를 불문하고 일관되었다.

NASA 위성 제어 시스템: 최종 테스트에서 KLOC당 0.1개 결함. COBOL 청구 시스템: KLOC당 0.3개 결함. Ada 실시간 시스템: KLOC당 0.4개 결함. 당시 업계 평균과 비교하면: 단위 테스트에서 KLOC당 1050개 결함, 납품 시에도 510개가 잔존했다.

Cleanroom 팀은 출시도 더 빨랐다. 당시 업계 데이터는 소프트웨어 공수의 50~70%가 테스트와 디버깅에 소비되었음을 보여준다. Cleanroom 팀은 그 시간을 설계에 쏟았고, 코드는 처음부터 작동했다.

왜 거의 아무도 사용하지 않는가

Cleanroom이 이토록 효과적이라면, 왜 모두가 실천하지 않는가?

한 스프린트에서 “살짝 시험해보는” 것은 불가능하다. 이 방법은 상호 의존적이다. 통계적 공정 관리는 모든 인크리먼트를 측정해야만 작동한다. box structure는 형식 검증을 수행해야만 작동한다. 실행 금지 규칙은 절대적이어야만 작동한다. 부분적 도입은 아무런 이익도 가져오지 않고 오버헤드만 남긴다.

시장도 변했다. 1970~80년대, 소프트웨어는 테이프로 출시되었다. 본 버그를 수정하는 것은 비용이 컸다. 오늘날 우리는 네트워크를 통해 출시하고, 몇 분 안에 패치를 적용할 수 있다. 제로 결함 소프트웨어에 대한 경제적 인센티브는 약해졌다.

우리 대부분은 자신의 코드를 실행하는 것을 좋아한다. 작성하고·실행하고·수정한다는 즉각적인 피드백 루프는 만족감을 준다. Cleanroom은 그 만족감을 문제를 완전히 생각해낼 때까지 미루라고 요구한다. 많은 개발자에게 이것은 쓴 글을 다시 읽지 못한 채 산문을 쓰는 것과 같다.

전체를 수용하지 않고 도입할 수 있는 것

아마도 당신의 회사에서 완전한 Cleanroom을 구현할 수는 없을 것이다. 상사가 이상하게 쳐다볼 것이다. 하지만 일부를 채택해 실제 이익을 얻을 수는 있다.

구현보다 먼저 인터페이스를 작성하라. 본문을 작성하기 전에 입력·출력·전제조건을 정의하라. 함수가 무엇을 하는지뿐 아니라 무엇을 약속하는지를 기술하라.

# precondition: items is a non-empty list of comparable elements
# postcondition: returns the smallest element in items
# raises: ValueError if items is empty
def min_item(items: list) -> any:
    if not items:
        raise ValueError("items must not be empty")
    smallest = items[0]
    for item in items[1:]:
        if item < smallest:
            smallest = item
    return smallest

이것은 사소한 함수이지만, 그 규율은 확장된다. 더 복잡한 예로, 구현하기 전에 state machine을 명시적으로 정의하라.

from enum import Enum, auto

class ConnectionState(Enum):
    DISCONNECTED = auto()
    CONNECTING = auto()
    CONNECTED = auto()
    CLOSING = auto()

# Allowed transitions:
# DISCONNECTED -> CONNECTING (on connect())
# CONNECTING -> CONNECTED (on handshake complete)
# CONNECTING -> DISCONNECTED (on timeout/error)
# CONNECTED -> CLOSING (on close())
# CLOSING -> DISCONNECTED (on ack received)
# Any other transition is illegal and raises StateError

class StateMachine:
    _transitions = {
        ConnectionState.DISCONNECTED: {ConnectionState.CONNECTING},
        ConnectionState.CONNECTING: {ConnectionState.CONNECTED, ConnectionState.DISCONNECTED},
        ConnectionState.CONNECTED: {ConnectionState.CLOSING},
        ConnectionState.CLOSING: {ConnectionState.DISCONNECTED},
    }

    def __init__(self):
        self.state = ConnectionState.DISCONNECTED

    def transition(self, new_state: ConnectionState) -> None:
        if new_state not in self._transitions.get(self.state, set()):
            raise StateError(f"Illegal transition: {self.state.name} -> {new_state.name}")
        self.state = new_state

유효한 전이를 명시적으로 인코딩함으로써, 불법 상태를 표현 불가능하게 만든다. state machine이 이를 막기 때문에 코드는 무효 상태에 들어갈 수 없다. 이것이 소형화된 box structure 철학이다.

다른 누군가에게 코드를 테스트하게 하라. 저작과 검증의 분리는 Cleanroom의 가장 급진적인 아이디어이자, 동시에 가장 이식 가능한 것이다. 자신의 코드를 스스로 테스트할 때, 자신의 가정을 테스트할 뿐이다. 다른 누군가는 당신이 “분명히 일어나지 않을 것”이라고 여겨 고려하지 않았던 케이스를 시도할 것이다.

작업 단위당 결함 밀도를 측정하라. 프로세스의 각 단계에서 얼마나 많은 버그가 빠져나가는지 추적하라. 만약 스프린트가 통합 버그를 안고 일관되게 출시된다면, 문제는 부주의한 개발자가 아니다. 문제는 당신의 프로세스가 통합 버그의 존재를 허용하고 있다는 것이다. 프로세스를 고쳐라.

한계

Cleanroom은 만능 해결책이 아니다. 요구사항이 안정적이고, 정확성이 시장 진출 속도보다 중요한 경우에 가장 효과를 발휘한다. 탐색적 개발이나 급속 프로토타이핑에는 부적합하다. 그곳의 목적은 알려진 사양을 올바르게 구현하는 것이 아니라, 사용자가 무엇을 원하는지 발견하는 것이기 때문이다.

또한 경영진의 지지도 필요하다. Cleanroom을 몰래 할 수는 없다. 통계적 공정 관리는 팀 전체에 걸친 데이터 수집을 필요로 한다. 실행 금지 규칙은 마감일이 다가오고 개발자가 “잠깐 이게 작동하는지 확인하고 싶다”고 생각할 때조차, 경영진이 실제로 집행해야 한다.

결론

IBM의 KLOC당 0.1개 결함은 기적이 아니었다. 결함을 탐지하는 것이 아니라 예방하도록 설계된 프로세스의 산출물이었다. box structure나 통계적 테스트, 개발자 격리 같은 구체적인 실천은 현대 소프트웨어 문화에 이질적으로 느껴질 수 있다. 하지만 근본 원리는 시대를 초월한다: 입력하기 전에 생각하는 것이, 나중에 디버깅하는 것보다 싸다.

Cleanroom을 통째로 도입할 필요는 없다. 하나의 함수부터 시작하라. 본문보다 먼저 계약을 작성하라. 실행하기 전에 동료에게 논리를 검토받아라. 버그의 출처를 추적하고, 그것을 낳는 프로세스를 고쳐라. KLOC당 제로 결함은 아마도 당신의 목표가 아닐 것이다. 하지만 50에서 5로 가는 것은 달성 가능하며, 그 방법은 같다. 먼저 생각하라. 다음에 입력하라. 마지막에 실행하라.