Elixir v1.20 점진적 타입 시스템, TypeScript 전철 밟나

목차

오늘은 Elixir v1.20 RC에 들어간 점진적 타입 시스템(gradual set-theoretic types)을 며칠 만져봤다. Erlang/OTP 위에서 돌아가는 함수형 언어가 동적 타입을 유지하면서 정적 타입을 얹는 방식이 TypeScript와 묘하게 닮은 듯 다르다. 프론트 2년 하다 백엔드 넘어온 입장에서 비교가 너무 흥미로워서 메모로 남긴다.

예를 들어, 이 글은 결론을 정리한 회고가 아니다. RC 빌드를 받아서 mix 프로젝트 하나에 부분 적용해본 TIL 메모다. 공식 문서와 José Valim의 EUC 2024 발표 자료("Type system for Elixir")를 참고했고, 실제 코드는 elixir 1.20.0-rc.0 기준이다.

오늘 한 것 — 점진적 타입을 부분 적용

가장 먼저 한 일은 RC 받아서 기존 사이드 프로젝트(작은 Phoenix API 서버)에 타입 어노테이션을 일부 함수에만 붙여본 거다. 전체 다 붙이지는 않았다. TypeScript에서 // @ts-check 한 파일만 켜는 느낌으로 했다.

어노테이션 문법

Elixir는 원래 @spec이라는 Dialyzer용 타입 명세가 있었다. v1.20부터는 이 @spec이 실제 컴파일러 타입 추론에 직접 쓰인다. 별도 신문법이 아니라 기존 문법 위에 의미를 얹은 형태다.

defmodule Billing.Invoice do
  # v1.19까지: Dialyzer가 별도 분석으로 검증
  # v1.20부터: 컴파일러가 직접 추론·체크
  @spec total(list(map())) :: integer()
  def total(items) do
    Enum.reduce(items, 0, fn item, acc ->
      acc + item.price * item.qty
    end)
  end
end

이걸 컴파일하면 item.priceitem.qtyinteger인지 컴파일러가 따져보고, 추론이 안 맞으면 경고를 띄운다. 기존 Dialyzer는 컴파일 후 별도 패스로 돌렸어야 했는데 이제는 mix compile 한 방에 잡힌다.

set-theoretic types — TypeScript와 다른 부분

물론, 여기가 제일 재밌었다. TypeScript는 구조적 타입(structural typing)이고, Elixir v1.20은 집합론 기반(set-theoretic)이다. 둘 다 union/intersection을 표현하지만 의미가 다르다.

# Elixir 1.20: 집합론 기반
@spec parse(binary() or integer()) :: {:ok, integer()} or {:error, :invalid}
def parse(value) when is_binary(value), do: Integer.parse(value)
def parse(value) when is_integer(value), do: {:ok, value}

TypeScript string | number 와 비슷해 보이지만, Elixir 쪽은 when is_binary 가드 절을 컴파일러가 읽어서 분기 안에서 타입을 좁힌다. TS의 typeof value === "string" narrowing과 같은 역할이다. 직접 해봤더니, TS 쓰던 손이 그대로 적응됐다. 한 가지 다른 점은 Elixir는 nullable이 별개 타입이 아니라 nil 자체가 atom이라는 거다. nil | integer() 같은 표현이 자연스럽다.

물론, 비교를 한 번 정리하면 이렇게 된다.

항목 TypeScript 5.4 Elixir 1.20 RC
타입 이론 기반 구조적 타이핑 set-theoretic types
기본 모드 strict 옵션 켜야 엄격 점진적 (어노테이션 있는 부분만)
추론 범위 전체 함수 본문 가드 절 + 패턴 매칭 활용
런타임 영향 없음 (컴파일만) 없음 (BEAM 바이트코드 동일)
마이그레이션 도구 // @ts-check, any 기존 @spec 그대로

작성 시점(2026-06-04) 기준 Elixir 1.20은 아직 RC 단계라 stable에서는 일부 추론이 더 보수적으로 동작할 가능성이 있다.

새로 알게 된 것 — Dialyzer는 어떻게 되나

