assertion 불안

대부분의 프로덕션 codebase는 두 진영 중 하나에 속한다. A 진영은 assert를 장식용 조미료처럼 다루어 함수가 편집증적인 변호사가 쓴 법률 계약서처럼 읽힐 때까지 한 줄 걸러 뿌린다. B 진영은 assertion을 개발 중에만 쓰는 보조 바퀴로 여겨 빌드 시점에 모두 제거하고, 테스트가 한 번이라도 통과했다면 프로덕션에서도 잘 작동하리라 기대한다.

두 진영 모두 틀렸다. 문제는 assertion을 쓸 것인지가 아니다. assertion이 실제로 무엇을 의미하는지가 문제다.

assertion은 error handling이 아니다. input validation도 아니다. 정중한 제안도 아니다. assertion은 ‘무언가가 불가능하다’는 주장이다. assertion이 발동된다면, 프로그램에 대한 당신의 mental model이 깨진 것이다. 이 구분이 assertion이 어디에 속하는지, 얼마나 많이 써야 하는지를 결정한다.

assertion은 invariant를 위한 것이지 error를 위한 것이 아니다

사용자가 API에 음수 나이를 전달하면, 그것은 error다. error는 예상된다. error는 제대로 된 handling, logging, 사용자 대응 메시지를 받을 자격이 있다. 성공한 것으로 알려진 쿼리 이후 내 계산 결과가 음수의 데이터베이스 row 개수를 낳는다면, 그것은 invariant violation이다. 그런 일은 절대 일어나선 안 된다. assertion이 존재하는 이유가 바로 그것이다.

이것은 너무 당연해 보이지만, 프로덕션 코드를 읽어보면 그렇지 않다. 문자열이 비어있지 않다고 assert한 뒤, 세 줄 뒤에 if (!str)을 검사하고 포맷된 예외를 던지는 함수를 본 적 있다. 개발자는 두 도구를 같은 조건에 사용했는데, 그 이유는 어떤 것이 실제 contract인지 결정하지 않았기 때문이다.

규칙은 이렇다. 조건이 외부 입력에 의해 발생할 수 있다면, 그것은 assertion이 아니다. 자신의 코드의 버그로만 발생할 수 있다면, 그것이 assertion이다.

def process_payment(user_id: str, amount_cents: int) -> Receipt:
    # NOT an assertion. Users or upstream services can send bad data.
    if amount_cents <= 0:
        raise ValueError("amount_cents must be positive")

    # NOT an assertion. The user_id comes from the outside world.
    if not user_id:
        raise ValueError("user_id is required")

    receipt = _charge_card(user_id, amount_cents)

    # THIS is an assertion. If charge_card returned None after
    # succeeding, our understanding of the universe is wrong.
    assert receipt is not None, "charge_card succeeded but returned None"

    # THIS is an assertion. A receipt with zero items after a
    # successful charge means our internal logic is broken.
    assert len(receipt.items) > 0, "receipt has no items after successful charge"

    return receipt

처음 두 개의 검사는 boundary를 지킨다. 마지막 두 개는 시스템의 내 consistency를 지킨다. 이 둘을 섞으면 누가 무엇을 책임져야 하는지에 대한 혼란이 생긴다.

assertion 3개 상한선

단일 함수에 3개 이상의 assertion을 작성하고 있다면, 두 가지 문제 중 하나를 안고 있다. 함수가 너무 많은 일을 하거나, invariant가 너무 모호해 강제할 수 없는 것이다.

12개의 assertion을 가진 함수는 방어적이 아니다. 불확실하다. 작성자는 자신을 호출하는 코드, 자신이 호출하는 코드, 그리고 그 사이를 흐르는 데이터를 믿지 못한다. 그 불확실성은 더 많은 assert 문을 추가하는 것이 아니라 refactoring으로 해결해야 한다.

실제 한계는 개발자가 머릿속에 담을 수 있는 것에서 온다. 함수는 하나의 명확한 contract를 가져야 한다. 그 contract는 소수의 invariant를 내포한다. 안전하다고 느끼기 위해 12개의 assertion이 필요하다면, 함수가 다른 곳에 속한 책임까지 흡수했을 가능성이 크다.

함수를 분할하라. 데이터를 변환하는 부분을 추출하라. 외부 서비스를 호출하는 부분을 추출하라. 추출된 각 함수에 자신만의 소수의 invariant를 부여하라. 함수당 3개의 assertion은 경고등이다. 5개는 펑크 난 타이어다.

프로덕션의 assertion: 켜기 또는 끄기?

언어마다 다른 선택을 한다. Python은 -O 플래그로 실행하면 assert 문을 제거한다. C와 C++ 컴파일러는 release build에서 일상적으로 assertion을 제거한다. JavaScript는 아예 내장 assert가 없다. polyfill을 하거나 프로덕션에서도 활성 상태로 남는 라이브러리를 사용해야 한다.

