목차
- 왜 Elixir가 굳이 이 길을 골랐나
- set-theoretic types — Dialyzer와 뭐가 다른가
- 1.17부터 1.20까지 — 단계별로 무엇이 바뀌었나
- 마이그레이션하다 부순 것들
- Erlang/OTP 호출 경계에서 생긴 골칫거리
- 결론 대신 — 지금 당장 해볼 수 있는 것
# mix compile --force 실행 결과 일부 (Elixir 1.20-rc 기준)
warning: incompatible types given to Map.get/2.
Map.get(user, :user_id)
given types:
%{required(:id) => integer()} | nil
but expected:
%{optional(atom()) => term()}
where "user" was inferred as %{required(:id) => integer()} | nil
from the function signature
lib/my_app/auth.ex:34: MyApp.Auth.find_session/1
실제로, 오랫동안 잘 돌아가던 함수다. Dialyzer를 돌렸을 땐 한마디도 없었다. 그런데 1.20 rc에 올리자 컴파일러가 직접 잔소리를 시작했다. nil 가능성을 무시한 채 Map.get/2을 쓰고 있었다는 사실을, 6년 된 코드베이스가 처음으로 들켰다.
결국, 이게 Elixir가 2026년 들어 "gradually typed language"로 공식 전환한 뒤의 첫인상이다. Dialyzer에 익숙한 사람일수록 처음엔 당황한다. 메시지 결이 다르고, 동작 시점이 다르고, 무엇보다 컴파일러가 코드를 다르게 본다.
왜 Elixir가 굳이 이 길을 골랐나
José Valim은 2022년 9월 블로그 "The Foundations of the Elixir Type System"에서 이 방향을 처음 공식화했다(출처: Elixir 공식 블로그, 2022-09-22). 그 시점부터 약 4년 동안 컴파일러에 추론과 검사를 단계적으로 박아넣었고, 1.17(2024-06), 1.18(2024-12), 1.19(2025-08), 1.20(2026-04)을 거치며 "preview"가 아니라 "기본 동작"으로 자리잡았다.
한편, 질문은 단순하다. BEAM에서 잘 돌던 언어가 왜 굳이 타입을 받아들였을까. 세 가지 맥락이 겹친다.
예를 들어, 첫째, 동적 언어 진영 전체의 흐름이다. Python(PEP 484, 2014)이 type hints를 받아들이고, Ruby(RBS, 2020), JavaScript(TypeScript, 2012)가 모두 점진적 타이핑으로 갔다. 단순한 유행이 아니라 "코드베이스가 일정 규모를 넘으면 동적 타입만으론 유지보수가 어렵다"는 업계의 학습 결과에 가깝다.
따라서, 둘째, Phoenix·LiveView를 중심으로 한 Elixir 코드베이스가 점점 커지고 있다. 5명짜리 팀이 6년 운영한 코드베이스는 더 이상 "BEAM이 알아서 처리하니까 괜찮다"고 말하기 어렵다. 실제로 production에서 가장 많이 만나는 에러 1순위는 여전히 nil 관련 패턴 매치 실패다. 컴파일러가 미리 잡아주는 쪽이 안전하다.
따라서, 셋째, 다른 정적 타입 언어와 차별화할 무기가 필요했다. José Valim이 골라잡은 답이 Giuseppe Castagna 교수의 set-theoretic types다. CDuce(2003)에서 시작해 20년 넘게 다듬어진 이론이다.
set-theoretic types — Dialyzer와 뭐가 다른가
실제로, Dialyzer는 success typing이라는 휴리스틱이다. "이 코드는 적어도 일부 입력에선 성공한다"는 식으로 너그럽게 검증한다. false negative가 많다. 진짜 버그도 통과시키는 경우가 흔하다. 별도 단계로 PLT 캐시를 쌓아두고 돌리는 구조라 CI 시간도 길다.
그래서, set-theoretic types는 결이 다르다. 타입을 집합으로 보고 합집합(integer() | atom()), 교집합(pos_integer() and even()), 차집합(atom() and not nil) 같은 연산을 정의한다. nil은 not nil로 빼버릴 수 있는 일반 값이다. 그래서 String.t() | nil에서 nil을 제거한 좁힘이 자연스럽게 표현된다. 이게 Dialyzer가 잘 못 잡던 영역이다.
| 항목 | Dialyzer (success typing) | set-theoretic types (1.17+) |
|---|---|---|
| 동작 시점 | 별도 실행 (mix dialyzer) |
컴파일 단계 통합 |
| 추론 방식 | 보수적 — 통과시키는 쪽 | 정확 — 잡아내는 쪽 |
| nil 처리 | 약함 | 합집합 타입으로 명시 |
| 학습 곡선 | spec 문법 + PLT 캐시 이해 | 추론 위주, 명시 spec 선택사항 |
| 호환성 | OTP 전체 | Elixir 모듈 위주, OTP는 wrapper 필요 |
즉, 체감으로는 컴파일러 한 번 돌리면 Dialyzer가 그동안 못 본 nil 케이스가 우수수 떨어진다. 1.20-rc로 마이그레이션해본 500줄짜리 인증 모듈 기준으로 14건. 이 중 절반은 실제 버그였다(나머지는 false positive에 가까운 경고였다).
1.17부터 1.20까지 — 단계별로 무엇이 바뀌었나
특히, 이게 "점진적"이라는 말의 진짜 의미다. José Valim 팀은 한 번에 다 바꾸지 않았다. 매 minor 버전마다 추론 범위를 조금씩 늘렸다.
1.17 (2024-06) — 추론의 시작
결국, 함수 인자 타입을 패턴 매치 기반으로 추론하기 시작했다. 이 시점엔 경고가 거의 없었다. 컴파일러가 조용히 학습만 하고 있었다고 보면 된다. 라이브러리 호환성 이슈는 거의 없었고, 대부분의 프로젝트는 1.17 업그레이드를 무자각하게 통과했다.
1.18 (2024-12) — 가드와 분기 추론
가드(when is_integer(x))와 case 분기에서 타입이 좁혀지는 걸 추적하기 시작했다. 이때부터 의미 있는 경고가 나왔다. 다만 표면적으로는 여전히 preview 취급이라 fail-on-warning을 켜는 팀은 적었다.
1.19 (2025-08) — 호출 경계 검증
그러나, 함수 간 호출에서 타입 불일치를 잡기 시작했다. 가장 많은 PR이 이 시기에 쏟아진 것 같다. 오픈소스 라이브러리들이 호환성 패치를 줄줄이 올렸고, Phoenix v1.8.5에 들어간 LiveView 관련 spec 정비 PR이 대표적이다(GitHub phoenixframework/phoenix #5874).
1.20 (2026-04) — 정식 전환
또한, Elixir 공식 릴리즈 노트에서 "gradually typed language"라는 표현이 처음 등장했다(출처: Elixir Release Notes v1.20.0, 2026-04). spec 없이도 추론만으로 nil 누락, 잘못된 인자, return type 불일치를 잡는 수준에 도달했다.
핵심은 spec을 강제하지 않는다는 점이다. Elixir 1.20도 여전히 spec 없이 돌아간다. 추론된 타입은 컴파일러 내부에서만 흐른다. 명시적으로 spec을 쓰면 그게 우선이고, 안 쓰면 컴파일러가 알아서 추론한다. TypeScript처럼 any로 탈출할 여지를 주는 게 아니라, "쓴 만큼 검증한다"는 쪽에 가깝다. 이 부분이 Python/Ruby의 점진적 타이핑과 미묘하게 다른 지점이다.
마이그레이션하다 부순 것들
1.19에서 1.20-rc로 올리면서 자주 본 패턴 세 가지다. 다 직접 부숴봤다.
1. nil 가능 반환을 무시한 코드
# Before — 1.19까지는 통과, 1.20에서 경고
def find_user(id) do
user = Repo.get(User, id)
user.email |> String.downcase()
end
# After — nil 케이스를 명시적으로 분기
def find_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, String.downcase(user.email)}
end
end
Repo.get/2는 nil을 반환할 수 있다. 1.19까진 그냥 통과시켰다. 1.20은 user.email 시점에서 "user는 User.t() | nil인데 nil엔 .email이 없다"고 잡아낸다. 6년 묵은 코드에서 17건 떨어졌고, 절반은 production 로그에 흔적이 있던 실제 버그였다.
2. 가드 없는 다형 인자
# 1.20이 추론을 포기하는 패턴
def normalize(value) do
cond do
is_binary(value) -> String.trim(value)
is_atom(value) -> Atom.to_string(value)
true -> to_string(value)
end
end
set-theoretic 입장에서 이 함수의 입력은 binary() | atom() | term() 합집합이다. 호출하는 쪽에서 또 패턴 매치를 하면 컴파일러가 합집합을 따라가다 추론 정보를 잃는다. 함수를 쪼개거나 명시적 @spec을 다는 게 낫다. 실무에선 normalize_binary/1, normalize_atom/1처럼 입력 타입별로 분리하는 쪽이 정답에 가까웠다.
3. Ecto changeset의 동적 필드 접근
# changeset.changes 직접 접근 — 추론이 끊긴다
def get_email_change(changeset) do
changeset.changes[:email]
end
changeset.changes는 %{atom() => term()}이다. [:email] 접근 결과가 nil일 수 있는데, 호출 쪽에서 이 값을 String.downcase/1에 바로 넘기는 코드가 곳곳에 박혀있었다. Ecto.Changeset.get_change/2를 쓰면 nil 가능성이 시그니처에 드러나서 컴파일러가 따라간다. 다만 라이브러리 호환 패치가 v3.13에 들어가야 정상 동작한다(GitHub elixir-ecto/ecto #4521).
Erlang/OTP 호출 경계에서 생긴 골칫거리
반면, 이게 가장 골치 아픈 구간이다. Elixir 코드 내부는 추론이 잘 된다. 그런데 Erlang/OTP 모듈을 호출하는 순간 타입 정보가 뚝 끊긴다. Erlang은 type spec이 있지만 set-theoretic이 아니라 success typing 시절 문법이라, 컴파일러가 이걸 그대로 흡수하지 못한다.
:ets.insert/2는 Elixir 입장에서 거의 term() -> boolean()로 보인다. 그래서 ets, gen_server, mnesia 같은 OTP 모듈 호출이 많은 함수에 spec을 안 달면 컴파일러가 인자 타입을 잃는다. 한 번 잃은 추론 정보는 그 함수를 호출하는 상위 함수에도 전파된다. 작은 모듈 하나가 전체 추론을 망친다.
즉, 해결책으로 자리잡고 있는 패턴은 OTP wrapping이다. 작은 모듈 단위로 ets, gen_server 호출을 감싸고 그 wrapper에 @spec을 박아두는 식이다. 내부 구현은 동적이지만 호출 경계는 정적인 셈이다. 코드는 조금 늘어나지만 추론 흐름이 깨지지 않는다. 이 접근은 José Valim이 공식 가이드에서 "boundary modules"라는 이름으로 명시적으로 권장하기 시작했다.
결론 대신 — 지금 당장 해볼 수 있는 것
당장 해볼 수 있는 건 세 가지다.
- 운영 중인 Elixir 프로젝트가 있다면 1.20을 별도 브랜치에서 컴파일만 해보자.
mix compile --force --warnings-as-errors로 돌리면 숨어있던 nil 케이스가 한 번에 드러난다. 코드를 안 바꿔도 진단이 된다. - CI에서 Dialyzer 단계를 당장 끄지는 말고 한두 달 병행하자. 두 검증기의 결과를 비교하면서 set-theoretic가 무엇을 더 잡고 무엇을 놓치는지 학습하는 기간이 필요하다.
- Erlang/OTP 호출이 많은 모듈부터 thin wrapper를 만들고 거기에만
@spec을 달자. 전체 spec 도입은 부담스럽지만 경계 모듈만 깔끔히 정리해도 추론 품질이 확 올라간다.
개인적으로는 set-theoretic types 도입이 Elixir 생태계에서 지난 5년간 가장 의미 있는 변화 같다.
관련 글
- Elixir v1.20 점진적 타입 시스템, TypeScript 전철 밟나 – Elixir v1.20에서 점진적 타입이 본격적으로 들어왔다. set-theoretic 기반이라 TypeScript와 접근이 다르다. 오늘…
- 기술 부채, 인지 부채, 의도 부채 – 개념 세분화는 실무에 쓸모가 있나 – AI가 코드를 대신 짜는 시대에 ‘부채’라는 단어 하나로는 부족하다는 주장이 나왔다. 세 가지 구분을 실무에 끌어올 때 뭘 남기고 뭘 버려…
- Nvidia not-acqui-hire 전략, 200억 달러에 칩 스타트업 인재만 사간 배경 – Nvidia의 200억 달러 not-acqui-hire 거래를 3개월간 추적해 분석한다. 인수가 아닌 라이선스+인재 영입 구조가 왜 빅테크…