여기서 잠깐 헷갈렸다. 그동안 Elixir에서 정적 검증은 Dialyzer가 도맡았다. mix dialyzer 한번 돌리면 PLT 빌드에 5분 넘게 걸리고, 결과는 success typing 기반이라 false negative가 많았다. v1.20 들어오면 Dialyzer는 어떻게 되는 거지?

게다가, 공식 블로그 글을 보니, 둘은 공존한다. 컴파일러 타입 시스템은 가장 흔한 버그(잘못된 atom, 잘못된 arity, 패턴 매칭 실패 등)를 빠르게 잡는 데 집중하고, Dialyzer는 더 깊은 데이터 흐름 분석 영역에 남는다. TypeScript와 ESLint의 관계와 비슷한 구도다. 다만 ESLint와 달리 Dialyzer는 점점 역할이 줄어들 가능성이 있다.

컴파일 속도 체감

이게 의외였다. RC 빌드로 기존 프로젝트(파일 약 60개) mix compile --force 돌려보니 체감상 v1.18 대비 미세하게 느려진 정도였다. 타입 추론이 추가됐는데 별로 안 느려졌다. 정확한 수치는 측정 환경(M2 Air, 16GB)에 따라 다를 거라 단언은 못 하겠다.

# Elixir 1.18.2
$ time mix compile --force
Compiling 62 files (.ex)
Generated billing_app app
mix compile --force  18.42s user 1.93s system 312% cpu 6.512 total

# Elixir 1.20.0-rc.0
$ time mix compile --force
Compiling 62 files (.ex)
Generated billing_app app
mix compile --force  19.88s user 2.07s system 308% cpu 7.115 total

10% 정도 늘었다. Dialyzer 5분짜리를 돌리지 않아도 된다는 점을 감안하면 이득이다.

에러 메시지 품질

RC를 만져보면서 가장 놀란 부분이 에러 메시지였다. 일부러 틀린 코드를 짜봤다.

defmodule Cart do
  @spec sum(list(integer())) :: integer()
  def sum(items) do
    Enum.reduce(items, 0, fn item, acc -> item.price + acc end)
  end
end

list(integer())를 받는다고 했는데 안에서 item.price처럼 map 접근을 한다. 컴파일 결과:

warning: incompatible types given to Enum.reduce/3

    Enum.reduce(items, 0, fn item, acc -> item.price + acc end)

given types:

    list(integer()), integer(), (integer(), integer() -> term())

but function expects:

    Enumerable.t(element), acc, (element, acc -> acc)

where "element" is integer() based on @spec

한편, TypeScript의 빨간 줄과 결이 비슷한데, 패턴 매칭 위주의 언어라 그런지 "이 spec에서 element가 integer로 추론됐다"고 추론 경로를 같이 보여준다. 디버깅하기 좋은 형태다.

코드 — Phoenix 컨트롤러에 적용해보기

결국, Phoenix 컨트롤러 액션에 spec을 붙여보면 어떻게 동작하는지 짧게 보여주려 한다.

defmodule BillingAppWeb.InvoiceController do
  use BillingAppWeb, :controller

  alias Billing.Invoice

  # 컨트롤러 액션에도 점진적으로 적용 가능
  @spec show(Plug.Conn.t(), %{required("id") => binary()}) :: Plug.Conn.t()
  def show(conn, %{"id" => id}) do
    case Invoice.get(id) do
      {:ok, invoice} ->
        # invoice가 Invoice.t() 구조체로 추론됨
        render(conn, :show, invoice: invoice)

      {:error, :not_found} ->
        conn
        |> put_status(:not_found)
        |> json(%{error: "invoice not found"})
    end
  end
end

그런데, 여기서 Invoice.get/1의 리턴 spec이 {:ok, Invoice.t()} | {:error, :not_found}로 선언돼 있으면, case 패턴마다 타입이 좁혀진다. 첫 번째 매치 안에서 invoice는 자동으로 Invoice.t()로 추론된다. TypeScript의 discriminated union narrowing과 흡사하다.

