How Invariant are Invariants? - David Olsen - ACCU 2026

원본 영상: How Invariant are Invariants? - David Olsen - ACCU 2026
채널: ACCU Conference

개요

클래스 불변식은 객체를 믿고 사용할 수 있게 하는 약속이다. 생성자가 올바른 상태를 만들고 공개 멤버 함수가 그 상태를 보존한다면, 사용자는 객체를 사용할 때마다 내부 상태를 확인할 필요가 없다. 그렇다면 생성자에서 값을 검사하고 데이터 멤버를 비공개로 감추는 것만으로 이 약속을 지킬 수 있을까?

David Olsen은 하한과 상한을 저장하는 구간 클래스에서 출발한다. 조건은 low <= high로 단순하다. 하지만 컴파일러가 자동으로 만드는 이동 연산과 복사 대입까지 살펴보면 이 관계는 쉽게 깨질 수 있다. 두 멤버가 각각 유효하더라도, 두 값 사이의 관계까지 유효하다는 보장은 없기 때문이다.

강연은 여러 대응책을 비교한 뒤 ‘유효한 객체’의 의미를 다시 살펴본다. Olsen은 클래스 작성자에게 가능한 한 강한 상태 보장을 제공하라고 권한다. 반면 사용자에게는 이동된 원본이나 예외로 수정이 중단된 객체의 상태를 낙관하지 말라고 조언한다.

불변식은 검사의 책임을 클래스 안으로 옮긴다

사용하는 곳마다 검사할 것인가

첫 예제는 두 경계값을 저장하는 구간 클래스와 이를 사용하는 myClamp 함수다. 이 함수는 std::clamp를 이용해 입력값을 구간 안으로 제한한다.

문제는 하한이 상한보다 큰 구간도 만들 수 있다는 데 있다. Olsen은 이런 경계를 std::clamp에 전달하면 정의되지 않은 동작에 해당한다고 설명한다. 사용 지점에서 매번 검사하고 예외를 던질 수도 있지만, 그러면 구간을 사용하는 모든 함수가 같은 책임을 떠안는다.

그가 제안하는 방향은 구간 클래스 자체가 올바른 경계 관계를 보장하도록 만드는 것이다. 잘못된 객체를 사용하는 순간마다 막기보다, 애초에 잘못된 객체가 만들어지지 않도록 설계한다.

불변식이 줄이는 추론의 부담

Olsen은 클래스 불변식을 외부 사용자 관점에서 항상 참이어야 하는 조건으로 설명한다. 생성자는 그 조건을 확립하고, 공개 멤버 함수는 조건이 성립한다고 가정한 채 시작해 다시 성립하는 상태로 끝나야 한다. 내부에서 객체를 수정하는 동안 일시적으로 조건이 깨질 수는 있지만, 외부에 제어를 돌려줄 때는 복구되어야 한다.

이 약속은 사용자와 구현자 모두의 부담을 줄인다. 사용자는 객체가 정상인지 반복해서 확인하지 않아도 되고, 구현자는 공개 함수의 시작점에서 정상 상태를 가정할 수 있다. 객체의 상태를 매번 증명하는 대신 다른 구성 요소와의 상호작용에 집중할 수 있는 것이다.

생성자에서 low <= high를 보장하기

구간 클래스의 불변식은 두 경계가 같은 경우를 포함한 low <= high다. Olsen은 데이터 멤버를 비공개로 바꾸고 값을 읽는 접근자를 제공한다. 한쪽 경계만 바꿔 관계를 깨뜨릴 수 있는 개별 설정 함수는 두지 않는다.

하한과 상한 사이에 low <= high 관계를 요구하는 구간 클래스의 설계

05:29 - 하한과 상한 사이에 low ⇐ high 관계를 요구하는 구간 클래스의 설계

생성자는 두 멤버를 초기화한 뒤 불변식을 검사한다. 조건이 성립하면 생성이 완료되고, 그렇지 않으면 예외를 던진다. 예외가 생성자 밖으로 빠져나가면 완성된 구간 객체는 만들어지지 않는다.

