SQLite 에서 assert() 사용

SQLite 에서 assert() 사용

SQLite는 표준 C의 assert(X) 매크로에 더해 NEVER(X), ALWAYS(X), testcase(X) 같은 assert()계 매크로를 추가로 사용해요. 이 글에서는 각 매크로가 어떤 뜻을 갖고, 빌드 유형에 따라 어떻게 다르게 동작하는지 설명해 드릴게요.

출처: The Use Of assert() In SQLite

본문

1. SQLite 의 Assert() 와 그와 유사한 매크로들

assert(X) 매크로는 표준 C의 일부로, assert.h 헤더 파일에 들어 있어요. SQLite는 assert()와 유사한 매크로 세 개를 더 추가하는데, 바로 NEVER(X), ALWAYS(X), testcase(X)예요.

  • assert(X) → assert(X) 문은 조건 X가 항상 참임을 나타내요. 다시 말해 X는 불변식(invariant)이에요. assert(X) 매크로는 반환값이 없다는 점에서 프로시저처럼 동작해요.
  • ALWAYS(X) → ALWAYS(X) 함수는 개발자가 아는 한 조건 X가 항상 참임을 나타내지만, X가 참이라는 증명이 없거나, 증명이 복잡하고 오류가 발생하기 쉬우며, 미래에 바뀔 가능성이 높은 구현 세부사항에 의존하는 경우를 뜻해요. ALWAYS(X)는 불리언 값 X를 반환하는 함수처럼 동작하며, "if" 문의 조건부 안에서 쓰도록 설계되었어요.
  • NEVER(X) → NEVER(X) 함수는 조건 X가 결코 참이 아님을 나타내요. ALWAYS(X) 함수의 반대(negative)에 해당해요.
  • testcase(X) → testcase(X) 문은 X가 때때로 참이고 때때로 거짓임을 나타내요. 다시 말해 testcase(X)는 X가 분명히 불변식이 아님을 뜻해요. SQLite는 100% MC/DC 테스트를 사용하므로, testcase(X) 매크로가 있다는 것은 X가 참 또는 거짓이 모두 가능할 뿐 아니라, 이를 증명하는 테스트 케이스가 존재한다는 뜻이에요.

SQLite 버전 3.22.0 (2018-01-22)에는 assert() 매크로 5290개, testcase() 매크로 839개, ALWAYS() 매크로 88개, NEVER() 매크로 63개가 포함되어 있어요.

1.1. assert() 의 철학

SQLite에서 assert(X)가 있다는 것은 개발자들이 X가 항상 참이라는 증명을 갖고 있음을 의미해요. 읽는 사람은 X가 참임을 믿고 코드를 추론할 수 있어요. assert(X)는 X의 진실성에 대한 강한 주장이에요. 의심의 여지가 없어요.

ALWAYS(X)와 NEVER(X) 매크로는 X의 진실성에 대한 더 약한 주장이에요. ALWAYS(X)나 NEVER(X)가 있다는 것은 개발자들이 X가 항상 또는 결코 참이라고 믿지만, 증명이 없거나, 증명이 복잡하고 오류가 발생하기 쉬우며, 바뀔 것 같아 보이는 시스템의 다른 측면에 의존한다는 뜻이에요.

다른 시스템들은 때때로 SQLite에서 ALWAYS(X)나 NEVER(X)를 쓰는 것과 비슷한 방식으로 assert(X)를 사용하기도 해요. 개발자들이 X가 항상 참이라고 완전히 믿지 않는다는 암묵적인 인정으로 assert(X)를 추가하기도 하죠. 우리는 이런 assert(X) 사용은 잘못이며, C에 assert(X)가 있는 본래 의도와 목적을 위반한다고 생각해요. assert(X)는 실수를 방지하기 위한 안전망이나 탑로프처럼 여겨져서는 안 돼요. assert(X)는 방어적 심층(defense-in-depth)에도 적합하지 않아요. 그런 경우에는 ALWAYS(X)나 NEVER(X) 매크로나 그와 유사한 것을 사용해야 해요. 왜냐하면 프로그래머의 추론이 틀렸음이 드러났을 때 실제로 문제를 처리할 코드가 ALWAYS(X)나 NEVER(X) 뒤에 이어지기 때문이에요. ALWAYS(X)나 NEVER(X) 뒤에 오는 코드는 테스트되지 않으므로, "return" 문처럼 검토만으로 쉽게 검증할 수 있는 매우 단순한 것이어야 해요.