흥미로운 건 두 번째 분기에서 put_status(:not_found):not_found가 정확히 알려진 atom인지도 체크된다는 거다. :notfound 같은 오타를 컴파일 단계에서 잡는다. 직접 오타를 내봤더니 다음 경고가 나왔다.

warning: the atom :notfound is not a known status code
  (BillingAppWeb.InvoiceController.show/2)

물론 모든 외부 라이브러리가 spec을 다 갖춘 건 아니라서 효과는 라이브러리에 따라 갈린다.

구조체와 map 타입

예를 들어, 이쪽이 TypeScript 경험자에게 가장 헷갈리는 지점이다.

defmodule Invoice do
  defstruct [:id, :total, :paid]

  @type t :: %__MODULE__{
    id: binary(),
    total: integer(),
    paid: boolean()
  }
end

%__MODULE__{} 표현이 TS의 interface Invoice에 해당한다. 다만 Elixir 구조체는 내부적으로 그냥 map이라, 타입 시스템도 "특정 키를 가진 map"으로 본다. nominal과 structural 사이 어딘가에 있다. José Valim이 이 부분을 set-theoretic types 논문에서 다뤘는데, 처음에는 직관적이지 않았다. TS의 interface와 다르게 nominal 정보가 추가로 붙어 있어서 다른 구조체와 우연히 모양이 같아도 호환되지 않는다.

메모 — TypeScript 전철을 밟을까

따라서, 문서들 읽으면서 든 생각을 메모로 남긴다. 결론은 아니다.

그런데, TypeScript는 2012년에 나왔고, 처음에는 "JS에 타입 붙인 거 굳이 왜?"라는 반응이 많았다. 5~6년 지나니 사실상 표준이 됐다. Elixir가 같은 길을 갈까? 환경이 다르다.

  • TypeScript는 별개 컴파일러(tsc)로 시작했고, JS와 분리된 진영이 있었다. Elixir 1.20은 처음부터 공식 컴파일러에 통합된다. 진영 분열이 일어날 여지가 적다.
  • TS는 any라는 출구가 있어서 사실상 강제 적용이 어렵다. Elixir의 점진적 모델은 어노테이션 없는 함수가 dynamic()으로 추론되고, 어노테이션 붙으면 그 영역만 엄격해진다. 출구는 같지만 더 명시적이다.
  • TS는 런타임에 타입 정보가 사라진다. Elixir도 같다. BEAM 바이트코드는 그대로다. 단 BEAM은 원래 pattern matching과 가드로 런타임 타입 체크가 들어가 있어서 런타임-컴파일타임 갭이 TS만큼 크지 않다.

실제로 v1.20 RC에서 dynamic() 타입이 어떻게 동작하는지가 핵심이다. 아래는 José Valim의 2023년 6월 글에서 정리한 도식인데, RC 코드에서도 같은 의미로 동작한다.

# 어노테이션 없음: 전체가 dynamic()
def transform(x), do: x * 2

# 어노테이션 일부: 입력만 좁혀짐
@spec transform(integer()) :: dynamic()
def transform(x), do: x * 2  # 리턴은 dynamic으로 남음

# 어노테이션 완전: 양쪽 다 좁혀짐
@spec transform(integer()) :: integer()
def transform(x), do: x * 2

TS의 --strict 옵션과 비슷한 그러데이션이 BEAM 한 모듈 안에서도 가능하다.

라이브러리 생태계 영향

게다가, 이게 모르겠다. Phoenix, Ecto, Broadway 같은 메이저 라이브러리가 spec을 얼마나 빨리 보강하느냐에 따라 체감 효과가 갈릴 거다. Phoenix 1.7은 이미 핵심 모듈에 spec이 꽤 있어서 컴파일러 추론을 그대로 받는다. Ecto는 매크로가 많아서 추론이 까다로울 수 있다. 이 부분은 더 써봐야 알 거 같다.

오늘 메모는 여기까지. 내일은 Ecto changeset에 RC 컴파일러가 어떻게 반응하는지 따로 만져볼 생각이다.

관련 글