검사식도 보장하려는 조건에 맞춰야 한다. Olsen은 오류 조건을 high < low가 아니라 !(low <= high)로 표현한다. 부동소수점의 NaN을 고려하면 두 식은 같은 의미가 아니기 때문이다. 익숙한 반대 조건으로 바꾸기보다, 유지하려는 조건 자체를 부정해 검사하는 방식이다.

부동소수점 값을 고려해 유지하려는 조건 자체를 부정하는 검사 방식

07:44 - 부동소수점 값을 고려해 유지하려는 조건 자체를 부정하는 검사 방식

이어 원소 타입에 movable과 totally_ordered 제약을 추가한다. 필요한 이동과 비교 연산을 지원하지 않는 타입은 컴파일 단계에서 거부하도록 한다.

이제 생성자는 불변식을 확립하고 접근자는 상태를 바꾸지 않는다. 그러나 검토는 끝나지 않았다. 직접 작성한 함수 외에도 컴파일러가 만들어 주는 특수 멤버 함수가 있기 때문이다.

이동에서는 목적지보다 원본이 문제다

자동 생성 연산도 계약의 일부다

예제는 특수 멤버 함수를 직접 선언하지 않는 Rule of Zero를 따른다. Olsen은 이 원칙이 불변식 보존까지 자동으로 보장하는지 묻는다.

자동 생성된 이동 생성자는 low와 high를 차례로 이동한다. 목적지가 원본의 값을 넘겨받는다는 전제에서는 새 객체의 경계 관계가 유지된다. 하지만 이동된 원본도 여전히 살아 있다.

구간 클래스는 타입 T의 이동 연산이 각 멤버를 어떤 값으로 남기는지 알 수 없다. 두 멤버가 각각 유효하다는 사실과, 이동 후에도 low <= high가 성립한다는 사실은 별개다.

멤버가 유효해도 관계는 깨질 수 있다

Olsen은 문자열과 정수의 쌍을 경계 타입으로 사용하는 예를 든다. 이동 전에는 문자열 비교로 두 경계의 순서가 결정된다. 하지만 이동 후 원본의 두 문자열이 모두 비게 되면 문자열 부분은 같아지고, 정수 부분이 순서를 결정하게 된다. 그 정수들의 순서가 반대라면 구간의 불변식은 깨진다.

문자열과 정수의 쌍으로 구성된 구간에서 이동 후 불변식이 깨지는 과정

16:17 - 문자열과 정수의 쌍으로 구성된 구간에서 이동 후 불변식이 깨지는 과정

그는 이동된 문자열이 반드시 빈 문자열이 된다고 표준이 보장하는 것은 아니라고 구분한다. 이 예의 핵심은 특정 이동 후 값을 일반화하는 데 있지 않다. 각 멤버가 자기 타입의 유효성을 유지해도, 멤버 사이의 관계는 달라질 수 있다는 점이다. 이동 대입에서도 같은 종류의 문제가 생긴다.

이동 후 상태를 다루는 선택들

Olsen은 하나의 정답 대신 여러 설계가 제공하는 보장과 비용을 비교한다.

이동을 억제하거나 타입을 제한한다

복사 연산을 명시적으로 선언해 자동 이동 연산이 선언되지 않도록 하면, 이동하려던 호출이 복사로 처리될 수 있다. 이는 이동 함수를 = delete로 선언하는 것과 다르다. 삭제된 이동 함수가 선택되면 복사로 대체되지 않고 컴파일 오류가 날 수 있기 때문이다.

복사로 대체하면 이동에 따른 원본 변경을 피할 수 있지만, 복사 가능한 타입을 요구하고 효율을 희생할 수 있다.

이동해도 원본 값이 바뀌지 않는 타입으로 사용 범위를 제한하는 방법도 있다. Olsen은 이 접근이 문제를 줄이는 대신 클래스의 활용 범위도 크게 좁힌다고 지적한다.

사용자 정의 이동으로 원본을 복구한다

이동 연산을 직접 구현해 목적지로 값을 옮긴 뒤 원본을 알려진 상태로 남길 수도 있다. 강연에서는 std::exchange를 이용해 원본의 두 경계에 기본 상태의 값을 남기는 접근을 설명한다.

