Elixir 1.20, 점진적 타입 시스템이 들어왔다 — 동적 언어에 타입을 붙인다는 것의 실체
오늘 HN 상단에 Elixir 1.20 릴리스 소식이 올라왔다. 헤드라인은 "이제 점진적 타입 언어가 됐다(gradually typed)"인데, 동적 언어 진영에서 타입 이야기가 나오면 보통 두 가지 반응이 갈린다. "드디어 컴파일 타임에 버그 잡겠네"와 "또 타입 어노테이션 노가다 시작이군". 결론부터 말하면 Elixir의 접근은 둘 다 아니다. 좀 독특해서 정리해둘 만하다.
왜 지금 화제인가
Elixir는 BEAM(Erlang VM) 위에서 도는 동적 타입 함수형 언어다. Phoenix LiveView 덕분에 실시간 웹이나 메시징 백엔드 쪽에서 꾸준히 쓰여왔는데, 동적 언어가 늘 그렇듯 "런타임에서야 터지는 타입 버그"가 고질병이었다.
핵심은 Elixir 코어팀이 2022년경부터 José Valim 주도로 추진해온 set-theoretic type system(집합론적 타입 시스템)이 단계적으로 들어오고 있다는 점이다. 1.17, 1.18을 거치며 패턴 매칭과 함수 헤드 추론이 들어왔고, 1.20에서 "gradually typed language"라고 공식적으로 부를 만큼 성숙했다는 발표로 보인다. 정확한 1.20 신규 범위는 릴리스 노트 확인이 필요하다.
여기서 중요한 건 타입 어노테이션을 거의 안 쓴 기존 코드도 컴파일러가 알아서 추론해서 검사한다는 점이다. 즉 "타입 붙이는 노가다"가 진입 조건이 아니다.
핵심: 집합론적 타입이 뭐가 다른가
TypeScript나 Java식 타입을 떠올리면 헷갈린다. Elixir의 타입은 "값들의 집합"으로 다룬다. 예를 들어 어떤 함수가 :ok 아니면 :error만 반환한다면, 그 반환 타입은 말 그대로 :ok or :error라는 합집합으로 표현된다.
Elixir에서 가장 흔한 함수 패턴이 이거다.
def fetch_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
이 함수의 반환 타입은 {:ok, %User{}} or {:error, :not_found}라는 합집합으로 추론된다. 그리고 이걸 호출하는 쪽에서 :ok 케이스만 처리하고 :error를 빼먹으면, 컴파일러가 "이 패턴은 매칭 안 되는 경우가 있다"고 경고할 수 있는 기반이 깔린다.
비유하자면 기존 Dialyzer(Erlang/Elixir의 기존 정적 분석 도구)는 "코드 다 짜고 나서 따로 돌리는 외부 검사기"였다. 느리고, success typing 기반이라 "확실히 틀린 것만" 잡았다. 반면 새 타입 시스템은 컴파일러 안에 통합되어 있어서 그냥 mix compile 할 때 같이 돈다. 별도 설정 없이 경고가 뜬다.
패턴 매칭이 핵심이라 이런 것도 잡힌다.
# map에 :name 키가 없는 구조체를 넘기면
def greet(%{name: name}), do: "Hello #{name}"
greet(%{title: "Mr"}) # 컴파일 타임에 잡힐 수 있는 케이스
동적 언어에서 이게 컴파일 단계에 걸린다는 게 체감상 가장 큰 변화다.
실무 관점: 도입할 때 따져봐야 할 것들
1. 기존 코드는 안 건드려도 된다. 이게 "gradual"의 핵심이다. 타입을 명시하지 않은 부분은 dynamic()이라는 동적 타입으로 취급되어 검사를 통과한다. 그래서 1.20으로 올린다고 기존 프로젝트가 빨간 에러로 도배되진 않는다. 다만 추론된 타입과 명백히 모순되는 코드에선 경고가 새로 뜰 수 있으니, CI에서 warnings-as-errors 옵션을 쓰는 팀은 업그레이드 직후 빌드가 깨질 수 있다. 이게 첫 번째 함정이다.
2. Dialyzer를 당장 버릴 수 있는 건 아니다. 새 타입 시스템이 커버하는 범위가 아직 전체가 아니다. 가드, 패턴 매칭, 일부 기본 타입 위주로 추론하는 단계로 보이고, 함수 시그니처 전반의 spec 검증은 여전히 Dialyzer/dialyxir 영역이 남아있다. 그러니 "이제 PLT 빌드 안 해도 되겠지" 하고 성급하게 걷어내지 말고, 두 도구를 한동안 병행하면서 새 시스템이 잡는 범위를 직접 확인하는 게 안전하다.
3. 타입 명시는 선택이지만, 명시하면 검사가 강해진다. Elixir에는 원래 @spec으로 함수 타입을 적는 관례가 있었다. 새 시스템은 이걸 점점 더 진지하게 활용하는 방향으로 가고 있다. 라이브러리 코드나 팀 공용 모듈처럼 인터페이스가 명확해야 하는 곳부터 spec을 붙이면 효과가 크고, 내부 한 번 쓰고 버리는 헬퍼는 추론에 맡기는 식의 전략이 현실적이다.
@spec fetch_user(integer()) :: {:ok, User.t()} | {:error, :not_found}
def fetch_user(id), do: ...
4. 흔한 오해 — "이제 타입 안전하다"는 과신. 점진적 타입의 본질상 dynamic() 경계를 넘는 값은 런타임에서 여전히 터질 수 있다. 외부에서 들어오는 JSON, DB 결과, 메시지 큐 페이로드 같은 건 어차피 동적이다. 타입 시스템이 들어왔다고 입력 검증(Ecto changeset, 패턴 매칭 가드)을 소홀히 하면 그게 진짜 사고 지점이 된다.
대안 비교 관점. "타입 때문에 동적 언어를 굳이?"라면 처음부터 Gleam(BEAM 위의 정적 타입 언어)이나 다른 정적 언어를 쓰는 선택지도 있다. 다만 Phoenix 생태계와 기존 Elixir 자산을 그대로 들고 가면서 점진적으로 안전성을 올리고 싶다면, 이번 방식이 가장 마찰이 적다. 기존 코드를 다시 안 짜도 된다는 게 결정적이다.
정리
한 줄 요약: Elixir 1.20의 점진적 타입은 "어노테이션 강제 없이, 컴파일러가 패턴 매칭 기반으로 추론해 런타임 버그를 미리 잡아주는" 방향이고, 기존 코드를 깨지 않으면서 점진적으로 도입하도록 설계됐다.
- 이미 Elixir/Phoenix 운영 중인 팀: 업그레이드해서 새로 뜨는 경고를 디버깅 힌트로 활용하면 이득. 단 CI warnings-as-errors는 한 번 점검.
- Dialyzer 쓰던 팀: 당장 걷어내지 말고 병행하며 커버리지 비교.
- 신규 프로젝트: 라이브러리·도메인 핵심 모듈부터
@spec붙이는 습관 들이면 타입 검사 효과 극대화.
동적 언어에서 "런타임에서야 알게 되는 nil 사고"에 데여본 사람이라면, 이번 변화는 충분히 깔고 갈 만하다.
참고 자료
- Elixir v1.20 릴리스 공식 블로그
- 관련 GeekNews 토픽 (그들은 가중치로 이루어져 있다 — 같은 날 HN 인기글)
- Elixir 공식 사이트 (타입 시스템 문서 및 정확한 1.20 신규 범위 확인 권장)
※ 1.20에 정확히 포함된 타입 기능 범위와 Dialyzer 대체 가능 여부는 위 릴리스 노트에서 직접 확인하길 권한다.