도메인 특화 언어(DSLs)
도메인 특화 언어(DSLs)
도메인 특화 언어(DSL)는 특정 애플리케이션 도메인에 맞춰진 언어예요. DSL을 만들기 위해 매크로가 필요하진 않아요. 모듈에서 정의하는 모든 데이터 구조와 모든 함수는 이미 여러분의 도메인 특화 언어의 일부이기 때문이에요.
예를 들어 데이터 검증을 위한 도메인 특화 언어를 제공하는 Validator 모듈을 구현하고 싶다고 해 봅시다. 데이터 구조, 함수, 매크로로 구현할 수 있어요. 그런 DSL들이 각각 어떻게 생겼는지 볼게요.
# 1. Data structures
import Validator
validate user, name: [length: 1..100], email: [matches: ~r/@/]
# 2. Functions
import Validator
user
|> validate_length(:name, 1..100)
|> validate_matches(:email, ~r/@/)
# 3. Macros + modules
defmodule MyValidator do
use Validator
validate_length :name, 1..100
validate_matches :email, ~r/@/
end
MyValidator.validate(user)
위 접근 중 첫 번째가 단연 가장 유연해요. 도메인 규칙을 데이터 구조로 인코딩할 수 있다면, 서로 다른 데이터 타입을 다루는 함수로 가득 찬 Elixir 표준 라이브러리 덕분에 조합하고 구현하기가 훨씬 쉬워요.
두 번째 접근은 함수 호출을 사용하는데, 더 복잡한 API(예: 많은 옵션을 넘겨야 하는 경우)에 더 잘 맞고 파이프 연산자 덕분에 Elixir에서 잘 읽혀요.
세 번째 접근은 매크로를 사용하며 단연 가장 복잡해요. 구현에 더 많은 코드 줄이 필요하고, (단순 함수를 테스트하는 것에 비해) 테스트하기 어렵고 비용이 크며, 모든 검증이 모듈 안에 정의돼야 하므로 사용자가 라이브러리를 사용하는 방식을 제한해요.
핵심을 짚기 위해, 주어진 조건이 충족될 때만 특정 속성을 검증하고 싶다고 상상해 봅시다. 첫 번째 해법은 데이터 구조를 적절히 조작하는 방식으로, 두 번째 해법은 함수를 호출하기 전에 조건문(if/else)을 쓰는 방식으로 쉽게 달성할 수 있어요. 하지만 매크로 접근 방식은 그 DSL이 확장되지 않는 한 불가능해요.
다시 말해:
data > functions > macros
그렇긴 해도 매크로와 모듈로 도메인 특화 언어를 만드는 게 유용한 경우는 여전히 있어요. Getting Started 가이드에서 데이터 구조와 함수 정의를 이미 다뤘으므로, 이번 장에서는 매크로와 모듈 속성(attribute)을 사용해 더 복잡한 DSL을 다루는 방법을 살펴볼게요.
본문
우리만의 테스트 케이스 만들기
이번 장의 목표는 다음을 작성할 수 있게 해 주는 TestCase라는 모듈을 만드는 거예요.
defmodule MyTest do
use TestCase
test "arithmetic operations" do
4 = 2 + 2
end
test "list operations" do
[1, 2, 3] = [1, 2] ++ [3]
end
end
MyTest.run()
위 예시에서 TestCase를 사용하면 test 매크로로 테스트를 작성할 수 있어요. 이 매크로는 모든 테스트를 자동으로 실행해 주는 run이라는 함수를 정의합니다. 우리 프로토타입은 어서션(assertion) 메커니즘으로 매치 연산자(=)에 의존할 거예요.
test 매크로
use했을 때 test 매크로를 정의하고 import하는 모듈을 만들어 봅시다.
defmodule TestCase do
# Callback invoked by `use`.
#
# For now it returns a quoted expression that
# imports the module itself into the user code.
@doc false
defmacro __using__(_opts) do
quote do
import TestCase
end
end
@doc """
Defines a test case with the given description.
## Examples
test "arithmetic operations" do
4 = 2 + 2
end
"""
defmacro test(description, do: block) do
function_name = String.to_atom("test " <> description)
quote do
def unquote(function_name)(), do: unquote(block)
end
end
end
TestCase를 tests.exs라는 파일에 정의했다고 가정하면, iex tests.exs로 열고 첫 테스트를 정의할 수 있어요.
iex> defmodule MyTest do
...> use TestCase
...>
...> test "hello" do
...> "hello" = "world"
...> end
...> end
지금은 테스트를 실행할 메커니즘이 없지만, 배후에서 test hello라는 함수가 정의된다는 걸 알고 있어요. 그걸 호출하면 실패해야 해요.
iex> MyTest."test hello"()
** (MatchError) no match of right hand side value: "world"
속성으로 정보 저장하기
TestCase 구현을 완성하려면 정의된 모든 테스트 케이스에 접근할 수 있어야 해요. 한 가지 방법은 __MODULE__.__info__(:functions)로 런타임에 테스트를 가져오는 것인데, 이는 주어진 모듈의 모든 함수 목록을 반환해요. 하지만 테스트 이름 외에 각 테스트에 대한 더 많은 정보를 저장하고 싶을 수도 있으니, 더 유연한 접근이 필요해요.
앞서 모듈 속성을 다룰 때 속성을 임시 저장소로 쓸 수 있다고 언급했죠. 이번 섹션에서 그 성질을 정확히 활용할 거예요.
__using__/1 구현에서 @tests라는 모듈 속성을 빈 리스트로 초기화한 뒤, 정의된 각 테스트의 이름을 이 속성에 저장해서 run 함수에서 테스트를 호출할 수 있게 할 거예요.
다음은 TestCase 모듈의 업데이트된 코드예요.
defmodule TestCase do
@doc false
defmacro __using__(_opts) do
quote do
import TestCase
# Initialize @tests to an empty list
@tests []
# Invoke TestCase.__before_compile__/1 before the module is compiled
@before_compile TestCase
end
end
@doc """
Defines a test case with the given description.
## Examples
test "arithmetic operations" do
4 = 2 + 2
end
"""
defmacro test(description, do: block) do
function_name = String.to_atom("test " <> description)
quote do
# Prepend the newly defined test to the list of tests
@tests [unquote(function_name) | @tests]
def unquote(function_name)(), do: unquote(block)
end
end
# This will be invoked right before the target module is compiled
# giving us the perfect opportunity to inject the `run/0` function
@doc false
defmacro __before_compile__(_env) do
quote do
def run do
Enum.each(@tests, fn name ->
IO.puts("Running #{name}")
apply(__MODULE__, name, [])
end)
end
end
end
end
새 IEx 세션을 시작하면 이제 테스트를 정의하고 실행할 수 있어요.
iex> defmodule MyTest do
...> use TestCase
...>
...> test "hello" do
...> "hello" = "world"
...> end
...> end
iex> MyTest.run()
Running test hello
** (MatchError) no match of right hand side value: "world"
몇 가지 세부 사항을 간과했지만, 이것이 Elixir에서 모듈과 매크로로 도메인 특화 언어를 만드는 핵심 아이디어예요. 매크로는 호출자에서 실행되는 quoted 표현식을 반환하게 해 주고, 이를 통해 코드를 변환하고 모듈 속성을 통해 관련 정보를 대상 모듈에 저장할 수 있어요. 마지막으로 @before_compile 같은 콜백은 모듈 정의가 완료될 때 코드를 그 모듈에 주입할 수 있게 해 줍니다.
@before_compile 외에도 @on_definition과 @after_compile 같은 유용한 모듈 속성이 있는데, 자세한 내용은 Module 문서를 참고하세요. 매크로와 컴파일 환경에 대한 유용한 정보는 Macro와 Macro.Env 문서에서도 찾을 수 있어요.