두 기본값이 같게 비교된다면 경계 관계를 다시 만족시킬 수 있다. 다만 기본 생성이 가능하고, 그렇게 만든 값들이 의도한 관계를 만족한다는 전제가 필요하다.

Olsen은 이런 요구를 클래스 전체에 붙이기보다 실제로 필요한 이동 연산에만 적용하라고 설명한다. 구간을 만들고 읽기만 하는 코드까지 이동 후 복구에 필요한 제약을 떠안을 필요는 없기 때문이다.

값 없는 상태를 계약에 포함한다

또 다른 방법은 값 없는 상태인 valueless state를 명시적으로 허용하는 것이다. Olsen은 이를 오류로 우연히 망가진 상태와 구분한다. 값 없는 상태는 들어가는 경로, 벗어나는 방법, 허용되는 연산이 계약에 포함되어 있다.

구간 클래스가 값 보유 여부를 기록하도록 하고, 이동된 원본을 값 없는 상태로 표시한다. 경계값은 값이 있을 때만 읽을 수 있다. 이때 불변식은 다음과 같이 바뀐다.

low <= high이거나, 객체가 값 없는 상태다.

값 없는 상태를 포함하도록 확장한 구간 클래스의 불변식

사용자는 모든 구간 객체에 읽을 수 있는 경계값이 있다고 가정할 수 없게 된다. 대신 이동 후 상태를 명시적인 계약 안에서 다룰 수 있다. Olsen은 사용 방식을 예측하기 어려운 공개 라이브러리에서 이런 방어적 설계가 적절할 수 있다고 본다.

검사와 사전조건의 한계

이동 후 원본의 불변식을 검사하는 방법은 실제 위반을 발견할 수 있다. 하지만 발견과 복구는 다르다. 검사에서 예외를 던져도 호출자가 이를 처리한 뒤에는 잘못된 원본이 그대로 남을 수 있다.

타입의 이동 연산이 구간의 관계를 깨뜨리지 않아야 한다는 사전조건을 문서화할 수도 있다. 실제 타입을 선택하는 사용자가 그 동작을 판단하도록 책임을 나누는 방식이다. 실행 중 검사를 함께 사용할 수 있지만, 이것도 모든 잘못된 상태를 자동으로 복구하지는 않는다.

Olsen은 각 선택의 비용과 한계가 다르며, 적용 환경과 무관한 최선의 해법은 없다고 평가한다.

복사 대입에서는 예외가 관계를 깨뜨린다

복사 생성과 대입의 차이

복사 생성은 새 객체를 만드는 작업이다. Olsen이 전제하는 정상적인 복사 의미 아래에서는 원본의 경계 관계가 새 객체에도 유지된다. 중간에 예외가 발생해도 새 객체는 완성되지 않고 원본은 그대로 남는다.

반면 복사 대입은 이미 존재하는 객체를 순서대로 수정한다. low를 바꾼 뒤 high를 바꾸는 과정에서 예외가 발생하면, 일부만 변경된 객체가 남을 수 있다.

강연에서 구분하는 예외 보장은 다음과 같다.

보장의미
무예외 보장연산이 예외를 통해 빠져나가지 않는다.
강한 보장예외가 발생하면 관련 객체의 관찰 가능한 상태가 연산 전과 같다.
기본 보장자원 누수가 없고 객체는 유효하지만, 구체적인 값은 정해져 있지 않다.
보장 없음프로그램이 유효하지 않거나 사용할 수 없는 상태에 남을 수 있다.

첫 번째 멤버의 대입이 강한 보장을 제공하고 실패한다면 아무것도 바뀌지 않아 구간도 안전하다. 하지만 두 번째 멤버의 대입이 실패하는 경우는 다르다. high의 대입 자체가 강한 보장을 제공해도 low는 이미 변경되었다. 새 low와 옛 high가 올바른 관계를 이룬다는 보장은 없다.

low가 이미 변경되어 개별 대입의 강한 보장만으로 구간을 보호할 수 없는 상황

각 연산의 강한 보장이 여러 연산으로 구성된 전체 작업의 강한 보장으로 자동 연결되지는 않는다.

실패 가능한 작업을 먼저 수행한다

