설계 관련 안티패턴(Design-related anti-patterns)
설계 관련 안티패턴(Design-related anti-patterns)
이 문서는 모듈·함수와 그 함수들이 코드베이스에서 맡는 역할과 관련된 잠재적 안티패턴을 정리한 문서예요. 어떤 것들이 문제가 되는지, 그리고 어떻게 리팩터링하면 좋을지 하나씩 살펴볼게요.
본문
대체 반환 타입(Alternative return types)
문제
이 안티패턴은 함수가 옵션(보통 키워드 리스트 파라미터)을 받아서 그에 따라 반환 타입이 크게 달라지는 경우를 말해요. 옵션은 선택적이고 때로는 동적으로 설정되기 때문에, 옵션에 따라 반환 타입까지 바뀌면 함수가 실제로 무엇을 반환하는지 이해하기 어려워질 수 있어요.
예시
아래처럼 함수가 파라미터로 받은 옵션에 따라 여러 대체 반환 타입을 가지는 경우가 바로 이 안티패턴의 예시예요.
defmodule AlternativeInteger do
@spec parse(String.t(), keyword()) :: integer() | {integer(), String.t()} | :error
def parse(string, options \\ []) when is_list(options) do
if Keyword.get(options, :discard_rest, false) do
case Integer.parse(string) do
{int, _rest} -> int
:error -> :error
end
else
Integer.parse(string)
end
end
end
iex> AlternativeInteger.parse("13")
{13, ""}
iex> AlternativeInteger.parse("13", discard_rest: false)
{13, ""}
iex> AlternativeInteger.parse("13", discard_rest: true)
13
리팩터링
이 안티패턴을 리팩터링하려면, 다음처럼 각 반환 타입에 대해 전용 함수(예: parse_discard_rest/1)를 추가해서 더 이상 인자로 넘기는 옵션에 위임하지 않으면 돼요.
defmodule AlternativeInteger do
@spec parse(String.t()) :: {integer(), String.t()} | :error
def parse(string) do
Integer.parse(string)
end
@spec parse_discard_rest(String.t()) :: integer() | :error
def parse_discard_rest(string) do
case Integer.parse(string) do
{int, _rest} -> int
:error -> :error
end
end
end
iex> AlternativeInteger.parse("13")
{13, ""}
iex> AlternativeInteger.parse_discard_rest("13")
13
불리언 집착(Boolean obsession)
문제
이 안티패턴은 정보를 인코딩하기 위해 아톰(atom) 대신 불리언(boolean)을 사용할 때 생겨요. 불리언을 쓰는 것 자체는 안티패턴이 아니지만, 상태가 겹치는 여러 불리언이 사용될 때는 불리언을 아톰(또는 튜플 같은 복합 데이터 타입)으로 바꾸는 게 더 명확한 코드가 될 수 있어요.
이는 Primitive obsession의 특수한 경우로, 불리언 값에 국한된 케이스예요.
예시
이 안티패턴의 예로는 함수가 editor: true와 admin: true처럼 두 개 이상의 옵션을 받아 서로 겹치는 방식으로 동작을 설정하는 경우가 있어요. 아래 코드에서 :admin이 설정되면 :editor 옵션은 아무 효과가 없어요. 즉 :admin 옵션이 :editor보다 우선순위가 높고, 결국 둘은 서로 관련된 거죠.
defmodule MyApp do
def process(invoice, options \\ []) do
cond do
options[:admin] -> # Is an admin
options[:editor] -> # Is an editor
true -> # Is none
end
end
end
리팩터링
여러 옵션을 쓰는 대신, 위 코드는 :role이라는 단일 옵션을 받도록 리팩터링할 수 있어요. :role은 :admin, :editor, :default 중 하나를 가질 수 있죠.
defmodule MyApp do
def process(invoice, options \\ []) do
case Keyword.get(options, :role, :default) do
:admin -> # Is an admin
:editor -> # Is an editor
:default -> # Is none
end
end
end
이 안티패턴은 우리 자신의 데이터 구조에서도 생길 수 있어요. 예를 들어 User struct에 :editor와 :admin이라는 두 불리언 필드를 둘 수도 있지만, :role이라는 단일 필드가 더 선호될 수 있어요.
마지막으로, 불리언 인자/옵션이 하나뿐인 경우에도 아톰을 쓰는 게 더 나을 수 있다는 점을 언급할 만해요. 예를 들어 승인/미승인 상태로 설정될 수 있는 인보이스(invoice)를 생각해 봅시다. 한 가지 방법은 불리언을 기대하는 함수를 제공하는 거예요.
MyApp.update(invoice, approved: true)
하지만 아톰을 쓰면 더 잘 읽히고, 나중에 (pending 같은) 상태를 추가하기도 더 간단해져요.
MyApp.update(invoice, status: :approved)
불리언은 내부적으로 아톰으로 표현된다는 점을 기억하세요. 따라서 한 접근이 다른 접근보다 성능상 손해를 보는 일은 없어요.
제어 흐름을 위한 예외(Exceptions for control-flow)
문제
이 안티패턴은 Exception을 제어 흐름에 사용하는 코드를 말해요. 예외 처리 자체는 안티패턴이 아니지만, 개발자는 코드의 흐름을 바꾸기 위해 try/rescue 대신 case와 패턴 매칭을 우선 사용해야 해요. 그와 함께 라이브러리 작성자는 예외 처리에 의존하지 않고도 오류를 다룰 수 있는 API를 개발자에게 제공해야 합니다. 개발자가 어떤 오류가 예외적(exceptional)인지 아닌지 결정할 자유가 없을 때, 그것은 안티패턴으로 간주돼요.
예시
파일 작업을 다룰 때 try/rescue를 사용하는 아래 예시가 이 안티패턴에 해당해요.
defmodule MyModule do
def print_file(file) do
try do
IO.puts(File.read!(file))
rescue
e -> IO.puts(:stderr, Exception.message(e))
end
end
end
iex> MyModule.print_file("valid_file")
This is a valid file!
:ok
iex> MyModule.print_file("invalid_file")
could not read file "invalid_file": no such file or directory
:ok
리팩터링
이 안티패턴을 리팩터링하려면, 파일을 읽지 못했을 때 예외를 던지는 대신 튜플을 반환하는 File.read/1을 사용하면 돼요.
defmodule MyModule do
def print_file(file) do
case File.read(file) do
{:ok, binary} -> IO.puts(binary)
{:error, reason} -> IO.puts(:stderr, "could not read file #{file}: #{reason}")
end
end
end
이게 가능한 이유는 File 모듈이 튜플 결과로 파일을 읽는 API(File.read/1)와 예외를 던지는 버전(File.read!/1)을 모두 제공하기 때문이에요. bang(느낌표)은 사실상 Elixir 네이밍 규칙의 일부입니다.
라이브러리 작성자도 같은 관례를 따르길 권장해요. 실제로 bang 변형은 예외를 던지지 않는 버전 위에 구현됩니다. 예를 들어 File.read!/1은 이렇게 구현돼 있어요.
def read!(path) do
case read(path) do
{:ok, binary} ->
binary
{:error, reason} ->
raise File.Error, reason: reason, action: "read file", path: IO.chardata_to_string(path)
end
end
커뮤니티가 따르는 흔한 관례는 예외를 던지지 않는 버전이 {:ok, result}나 {:error, Exception.t}를 반환하게 하는 거예요. 예를 들어 HTTP 클라이언트는 성공 시 {:ok, %HTTP.Response{}}를, 실패 시 {:error, %HTTP.Error{}}를 반환할 수 있는데, 이때 HTTP.Error는 예외로 구현됩니다. 이렇게 하면 누구나 Kernel.raise/1만 호출해서 쉽게 예외를 던질 수 있어요.
추가 논의
이 안티패턴은 라이브러리 작성자와 다른 개발자·서드파티 코드가 호출할 함수를 작성할 때 특히 중요해요. 그럼에도 개발자가 직접 예외를 던져도 괜찮은 시나리오는 여전히 존재합니다. 예를 들어:
- 유효하지 않은 인자: 함수는 유효하지 않은 인자에 대해서는 예외를 던지리라 기대돼요. 그런 오류는 구조적(structural) 오류이지 의미론적(semantic) 오류가 아니거든요. 예를 들어
File.read(123)은 항상 예외를 던집니다.123은 절대 유효한 파일 이름이 될 수 없으니까요. - 테스트, 스크립트 등: 오류가 나면 가능한 한 빨리 실패시키고 싶은 흔한 시나리오예요.
File.read!/1같은!함수를 쓰면 명확한 오류 메시지와 함께 빠르게 실패시킬 수 있어요. - Phoenix 같은 일부 프레임워크: 개발자가 코드에서 예외를 던지도록 허용하고, 프로토콜을 사용해 이 예외를 의미론적 HTTP 응답으로 변환해요.
이 안티패턴은 이전에 Using exceptions for control-flow로 불렸어요.
기본형 집착(Primitive obsession)
문제
이 안티패턴은 Elixir의 기본 타입(예: integer, float, string)이 도메인을 더 잘 표현할 수 있는 특정 복합 데이터 타입(예: tuple, map, struct)을 만들지 않고, 구조화된 정보를 나르는 데 과도하게 사용될 때 생겨요.
예시
이 안티패턴의 예시는 단일 문자열로 Address를 표현하는 경우예요. Address는 단순한 기본(일명, primitive) 값보다 훨씬 복잡한 구조죠.
defmodule MyApp do
def extract_postal_code(address) when is_binary(address) do
# Extract postal code with address...
end
def fill_in_country(address) when is_binary(address) do
# Fill in missing country...
end
end
데이터베이스, 웹 요청, 서드파티로부터 address를 문자열로 받을 수는 있어요. 하지만 그 문자열을 자주 조작하거나 정보를 추출한다는 걸 깨닫게 되면, 그건 주소를 구조화된 데이터로 변환해야 한다는 좋은 신호예요.
이 안티패턴의 또 다른 예시는 더 풍부한 데이터 구조를 선호해야 할 때 화폐·통화를 부동소수점 숫자로 모델링하는 경우예요.
리팩터링
이 안티패턴을 해결하는 방법은 주소를 모델링할 때 map이나 struct를 사용하는 거예요. 아래 예시는 Address struct를 만들어 복합 타입으로 이 도메인을 더 잘 표현합니다. 추가로 문자열을 Address로 변환하는 parse/1 함수를 도입해 나머지 함수의 로직을 단순화했어요. 이 수정 덕분에 필요할 때 이 복합 타입의 각 필드를 개별적으로 추출할 수 있어요.
defmodule Address do
defstruct [:street, :city, :state, :postal_code, :country]
end
defmodule MyApp do
def parse(address) when is_binary(address) do
# Returns %Address{}
end
def extract_postal_code(%Address{} = address) do
# Extract postal code with address...
end
def fill_in_country(%Address{} = address) do
# Fill in missing country...
end
end
무관한 다중 절 함수(Unrelated multi-clause function)
문제
다중 절(multi-clause) 함수를 쓰는 건 Elixir의 강력한 기능이에요. 하지만 일부 개발자는 이 기능을 남용해 서로 무관한 기능을 한 함수에 묶을 수 있고, 이는 안티패턴이에요.
예시
이 다중 절 함수 사용의 흔한 예는, 각 절의 동작이 다른 절과 완전히 구별되는 방식으로 무관한 비즈니스 로직을 같은 함수 정의에 섞어 넣는 경우예요. 그런 함수는 종종 너무 광범위한 명세를 가지게 되어 다른 개발자가 이해하고 유지보수하기 어려워져요.
일부 개발자는 코드 가독성 부족을 보완하기 위해 @doc 같은 문서화 메커니즘을 사용하기도 해요. 하지만 그 문서 자체가 서로 다른 인자 조합마다 함수가 어떻게 동작하는지 설명하는 조건문 투성이가 될 수 있어요. 이는 결국 그 절들이 서로 무관하다는 좋은 신호예요.
@doc """
Updates a struct.
If given a product, it will...
If given an animal, it will...
"""
def update(%Product{count: count, material: material}) do
# ...
end
def update(%Animal{count: count, skin: skin}) do
# ...
end
동물(animal) 업데이트가 상품(product) 업데이트와 완전히 다르고 별개의 규칙 집합을 요구한다면, 그 로직을 서로 다른 함수나 심지어 서로 다른 모듈로 나누는 게 좋을 수 있어요.
리팩터링
아래처럼, 이 안티패턴을 해결하는 방법은 단일 무관한 다중 절 함수에 섞여 있던 비즈니스 규칙을 단순한 함수들로 쪼개는 거예요. 각 함수는 자신의 동작과 받는 파라미터를 설명하는 구체적인 이름과 @doc를 가질 수 있어요. 이 리팩터링은 단순해 보이지만 함수의 호출자에게 영향을 줄 수 있으니 조심하세요!
@doc """
Updates a product.
It will...
"""
def update_product(%Product{count: count, material: material}) do
# ...
end
@doc """
Updates an animal.
It will...
"""
def update_animal(%Animal{count: count, skin: skin}) do
# ...
end
이 함수들은 절이 관련된 기능을 묶는 한 여전히 여러 절로 구현할 수 있어요. 예를 들어 update_product는 실제로 다음과 같이 구현될 수 있습니다.
def update_product(%Product{count: 0}) do
# ...
end
def update_product(%Product{material: material})
when material in ["metal", "glass"] do
# ...
end
def update_product(%Product{material: material})
when material not in ["metal", "glass"] do
# ...
end
이 패턴은 Elixir 자체에서도 볼 수 있어요. +/2 연산자는 Integer와 Float는 더할 수 있지만 String은 더하지 못하고, 문자열은 대신 <>/2 연산자를 사용해요. 이런 의미에서 정수와 부동소수를 같은 연산으로 다루는 건 합리적이지만, 문자열은 충분히 무관해서 자기만의 함수가 필요하죠.
Elixir에는 어떤 struct든 동작하는 함수도 있는데, 이는 겉보기엔 이 안티패턴처럼 보일 수 있지만 그렇지 않은 경우가 있어요. 예를 들어 struct/2가 그렇죠.
iex> struct(URI.parse("/foo/bar"), path: "/bar/baz")
%URI{
scheme: nil,
userinfo: nil,
host: nil,
port: nil,
path: "/bar/baz",
query: nil,
fragment: nil
}
여기서 차이는 struct/2가 어떤 struct가 주어지든 정확히 똑같이 동작한다는 점이에요. 그래서 이 함수가 서로 다른 입력을 어떻게 다루는지에 대한 의문이 없어요. 모든 입력에 대해 동작이 명확하고 일관적이라면 안티패턴이 성립하지 않습니다.
라이브러리에 애플리케이션 설정 사용하기(Using application configuration for libraries)
문제
애플리케이션 환경은 Elixir 시스템에서 사용할 전역 값을 파라미터화하는 데 쓸 수 있어요. 이 메커니즘은 매우 유용해서 그 자체로는 안티패턴으로 여겨지지 않습니다. 하지만 라이브러리 작성자는 자기 라이브러리를 설정하는 데 애플리케이션 환경을 사용하는 걸 피해야 해요. 그 이유는 정확히 애플리케이션 환경이 전역 상태라서, 애플리케이션 환경의 각 키에는 단 하나의 값만 존재할 수 있기 때문이에요. 이 때문에 같은 라이브러리에 의존하는 여러 애플리케이션이 라이브러리의 같은 측면을 서로 다르게 설정하는 것이 불가능해져요.
예시
DashSplitter 모듈은 전역 애플리케이션 환경을 통해 자체 함수의 동작을 설정하는 라이브러리를 나타내요. 이 설정은 아래처럼 config/config.exs 파일에 집중되어 있어요.
import Config
config :app_config,
parts: 3
import_config "#{config_env()}.exs"
DashSplitter 라이브러리가 구현한 함수 중 하나는 split/1이에요. 이 함수는 파라미터로 받은 문자열을 특정 수의 부분으로 나누는 역할을 해요. split/1에서 구분자로 쓰이는 문자는 항상 "-"이고, 문자열이 나뉘는 부분 수는 애플리케이션 환경에 의해 전역적으로 정의됩니다. 이 값은 split/1이 Application.fetch_env!/2를 호출해 가져와요.
defmodule DashSplitter do
def split(string) when is_binary(string) do
parts = Application.fetch_env!(:app_config, :parts) # <= retrieve parameterized value
String.split(string, "-", parts: parts) # <= parts: 3
end
end
DashSplitter 라이브러리가 사용하는 이 파라미터화된 값 때문에, 여기에 의존하는 모든 애플리케이션은 문자열 분리로 생성되는 부분 수에 관해 똑같은 동작으로만 split/1을 사용할 수 있어요. 현재 이 값은 3인데, 아래 사용 예시에서 볼 수 있듯이요.
iex> DashSplitter.split("Lucas-Francisco-Vegi")
["Lucas", "Francisco", "Vegi"]
iex> DashSplitter.split("Lucas-Francisco-da-Matta-Vegi")
["Lucas", "Francisco", "da-Matta-Vegi"]
리팩터링
이 안티패턴을 없애려면, 이런 유형의 설정은 함수에 전달되는 파라미터를 사용해 수행해야 해요. 아래 코드는 split/1을 키워드 리스트를 새 선택적 파라미터로 받도록 리팩터링한 모습이에요. 이 새 파라미터 덕분에 호출 시점에 함수의 기본 동작을 수정할 수 있어서, 같은 애플리케이션 안에서 split/2를 여러 가지 방식으로 사용할 수 있어요.
defmodule DashSplitter do
def split(string, opts \\ []) when is_binary(string) and is_list(opts) do
parts = Keyword.get(opts, :parts, 2) # <= default config of parts == 2
String.split(string, "-", parts: parts)
end
end
iex> DashSplitter.split("Lucas-Francisco-da-Matta-Vegi", [parts: 5])
["Lucas", "Francisco", "da", "Matta", "Vegi"]
iex> DashSplitter.split("Lucas-Francisco-da-Matta-Vegi") #<= default config is used!
["Lucas", "Francisco-da-Matta-Vegi"]
물론 라이브러리가 애플리케이션 환경을 사용하는 모든 경우가 잘못된 건 아니에요. 한 가지 예는 라이브러리의 한 컴포넌트(또는 의존성)를 정확히 똑같이 동작해야 하는 다른 것으로 교체하기 위해 설정을 사용하는 경우예요. CSV 파일을 파싱해야 하는 라이브러리를 생각해 봅시다. 라이브러리 작성자는 기본 파서로 쓸 패키지 하나를 고르되, 사용자가 애플리케이션 환경을 통해 다른 구현으로 바꿀 수 있게 할 수 있어요. 결국 다른 CSV 파서를 선택해도 결과가 바뀌어선 안 되며, 라이브러리 작성자는 behaviours 정의로 기대하는 정확한 의미론을 강제할 수도 있어요.
추가 논의: 슈퍼비전 트리
실제로는 라이브러리가 키워드 리스트 이상의 추가 설정을 요구할 수 있어요. 예를 들어 라이브러리가 슈퍼비전 트리를 시작해야 한다면, 그 라이브러리의 사용자는 자기 슈퍼비전 트리를 어떻게 커스터마이즈할 수 있을까요? 슈퍼비전 트리 자체는 (라이브러리에 속해 있으므로) 전역적이기 때문에, 라이브러리 작성자는 다시 애플리케이션 설정을 쓰고 싶어질 수 있어요.
한 가지 해결책은 라이브러리가 슈퍼비전 트리를 직접 시작하는 대신 **자기만의 자식 명세(child specification)**를 제공하는 거예요. 이러면 사용자가 필요한 모든 프로세스를 자기 슈퍼비전 트리 아래에서 시작하면서, 초기화 시 커스텀 설정 옵션을 전달할 수도 있어요.
Nx나 DNS Cluster 같은 프로젝트에서 이 패턴을 볼 수 있어요. 이 라이브러리들은 여러분이 프로세스를 자신의 슈퍼비전 트리 아래에 나열하길 요구합니다.
children = [
{DNSCluster, query: "my.subdomain"}
]
그런 경우 DNSCluster의 사용자가 환경별로 DNSCluster를 설정해야 한다면, 라이브러리가 강제하지 않아도 사용자가 직접 애플리케이션 환경을 읽을 수 있어요.
children = [
{DNSCluster, query: Application.get_env(:my_app, :dns_cluster_query) || :ignore}
]
Ecto 같은 일부 라이브러리는 애플리케이션 이름을 옵션(:otp_app 등)으로 전달하게 한 뒤 여러분의 애플리케이션에서 환경을 자동으로 읽습니다. 이는 각 개별 애플리케이션에서 읽으므로 애플리케이션 환경이 전역이라는 문제를 해결하지만, 사용자가 원할 때 자기 코드에서 직접 애플리케이션 환경을 명시적으로 읽는 위 예시에 비해 약간의 간접(indirection) 비용이 듭니다.
추가 논의: 컴파일 타임 설정
컴파일 타임 설정에 대해서도 비슷한 논의가 이어져요. 라이브러리 작성자가 컴파일 시점에 어떤 설정을 제공받아야 한다면 어떻게 할까요?
이번에도 라이브러리 사용자에게 컴파일 타임 설정을 강제하는 대신, 사용자가 코드를 직접 생성하도록 하는 게 낫습니다. 이는 Ecto 같은 라이브러리들이 취하는 접근이에요.
defmodule MyApp.Repo do
use Ecto.Repo, adapter: Ecto.Adapters.Postgres
end
한 개의 저장소(repo)를 공유하도록 강제하는 대신, Ecto는 사용자가 원하는 만큼 많은 저장소를 정의할 수 있게 합니다. :adapter 설정은 컴파일 타임에 필요하므로 use Ecto.Repo에서 필수 값이에요. 개발자가 환경별로 adapter를 설정하고 싶다면 그건 개발자의 선택이죠.
defmodule MyApp.Repo do
use Ecto.Repo, adapter: Application.compile_env(:my_app, :repo_adapter)
end
반면, 코드 생성은 그 자체의 안티패턴을 동반하므로 신중히 고려해야 해요. 다시 말해, 라이브러리에 애플리케이션 환경을 쓰는 게 권장되지 않지만, 특히 컴파일 타임 설정의 경우 어떤 상황에서는 최선의 선택일 수 있어요. 예를 들어 라이브러리가 데이터 파일을 기반으로 코드를 생성하기 위해 CSV나 JSON 파일을 파싱해야 한다고 합시다. 그런 경우 각 사용자에게 정확히 똑같은 코드를 생성하라고 요구하는 대신, 합리적인 기본값을 제공하고 애플리케이션 환경을 통해 커스터마이즈할 수 있게 하는 게 최선이에요.
추가 논의: Mix 태스크
Mix 태스크와 관련 도구의 경우 프로젝트별 설정을 제공해야 할 수 있어요. 예를 들어 출력 파일과 상세(verbosity) 레벨 설정을 지원하는 :linter 프로젝트가 있다고 해 봅시다. 애플리케이션 환경으로 설정할 수도 있지만,
config :linter,
output_file: "/path/to/output.json",
verbosity: 3
Mix은 태스크가 Mix.Project.config/0을 통해 프로젝트별 설정을 읽을 수 있게 해 줍니다. 이 경우 mix.exs 파일 안에서 :linter를 직접 설정할 수 있어요.
def project do
[
app: :my_app,
version: "1.0.0",
linter: [
output_file: "/path/to/output.json",
verbosity: 3
],
...
]
end
추가로, Mix 태스크가 있다면 이 옵션을 명령줄 인자로도 받을 수 있어요(OptionParser).
mix linter --output-file /path/to/output.json --verbosity 3