
모든 프로그램은 결국 거대한 상태 머신이다. 문제는, 이 머신이 '도메인에 존재해서는 안 될 잘못된 상태마저 메모리상에 기꺼이 표현해 준다는 데 있다. 그렇다면 우리는 이 상태 공간을 어떻게 통제해야 하는가?
사료에 대해
초기 프로그래밍 언어사의 계보를 한 번에 참조할 만한 믿을 만한 글을 찾기 어려웠다. 글이 참고한 조사 가운데 하나는 자기 결론을 "A Very Shaky Summary"라고 적어두었다.
논문은 남아 있어도 누가 누구에게서 영향을 받았는지는 대개 추정이다.
읽기 위해 필요한 배경지식
1. 상태 공간 = 경우의 수
프로그램이 가질 수 있는 모든 상태는 결국 경우의 수
예를 들어, 쇼핑몰의 결제 상태를 생각해보자.
소위, 옛날 방식으로는 이렇게 한다
(물론 여전히 자주 쓰이지만, 요즘 유행하는 코드 스타일이라기에는 거리가 조금 있다)
bool is_paid; // 결제됐나?
bool is_refunded; // 환불됐나?
bool is_failed; // 실패했나?이 방식의 문제는 불가능한 상태가 수학적으로 존재한다는 것이다.
is_paid == true그리고is_refunded == true- 결제됐으면서 환불됐다? (모순)is_failed == true그리고is_paid == true- 실패했으면서 성공했다? (모순)
불리언 플래그가 3개면 경우의 수는 2^3 = 8이다. 하지만 실제로 도메인에서 허용되는 상태는 4개뿐이다.
Pending(대기),Paid(결제됨),Failed(실패),Refunded(환불됨)
프로그램은 8개 중 허용된 4개뿐 아니라, 허용하지 않는 나머지 4개의 조합에도 빠질 수 있고, 그게 바로 버그이다.
2. 합 타입(Sum Type): "여러 경우 중 하나다"
매카시가 1961년에 사용한 구성은 쇼핑몰의 상태를 수학적으로 표현하는 데 그대로 쓸 수 있는데,
PaymentState는 다음 중 하나이다.
PaymentState = Pending + Paid + Failed + Refunded여기서 +는 "또는"이라는 뜻이다.
(수학적으로는 '서로 겹치지 않는 합'이라고 부른다.)
이렇게 하면 경우의 수가 정확히 4개로 제한된다. Paid이면서 Refunded인 상태는 애초에 표현할 수 없다. 닫힌 합 타입을 지원하는 언어에서는 타입 검사기가 그런 값을 만들지 못하게 막을 수 있다. 닫힌 합과 망라성 검사를 지원하는 언어에서는 컴파일러가 모든 경우를 처리했는지 검사할 수 있고, 타입에 존재하지 않는 상태 조합은 처음부터 만들 수 없다.
3. 곱 타입(Product Type): "이것과 저것이 함께 있다"
반대로, 사용자 정보처럼 여러 조각이 항상 함께 있어야 하는 경우를 생각해보자
User = Id × Name × Age여기서 ×는 "그리고"라는 뜻이다.
사용자는 반드시 Id와 Name과 Age를 모두 가져야 한다.
하나만 빠져도 User가 아니다.
이것을 '곱'이라고 부르는 이유는 경우의 수가 곱셈이 되기 때문이다.
Id가 1,000가지,Name이 1,000,000가지,Age가 100가지라면가능한
User의 조합은1,000 × 1,000,000 × 100가지이다.
4. 합과 곱을 섞어 재귀적으로 쌓아 올리기
합과 곱, 이 두 연산으로 재귀적인 구조를 만들 수 있다는 것이 핵심이다.
예를 들어, 리스트(목록)를 보자
S = 1 + (A × S)이것을 사람 말로 풀면:
리스트 S는 빈 리스트(1) 이거나, 원소 A 하나와 나머지 리스트 S의 곱이다.
즉, 리스트는 '비어 있거나' 또는 '머리와 꼬리'로 정의된다.
빈 리스트의 경우 = `1` (`Nil` 하나)
List<A> = Nil + Cons(A × List<A>)
[1, 2, 3] = Cons(1, Cons(2, Cons(3, Nil)))이 정의 하나로 모든 길이의 리스트를 표현할 수 있다.
이것이 바로 오늘날 함수형 언어에서 리스트를 정의하는 표준 방식이고, 많은 자료구조가 이 패턴을 따른다.
5. 왜 이게 그렇게 중요한가?
이 두 가지(합과 곱)와 빈 타입(0), 단위 타입(1),그리고 재귀를 조합하면, 많은 유한 생성자 기반 재귀 데이터 타입을 표현할 수 있기때문이다.
불리언 =
True + False(합)튜플 =
A × B(곱)옵셔널 =
None + Some(A)(합)트리 =
Leaf + (Node × Tree × Tree)(재귀적 합과 곱)
그리고 이 수학적 모델이 중요한 이유는 타입 검사기가 상태 공간의 일부를 검사할 수 있기 때문이다. 닫힌 합과 망라성 검사를 지원하는 언어에서는 빠진 경우를 찾아낼 수 있고, 타입에 정의되지 않은 상태 조합은 처음부터 만들 수 없다.
요약하면, 서로 배타적인 상태를 표현해야 한다면 여러 불리언을 나열하기보다 합 타입으로 상태 자체를 모델링하는 것이 잘못된 상태를 표현 불가능하게 만드는 첫걸음이다.
1961년, 상태 공간에 대수(Algebra)를 가져오다.
우리가 타입(Type)이라고 부르는 것의 본질은 결국 '허용된 경우의 수'다. 개발자들이 수십 개의 불리언(Boolean) 플래그를 조합하며 런타임 에러와 사투를 벌이기 훨씬 전인, 1961년 존 매카시는 「A Basis for a Mathematical Theory of Computation」에서 새로운 집합을 만드는 연산으로 데카르트 곱 (Atimes B)과 서로 겹치지 않는 합 (Aoplus B)을 사용했다. 오늘날의 용어로 보면 곱 타입과 합 타입에 매우 가까운 구성이다. 물론 매카시는 이들을 타입이라고 부르지 않았고, 현대의 algebraic data type이라는 이름으로 묶지는 않았지만 대개 많은 ADT 프로그래밍 글을 보면 시작점을 이곳으로 잡는다..1
어쨌건, 곱은 이것과 저것이 함께 있다는 뜻이고, 합은 여러 경우 가운데 하나다라는 뜻이다.
User = Id × Name × Age
PaymentState = Pending + Paid + Failed + Refunded매카시는 여기서 더 나아간 것을 알 수 있는데,
빈 집합 (0),
원소 하나짜리 집합 (1),
합과 곱의 분배법칙, 재귀적으로 정의되는 데이터 공간까지 다뤘는데,
예를 들어
는 빈 시퀀스이거나, (A) 하나와 다시 (S)가 이어지는 시퀀스를 나타낸다. 오늘날의 시선으로 보면 합·곱·단위 타입·빈 타입·재귀만으로 일반적인 재귀적 ADT 상당수를 구성할 수 있다는 생각과 그대로 이어진다.
수학자들에게 이것은 단순한 집합의 연산이었지만, 프로그래머에게 이 수식은 '프로그램이 가질 수 있는 모든 상태의 경우의 수(State Space)'를 수학적으로 엄밀하게 계산하고 통제할 수 있다는 뜻이었다. (적어도 이론상으로는 그랬다는 뜻이다. 현실에서는 2026년에도 여전히 NullPointerException과 싸우고 있지만.)
이 추상적인 데이터 공간의 개념을 프로그래밍 언어의 데이터 타입으로 끌어내린 것이 3년 뒤인 1964년이다.
1964년 매카시는 「Definition of new data types in ALGOL x」에서 cartesian과 union이라는 데이터 타입을 실제 프로그래밍 언어에 넣는 방안을 제안했다. 이후 ALGOL 68은 STRUCT와 UNION이라는 구성 수단을 제공했다. 여기서는 이를 하나의 직선적인 계보라기보다, 수학적 데이터 공간의 구성이 언어의 데이터 모델로 옮겨가던 흐름으로 보는 편으로 해석하는 것이 안전할 것이다. 2
상태 공간을 세어 보기
합과 곱의 재미있는 점은 단순한 비유로 끝나지 않는다는 것이다. 유한 타입에서는 경우의 수에 그대로 산술을 적용할 수 있다.
합 타입, OR: 'A이거나 B이다'. 열거형(Enum)이나 Union 타입이 여기에 해당한다. 두 타입을 합치면 경우의 수는 덧셈으로 늘어난다.
(다만, Enum은 payload 없는 합의 간단한 형태이고, Union은 각 경우가 서로 다른 데이터를 가질 수도 있다.)
곱 타입, AND : 'A와 B가 함께 있다'. 우리가 흔히 작성하는 class나 struct다. 필드를 하나 나열할 때마다 전체 경우의 수는 곱셈으로 폭발한다.
함수 타입 : 'A를 넣으면 B 하나가 나온다.' 입력 A가 하나 늘어날 때마다 그 입력에 대해 가능한 B 가운데 하나를 다시 골라야 한다. 그래서 유한 타입에서 가능한 함수의 수는 개다.
그리고 이 수학적 산술은 프로그래밍 현실로 넘어오면 곧바로 데이터 모델의 품질 문제이자 버그의 온상이 된다. 아래의 전형적인 결제(Payment) 클래스를 보자.
public sealed class Payment
{
public bool IsPending { get; set; }
public bool IsPaid { get; set; }
public bool IsFailed { get; set; }
public bool IsRefunded { get; set; }
public string? TransactionId { get; set; }
public string? FailureReason { get; set; }
}불리언 필드 네 개만으로도
가지 조합이 생긴다.
여기에 TransactionId와 FailureReason 두 nullable 필드가 존재하는지 아닌지만 세어도 다시
가지가 곱해진다.
따라서 값의 실제 내용은 전부 무시하고, 각 필드가 어떤 상태에 있는지만 세어도 이 클래스는
가지 모양을 표현할 수 있다.
그런데 실제 도메인에서 허용한다고 생각해 볼 결제 상태는 다음 네 가지뿐이다.
Pending —
TransactionId없음,FailureReason없음Paid —
TransactionId필수,FailureReason없음Failed —
TransactionId없음,FailureReason필수Refunded —
TransactionId필수,FailureReason없음
즉 64가지 모양 가운데 우리가 원한 것은 네 가지뿐이다.
나머지 60가지는 프로그램으로 표현할 수는 있지만 도메인에는 존재해서는 안 된다.
만약 '결제가 실패했는데 환불이 완료된' 상태의 화면을 캡처한 QA가 당신의 자리로 찾아온다면, 수학적 다원우주(Multiverse)의 존재를 설명하는 것 외에는 변명할 방법이 없기 때문이다.
IsPaid = true
IsFailed = true
TransactionId = null
IsPending = true
IsRefunded = true
FailureReason = "card declined"타입만 놓고 보면 둘 다 아무 문제가 없다. 컴파일러는 이 조합이 결제 시스템에서 말이 되는지 알지 못한다. 결국 객체를 사용하는 쪽에서 매번 다시 검사해야 한다.
"그러면 대체 문제가 무엇일까? 데이터가 아니라, 데이터가 가질 수 있도록 활짝 열어 둔 '상태 공간' 자체에 있다.
우리는 프로그래머에게 '결제가 대기 중이면서 동시에 환불될 수 있는' 무한한 자유를 허락했다. 물론 물리학과 출신인 나에게는 이런 상태의 중첩이 그리 낯설진 않지만, 소프트웨어에서는 존재해서는 안되는 상태이다. 소프트웨어 업계의 오랜 진리가 증명하듯, 프로그래머에게 너무 넓은 상태 공간과 무한한 자유를 쥐여주면 그들은 가장 먼저 그 자유를 활용해 자기 자신의 발등을 쏘아 버린다.
같은 상태를 합 타입으로 다시 표현해 보자. 이번엔 C#대신에 TypeScript의 판별 공용체로 쓰면 다음처럼 만들 수 있다.
type Payment =
| {
readonly kind: "pending";
}
| {
readonly kind: "paid";
readonly transactionId: string;
}
| {
readonly kind: "failed";
readonly failureReason: string;
}
| {
readonly kind: "refunded";
readonly transactionId: string;
};이번에는 구조 자체가 다른 것을 알 수 있다.
transactionId는 paid와 refunded일 때만 존재하고, failureReason은 failed일 때만 존재한다. pending에는 둘 다 없다.
따라서 TypeScript의 판별 공용체는 별도의 검증 규칙을 매번 작성하는 대신, kind와 필수 payload의 관계를 타입 검사기가 추적하게 한다. 다만 TypeScript는 구조적 타입 시스템이므로 객체에 다른 variant의 추가 필드가 물리적으로 존재하는 것까지 금지하는 exact union은 아니다. 핵심적인 보장은 각 kind에서 요구되는 payload와 타입 안전한 narrowing이다.
Paid인데 TransactionId가 없음
Failed인데 FailureReason이 없음
kind가 동시에 "paid"와 "failed"임불리언 모델에서는 먼저 넓은 공간을 만든 뒤 잘못된 부분을 검증 코드로 잘라낸다.
합 타입에서는 처음부터 네 갈래만 만든다.(다분히 효율적이라고 할 수 있다)
Pending
Paid(TransactionId)
Failed(FailureReason)
Refunded(TransactionId)물론 ADT로 바꾼다고 실제 값의 개수가 네 개가 되는 것은 아니지만,
Paid 하나만 해도 TransactionId로 어떤 문자열을 허용하느냐에 따라 수많은 값이 존재하고, 결제 시각이나 금액 같은 필드를 더 넣으면 그 값의 수는 다시 곱해진다.
여기서 줄이려는 것은 값 전체의 개수가 아니라 상태의 모양이라고 할 수 있다.
Boolean 모델 유효 모양 4 / 표현 가능한 모양 64 = 1/16
개념적인 닫힌 ADT 모델 유효 모양 4 / 표현 가능한 모양 4 = 1좋은 데이터 모델은 이 비율을 가능한 한 1에 가깝게 만드는 것이라고 할 수 있다.
표현 가능한 상태와 도메인이 허용하는 상태가 같아질수록, 그 차이를 메우기 위해 작성해야 하는 방어 코드도 줄어든다.
표현 가능한 상태 = 유효한 상태
가 된다면 적어도 상태 조합이 유효한지를 반복해서 검사하는 코드는 둘 이유가 없어진다.
결국 이 글이 다루는 것도 이 문제다.
합과 곱이라는 단순한 수학적 구성으로 상태 공간을 조립할 수 있다는 생각은 오래전에 등장했다. 그런데 이렇게 단순해 보이는 생각이 주류 범용 프로그래밍 언어의 일상적인 문법으로 들어오는 데에는 수십 년이 걸렸다.
왜 그랬을까?
1970년대, 태그를 누가 책임지는가
수학에서 A + B의 값은 A에서 온 값인지 B에서 온 값인지가 구분된다. 단순히 같은 값을 한곳에 모아 놓는 집합의 합집합과는 다르다고 할 수 있다.
컴퓨터에서 이런 구분을 구현하려면 값과 함께 지금 어떤 경우인지를 나타내는 정보가 필요하다. 이것이 태그(tag), 또는 판별자(discriminant)다.
예를 들어 다음과 같은 타입이 있다고 하자.
PaymentState
= Paid(TransactionId)
+ Failed(ErrorCode)개념적으로는 다음처럼 생각할 수 있다.
tag = Paid
value = TransactionId("A123")이게 무슨뜻인가? 언어가 태그와 payload의 관계를 타입으로 묶어 두었다면, Paid로 판별된 분기에서는 payload를 TransactionId로만 다룰 수 있다. Paid라는 값의 구조 안에는 ErrorCode라는 필드가 존재하지 않으므로, 성공한 결제에 에러 값을 욱여넣는 실수는 물리적으로 불가능해진다.
또는
tag = Failed
value = ErrorCode(500)태그가 Failed일 경우, 시스템은 value를 오직 '에러 코드'로만 읽어들인다. 결제가 실패했으므로 Failed variant에는 처음부터 TransactionId를 둘 자리가 정의되어 있지 않다라고 보아야한다. 앞서 곱 타입에서 보았던 "결제가 실패했는데 결제 번호는 남아있는" 기괴한 상태는 이 구조에서 인스턴스화조차 불가능하다.
메모리 표현을 단순화하면 이런 모습이다.
태그가 Paid라면 payload를 TransactionId로 읽고, Failed라면 ErrorCode로 읽는다.
다만 이것은 개념적인 표현이다. 실제 컴파일러가 항상 별도의 태그 필드를 메모리에 저장한다는 뜻은 아니다. 값의 비트 패턴이나 사용되지 않는 표현을 이용해 태그를 생략하는 최적화도 가능하다. 중요한 것은 물리적으로 몇 바이트를 쓰느냐가 아니라 어느 variant가 현재 유효한지를 프로그램과 타입 시스템이 알 수 있느냐다.
호어는 1972년 「Notes on Data Structuring」에서 discriminated union의 값이 어느 구성 타입에서 왔는지를 나타내는 tag field를 함께 가진다고 설명했다. 3
그럼 이제 문제가 선명할 것이다. 이 태그를 대체 누가 관리할 것인가?
물론 어느 쪽이든 장단점은 있다. 프로그래머에게 맡기면 런타임에 100% 사고가 날 것이고, 컴파일러에게 맡기면 컴파일 타임에 100% 스트레스가 발생한다는 점이다. 결국 우리는 어떤 종류의 고통을 선택할지 결정해야만 한다.
그런면에서 각각의 프로그래밍 언어들이 보여준 선택이 흥미롭다.
Pascal은 프로그래머에게 맡겼다
Pascal에는 variant record가 있었다.
예를 들어 하나의 record가 원인지 사각형인지에 따라 서로 다른 필드를 가지게 만들 수 있었다.
type
ShapeKind = (Circle, Rectangle);
Shape = record
case Kind: ShapeKind of
Circle:
(Radius: Real);
Rectangle:
(Width, Height: Real);
end;여기서 Kind가 태그다.
Kind = Circle
→ Radius가 의미 있음
Kind = Rectangle
→ Width, Height가 의미 있음그런데 Pascal은 variant record의 tag field 자체를 생략하는 형태도 허용했다. 즉 현재 어느 variant가 유효한지 값 안에서 확인할 수 없는 표현도 가능했다. Wirth는 이후 Pascal을 평가한 글에서도 1973년 개정판에서 variant record의 tag field가 optional이었다고 언급한다.
Pascal의 원래 언어 정의와 variant record는 Jensen과 Wirth의 『PASCAL User Manual and Report』에서 확인할 수 있다.4
이 설계에서는 어느 variant가 현재 의미 있는지를 프로그램이 올바르게 관리해야 했는데, 잘못된 variant의 필드를 읽었다고 해서 반드시 프로그램이 즉시 멈추는 것도 아니었지만, 이 지점에서 태그가 단순한 부가 정보가 아니라 타입 안전성을 누가 책임지는가의 문제가 된다.
프로그래머를 믿어라(Trust the programmer)
Pascal과 C는 닮은 점이 있다. 같은 저장 공간을 여러 타입의 값이 공유할 수 있지만, 지금 그중 어느 값이 유효한지를 언어가 끝까지 추적해 주지는 않는다.
Dennis Ritchie는 자신의 글 「The Development of the C Language」에서 ALGOL 68의 union과 cast가 C언어에 영향을 주었다고 회고했다.
Algol 68's concept of unions and casts also had an influence that appeared later.5
프로그래머를 믿으라고 하면서, union은 여러 타입이 같은 저장 공간을 쓰게 해 주지만, 지금 어느 멤버가 의미 있는지는 알려 주지 않는다.
예를 들어 정수와 실수를 하나의 union에 넣었다면, 메모리만 보고 현재 값이 정수인지 실수인지 알 수 없다. 결국 프로그램이 별도의 enum 같은 값을 두고 직접 기억해야 한다.
쉽게 말하면 C는 상자만 준다.
여기에는 정수를 넣어도 되고 실수를 넣어도 된다. 단, 지금 무엇을 넣었는지는 네가 기억해라.