대응책 하나는 복사 대입이 예외를 던지지 않는 타입만 허용하는 것이다. 이 제약도 클래스 전체보다 해당 연산에만 적용하면 사용 범위를 덜 제한할 수 있다.

복사 대입을 직접 구현하는 방법도 있다. 먼저 원본을 임시 객체에 복사하고, 복사가 성공하면 임시 객체를 목적지로 이동 대입한다. 첫 단계에서 실패하면 목적지는 바뀌지 않는다. 마지막 이동 대입이 예외를 던지지 않는다면 변경을 안전하게 마무리할 수 있다.

그러나 마지막 단계의 무예외 보장은 자동으로 주어지지 않는다. 성능 비용도 고려해야 한다. Olsen은 이동이 언제나 저렴하지는 않으며, 내부 원소를 하나씩 이동해야 하는 타입에서는 복사 후 이동의 비용이 커질 수 있다고 설명한다.

예외 뒤의 상태를 명시한다

값 없는 상태를 사용하는 설계라면 대입 중 예외가 발생했을 때 객체를 그 상태로 표시하고 원래 예외를 다시 던질 수 있다. 오류 자체를 해결하는 대신, 오류가 전파되는 동안 객체를 계약에 맞는 상태로 정리하는 것이다.

반면 예외를 잡은 뒤 불변식을 검사하는 방식은 권하지 않는다. 검사도 예외를 던지면 실제 대입 실패를 설명하던 원래 예외가 다른 예외로 대체될 수 있다. 객체는 복구하지 못하면서 원인 파악에 필요한 정보까지 잃는 셈이다.

복사 대입이 실제 실행에서 예외를 던지지 않아야 한다는 사전조건을 문서화하는 선택도 있다. 이는 연산자가 반드시 noexcept로 선언되어야 한다는 요구와는 다르다. 컴파일 단계의 제한은 줄지만, 계약 위반을 막아 주지는 않는다.

‘유효하지만 미지정인 상태’는 그대로 합성되지 않는다

이동된 원본과 복사 대입 중 예외가 발생한 목적지는 서로 다른 문제처럼 보인다. Olsen은 두 상황의 구조가 같다고 설명한다.

구간의 불변식은 두 하위 객체 사이의 관계다. 하지만 구간 클래스는 이동이나 예외 이후 각 멤버가 어떤 값을 갖는지 알 수 없다. 각 멤버가 자기 타입의 요구사항을 만족해도 둘의 순서는 뒤집힐 수 있다.

개별 하위 객체의 유효성이 복합 객체의 유효성으로 자동 연결되지 않는다는 논점

46:01 - 개별 하위 객체의 유효성이 복합 객체의 유효성으로 자동 연결되지 않는다는 논점

두 하위 객체가 각각 유효하지만 값이 정해져 있지 않은 상태라고 해서, 이들을 포함하는 객체도 유효한 것은 아니다.

문제는 개별 멤버의 손상보다 멤버 사이의 관계에 있다. 앞서 살펴본 설계들은 이 불확실한 상태를 배제하거나, 알려진 상태로 바꾸거나, 값 없는 상태로 계약에 포함하거나, 사전조건으로 책임을 나누는 서로 다른 답이다.

객체의 유효성을 두 수준으로 나누기

완전히 유효한 상태와 재대입 가능한 상태

Olsen은 불변식이 깨진 객체라도 안전하게 파괴하거나 새 값을 부여할 수는 있다는 점에 주목한다. 그는 이를 두 수준으로 구분한다.

완전히 유효한 상태 대신 파괴와 재대입이 가능한 상태를 요구하는 최소 예외 보장

49:21 - 완전히 유효한 상태 대신 파괴와 재대입이 가능한 상태를 요구하는 최소 예외 보장

  • 완전히 유효한 상태(fully valid): 모든 불변식이 성립하고 객체가 문서화된 대로 동작한다.
  • 재대입 가능한 상태(valid for assignment): 안전하게 파괴하거나 새 값을 부여할 수 있지만, 그 밖의 연산에는 사용할 수 있다고 가정하지 않는다.

valid for assignment는 Olsen이 이 강연에서 사용하는 명칭이다. 여기서 새 값을 부여한다는 것은 대입 연산자 호출에만 한정되지 않는다. 현재 값에 의존하지 않고 객체를 알려진 상태로 만드는 동작도 포함한다.