이것은 진정한 딜레마를 만든다. assertion을 제거하면, 가장 필요한 순간에 안전망을 잃게 된다. 프로덕션에서만 나타나는 버그는 빠르게 실패하는 대신 조용히 데이터를 손상시킨다. assertion을 유지하면, 이론상 불가능하지만 실제로는 치명적이지 않은 조건 때문에 프로덕션 프로세스가 crash 날 위험이 있다.

답은 계속 실행하는 비용에 달려 있다. invariant를 위반하면 다음 연산이 데이터베이스를 손상시키거나 민감한 데이터를 유출한다면, assertion은 프로세스를 crash시켜야 한다. 강제 종료가 침묵 속 침해보다 낫다. invariant 위반이 약간 틀린 log entry나 사소한 UI glitch를 의미한다면, 로그를 남기고 계속 진행하라.

// This should probably crash. Continuing with a null user
// after auth succeeded is a security hole waiting to happen.
assert(user !== null, "auth middleware returned null user after success");

// This should probably not crash. A stale cache timestamp
// is annoying but not dangerous.
if (cache.timestamp > Date.now()) {
  logger.warn("cache timestamp is in the future, ignoring");
}

모든 invariant가 같은 대응을 받을 자격이 있는 것은 아니다. “이건 멈춰야 한다”와 “이상하지만 살아갈 수 있다”의 차이를 구별하는 법을 배워라.

우리가 시도했지만 실패한 것

한 프로젝트 초기에, 우리는 모든 함수의 precondition을 assert하는 것을 시도했다. 모든 인자가 null, type, range, format으로 검사되었다. 결과는 예상 가능했다. 테스트는 훌륭하게 통과했다. 프로덕션에서는 third-party API가 숫자 대신 문자열로 필드를 반환한 첫 순간에 crash가 났다.

문제는 assertion 자체가 아니었다. 문제는 우리가 통제할 수 없는 데이터에 assertion을 걸고, 프로덕션에서 assertion을 활성화한 채 컴파일했다는 것이다. 잘못된 외부 응답은 정제되고 처리되는 대신 우리 프로세스를 죽였다. 우리는 내부적으로는 consistent하지만 외부적으로는 fragile한 시스템을 만들었다.

우리는 boundary와 내부를 분리하는 법을 배웠다. boundary에서, 적극적으로 parse하고 validate하라. 외부의 혼란을 내부의 확실성으로 전환하라. boundary 내부에서는 그 확실성을 정의하는 invariant를 assert하라. assertion은 그대로 남았다. input validation은 명시적인 parsing 함수로 이동했는데, 그 함수들은 throw하는 대신 Result 타입을 반환했다.

실용적인 체크리스트

assertion을 추가하기 전에, 다음 목록을 점검하라:

  1. 외부 입력이 이것을 유발할 수 있는가? 그렇다면 assertion이 아닌 validation을 사용하라.
  2. 이것이 프로덕션에서 발동되면 프로세스를 멈춰야 하는가? 아니라면 대신 warning을 로그하라.
  3. 이 함수에 이미 3개 이상의 assertion이 있는가? 그렇다면 추가하기 전에 refactoring을 고려하라.
  4. 6개월 후에 이 코드를 읽는 사람에게 이 assertion이 여전히 이해될 것인가? 모호한 assertion은 refactor 과정에서 삭제된다. 명확한 것만 살아남는다.

assertion은 안전 도구만큼이나 커뮤니케이션 도구다. assertion은 다음 개발자에게 “이 조건은 설계상 불가능하다”고 말한다. 조건이 실제로 설계상 불가능하지 않다면, assertion은 거짓말을 하고 있는 것이다. 그리고 프로덕션 코드 속 거짓말은 비싸다.

FAQ

함수 인자에 assertion을 걸어야 하나?

호출자 역시 당신의 코드이고, 인자가 외부 입력이 아닌 내 logic의 결과물일 때만 그렇게 하라. Public API 함수는 validate해야 한다. Private helper 함수는 받은 값에 대한 invariant를 assert할 수 있다.

TypeScript는 어떤가? 이미 컴파일 시점에 null을 잡아낸다.

TypeScript의 type system은 강력한 assertion layer지만, runtime에서는 사라진다. 컴파일러가 증명할 수 있는 모든 것에 사용하라. 틈새에는 runtime assertion을 추가하라: API responses, deserialized data, 그리고 type checker를 우회하는 모든 as cast.

assertion은 성능을 해치나?

대부분의 언어에서, 적절한 위치의 assertion은 마이크로초 단위의 비용이다. 수백만 개의 항목을 처리하는 tight loop 내부에서 assertion을 한다면, assertion을 loop 밖으로 옮겨라. 각 요소가 아닌 batch 단위로 invariant를 검사하라.

custom assert 함수를 작성해야 하나?

내장 assertion 메시지가 도움이 되지 않을 때만 그렇게 하라. 실제 배열 길이를 출력하는 custom assertNonEmpty는 맥락 없이 crash 나는 일반적인 assert len(items) > 0보다 유용하다. 작게 유지하라. assertion framework를 만들지 마라.