이건 마치 어린왕자에게 상자를 그려주며 "양이 여기 안에 있다"고 말하는 것과 비슷하다.
차이가 있다면, 어린왕자는 상자 속 양을 순수하게 상상했지만, C 프로그래머는 상자 속에 자기가 뭘 넣었는지 끝까지 혼자 책임져야 한다는 것 정도다. 어린왕자는 행복했을지 모르지만, 유지보수하는 개발자는 그렇지 않다.
그래서 C에서 흔히 보이는 enum + union 조합은 사실상 프로그래머가 손으로 만든 tagged union이다.
문제는 두 값을 따로 관리한다는 데 있다. 태그에는 Integer라고 적어 놓고 실제 union에는 실수를 넣는 것도 가능하다. 쉽게 비유하자면 파일 확장자는 ZIP인데 확장자만 .jpg라고 붙여 놓은 것과 비슷하다.
컴파일러는 이 위장을 잡아내지 못한다. 아니, 굳이 잡아낼 필요가 없다. C언어에는 "프로그래머를 믿어라(Trust the programmer)"라는 아주 훌륭한 시스템 면책 조항이 있기 때문이다. 그러한 잘못은 그저 자유를 감당하지 못한 나약한 인간의 몫으로 깔끔하게 전가된다.
결국 문제는 다시 처음으로 돌아온다.
태그와 실제 값이 일치한다는 것을, 인간을 제외하고 대체 누가 보장할 것인가?
Hoare는 이 둘을 붙여서 생각했다
1972년 C. A. R. Hoare의 「Notes on Data Structuring」에는 아예 The Discriminated Union이라는 절이 있다.
핵심은 어렵지 않은데, 여러 형태 가운데 하나의 값을 저장한다면, 그 값이 어느 형태인지 나타내는 판별자도 같이 있어야 한다는 것이다.
즉 값 하나를 단순히 payload만으로 보는 것이 아니라,
어느 경우인가 + 그 경우가 가진 값
으로 본다.
태그를 빼 버리면 현재 메모리를 어떻게 읽어야 하는지 다른 정보에 의존해야 한다. 그리고 그 외부 정보가 틀리면 프로그램도 같이 틀린다는 것이다.6
중요한 변화는 여기서부터라고 할 수 있는데,
Pascal이나 C에서는 이 관계를 상당 부분 프로그래머의 규율에 맡겼다면, 이후 언어들은 태그와 값의 관계를 언어가 더 많이 알고 있도록 만드는 방향으로 바뀌었다.
CLU는 태그와 값을 한 덩어리로 만들었다
Barbara Liskov와 CLU 연구진은 oneof라는 기능을 만들었다.
CLU의 oneof는 이름 그대로 여러 경우 가운데 하나를 나타낸다. 중요한 것은 태그와 payload가 서로 떨어져 있지 않다는 점이다.
정수인 경우에는 정수 값이 따라오고, 실수인 경우에는 실수 값이 따라온다. 어느 경우인지 확인하면서 동시에 그 경우에 맞는 값을 꺼낸다.
CLU에서는 이를 tagcase라는 연산으로 처리했다.
여기서 이전의 C 방식과 차이가 선명해진다.
C에서는 대체로 프로그래머가 이렇게 생각해야 한다.
kind가 Integer인지 먼저 확인하고, 맞으면 union의 integer 멤버를 읽어야지.
CLU에서는 이 두 작업이 언어의 타입 연산 안으로 들어간다.
C 언어의 union 방식에서는 프로그래머가 kind를 먼저 확인하고, 그에 맞는 멤버를 수동으로 읽어야 했다면, 이는 철저히 인간의 규율에 의존하는 불안정한 구조로, 확인과 추출이 분리되어 있기 때문에, 태그와 실제 멤버가 어긋나도 컴파일러가 막지 못하고 잘못된 값 해석이나 논리적 상태 불일치로 이어질 수 있다.
반면 CLU의 tagcase는 어느 경우인지 검사하는 제어 흐름과 그 타입의 값을 추출하는 데이터 흐름을 하나로 통합했다. CLU는 payload를 태그와 무관하게 직접 꺼내지 못하게 하고, tagcase 안에서 태그를 확인한 경우에만 그 태그에 대응하는 타입의 값을 지역 변수로 바인딩하도록 했다고 할 수 있다.
어느 경우인지 확인하는 일과, 그 경우에 맞는 타입의 값을 얻는 일이 분리되지 않는다.
Liskov 등의 1977년 논문도 oneof를 discriminated union으로 설명하며, 이를 개념적으로 태그와 값의 쌍으로 정의한다.7
CLU에는 cluster도 있었다. 이것은 이름 때문에 조금 헷갈릴 수 있지만,oneof가 값이 어떤 경우들로 이루어지는지를 다룬다면, cluster는 타입의 내부 구현을 외부에서 보이지 않게 감추는 장치라고 이해하는 것이 편하다.
그래서 오늘날 ADT라는 약어에는 서로 다른 두 말이 들어 있다.
Algebraic Data Type은 값의 가능한 형태를 다루고, Abstract Data Type은 내부 표현을 어디까지 공개할지를 다룬다.
좀 더 요약하자면 대수적 데이터 타입의 합 타입 측면을 CLU의 oneof가 보여준다면이 '상태 공간에 허용된 값의 형태'를 제어한다라고 할 수 있다.
추상 자료형(Abstract Data Type, cluster)은 '그 값들이 외부와 상호작용하는 경계(Boundary)'를 통제한다라고 이해하면 된다. 대수적 데이터 타입을 모듈 안에 숨겨 추상 자료형으로 제공할 수도 있다.
Ada는 잘못된 태그 접근을 검사한다.
Ada에서는 이 관계를 더 직접적으로 언어가 관리한다.
Ada의 variant record에는 discriminant가 있다. 이것이 현재 record가 어느 variant인지를 결정한다.
예를 들어 Printer인 장치에는 프린터에 필요한 필드가 있고, Disk인 장치에는 디스크에 필요한 필드가 있다. 현재 discriminant가 Printer인데 디스크용 필드를 읽으려고 하면 Ada는 이를 정상적인 접근으로 취급하지 않는다.
필요한 경우 실행 시점에 discriminant check가 수행되고, 맞지 않으면 Constraint_Error가 발생한다.8
여기서 중요한 흐름은
프로그래머가 기억해야 했던 규칙을 언어가 자기 규칙으로 가져오기 시작했다는 것이다.
C에서는 “내가 지금 어떤 값을 넣었지?”를 사람이 기억해야 했다.
Ada에서는 적어도 그 관계의 일부를 언어가 알고 검사한다.
물론 언어가 더 많은 것을 검사할수록 프로그래머가 지켜야 할 규칙도 늘어나지만 어쩌면 더 안전하다고 할 수 있다.
런타임에서 잘못된 메모리를 읽으며 고생할 것인가, 컴파일러와 타입 시스템을 설득하며 고생할 것인가. 결국 어디서 고통받느냐의 차이다. 하지만 현대 시스템의 실패 대부분은 결국 인간을 믿는데서 시작한다는게 안타까운 점이다.
TypeScript에서는 절반씩 나눠 맡는다
사실 이 글을 쓴 이유는 타입스크립트 때문인데, 재미있는 것은 이 문제가 지금도 끝나지 않았다는 점이다.
"어느 경우인지 누가 기록하고, 그 기록과 실제 값이 일치한다는 것을 누가 증명할 것인가?"
1970년대부터 이어진 이 질문에 대해, 프로그래밍 언어의 역사는 개발자의 책임을 컴파일러로 강제 이양하는 과정이었다.
TypeScript에서는 보통 개발자가 kind 같은 필드를 직접 만든다.
예를 들어 다운로드 상태가 queued, running, completed, failed 가운데 하나라면 각 객체에 kind를 넣는다. running에는 progress가 있고, failed에는 errorMessage가 있다고 정의하는 식이다.
여기까지는 C에서 enum을 따로 두던 것과 얼핏 비슷하다 할 수 있는데 차이는 다음에서 온다.
state.kind === "running"이라고 검사하는 순간 TypeScript는 그 안에서 state가 running 상태라는 사실을 추적한다. 따라서 progress가 존재한다는 것도 같이 안다.
즉 TypeScript에서는 일을 절반씩 나눠 맡는다.
태그는 개발자가 만든다. 태그와 payload의 관계는 타입 검사기가 추적한다.
C에서는 둘 다 프로그래머의 규율에 가까웠다. TypeScript에서는 판별자를 만드는 것은 프로그래머의 몫이지만, 일단 제대로 정의하면 이후의 관계를 컴파일러가 따라간다.
하지만 Sum Type을 지원하는 Rust, Swift, F# 같은 언어는 여기서 한 발 더 나간다. 어떤 경우들이 존재하고 각 경우가 어떤 값을 가지는지를 합 타입 자체의 정의로 만든다.(다만 나는 위 3가지 언어를 그렇게 잘 알지 못하므로 크게 설명하진 않을 것이다)
1970년대부터 계속 반복된 질문은 하나였다.
어느 경우인지 누가 기록하고, 그 기록과 실제 값이 일치한다는 것을 누가 증명할 것인가?
이것을 어디까지 인간에게 허용하고, 어디까지 컴파일러가 책임 질 것인가가 현대 언어의 진화사와 엮여있다. 현재까지의 흐름을 볼때 대부분은 컴파일러쪽으로 밀어두는게 훨씬 안전하다고 보인다.
1969년에서 1980년, 망라성은 증명에서 나온 것으로 추측된다.
패턴 매칭과 망라성 검사(Exhaustiveness Checking)은 프로그램의 논리적 무결성을 수학적으로 증명하기 위한 강박적인 산물이라고 할 수 있다.