이 상태는 명시적인 값 없는 상태와도 다르다. 상태를 조회하는 함수가 없을 수 있으므로, 사용자가 프로그램의 제어 흐름을 통해 어떤 객체가 영향을 받았는지 알아야 한다.

기본 보장보다 약한 ‘최소 보장’

이 구분에 따라 Olsen은 기본 보장과 보장 없음 사이에 *최소 보장(minimal guarantee)*을 놓는다.

최소 보장도 자원 누수가 없어야 한다. 다만 모든 객체가 완전히 유효할 것을 요구하는 대신, 적어도 안전하게 파괴하고 새 값을 부여할 수 있어야 한다.

그가 제시하는 지침은 최소 보장을 반드시 제공하고, 가능하면 더 강한 보장을 제공한 뒤 문서화하라는 것이다. 다른 코드의 보장이 문서화되어 있지 않다면 최소 보장만 있다고 가정하자고 권한다.

이는 불변식의 중요성을 낮추자는 주장이 아니다. 모든 관계를 유지하기 어려운 상황에서도 반드시 지켜야 할 상태의 하한선을 분명히 하자는 제안이다.

사용 환경에 따라 달라지는 설계

Olsen은 공개 라이브러리와 내부 프로젝트에서 서로 다른 선택이 가능하다고 설명한다.

공개 라이브러리는 사용 방식을 예측하기 어렵다. 따라서 값 없는 상태와 사용자 정의 연산을 결합해 다양한 실패 경로를 다루는 방안을 검토한다. 원본을 알려진 상태로 남길 수 있으면 그렇게 하고, 그 보장이 어렵다면 값 없는 상태를 활용하는 식이다.

다만 그가 제시한 복합 구현은 그대로 가져다 쓸 완성 코드가 아니라 설계 아이디어의 예다. Olsen은 자신도 구현의 결함을 발견했다고 밝히며 이 점을 강조한다.

반대로 모든 사용처를 추적할 수 있는 내부 프로젝트에서는 더 단순하게 시작할 수 있다고 본다. 불변식과 연산의 사전조건을 문서화하고, 클래스 작성자와 사용자가 협의하며 필요한 보장을 보강하는 방식이다. 어떤 상태를 코드로 방어하고 어떤 조건을 계약으로 둘지는 사용 환경과 함께 판단해야 한다.

작성자는 강하게 보장하고, 사용자는 보수적으로 가정한다

클래스 작성자에게 Olsen은 이동된 원본을 최소한 파괴·재대입 가능한 상태로 남기라고 권한다. 가능하다면 모든 불변식이 성립하도록 하고, 기본 상태로 돌려놓기 쉽다면 그렇게 하라는 것이다. 약한 유효성은 불변식 유지가 어려울 때의 후퇴 지점이다.

사용자에게는 더 보수적인 습관을 제시한다. 이동된 원본은 새 값을 부여하기 전까지 사용하지 말라.

이동된 원본은 새 값을 부여하기 전까지 사용하지 말라는 권고

예외에도 같은 관점이 적용된다. 작성자는 수정 중이던 객체를 최소한 안전하게 파괴하고 재대입할 수 있는 상태로 남겨야 한다. 가능하다면 기본 보장이나 강한 보장을 제공하고 문서화해야 한다.

사용자는 예외로 수정이 중단된 객체를 곧바로 재사용하지 않아야 한다. 강한 보장이 문서화되어 있다면 이전 상태가 유지되었다고 판단할 수 있지만, 보장이 불명확하다면 새 값을 부여하기 전까지 사용하지 않는 편을 권한다. 여기서 대상은 예외 발생 당시 존재하던 모든 객체가 아니라, 해당 작업에서 수정 중이던 객체다.

강연이 강조하는 것은 불변식을 포기하는 태도가 아니다. 불변식은 복잡한 코드를 이해하는 데 필수적이므로, 생성 시점뿐 아니라 이동과 실패 이후에도 어떤 약속이 남는지 정확히 설계해야 한다는 것이다.

출처

How Invariant are Invariants? - David Olsen - ACCU 2026 — ACCU Conference