assert()가 오용될 수 있고 실제로 흔히 오용되기 때문에, 일부 프로그래밍 언어 이론가와 설계자들은 이것을 불리하게 여겨요. 예를 들어 Go 프로그래밍 언어 설계자들은 의도적으로 내장 assert()를 생략했어요. 그들은 assert() 오용으로 인한 피해가 언어 내장으로 포함함으로써 얻는 이점보다 크다고 생각해요. SQLite 개발자들은 여기에 동의하지 않아요. 사실 이 글의 원래 목적은 assert()가 해롭다는 흔한 생각에 반박하기 위한 것이에요. 우리 경험상 assert()가 없다면 SQLite를 개발·테스트·유지보수하는 것이 훨씬 더 어려웠을 거예요.

1.2. 빌드 유형에 따른 서로 다른 동작

SQLite 소프트웨어를 검증하는 데는 세 가지 별도의 빌드가 사용돼요.

  1. 기능 테스트(functionality testing) 빌드는 소스 코드를 검증하는 데 쓰여요.
  2. 커버리지 테스트(coverage testing) 빌드는 테스트 스위트가 100% MC/DC를 제공하는지 확인하기 위해 테스트 스위트를 검증하는 데 쓰여요.
  3. 릴리스 빌드(release)는 생성된 기계어 코드를 검증하는 데 쓰여요.

모든 테스트는 세 빌드 모두에서 같은 답을 줘야 해요. 자세한 내용은 "How SQLite Is Tested" 문서를 참고해 주세요.

다양한 assert()계 매크로들은 SQLite 빌드 방식에 따라 다르게 동작해요.

Functionality Testing Coverage Testing Release
assert(X) abort() if X is false no-op no-op
ALWAYS(X) abort() if X is false always true pass through the value X
NEVER(X) abort() if X is true always false pass through the value X
testcase(X) no-op do some harmless work if X is true no-op

표준 C에서 assert(X)의 기본 동작은 릴리스 빌드에서도 활성화되는 것이에요. 이것은 합리적인 기본값이에요. 하지만 SQLite 코드베이스는 성능에 민감한 영역에 assert() 문이 많아요. assert(X)를 켜두면 SQLite가 약 세 배 느리게 실행돼요. 또한 SQLite는 전달된 그대로의 구성에서 100% MC/DC를 제공하려고 노력하는데, assert(X) 문이 활성화되면 이는 분명히 불가능해져요. 이런 이유로 SQLite에서 assert(X)는 릴리스 빌드에서 no-op이에요.

ALWAYS(X)와 NEVER(X) 매크로는 기능 테스트 동안 assert(X)처럼 동작해요. X의 값이 기대와 다르면 개발자가 즉시 통보받고 싶어 하기 때문이에요. 하지만 배포용으로는 ALWAYS(X)와 NEVER(X)는 단순한 pass-through 매크로로, 방어적 심층(defense-in-depth)을 제공해요. 커버리지 테스트에서는 ALWAYS(X)와 NEVER(X)가 하드코딩된 불리언 값이 되어 도달 불가능한 기계어 코드가 생성되지 않게 해요.

testcase(X) 매크로는 보통 no-op이지만, 커버리지 테스트 빌드에서는 X가 참인 테스트 케이스와 거짓인 테스트 케이스가 모두 존재함을 확인하기 위해, 적어도 하나의 분기를 포함하는 약간의 추가 코드를 생성해요.

2. 예제 (Examples)

assert() 문은 종종 내부 함수와 메서드의 전제 조건(pre-condition)을 검증하는 데 사용돼요. 예: https://sqlite.org/src/artifact/c1e97e4c6f?ln=1048. 이것은 헤더 주석에 전제 조건을 적어 두는 것보다 낫다고 여겨져요. assert()가 실제로 실행되기 때문이에요. SQLite처럼 철저히 테스트된 프로그램에서는 읽는 사람이, SQLite에 대해 실행된 수억 개의 테스트 케이스 모두에서 전제 조건이 참임을 안다고 확신할 수 있어요. assert()가 이를 검증했기 때문이죠. 반대로 헤더 주석의 텍스트 전제 조건은 테스트되지 않아요. 코드를 작성할 당시에는 참이었을지 몰라도, 지금도 참이라고 누가 보장할 수 있을까요?