1969년, 로빈 버스톨(Rod Burstall)은 「Proving properties of programs by structural induction」에서 재귀적 자료구조 위에서 구조적 귀납법을 통해 프로그램의 성질을 증명하는 방식을 제시했다. 이 증명이 논리적으로 성립하기 위한 전제 조건은 명확했다.
값을 생성하는 방법이 유한하게 열거될 것.
각 생성자가 서로 배타적일 것.
증명이 모든 생성자의 경우를 빠짐없이 다룰 것.
앞의 두 조건이 오늘날 '태그된 합(Tagged Union)'의 설계 사상이라면, 마지막 조건은 컴파일러가 제어 흐름을 통제하는 '망라성 검사'의 수학적 뼈대다.
1978년에 발표된 로빈 밀너(Robin Milner)의 「A Theory of Type Polymorphism in Programming」은 합에 +, 곱에 ×를 명시적으로 도입하며 초기 ML 계열의 골조를 짰다고 할 수 있다.
ML(Meta Language)은 본래 LCF 정리 증명기(Theorem Prover)의 통제력을 확보하기 위한 메타 언어로 설계되었는데, 여기서 객체 언어의 구문 트리는 임의로 분해되거나 조립될 수 없었고, 오직 신뢰된 추론 규칙을 통해서만 생성되어야 했다. LCF의 시스템적 안전성은 바로 이 엄격한 생성 통제력에서 나왔으며, 이는 훗날 대수적 데이터 타입(ADT)이 정착하는 토양이 되었다.
마침내 1980년, 버스톨, 맥퀸, 산넬라가 발표한 언어 HOPE에 이르러 '생성자가 붙은 합 타입', '패턴 매칭', 그리고 '컴파일러가 검증하는 망라성'이 하나의 시스템 안에서 물리적으로 결합된다. 물론 이것을 두고 오늘날의 Rust나 Swift와 같은 형태가 이때 완성되었다고 말할 수는 없을 것이다. 레코드와의 결합, 메모리 표현 최적화, 제네릭, 분리 컴파일 등 지난 40년간의 엔지니어링 투쟁을 지워버리는 것은 잘못된 것이다. 다만, 타입 시스템과 컴파일러가 상호작용하며 데이터의 무결성을 강제하는 '핵심 아키텍처'가 비로소 출현했다고 정의하는 것은 타당하다고 할 것이다.
오늘날 우리가 망라성 검사가 주는 런타임의 안전성을 누리기 위해 굳이 복잡한 정리 증명기까지 배울 필요는 없다. 참으로 다행스러운 일이다. 나 역시 그것을 배우다 포기했으니 말이다.
실전에서 망라성 검사를 변경 감지기로 쓰기
ADT의 이점은무엇일까? 코드가 이뻐지는 것? 물론 코드의 미학이야 개인마다 다르지만 그것에만 있지는 않다.(더 복잡해지는데 있다고 하면 사실이다)
보통 망라성 검사기를 쓰는 이유는 새 상태를 추가했을 때 어디를 고쳐야 하는지 컴파일러가 목록으로 내놓는 데 있다.
const assertNever = (value: never): never => {
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
};
const getDisplayText = (state: DownloadState): string => {
switch (state.kind) {
case "queued": {
return "Waiting";
}
case "running": {
return `Downloading: ${state.progress}%`;
}
case "completed": {
return `Saved to ${state.filePath}`;
}
case "failed": {
return `Failed: ${state.errorMessage}`;
}
default: {
return assertNever(state);
}
}
};DownloadState에 "paused"를 추가하면 default 분기의 state가 더 이상 never가 아니게 되고 assertNever 호출이 컴파일 오류가 된다. 이 함수가 열 개면 열 개가 전부 목록에 뜬다.
여기에 전제가 있는데, TypeScript는 switch가 빠졌다고 저절로 오류를 내지 않는다. assertNever 관용구를 두거나, 반환 타입을 명시하고 noImplicitReturns를 켜거나, switch 대신 Record<Kind, Handler> 매핑을 쓴다. 셋 다 없으면 새 variant가 추가되어도 컴파일되기에 이런 장치를 코드에 남겨야 망라성 검사를 변경 감지기로 쓸 수 있다.
C#에서는 판별 공용체가 아직 안정 릴리스에 없으므로 record 계층으로 근사한다.
using System;
using System.Diagnostics;
public abstract record PaymentState
{
private PaymentState()
{
}
public sealed record Pending(DateTimeOffset CreatedAt) : PaymentState;
public sealed record Paid(string TransactionId, DateTimeOffset PaidAt) : PaymentState;
public sealed record Failed(string Code, string Message) : PaymentState;
public sealed record Refunded(string TransactionId, DateTimeOffset RefundedAt) : PaymentState;
}
public static class PaymentFormatter
{
public static string Describe(PaymentState state)
{
return state switch
{
PaymentState.Pending pending => $"Pending since {pending.CreatedAt:O}",
PaymentState.Paid paid => $"Paid: {paid.TransactionId}",
PaymentState.Failed failed => $"Failed: {failed.Code} - {failed.Message}",
PaymentState.Refunded refunded => $"Refunded: {refunded.TransactionId}",
_ => throw new UnreachableException(),
};
}
}기본 생성자를 private으로 두고 variant를 중첩 타입으로 정의하면 외부에서 하위 타입을 추가할 수 없다. 중첩 타입은 바깥 타입의 private 멤버에 접근할 수 있으므로 성립한다. 한계는 마지막 줄이다. 컴파일러가 계층이 닫혀 있음을 증명하지 못하므로 마지막 분기를 빼면 CS8509 경고가 나고, 두면 도달하지 않을 예외가 남는다.
Python은 3.10의 match와 3.11의 typing.assert_never를 조합하되, mypy나 Pyright를 CI에 걸지 않으면 정적으로는 아무것도 잡히지 않는다. 실제 실행이 typing.assert_never()에 도달하면 예외가 발생한다.
Python은 ADT를 도입하지 않았다. 다만, ADT를 소비하는 문법을 도입하고, 생산은 사용자에게 맡기는 영리한 판단을 했다.
default: throw는 빠진 경우를 실행 시점에 막아 주지만, 새 variant에 대한 정적 변경 감지는 포기한다. 컴파일러가 알려줄 일을 실제로 그 값을 받은 호출까지 미루는 셈이다. TypeScript의 assertNever(state)는 default 분기를 남기면서도 새 variant를 컴파일 오류로 만드는 장치라서 둘을 같은 것으로 보면 안 된다.
1985년, 이름이 붙은 해
터너의 1985년 Miranda 논문에 "Algebraic data types"라는 절이 있고 자유 대수를 명시적으로 논한다. 현재 확인되는 가장 이른 명시적 용례 가운데 하나다. HOPE는 그냥 data type이라고 불렀다.
We call it a free algebra, because there are no associated laws, such as a law equating a tree with its mirror image.
자유 대수에서는 생성자가 서로 구별되고 각 생성자가 단사다. 그 결과 값의 생성자 분해가 유일해진다. Add(x, y)와 Add(y, x)를 같게 만드는 교환법칙이 주어지지 않으므로 x와 y가 다르면 두 값은 구별된다. 덧셈의 교환법칙은 생성자 Add의 성질이 아니다. 그것을 해석하는 함수 쪽의 법칙이다.
분해가 유일하기 때문에 구조적 패턴 매칭이 그대로 성립한다. 생성자에 등식 법칙을 추가하면 서로 다른 표현이 같은 값을 나타낼 수 있어서 단순한 구문적 매칭이 등식과 양립하지 않을 수 있다. 불가능해지는 것은 아니다. 정규화, view pattern, 몫을 인식하는 소거로 할 수 있고, Miranda가 실제로 법칙 붙은 타입을 실험했다가 추론이 복잡해진다는 이유로 제거했다.
합과 곱의 대수적 법칙과 카디널리티 산술도 이름을 설명하는 정당한 맥락이다. 둘 중 하나만 어원이라고 단정할 근거는 없다. 필자가 찾아서 확인한 것은 터너의 용례가 자유 대수 쪽을 명시했다는 사실까지다.
재귀 ADT에서는 구조가 문법이 된다
List<T> = Nil + Cons(T × List<T>)함자 F(X) = 1 + T × X의 초기 대수가 유한하고 well-founded한 리스트다. 방정식만으로는 의미론이 정해지지 않는다. 초기 대수를 취하면 유한한 귀납적 리스트이고 종말 여대수를 취하면 무한 구조까지 포함하며, Miranda 자체가 비엄격 언어라 잠재적으로 무한한 리스트를 다룬다. 초기성이 보장하는 것은 각 생성자를 어떻게 처리할지만 정하면 전체 구조에 대한 함수가 유일하게 결정된다는 것이고, fold가 여기서 나온다.
type Expression =
| {
readonly kind: "number";
readonly value: number;
}
| {
readonly kind: "add";
readonly left: Expression;
readonly right: Expression;
}
| {
readonly kind: "multiply";
readonly left: Expression;
readonly right: Expression;
};
const evaluate = (expression: Expression): number => {
switch (expression.kind) {
case "number": {
return expression.value;
}
case "add": {
return evaluate(expression.left) + evaluate(expression.right);
}
case "multiply": {
return evaluate(expression.left) * evaluate(expression.right);
}
default: {
return assertNever(expression);
}
}
};평가 함수가 타입 정의를 그대로 따라간다. 파서, 컴파일러, 규칙 엔진, UI 상태 트리가 ADT와 잘 맞는 이유다. 다만 재귀 순회의 스택 사용량은 별도 문제다. 트리가 깊으면 스택도 입력 깊이만큼 자라므로, 신뢰할 수 없는 입력을 파싱할 때는 깊이를 제한하거나 명시적 스택으로 바꿔야 한다. 타입 검사가 이 문제까지 해결해 주지는 않는다.
후에가 1997년에 지퍼를 제시했고 맥브라이드가 2001년에 정규 타입의 형식적 미분이 구멍 하나짜리 문맥을 계산한다는 것을 보였다. d/dT (T × T × T) = 3 × T × T. 튜플에서 한 자리를 비우는 방법이 세 가지라는 뜻이다. 타입을 미분할 수 있다는 사실은 실무에서 쓸 일이 거의 없다.
1965년과 2009년, null
1965년 토니 호어(Tony Hoare)가 ALGOL W에 null 참조를 도입했고, 2009년 QCon에서 이를 "10억 달러짜리 실수"라 부르며 공개적으로 사과했다. 10억 달러는 2009년 시점의 추산이긴 하나, 발명자 본인이 자신의 설계를 실패로 규정하고 사과했다는 점은 이 업계에서 이례적으로 깔끔한 결말이다. (물론 개인적으로는 현실을 모델링할 때 null이 주는 편의성을 옹호하는 편이긴 하다.)
이 "10억 달러짜리 실수"의 본질은, null이 대수적 데이터 타입(ADT), 즉 '합 타입(Sum Type)'이 있어야 할 자리를 너무 값싸게 대체해 버렸다는 데 있다.
사실 이걸 보통 바로 이해하기 어려운 사람이 있을텐데, 합 타입의 가장 대표적인 구현체가 바로 Option(또는 Maybe) 타입이다. Option은 데이터의 상태 공간을 '값이 존재하는 경우(Some)'와 '값이 없는 경우(None)' 딱 두 가지로 분리하여 명시적으로 정의한다.
null이 모든 참조 타입에 은밀하게 기생하는 유령 같은 존재라면, Option은 데이터의 '부재(Absence)' 자체를 언어가 인지하고 제어할 수 있는 합법적인 상태로 격상시킨 것이다.
흔히 이를 두고 "null은 태그가 없는 Option 타입이다"라고 비유하곤 하지만, 이는 비유로서만 유효할 뿐 기술적 사실로는 틀린 말이라 할 수 있다.
진짜 차이는 물리적 태그의 유무에 있지 않기때문이다.
가령 Rust는 Option<NonNull<T>>와 같은 특정 타입에서 null 비트 패턴을 틈새(Niche)로 활용하여, 별도의 물리적 메모리 태그 없이도 부재(Absence)를 표현하는 최적화를 수행한다.
가장 핵심적인 구조적 차이는 '데이터의 부재가 타입 계약(Type Contract)에 명시되고, 사용 전에 컴파일러에 의해 강제로 제거(Unwrapping)되어야 하는가'에 있다.
Unchecked / Legacy Null Reference: 데이터 부재의 가능성이 타입 계약에 제대로 반영되지 않는다. 호출자는
null가능성을 놓친 채 값에 접근할 수 있으며, 실제로는 값이 없을 수 있는데도 API 표면에서는 마치 항상 값이 존재하는 것처럼 보이는 상태를 만든다.Option: 데이터의 부재 자체가 타입 시스템의 계약으로 명시된다. 값을 얻기 위해서는 반드시 변형(Variant)을 벗겨내는 제어 흐름을 거쳐야만 한다.
Option의 필요성
null의 가장 큰 문제는 값이 없다는 사실을 데이터 안에 바로 볼 수 없다는 점에 있다.
함수 하나를 생각해보자.
FindUser(id) → User이 선언만 보면 호출자는 User가 반드시 돌아온다고 생각한다.
그런데 실제 구현은 사용자를 찾지 못하면 null을 반환한다고 하자.
그러면 함수의 진짜 계약은 사실 이것이다.
FindUser(id) → User 또는 없음문제는 타입에는 User라고만 적혀 있다는 것이다.
null은 존재하지만 계약에는 존재하지 않는다.
Option<T>가 하는 일은 이 숨겨진 경우를 타입 밖으로 끌어내는 것이라고 할 수 있다.
Option<User>
= Some(User)
+ None뜻은 단순하다.
사용자가 있을 수도 있고, 없을 수도 있다.
여기서 중요한 것은 Option이 값을 더 안전하게 저장한다는 점이 아니라, 값이 없을 가능성을 호출자가 모른 척할 수 없게 만든다는 점이다.
User를 바로 얻을 수 없고, 먼저 Some인지 None인지 확인해야 한다.
Some(User)
→ 값이 있다
None
→ 값이 없다그래서 Option은 런타임의 null check를 없애는 방식이라기보다는, 그 검사를 함수 계약으로 끌어올리는 방식이라고 보는 편이 정확할 것이다.
즉,
에 가깝다.
이런 구조가 필요한 이유는 간단하다.
프로그램에서 값이 없는 것은 언제나 오류가 아니기 때문이다.
사용자가 별명을 입력하지 않았을 수도 있고, 캐시에 아직 값이 없을 수도 있고, 검색 결과가 0개일 수도 있다. 이런 경우는 실패가 아니다.
그냥 없음도 정상적인 상태 공간의 일부다.
Option에는 이유가 없다
하지만 Option은 일부러 정보가 적다.
다음 세 경우를 생각해보자.
앞의 두 경우는 None으로 표현해도 충분할 수 있다.
하지만 세 번째까지 None으로 만들어 버리면 문제가 생긴다.
호출자는 이제 알 수 없다.
사용자가 없는 건가? DB가 죽은 건가? 네트워크가 끊긴 건가?
Option에는 이유를 담을 자리가 없기 때문이다.
Option<T>
= Some(T)
+ NoneNone은 그냥 없다.
왜 없는지는 말하지 않는다.
그래서 부재의 이유가 프로그램의 행동을 바꾼다면 Option에서 내려와야 한다.
그때 필요한 것이 Result다.
Result<T, E>
= Ok(T)
+ Error(E)예를 들어 사용자 조회라면 더 명확하게 만들 수도 있다.
LookupOutcome<T>
= Found(T)
+ NotFound
+ QueryFailed(DbError)여기서는 세 경우가 더 이상 하나의 null이나 None 뒤에 숨어 있지 않는다.
찾음
찾지 못함
찾는 과정 자체가 실패함
각각이 서로 다른 상태다.
이것이 합 타입을 오류 처리에 쓰는 이유다.
Result는 실패를 반환 타입 안으로 끌어들인다
예측 가능한 실패를 예외로 처리하면 제어 흐름이 함수의 반환 타입 밖으로 빠져나간다.
User LoadUser(id)라고 적혀 있어도 실제로는:
User
또는
DbException
또는
TimeoutException
또는
...일 수 있다.
반면 Result로 모델링하면 실패가 다시 타입 안으로 들어온다.
LoadUser(id)
→ Result<User, LoadUserError>호출자는 이제 이 함수가 실패할 수 있다는 사실을 함수 선언만 보고 알 수 있다.
그리고 성공하면 다음 계산을 계속하고, 실패하면 남은 계산을 건너뛰고 오류를 그대로 전달하는 패턴이 자연스럽게 나온다.
type Result<TValue, TError> =
| {
readonly kind: "ok";
readonly value: TValue;
}
| {
readonly kind: "error";
readonly error: TError;
};
const bindResult = <TValue, TNext, TError>(
result: Result<TValue, TError>,
transform: (value: TValue) => Result<TNext, TError>,
): Result<TNext, TError> => {
if (result.kind === "error") {
return result;
}
return transform(result.value);
};모나딕 체이닝의 핵심은 결국 이것이다. 2010년대 이후 객체지향 언어들이 런타임 예외(try/catch)가 통제 불능에 빠지는 것을 막기 위해 함수형 패러다임을 수혈하면서, 이제 이 패턴은 단순한 '테크닉'을 넘어 예측 가능한 시스템을 설계하기 위한 가장 핵심적인 제어 흐름 표준이 되었다
성공하면 다음 계산을 한다. 실패하면 더 이상 진행하지 않고 실패를 전달한다.
이렇게 보면 거창해 보이던 Result도 결국 제어 흐름을 데이터로 만든 합 타입이다.
예외와 Result는 같은 실패가 아니다
사실 Result를 처음 배우면 모든 예외를 Result로 배우는 강박증에 시달리는데, 모든 실패를 무조건 Result로 만들 필요는 없다.
예를 들어 보자
사용자가 존재하지 않음
결제 승인 거부
파일 형식이 잘못됨
권한이 없음
같은 것은 프로그램이 충분히 예상할 수 있다.
이런 실패는 도메인의 일부다.
반대로
OutOfMemory
런타임 내부 불변식 붕괴
프로그래머가 잘못된 인덱스를 사용
라이브러리 내부 결함
같은 것까지 일일이 Result variant로 만드는 것은 대개 의미가 없다.
그래서 일반적으로는 크게 2가지를 경계로 나누는 걸 추천들 한다.
예측 가능한 실패
-> Result
프로그래밍 오류 / 치명적 시스템 오류
-> Exception / fail-fast
프로그래밍이 어려운 이유는 이 경계가 늘 무 자르듯 명확하지 않기 때문이다. '살인은 나쁘다'는 명제는 쉽지만, '아사 직전의 조난자가 생존을 위해 시체를 먹는 것은 윤리적인가'라는 극단적 딜레마 앞에서는 철학적 결단이 필요하듯, 프로그래밍 역시 예측 불가능해 보이는 결함과 도메인 실패 사이의 좁은 회색 지대를 매일 마주해야 한다.
그리고 이 애매한 딜레마를 가진 언어들이 있는데, 대표적으로 C#이 있다.
.NET BCL은 역사적으로 예외를 적극적으로 사용한다. 파일 I/O, 네트워크, 데이터베이스, 직렬화 등 외부 시스템과 만나는 순간 수많은 API가 예외를 던진다.
그렇다고 이 예외를 그대로 비즈니스 로직 안까지 흘러들어가면 안된다.
외부 세계가 예외를 던진다면, 경계에서 잡아서 도메인이 이해할 수 있는 실패로 변환한다.
.NET / 외부 API
↓
Exception
↓
Boundary
↓
Result<Success, Failure>
↓
Domain / Application예를 들어 데이터베이스 드라이버가 TimeoutException을 던진다고 하자.
도메인 로직이 이 예외 타입까지 알아야 할 이유는 없다.
경계에서:
TimeoutException
→ QueryFailed.Timeout으로 바꾸면 된다.
이렇게 하면 예외는 외부 시스템의 오류 전달 방식으로 남고, Result는 우리 프로그램 내부의 실패 계약이 된다.
둘을 아무 데서나 섞으면 이중 오류 채널이 되지만, 경계를 명확하게 두면 역할도 명확해진다.
로 정리할 수 있다.
C#과 같은 예외 중심 생태계에서 Result를 쓰지 말아야 하는 것이 아니라, 어디에서 예외를 끝내고 어디서부터 Result를 시작할 것인가를 정해야 한다는 이야기다.
F#이 같은 .NET 런타임과 BCL 위에서도 Result, Option을 적극적으로 사용하는 것도 이 구분이 가능하다는 좋은 사례다.
도메인 상태 모델링: Option vs Result vs Custom ADT
타입 (Type) | 선택 기준 (Decision Criteria) | 도메인 의미 | 아키텍처 적용 예시 |
| 데이터가 없을 수 있으며, 그 부재의 '이유'가 제어 흐름에 영향을 주지 않음 | 실패가 아닌 정상적인 부재 (Absence) |
캐시 미스는 시스템의 실패가 아님 |
| 작업이 실패할 수 있으며, 실패의 '이유(E)'에 따라 호출자의 후속 행동이 달라짐 | 예측 가능한 실패 (Expected Failure) |
DB 조회 실패는 원인(타임아웃, 권한 등)에 따라 처리가 갈림 |
전용 합 타입 | 성공/실패의 이분법을 넘어, 결과의 종류 자체가 비즈니스 도메인의 상태를 규정함 | 도메인에 특화된 닫힌 상태 공간 |
(Approved / Declined / GatewayUnavailable) |
- #1
타입 (Type)
- #2
선택 기준 (Decision Criteria)
- #3
도메인 의미
- #4
아키텍처 적용 예시
- #1
Option<T>- #2
데이터가 없을 수 있으며, 그 부재의 '이유'가 제어 흐름에 영향을 주지 않음
- #3
실패가 아닌 정상적인 부재 (Absence)
- #4
FindCachedValue()캐시 미스는 시스템의 실패가 아님
- #1
Result<T, E>- #2
작업이 실패할 수 있으며, 실패의 '이유(E)'에 따라 호출자의 후속 행동이 달라짐
- #3
예측 가능한 실패 (Expected Failure)
- #4
LoadValueFromDatabase()DB 조회 실패는 원인(타임아웃, 권한 등)에 따라 처리가 갈림
- #1
전용 합 타입
- #2
성공/실패의 이분법을 넘어, 결과의 종류 자체가 비즈니스 도메인의 상태를 규정함
- #3
도메인에 특화된 닫힌 상태 공간
- #4
PaymentOutcome(Approved / Declined / GatewayUnavailable)
반드시 Result나 Option이라는 범용적인 이름에 얽매일 필요는 없다. 대수적 데이터 타입(ADT)을 제어 흐름에 도입하는 진짜 목적은 특정 네이밍 컨벤션을 따르는 것이 아니라, 시스템이 마주할 수 있는 예측 가능한 모든 경우의 수를 타입 시스템의 경계 안으로 완벽하게 닫아두는(Close) 데 있다.
None과 Some(null)은 같은가
사실 꽤 많이 헷갈리는 지점 중 하나인데, 이상한 타입이 하나 있다.
Option<T?>처음 보면 이상하고 이해하기 어렵다.
None도 값이 없다는 뜻이고 null도 값이 없다는 뜻인데, 왜 둘을 겹쳐 놓는가?
대부분의 경우에는 실제로 필요 없다.
None
Some(null)이 둘이 똑같은 의미라면 상태 공간만 쓸데없이 하나 늘어난다.
하지만 두 상태가 서로 다른 의미를 가진다면 이야기가 달라진다.
PATCH API가 대표적이다.
사용자의 이름을 수정한다고 해보자.
요청에는 세 가지 의미가 필요할 수 있다.
None
→ 이 필드는 수정하지 않는다
Some(null)
→ 기존 값을 명시적으로 삭제한다
Some("Alice")
→ 값을 Alice로 변경한다여기서는 세 상태가 모두 의미가 다르다.
따라서 Option<T?>도 정당하다.
문제는 이 형태를 그대로 시스템 내부까지 끌고 들어가는 것이다.
PATCH DTO의 None / Some(null) / Some(value)는 외부 요청의 의미를 해석하기 위한 경계 표현이다.
한 번 의미를 결정했으면 내부에서는 더 명확한 명령으로 정규화하는 편이 좋다.
PATCH DTO
↓
None
Some(null)
Some(value)
↓
Boundary parsing
↓
NoChange
ClearValue
SetValue(value)
↓
Domain즉 null이 필요한 것이 아니라 사실 우리가 필요했던 것은 세 번째 상태였다.
FieldUpdate<T>
= NoChange
+ Clear
+ Set(T)이렇게 바꾸면 도메인 내부에서는 null이 다시 사라진다.
경계에서 모호한 외부 표현을 받아들이는 것과, 그 모호함을 내부 모델까지 허용하는 것은 전혀 다른 문제
외부 JSON은 우리가 통제할 수 없다.
하지만 내부 상태 공간은 우리가 통제할 수 있다.
에픽테토스는 《람(Enchiridion)》의 첫 문장을 이렇게 시작한다. "어떤 것은 우리의 통제하에 있고, 어떤 것은 우리의 통제 밖에 있다(Dichotomy of Control)." 프로그래밍도 이러한 이분법에서 시작하는 경우가 많다.
외부에서 밀려드는 JSON 페이로드, 사용자의 입력, 서드파티 API의 응답은 우리가 통제할 수 없는 '외부 세계의 혼돈'이다. 프런트엔드가 약속된 스키마를 무시하고 null을 던지거나, 결제 게이트웨이가 문서에 없는 새로운 에러 코드를 내려보내는 것을 우리는 원천적으로 막을 수 없다. 하지만 그 혼돈을 수용하고 런타임을 구동하는 '내부 상태 공간(Internal State Space)'은 온전히 우리의 통제하에 있다.
스토아 철학자들은 외부의 사건(Impression, 표상)이 내면을 어지럽히지 못하도록, 이성의 필터(Assent, 승인)를 거쳐 스스로의 '내면의 성채(Inner Citadel)'를 방어했다. 견고한 시스템의 경계(Boundary) 역시 이와 정확히 동일한 철학적 여과 과정을 수행한다. 통제할 수 없는 외부의 날것(Raw JSON)을 비즈니스 도메인 내부로 그대로 끌고 들어오는 것은, 스스로 내면의 성채 문을 열고 트로이의 목마를 들이는 격이다.
그래서 우리는 경계에서 스토아적 결단을 내린다. 외부의 모호한 값을 수용하되, 철저히 검증(Validate)하고, 우리 도메인에 맞는 의미를 강제로 부여하며, 표현 가능한 경우의 수를 극단적으로 쳐내어 '더 좁고 단단한 타입(Narrowed Type)'으로 환골탈태시킨다.
결국 외부 API가 던지는 모호한 string이나 nullable 덩어리는 시스템의 경계라는 이성의 필터를 통과하는 순간, 도메인이 완벽하게 통제할 수 있는 Result나 닫힌 합 타입(ADT)으로 정규화되어야 한다. 통제할 수 없는 외부의 무한한 경우의 수를, 통제 가능한 내부의 닫힌 상태로 치환하는 것. 이것이 외부 세계의 불확실성 속에서도 시스템의 논리적 무결성을 지켜내는 엔지니어링의 스토아주의다.
1990년에서 2005년, 왜 늦게나마 사용되었을까?
합 타입의 자리는 오랫동안 서브타입 다형성(Subtype Polymorphism)이 차지하고 있었다. 여러 경우 중 하나를 다루는 일을 추상 클래스와 하위 클래스로 해결했고, 경우별로 각기 다른 분기 로직이 필요할 때는 비지터 패턴(Visitor Pattern)을 썼다.
비지터 패턴은 본질적으로 '언어 차원에서 지원하지 않는 패턴 매칭을 런타임 다형성으로 억지로 구현한 수동 라우팅 기법'이다.
컴파일러가 match 구문처럼 타입을 안전하게 분기해 주지 않으니, 개발자들은 데이터 객체 내부에 Accept 메서드를 열어두고 방문자(Visitor)를 받아들인 뒤, 다시 방문자의 Visit 메서드에 자기 자신을 넘겨주는 이중 디스패치(Double Dispatch) 구조를 직접 엮어야 했다. 비지터가 그토록 번거로운 이유는 결국 컴파일러가 해야 할 분기 처리를 사람이 인터페이스와 콜백으로 손수 땜질하고 있었기 때문이다.
이렇게 말하면 나쁜 패턴이라고 생각할 수 있으나, 합 타입이 없는 언어에서 자란 세대의 불가피한 사회경제적 산물이다. 1994년 GoF(Gang of Four)는 그것을 위대한 '디자인 패턴'이라 불렀고, 오늘날 우리는 그것을 단순히 match 키워드 하나로 부른다. 물론 오늘날에도 어디선가에서는 여전히 Vistor 패턴이 양산되지만 말이다.
이것을 표현 문제(Expression Problem)로 정리하면 확장 축의 대립이 명확해진다.
새 구현(타입)이 계속 추가된다: 객체지향이 강하다. (비지터 패턴에서는 새 타입을 추가하면 모든 Visitor 클래스를 뜯어고쳐야 하므로 쥐약이다.)
새 연산이 계속 추가된다: ADT가 강하다. (기존 데이터 구조를 건드리지 않고 패턴 매칭 함수만 새로 작성하면 된다.)
1990년대 산업의 확장 축이 구현 쪽이었다는 것이 필자의 해석이다. GUI 위젯, 디바이스 드라이버, 파일 포맷 핸들러, 플러그인. 지금은 확장 축이 반대인 영역이 늘었다. 프로토콜 메시지, UI 상태, 컴파일러 IR, 이벤트 소싱. 종류는 고정되어 있고 그 위에서 하는 일이 계속 늘어난다.
ADT가 맞지 않는 자리
expression problem은 역사 해석으로는 불확실해도 실무 선택 기준으로는 대부분 패턴화 되어있다.
변경 형태 | 적합한 모델 |
|---|---|
닫힌 variant 집합, 연산 추가가 잦음 | ADT + 패턴 매칭 |
열린 구현 집합, 플러그인 추가가 잦음 | 인터페이스 + 다형성 |
실행 중 동적 등록 | Registry |
상태 전이 중심 | ADT + FSM |
독립 옵션 조합 | 곱 타입 |
- 변경 형태
닫힌 variant 집합, 연산 추가가 잦음
- 적합한 모델
ADT + 패턴 매칭
- 변경 형태
열린 구현 집합, 플러그인 추가가 잦음
- 적합한 모델
인터페이스 + 다형성
- 변경 형태
실행 중 동적 등록
- 적합한 모델
Registry
- 변경 형태
상태 전이 중심
- 적합한 모델
ADT + FSM
- 변경 형태
독립 옵션 조합
- 적합한 모델
곱 타입
쓰지 않는 편이 나은 경우도 여기서 나온다. canRead, canWrite, canDelete처럼 동시에 가질 수 있는 값은 배타적 상태가 아니므로 곱 타입이 맞다. 외부 모듈이 새 구현을 계속 등록해야 하면 인터페이스가 맞다. 수십 개 variant가 한 union에 몰리면 타입 자체가 God Object가 되므로 bounded context를 섞지 않는다.
새 타입은 실제 불변식이나 의미 차이를 보호할 때만 만든다. 모든 string을 UserName으로 감싸면 비용만 남고, 검증된 이메일과 일반 문자열을 타입으로 구분해야 하면 EmailAddress를 만든다.
2004년에서 2026년, 돌아온 경로
수학적 토대가 나타난 1961년부터 algebraic data types라는 이름이 확인되는 1985년까지 약 24년이 걸렸다. 생성자와 패턴 매칭과 망라성의 조합은 1980년 HOPE에 이미 있었지만, 대형 범용 언어에 퍼지는 데에는 다시 30년 안팎이 걸렸다.
이 30년의 거대한 지연은 기술적 한계가 아닌 '통제권'을 둘러싼 관성의 결과라고 생각한다.
1990년대부터 산업을 장악한 객체지향 생태계는 서브타입 다형성(Subtype Polymorphism)을 무기로 내세웠고, 런타임에 다이내믹하게 뻗어 나가는 무한한 확장성에 심취해 있었다. 프로그래머들은 상태의 부재나 예측 가능한 실패를 null과 예외(Exception)로 마음껏 던지는 자유를 누렸고, 그 자유가 런타임의 통제 불가능한 시한폭탄이 된다는 사실을 애써 외면했다. 반면 ADT와 망라성 검사(Exhaustiveness Checking)는 개발자의 파편화된 자유를 회수하여 컴파일러의 엄격한 증명 아래 가두는 방식이었기에, 주류 생태계가 이를 수용하는 데에는 필연적으로 커뮤니티의 저항과 세대교체의 시간이 필요했다.
Java는 records, sealed classes, switch pattern matching을 단계적으로 도입했고, C#은 nullable reference types, records, pattern matching을 확장해 왔다. 2026년 현재 C# 15 preview에는 union types와 closed hierarchies까지 추가되어, 닫힌 상태 공간을 언어가 직접 표현하는 방향이 더욱 분명해졌다.
산업이 성숙해짐에 따라서 프로그램이 점차 복잡해짐에 따라서 프로그래머가 생각해야할 복잡도가 늘어났기에,자주 사용되는 비즈니스 패턴과 제어 흐름에 대한 모델링 기법 역시 함께 성숙해진 결과다. 즉, 패턴화가 가능한 상태 공간은 ADT와 타입 시스템에 위임하여 인지적 부하를 기계적으로 덜어내고, 사전에 엄격히 모델링하기 어려운 동적이고 열린 요구사항들에 대해서만 예외적으로 다형성(Polymorphism)을 남겨두는 실용적인 프로그래밍적 진화에 도달 한 것이다.
물론 Jane Street처럼 ADT와 망라성 검사를 오래전부터 적극적으로 사용해 온 사례가 있다.
그리고 최근들어 LLM 생성 코드가 늘어나는 현재에는 이러한 닫힌 타입이 인간뿐 아니라 생성 코드의 상태 공간까지 제한하는 장치로 새롭게 중요해질 가능성이 있다.
확률론적으로 다음 토큰을 예측할 뿐인 LLM이 시스템의 내부 로직을 무서운 속도로 쏟아내는 시대가 도래했다. 결국 앞으로의 소프트웨어 엔지니어링은 '어떻게 로직을 구현할 것인가'에서 '어떻게 상태를 제약할 것인가'로 그 중심축이 완전히 이동할 것이다. 그때는 분명 ADT 프로그래밍이 강력한 축으로써 도움이 될 것이다라고 필자는 생각하고 있다.
일차 문헌
John McCarthy, A Basis for a Mathematical Theory of Computation (1961)
John McCarthy, Definition of new data types in ALGOL x (1964)
C. A. R. Hoare, Notes on Data Structuring, in Dahl, Dijkstra, Hoare, Structured Programming (1972)
Gérard Huet, The Zipper, Journal of Functional Programming 7(5) (1997)
Conor McBride, The derivative of a regular type is its type of one-hole contexts (2001)
Tony Hoare, Null References: The Billion Dollar Mistake, QCon London (2009)
언어 문서
Microsoft, Explore new features available in C# 15 preview, .NET Blog (2026-08-24)
Lysxia, Where does the name "algebraic data type" come from?
각주
- https://dl.acm.org/doi/10.1145/1460690.1460715 ↩
- [Basis for Math Theory of Computation ](https://www.cs.cornell.edu/courses/cs6110/2015sp/docs/McCarthy-Basis-for-Math-Theory-of-Computation.pdf) ↩
- [C. A. R. Hoare, Notes on Data Structuring — Oxford Research Archive ](https://ora.ox.ac.uk/objects/uuid:d583a134-8e53-42e4-8508-6bb1111a81e2) ↩
- https://link.springer.com/book/10.1007/978-3-540-37500-5 ↩
- https://www.nokia.com/bell-labs/about/dennis-m-ritchie/chist.html ↩
- https://ora.ox.ac.uk/objects/uuid:d583a134-8e53-42e4-8508-6bb1111a81e2 ↩
- https://www.cs.tufts.edu/~nr/cs257/archive/barbara-liskov/abstraction-in-clu.pdf ↩
- https://people.cs.kuleuven.be/~dirk.craeynest/ada-belgium/docs/rm83/lrm-03-07.html ↩