메타 프로그래밍 안티 패턴
메타 프로그래밍 안티 패턴
이 문서는 메타 프로그래밍과 관련된 잠재적인 안티 패턴들을 정리해요. 매크로를 쓸 때 흔히 빠지기 쉬운 함정들을 실제 예시와 함께 짚어보고, 각각 어떻게 리팩터링하면 좋은지 설명합니다.
본문
컴파일 타임 의존성
문제
이 안티 패턴은 Elixir에서 파일 사이의 의존성과 관련돼요. 매크로는 컴파일 시점에 사용되기 때문에, 어떤 매크로를 쓰는 순간 그 매크로를 정의한 모듈에 컴파일 타임 의존성이 생겨요.
문제는 모듈 본문 안에서 매크로를 쓸 때, 매크로에 넘긴 인자 자체가 컴파일 타임 의존성이 될 수 있다는 점이에요. 이런 의존성은 한 파일만 바꿔도 여러 파일을 다시 컴파일해야 하는 의존성 그래프를 만들 수 있어요.
예시
Plug 라이브러리를 예로 들어볼게요. Plug는 요청이 들어올 때마다 호출될 여러 모듈(플러그)을 지정하게 해주는데, 사용자는 이렇게 써요.
defmodule MyApp do
use Plug.Builder
plug MyApp.Authentication
end
Plug가 위 매크로를(단순화해서) 이렇게 정의한다고 상상해 보세요.
defmodule Plug.Builder do
defmacro __using__(_opts) do
quote do
Module.register_attribute(__MODULE__, :plugs, accumulate: true)
@before_compile Plug.Builder
end
end
defmacro plug(mod) do
quote do
@plugs unquote(mod)
end
end
...
end
구현은 모든 모듈을 @plugs 모듈 속성에 쌓아요. 모듈이 컴파일되기 직전 Plug.Builder는 @plugs에 담긴 모듈들을 읽어 함수로 컴파일해요. 문제는 plug MyApp.Authentication이 컴파일 타임에 호출되었기 때문에, MyApp.Authentication이 컴파일 타임에 전혀 쓰이지 않더라도 MyApp의 컴파일 타임 의존성이 된다는 거예요. 만약 MyApp.Authentication이 다른 모듈에 의존한다면, 변경 시 큰 재컴파일 그래프로 이어질 수 있어요.
리팩터링
이 안티 패턴을 해결하려면 매크로가 리터럴을 사용될 문맥 안에서 확장하면 돼요.
defmacro plug(mod) do
mod = Macro.expand_literals(mod, %{__CALLER__ | function: {:call, 2}})
quote do
@plugs unquote(mod)
end
end
mod가 call/2 함수 안에서만 쓰이므로, 마치 call/2 안에 있는 것처럼 모듈 참조를 미리 확장해요. 이제 MyApp.Authentication은 MyApp의 런타임 의존성일 뿐, 컴파일 타임 의존성이 아니에요.
단, 이 방식은 매크로가 컴파일 타임에 어떤 함수를 호출하거나 구조체·모듈 메타데이터에 접근하지 않을 때만 해야 해요. 함수 정의 밖에서 매크로에 주어진 모듈과 상호작용한다면 사실상 컴파일 타임 의존성이에요. 그리고 보통 피하고 싶지만 항상 가능한 건 아니에요.
실제 프로젝트에서는 mix xref trace path/to/file.ex로 파일을 실행해 어떤 모듈에 의존하는지, 그 모듈이 컴파일 타임·런타임·export 의존성 중 무엇인지 출력해 볼 수 있어요.
과도한 코드 생성
문제
이 안티 패턴은 너무 많은 코드를 생성하는 매크로와 관련돼요. 매크로가 대량의 코드를 만들면 컴파일러나 런타임 동작에 영향을 줘요. Elixir가 코드를 여러 번 펼치고(expand) 컴파일하고 실행해야 할 수 있어서, 컴파일이 느려지고 결과 컴파일 산출물도 커져요.
예시
웹 애플리케이션의 라우터를 정의한다고 상상해 보세요. get/2 같은 매크로가 있을 텐데, 매크로를 호출할 때마다(수백 번일 수 있어요) get/2 안의 코드가 펼쳐지고 컴파일돼서 전체적으로 큰 코드가 생성될 수 있어요.
defmodule Routes do
defmacro get(route, handler) do
quote do
route = unquote(route)
handler = unquote(handler)
if not is_binary(route) do
raise ArgumentError, "route must be a binary"
end
if not is_atom(handler) do
raise ArgumentError, "handler must be a module"
end
@store_route_for_compilation {route, handler}
end
end
end
리팩터링
이 안티 패턴을 없애려면 매크로를 단순화하고 작업의 일부를 다른 함수로 위임해야 해요. 아래처럼 quote/1 안의 코드를 __define__/3 함수로 옮기면, 매크로를 호출할 때마다 펼쳐지고 컴파일되는 코드를 줄이고 대신 함수로 디스패치해 대부분의 작업을 처리하게 돼요.
defmodule Routes do
defmacro get(route, handler) do
quote do
Routes.__define__(__MODULE__, unquote(route), unquote(handler))
end
end
def __define__(module, route, handler) do
if not is_binary(route) do
raise ArgumentError, "route must be a binary"
end
if not is_atom(handler) do
raise ArgumentError, "handler must be a module"
end
Module.put_attribute(module, :store_route_for_compilation, {route, handler})
end
end
불필요한 매크로
문제
매크로는 Elixir에서 언어를 확장하는 강력한 메타 프로그래밍 메커니즘이에요. 매크로를 쓰는 것 자체가 안티 패턴은 아니지만, 반드시 필요할 때만 써야 해요. 함수나 기존 Elixir 구조로 같은 문제를 해결할 수 있는데도 매크로를 쓰면 코드가 불필요하게 복잡해지고 읽기 어려워져요. 매크로는 구현하고 추론하기 어렵기 때문에 무분별한 사용은 시스템 진화를 위험하게 하고 유지보수성을 떨어뜨려요.
예시
MyMath 모듈이 파라미터로 받은 두 숫자의 합을 구하는 sum/2 매크로를 구현한다고 해요. 문법 오류도 없고 결과도 맞지만 불필요하게 복잡해져요.
defmodule MyMath do
defmacro sum(v1, v2) do
quote do
unquote(v1) + unquote(v2)
end
end
end
iex> require MyMath
MyMath
iex> MyMath.sum(3, 5)
8
iex> MyMath.sum(3 + 1, 5 + 6)
15
리팩터링
불필요한 매크로를 named function처럼 쓰고 이해하기 쉬운 구조로 바꿔야 해요. 아래는 위 예시의 리팩터링 결과예요. sum/2 매크로가 일반 named function이 됐고, require/2 호출도 더 이상 필요 없어요.
defmodule MyMath do
def sum(v1, v2) do # <= 매크로가 named function이 됐어요
v1 + v2
end
end
iex> MyMath.sum(3, 5)
8
iex> MyMath.sum(3+1, 5+6)
15
import 대신 use
문제
Elixir는 모듈 사이의 의존성을 만들기 위해 import/1, alias/1, use/1 같은 메커니즘을 제공해요. 이 메커니즘으로 구현된 코드 자체는 문제가 아니에요. 하지만 import/1과 alias/1은 렉시컬 스코프를 가지며 한 모듈이 다른 모듈의 함수를 호출하기 쉽게 해주는 반면, use/1은 더 넓은 스코프를 가져서 문제가 될 수 있어요.
use/1은 한 모듈이 다른 모듈에 어떤 종류의 코드든 주입할 수 있게 해줘요. 의존성 전파도 포함이에요. 그래서 use/1을 쓰면 코드를 읽기 어려워져요. 모듈을 참조했을 때 정확히 무슨 일이 일어날지 이해하려면 참조된 모듈의 내부 세부 사항을 알아야 하기 때문이에요.
예시
아래 코드가 이 안티 패턴의 예시예요. ModuleA, Library, ClientApp 세 모듈을 정의해요. ClientApp은 use/1로 Library의 코드를 재사용하지만 내부 세부 사항은 모르는 상태예요. 게다가 Library는 foo/0을 정의하는 ModuleA를 import하는데, 이는 ClientApp에서 정의한 로컬 함수와 충돌해요.
defmodule ModuleA do
def foo do
"From Module A"
end
end
defmodule Library do
defmacro __using__(_opts) do
quote do
import Library
import ModuleA # <= 의존성 전파!
end
end
def from_lib do
"From Library"
end
end
defmodule ClientApp do
use Library
def foo do
"Local function from client app"
end
def from_client_app do
from_lib() <> " - " <> foo()
end
end
ClientApp을 컴파일하면 Elixir가 충돌을 감지하고 다음 에러를 던져요.
error: imported ModuleA.foo/0 conflicts with local function
└ client_app.ex:4:
리팩터링
라이브러리 작성자는 alias/1이나 import/1로 대체할 수 있을 때마다 __using__/1 콜백 제공을 피하는 걸 권해요. 아래 코드는 use Library가 더 이상 없고 ClientApp을 그렇게 리팩터링한 상태예요. 코드가 더 명확해지고 앞의 충돌도 사라져요.
defmodule ClientApp do
import Library
def foo do
"Local function from client app"
end
def from_client_app do
from_lib() <> " - " <> foo()
end
end
iex> ClientApp.from_client_app()
"From Library - Local function from client app"
추가 참고
import와 alias보다 더 많은 일을 해야 하는 상황이라면 use MyModule 제공이 필요할 수 있어요. Elixir 생태계에서 공통 확장 지점을 제공하기 때문이에요.
지침과 명확성을 위해 라이브러리 작성자는 @moduledoc에 use MyModule이 개발자 코드에 어떤 영향을 주는지 설명하는 admonition 블록을 넣는 걸 권해요. GenServer 문서의 예시를 볼게요.
use GenServer{: .info}
use GenServer를 하면GenServer모듈이@behaviour GenServer를 설정하고child_spec/1함수를 정의해, 모듈을 감독 트리(supervision tree)의 자식으로 쓸 수 있게 해줘요.
이 요약을 코드 생성의 "영양성분표"라고 생각하면 좋아요. 모듈의 공개 API에 가해진 변경만 나열해야 해요. 예를 들어 use Library가 @_some_module_info 같은 내부 속성을 설정하고 이 속성이 절대 공개될 일이 없다면, 영양성분표에 문서화하지 않는 게 좋아요.
추적되지 않는 컴파일 타임 의존성
문제
이 안티 패턴은 "컴파일 타임 의존성"의 반대예요. 컴파일 타임 의존성이 실수로 우회되어 Elixir 컴파일러가 의존성을 추적하고 파일을 제대로 재컴파일하지 못하는 상황이에요. 모듈 안이나 매크로 안에서 별칭(즉 모듈 이름)을 동적으로 만들 때 발생해요.
예시
컴파일 타임에 모듈을 호출한다고 상상해 보세요.
defmodule MyModule do
SomeOtherModule.example()
end
이 경우 Elixir는 MyModule이 함수 밖에서 SomeOtherModule.example/0을 호출하므로 컴파일 타임에 호출된다는 걸 알아요.
Elixir는 동적 호출에서도 모듈 이름을 추적할 수 있어요.
defmodule MyModule do
mods = [OtherModule.Foo, OtherModule.Bar]
for mod <- mods do
mod.example()
end
end
이 예에서 Elixir는 example/0 함수가 어떤 모듈에서 호출됐는지는 몰라도 OtherModule.Foo와 OtherModule.Bar가 함수 밖에서 참조되므로 컴파일 타임 의존성이 된다는 걸 알아요. 그중 하나라도 바뀌면 MyModule을 다시 컴파일해요.
하지만 모듈 이름 자체를 프로그래밍적으로 생성하면 안 돼요. 그러면 Elixir가 추적할 수 없거든요. 정확히 말하면 이런 코드를 쓰지 마세요.
defmodule MyModule do
parts = [:Foo, :Bar]
for part <- parts do
Module.concat(OtherModule, part).example()
end
end
이 경우 모듈 전체가 생성되었기 때문에 Elixir는 OtherModule에만 의존한다고 보고 OtherModule.Foo와 OtherModule.Bar는 보지 못해요. 프로젝트를 재컴파일할 때 불일치가 생길 수 있어요.
별칭이 그냥 아톰이라는 성질을 남용해 아톰을 직접 정의할 때도 비슷한 버그가 생길 수 있어요. 아래 경우 Elixir는 별칭을 전혀 보지 못해 추적되지 않는 컴파일 타임 의존성이 생겨요.
defmodule MyModule do
mods = [:"Elixir.OtherModule.Foo", :"Elixir.OtherModule.Bar"]
for mod <- mods do
mod.example()
end
end
리팩터링
모듈 이름을 프로그래밍적으로 정의하는 걸 피해야 해요. 예를 들어 여러 모듈로 디스패치해야 한다면 전체 모듈 이름을 사용해요. 정말로 모듈을 동적으로 정의해야 한다면 메타 프로그래밍으로 컴파일 타임에 전체 모듈 이름을 만들어요.
defmodule MyMacro do
defmacro call_examples(parts) do
for part <- parts do
quote do
# 이 코드는 컴파일 타임에 OtherModule.Foo를 만든다
OtherModule.unquote(part).example()
end
end
end
end
defmodule MyModule do
import MyMacro
call_examples [:Foo, :Bar]
end
실제 프로젝트에서는 mix xref trace path/to/file.ex로 어떤 모듈에 의존하는지, 그 모듈이 컴파일 타임·런타임·export 의존성 중 무엇인지 출력해 볼 수 있어요. 외부 모듈과 관련해 의존성이 제대로 추적되는지 디버깅하는 데 도움이 돼요.
더 알아보기
Macro모듈: 매크로 확장·리터럴·예외 처리 유틸리티Kernel.SpecialForms.quote/2: 매크로가 코드를 정의하는 데 쓰는 특수 형식mix xref: 파일 간 의존성 추적 도구