때때로 SQLite는 컴파일 타임에 평가할 수 있는 assert() 문을 사용해요. https://sqlite.org/src/artifact/c1e97e4c6f?ln=2130-2138의 코드를 살펴보세요. 네 개의 assert() 문이 컴파일 타임 상수의 값을 검증해서, 읽는 사람이 별도의 헤더 파일에서 상수 값을 찾아볼 필요 없이 뒤따르는 if-문의 타당성을 빠르게 확인할 수 있어요.

때때로 컴파일 타임 assert() 문은 SQLite가 올바르게 컴파일되었는지 검증하는 데 사용돼요. 예를 들어 https://sqlite.org/src/artifact/c1e97e4c6f?ln=157의 코드는 대상 아키텍처에 대해 SQLITE_PTRSIZE 전처리기 매크로가 올바르게 설정되었는지 검증해요.

CORRUPT_DB 매크로는 많은 assert() 문에서 사용돼요. 기능 테스트 빌드에서 CORRUPT_DB는, 데이터베이스 파일이 손상을 포함할 수 있는 경우 참인 전역 변수를 참조해요. 이 변수는 기본적으로 참인데, 우리는 보통 데이터베이스가 손상인지 아닌지 알지 못하기 때문이에요. 하지만 잘 형성되었다고 알려진 데이터베이스로 작업하는 테스트 동안에는 그 전역 변수를 거짓으로 설정할 수 있어요. 그러면 CORRUPT_DB 매크로를 https://sqlite.org/src/artifact/18a53540aa3?ln=1679-1680에서 볼 수 있는 것처럼 assert() 문에 사용할 수 있어요. 그 assert()들은 일관된 데이터베이스 파일에는 참이지만, 데이터베이스 파일이 손상된 경우에는 거짓일 수 있는 루틴의 전제 조건을 지정해요. 이런 종류의 조건에 대한 지식은 코드 블록을 단독으로 이해하려는 읽는 사람에게 매우 유용해요.

ALWAYS(X)와 NEVER(X) 함수는 개발자들이 X의 값이 항상 또는 결코 참이라고 믿음에도 불구하고 테스트가 항상 발생하기를 원하는 곳에서 사용돼요. 예를 들어 sqlite3BtreeCloseCursor() 루틴은 모든 커서의 연결 리스트에서 닫는 커서를 반드시 제거해야 해요. 커서가 리스트에 있음을 알고 있으므로 루프가 "break" 문으로 종료되어야 하지만, 다른 코드 부분의 오류가 연결 리스트를 손상시킨 경우 리스트 끝을 지나치게 실행되는 것을 막기 위해 https://sqlite.org/src/artifact/18a53540aa3?ln=4371에서 ALWAYS(X) 테스트를 사용하는 것이 편리해요.

ALWAYS(X)나 NEVER(X)는 때때로 다른 코드 부분이 미묘한 방식으로 수정되면 바뀔 수 있는 전제 조건을 검증해요. https://sqlite.org/src/artifact/18a53540aa3?ln=5512-5516에는 sqlite3BtreeRowCountEst() 함수의 제한된 사용 범위 때문에만 참인 두 전제 조건에 대한 테스트가 있어요. SQLite의 향후 개선은 sqlite3BtreeRowCountEst()를 이 전제 조건이 더 이상 유지되지 않는 새로운 방식으로 사용할 수도 있는데, 그런 상황이 발생하면 NEVER() 매크로가 개발자에게 그 사실을 신속하게 알려줄 거예요. 하지만 어떤 이유로든 릴리스 빌드에서 전제 조건이 충족되지 않더라도, 프로그램은 여전히 정상적으로 동작하고 정의되지 않은 메모리 접근을 하지 않아요.

testcase() 매크로는 종종 부등식 비교의 경계 케이스가 확인되는지 검증하는 데 사용돼요. 예를 들어 https://sqlite.org/src/artifact/18a53540aa3?ln=5766을 참고해 주세요. 이런 종류의 검사는 off-by-one 오류를 방지하는 데 도움이 돼요.

더 알아보기 (Learn more)