코드 관련 안티패턴
코드 관련 안티패턴
이 문서는 여러분의 코드와 Elixir의 특정 관용구(idiom) 및 기능과 관련된 잠재적 안티패턴들을 정리한 문서예요.
주석 과다 사용
문제
주석을 과하게 쓰거나 스스로 설명이 되는(self-explanatory) 코드에 주석을 달면, 오히려 코드를 덜 읽기 쉽게 만드는 결과를 낳을 수 있어요.
예시
# Returns the Unix timestamp of 5 minutes from the current time
defp unix_five_min_from_now do
# Get the current time
now = DateTime.utc_now()
# Convert it to a Unix timestamp
unix_now = DateTime.to_unix(now, :second)
# Add five minutes in seconds
unix_now + (60 * 5)
end
리팩터링
가능하면 함수명, 모듈명, 변수명을 명확하고 스스로 설명이 되는 이름으로 쓰는 게 좋아요. 위 예시에서 함수 이름이 이미 함수가 하는 일을 잘 설명하므로, 그 앞의 주석은 필요 없을 가능성이 커요. 코드도 변수명과 명확한 함수 호출 덕분에 연산을 잘 설명하고 있습니다.
위 코드는 이렇게 리팩터링할 수 있어요:
@five_min_in_seconds 60 * 5
defp unix_five_min_from_now do
now = DateTime.utc_now()
unix_now = DateTime.to_unix(now, :second)
unix_now + @five_min_in_seconds
end
불필요한 주석을 제거했고, 60 * 5라는 "매직 넘버"에 @five_min_in_seconds라는 이름을 붙여 코드를 더 명확하고 표현력 있게 만들었어요.
추가 설명
Elixir는 **문서(documentation)**와 코드 주석을 명확히 구분해요. 이 언어는 @doc, @moduledoc 등을 통해 일급 시민(first-class)으로 문서를 지원합니다. 자세한 내용은 "문서 작성하기" 가이드를 참고하세요.
with에서 복잡한 else 절
문제
이 안티패턴은 with 표현식에서 모든 오류 절을 하나의 복잡한 else 블록으로 평평하게 모아버리는 경우를 말해요. 그러면 오류 값이 어느 절에서 왔는지 알기 어려워져서 코드의 가독성과 유지보수성이 나빠집니다.
예시
아래처럼 Base64로 인코딩된 문자열 내용을 파일에서 읽어 디코딩된 이진 문자열을 반환하는 open_decoded_file/1 함수를 볼게요. 이 함수는 두 가지 오류를 처리해야 하는데, 모두 하나의 복잡한 else 블록에 모여 있어요.
def open_decoded_file(path) do
with {:ok, encoded} <- File.read(path),
{:ok, decoded} <- Base.decode64(encoded) do
{:ok, String.trim(decoded)}
else
{:error, _} -> {:error, :badfile}
:error -> {:error, :badencoding}
end
end
위 코드에서 <- 왼쪽의 각 패턴이 마지막의 오류와 어떻게 연결되는지 잘 보이지 않아요. with에 패턴이 많을수록 코드는 더 불명확해지고, 서로 무관한 실패가 겹칠 가능성도 커집니다.
리팩터링
이런 상황에서는 복잡한 else 블록 하나에 모든 오류 처리를 몰아넣기보다, 특정 private 함수들에서 반환 타입을 정규화하는 게 좋아요. 그러면 with는 성공 케이스에 집중하고, 오류는 발생한 곳 가까이에서 정규화되어 더 정돈되고 유지보수하기 쉬운 코드가 됩니다.
def open_decoded_file(path) do
with {:ok, encoded} <- file_read(path),
{:ok, decoded} <- base_decode64(encoded) do
{:ok, String.trim(decoded)}
end
end
defp file_read(path) do
case File.read(path) do
{:ok, contents} -> {:ok, contents}
{:error, _} -> {:error, :badfile}
end
end
defp base_decode64(contents) do
case Base.decode64(contents) do
{:ok, decoded} -> {:ok, decoded}
:error -> {:error, :badencoding}
end
end
절(clause)에서의 복잡한 추출
문제
다중 절(multi-clause) 함수를 쓸 때, 절 안에서 값들을 추출해 이후 사용이나 패턴 매칭/가드 검사에 활용할 수 있어요. 추출 자체는 안티패턴이 아니지만, 여러 절과 같은 함수의 여러 인자에 걸쳐 추출이 흩어져 있으면, 어떤 추출 부분이 패턴/가드에 쓰이고 어떤 것이 함수 본문에서만 쓰이는지 알기 어려워집니다. 이 안티패턴은 무관한 다중 절 함수와 관련이 있지만 나름의 함의를 갖고, 다른 방식으로 코드 가독성을 해칩니다.
예시
다중 절 함수 drive/1이 %User{} 구조체의 필드를 절 표현식(age)과 함수 본문(name)에서 각각 사용하기 위해 추출합니다:
def drive(%User{name: name, age: age}) when age >= 18 do
"#{name} can drive"
end
def drive(%User{name: name, age: age}) when age < 18 do
"#{name} cannot drive"
end
위 예시는 작고 안티패턴은 아니지만, 추출과 패턴 매칭이 섞여 있는 사례예요. drive/1이 더 복잡해져 절·인자·추출이 훨씬 많아지면, 어떤 변수가 패턴/가드에 쓰이고 어떤 게 아닌지 한눈에 알기 어려워질 겁니다.
리팩터링
아래처럼 인자나 절이 많아질 때는 시그니처에서 패턴/가드 관련 변수만 추출하는 것이 한 해결책입니다:
def drive(%User{age: age} = user) when age >= 18 do
%User{name: name} = user
"#{name} can drive"
end
def drive(%User{age: age} = user) when age < 18 do
%User{name: name} = user
"#{name} cannot drive"
end
동적 atom 생성
문제
Atom은 값이 곧 자기 이름인 Elixir의 기본 타입이에요. atom은 리소스를 식별하거나 어떤 연산의 상태·결과를 표현할 때 자주 유용합니다. atom을 동적으로 생성하는 것 자체는 안티패턴이 아니에요. 하지만 Erlang 가상 머신은 atom을 가비지 컬렉션하지 않아서, 이 타입의 값은 소프트웨어 실행 내내 메모리에 남아 있습니다. Erlang VM은 기본적으로 애플리케이션에 존재할 수 있는 atom 수를 1,048,576개로 제한하는데, 이는 프로그램에 정의된 모든 atom을 덮기에 충분하지만, 동적 생성으로 atom을 "누출"시키는 애플리케이션에 대한 조기 한계 역할을 하려는 것이에요.
이런 이유로, 소프트웨어 실행 중 생성될 atom 수를 개발자가 통제할 수 없을 때 동적 atom 생성은 안티패턴으로 간주될 수 있어요. 이렇게 예측 불가능한 상황은 과도한 메모리 사용, 심지어 가능한 atom의 최대 수에 도달하는 것으로 인한 예상치 못한 동작으로 소프트웨어를 노출시킬 수 있습니다.
예시
문자열 값을 atom으로 변환하는 코드를 구현한다고 상상해 볼게요. 이 문자열들은 외부 시스템에서 받은 것일 수 있는데, 애플리케이션으로의 요청 일부이거나 애플리케이션의 응답 일부일 수 있어요. 이렇게 동적이고 예측 불가능한 시나리오는 보안 위험을 지니는데, 통제되지 않은 변환이 메모리 부족(out-of-memory) 오류를 촉발할 수 있기 때문입니다.
defmodule MyRequestHandler do
def parse(%{"status" => status, "message" => message} = _payload) do
%{status: String.to_atom(status), message: message}
end
end
iex> MyRequestHandler.parse(%{"status" => "ok", "message" => "all good"})
%{status: :ok, message: "all good"}
String.to_atom/1로 atom을 동적으로 생성하면 본질적으로 시스템에 임의의 atom을 만들 수 있는 접근 권한을 얻게 되어, BEAM이 정한 한계를 지키는 통제력을 잃게 돼요. 이 문제는 악용되어 시스템을 종료시킬 만큼 atom을 만들어내는 데 쓰일 수 있습니다.
리팩터링
이 안티패턴을 없애려면 개발자는 문자열을 atom으로 매핑하는 명시적 변환을 수행하거나, String.to_atom/1 대신 String.to_existing_atom/1을 사용해야 해요. 명시적 변환은 다음과 같이 할 수 있습니다:
defmodule MyRequestHandler do
def parse(%{"status" => status, "message" => message} = _payload) do
%{status: convert_status(status), message: message}
end
defp convert_status("ok"), do: :ok
defp convert_status("error"), do: :error
defp convert_status("redirect"), do: :redirect
end
iex> MyRequestHandler.parse(%{"status" => "status_not_seen_anywhere", "message" => "all good"})
** (FunctionClauseError) no function clause matching in MyRequestHandler.convert_status/1
지원하는 모든 status를 명시적으로 나열하면 변환이 일어날 수 있는 횟수가 제한됨을 보장해요. 유효하지 않은 status를 넘기면 함수 절 오류가 발생합니다.
또 다른 방법은 String.to_existing_atom/1을 쓰는 것인데, 이 함수는 시스템에 이미 존재하는 atom일 때만 문자열을 atom으로 변환합니다:
defmodule MyRequestHandler do
def parse(%{"status" => status, "message" => message} = _payload) do
%{status: String.to_existing_atom(status), message: message}
end
end
iex> MyRequestHandler.parse(%{"status" => "status_not_seen_anywhere", "message" => "all good"})
** (ArgumentError) errors were found at the given arguments:
* 1st argument: not an already existing atom
이런 경우, status가 시스템 어딘가에 atom으로 정의된 적이 없다면 알려지지 않은 status를 넘겼을 때 오류가 발생해요. 하지만 status가 :ok, :error, :redirect 중 하나라고 가정할 때, 그 atom들이 존재함을 어떻게 보장할까요? String.to_existing_atom/1이 호출되는 같은 모듈 안 어딘가에 그 atom들이 존재하도록 해야 해요. 예를 들어 이 코드가 있다면:
defmodule MyRequestHandler do
def parse(%{"status" => status, "message" => message} = _payload) do
%{status: String.to_existing_atom(status), message: message}
end
def handle(%{status: status}) do
case status do
:ok -> ...
:error -> ...
:redirect -> ...
end
end
end
모든 유효한 status가 같은 모듈 안에 atom으로 정의되어 있으니 충분해요. 명시적으로 하고 싶다면 그것들을 나열하는 함수를 둘 수도 있습니다:
def valid_statuses do
[:ok, :error, :redirect]
end
다만 모듈 속성(attribute)을 쓰거나 함수 밖 모듈 본문에 atom을 정의하는 것은 충분하지 않다는 점을 기억하세요. 모듈 본문은 컴파일 중에만 실행되고, 런타임에 로드된 컴파일된 모듈에 반드시 포함되지는 않기 때문입니다.
긴 인자 목록
문제
Elixir 같은 함수형 언어에서는 함수가 변경(mutation)이나 부작용에 의존하기보다 모든 입력을 명시적으로 받고 관련 출력을 반환하는 경향이 있어요. 함수가 복잡해질수록 함께 다뤄야 할 인자(파라미터) 수도 늘어나서, 어느 지점이 되면 함수의 인터페이스가 혼란스러워지고 사용 중 오류가 나기 쉬워질 수 있습니다.
예시
다음 예시의 loan/6 함수는 인자를 너무 많이 받아서 인터페이스가 혼란스럽고, 이 함수를 호출할 때 개발자들이 오류를 범할 가능성이 있습니다.
defmodule Library do
# Too many parameters that can be grouped!
def loan(user_name, email, password, user_alias, book_title, book_ed) do
...
end
end
리팩터링
이 안티패턴을 해결하기 위해, 관련된 인자들을 맵, 구조체, 또는 선택적 인자의 경우 키워드 목록 같은 키-값 데이터 구조로 묶을 수 있어요. 그러면 인자 수가 실질적으로 줄어들고, 키-값 구조가 호출자에게 명확성을 더해 줍니다.
이 특정 예시에서 loan/6의 인자들은 두 개의 맵으로 묶어 loan/2로 줄일 수 있어요:
defmodule Library do
def loan(%{name: name, email: email, password: password, alias: alias} = user, %{title: title, ed: ed} = book) do
...
end
end
어떤 경우에는 인자가 너무 많은 함수가 private 함수일 수 있는데, 그러면 함수 인자를 어떻게 나눌지 더 유연하게 결정할 수 있어요. 이런 시나리오의 한 제안은 인자를 두 맵(또는 튜플)으로 나누는 것입니다. 하나는 변할 수 있는 데이터를, 다른 하나는 변하지 않는(읽기 전용) 데이터를 갖게 하는 거예요. 이렇게 하면 코드를 기계적으로 리팩터링할 수 있는 선택지가 생깁니다.
다른 때는 함수가 정당하게 서로 완전히 무관한 인자를 여섯 개 이상 받을 수도 있어요. 그렇다면 그 함수가 너무 많은 일을 하려는 것일 수 있으며, 전체 책임의 더 작은 조각을 각각 담당하는 여러 함수로 쪼개는 게 낫다는 신호일 수 있어요.
네임스페이스 침범
문제
이 안티패턴은 패키지 작성자나 라이브러리가 자체 "네임스페이스" 밖에 모듈을 정의할 때 나타나요. 라이브러리는 모든 모듈에 자기 이름을 "접두어"로 써야 합니다. 예를 들어 :my_lib라는 패키지는 MyLib.User, MyLib.SubModule, MyLib.Application, 그리고 MyLib 자체처럼 모든 모듈을 MyLib 네임스페이스 안에 정의해야 해요.
이것이 중요한 이유는 Erlang VM이 한 번에 모듈의 한 인스턴스만 로드할 수 있기 때문이에요. 따라서 여러 라이브러리가 같은 모듈을 정의한다면, 이 제약 때문에 서로 호환되지 않습니다. 항상 라이브러리 이름을 접두어로 쓰면 고유한 접두어 덕분에 모듈 이름 충돌을 피할 수 있어요.
예시
이 문제는 다른 라이브러리의 확장을 작성할 때 흔히 나타납니다. 예를 들어 Plug에 인증을 추가하는 :plug_auth라는 패키지를 작성한다고 상상해 보세요. Plug 네임스페이스 안에 모듈을 정의하는 것을 피해야 합니다:
defmodule Plug.Auth do
# ...
end
Plug가 현재 Plug.Auth 모듈을 정의하지 않더라도, 미래에 그런 모듈을 추가할 수 있고 그렇게 되면 plug_auth의 정의와 충돌하게 됩니다.
리팩터링
:plug_auth라는 이름의 패키지라면 PlugAuth 네임스페이스 안에 모듈을 정의해야 합니다:
defmodule PlugAuth do
# ...
end
추가 설명
이 안티패턴에는 잘 알려진 예외가 몇 가지 있어요:
- 프로토콜 구현은 설계상 프로토콜 네임스페이스 아래 정의됩니다
- 어떤 시나리오에서는 네임스페이스 소유자가 이 규칙에 예외를 허용할 수 있어요. 예를 들어 Elixir 자체에서도 커스텀 Mix 태스크를
Mix.Tasks.PlugAuth처럼Mix.Tasks네임스페이스 아래에 정의합니다 plug와plug_auth둘 다의 관리자라면plug_auth가Plug.Auth처럼Plug네임스페이스로 모듈을 정의하는 것을 허용할 수 있어요. 다만 미래에 생길 수 있는 어떤 충돌을 피하거나 관리할 책임은 여러분에게 있습니다
비단정적(non-assertive) 맵 접근
문제
Elixir에서는 Map(키-값 데이터 구조)에서 값을 정적으로 또는 동적으로 접근할 수 있어요.
키가 맵에 존재할 것으로 기대된다면 map.key 표기법으로 접근해야 해요. 그러면 키가 반드시 존재해야 한다는 점이 개발자(와 컴파일러)에게 분명해집니다. 키가 존재하지 않으면 예외가 발생하고(경우에 따라 컴파일러 경고도 나옵니다). 이는 코드 작성 시점에 키가 알려져 있으므로 정적 표기법이라고도 합니다.
키가 선택적이라면 map[:key] 표기법을 대신 사용해야 해요. 이렇게 하면 주어진 키가 존재하지 않을 때 nil을 반환합니다. 이는 동적 표기법인데, map[some_var]처럼 동적 키 접근도 지원하기 때문이에요.
맵에 항상 존재하는 키를 map[:key]로 접근하면 코드가 개발자와 컴파일러에게 덜 명확해져요. 이제 그들은 키가 없을 수도 있다는 가정을 가지고 작업해야 하니까요. 이런 불일치는 특정 버그를 추적하기도 어렵게 만들 수 있어요. 키가 예기치 않게 없으면 맵 접근 시 오류를 던지는 대신 nil 값이 시스템 전체로 퍼져나가게 됩니다.
표: 맵 접근 표기법 비교
| 접근 표기법 | 키가 존재 | 키가 존재하지 않음 | 사용 사례 |
|---|---|---|---|
map.key |
값을 반환 | KeyError 발생 |
알려진 atom 키를 가진 구조체와 맵 |
map[:key] |
값을 반환 | nil 반환 |
모든 Access 기반 데이터 구조, 선택적 키 |
예시
plot/1 함수는 데카르트 평면에서 점의 위치를 나타내는 그래픽을 그리려고 합니다. 이 함수는 점의 속성을 가진 Map 타입 파라미터를 받는데, 이는 2D 또는 3D 데카르트 좌표계의 점일 수 있어요. 이 함수는 동적 접근을 사용해 맵 키의 값을 가져옵니다:
defmodule Graphics do
def plot(point) do
# Some other code...
{point[:x], point[:y], point[:z]}
end
end
iex> point_2d = %{x: 2, y: 3}
%{x: 2, y: 3}
iex> point_3d = %{x: 5, y: 6, z: 7}
%{x: 5, y: 6, z: 7}
iex> Graphics.plot(point_2d)
{2, 3, nil}
iex> Graphics.plot(point_3d)
{5, 6, 7}
2D와 3D 점을 모두 그리고 싶으니 위 동작은 기대된 것입니다. 하지만 :x나 :y가 없는 점을 실수로 넘기면 어떻게 될까요?
iex> bad_point = %{y: 3, z: 4}
%{y: 3, z: 4}
iex> Graphics.plot(bad_point)
{nil, 3, 4}
우리 함수는 :x 키가 없는 점과는 동작하지 않아야 하므로 위 동작은 예상 밖이에요. 이는 미묘한 버그로 이어집니다. 아래처럼 일찍 오류를 던지는 대신 nil을 다른 함수에 넘길 수 있기 때문이에요:
iex> point_without_x = %{y: 10}
%{y: 10}
iex> {x, y, _} = Graphics.plot(point_without_x)
{nil, 10, nil}
iex> distance_from_origin = :math.sqrt(x * x + y * y)
** (ArithmeticError) bad argument in arithmetic expression
:erlang.*(nil, nil)
위 오류는 코드에서 더 나중에 발생하는데, nil(없는 :x에서 온)이 산술 연산에 유효하지 않기 때문이에요. 그래서 원래 문제를 찾기가 더 어려워집니다.
리팩터링
이 안티패턴을 없애려면 요구 사항에 따라 동적 map[:key] 문법과 정적 map.key 표기법을 사용해야 해요. :x와 :y는 항상 존재하길 기대하지만 :z는 그렇지 않습니다. 다음 코드는 이 안티패턴을 제거한 plot/1의 리팩터링을 보여줍니다:
defmodule Graphics do
def plot(point) do
# Some other code...
{point.x, point.y, point[:z]}
end
end
iex> Graphics.plot(point_2d)
{2, 3, nil}
iex> Graphics.plot(bad_point)
** (KeyError) key :x not found in: %{y: 3, z: 4}
graphic.ex:4: Graphics.plot/1
이것은 다음 이유로 이롭습니다:
- 코드를 읽는 다른 사람들에게 기대를 명확히 함
- 필요한 데이터가 없을 때 빠르게 실패(fail fast)함
- 특히 구조체 같은 컴파일 타임 구조에서 존재하지 않는 필드에 접근할 때 컴파일러가 경고를 제공하게 함
전반적으로 map.key와 map[:key]의 사용은 데이터 구조에 대한 중요한 정보를 담아, 개발자들이 의도를 명확히 하게 해줘요. Access 모듈 문서도 이 주제에 대한 유용한 참고 자료를 제공합니다. 어떤 키든 가진 맵을 다룰 때는 Map 모듈도 고려할 수 있는데, 이 모듈은 키 가져오기(기본값 유무 포함), 키 업데이트·제거, 순회 등의 함수를 담고 있어요.
이 안티패턴을 리팩터링하는 또 다른 대안은 패턴 매칭을 사용해 2D vs 3D 점에 대한 명시적 절을 정의하는 것입니다:
defmodule Graphics do
# 3d
def plot(%{x: x, y: y, z: z}) do
# Some other code...
{x, y, z}
end
# 2d
def plot(%{x: x, y: y}) do
# Some other code...
{x, y}
end
end
패턴 매칭은 여러 키와 그 값들 자체를 동시에 매칭할 때 특히 유용해요. 위 예시에서 코드는 값을 추출할 뿐만 아니라 필요한 키가 존재하는지도 검증합니다. 필요한 키가 없는 맵으로 plot/1을 호출하면 FunctionClauseError를 얻게 됩니다:
iex> incomplete_point = %{x: 5}
%{x: 5}
iex> Graphics.plot(incomplete_point)
** (FunctionClauseError) no function clause matching in Graphics.plot/1
The following arguments were given to Graphics.plot/1:
# 1
%{x: 5}
또 다른 선택지는 구조체를 사용하는 것입니다. 기본적으로 구조체는 필드에 대한 정적 접근만 지원해요. 그런 시나리오에서는 2D와 3D 점 각각에 구조체를 정의하는 것을 고려할 수 있습니다:
defmodule Point2D do
@enforce_keys [:x, :y]
defstruct [x: nil, y: nil]
end
일반적으로 구조체는 모듈 간에 데이터 구조를 공유할 때 유용하지만, 모듈 사이에 컴파일 타임 의존성을 추가하는 비용이 있어요. 모듈 A가 모듈 B에 정의된 구조체를 사용한다면, 구조체 B의 필드가 바뀔 때 A를 다시 컴파일해야 합니다.
요약하면 Elixir는 맵 값을 접근하는 여러 방법을 제공하며, 각각 동작이 달라요:
- 정적 접근 (
map.key): 키가 없을 때 빠르게 실패. 알려진 atom 키를 가진 구조체와 맵에 이상적 - 동적 접근 (
map[:key]): 어떤Access데이터 구조에서도 동작. 선택적 필드에 적합, 없는 키에는 nil 반환 - 패턴 매칭: 한 연산에서 값을 추출하고 필수 맵/구조체 키가 존재함을 보장하는 강력한 방법
올바른 방법을 고르는 것은 키를 미리 아는지 여부에 달려 있어요. 정적 접근과 패턴 매칭은 대체로 동등합니다(패턴 매칭은 여러 키를 동시에 매칭할 수 있고 구조체 이름에도 매칭할 수 있다는 점 제외).
추가 설명
이 안티패턴은 이전에 존재하지 않는 맵/구조체 필드 접근하기로 알려져 있었어요.
비단정적 패턴 매칭
문제
전반적으로 Elixir 시스템은 많은 감독(supervised) 프로세스로 구성되어 있어서, 오류의 영향이 단일 프로세스에 국한되고 애플리케이션 전체로 퍼지지 않아요. 감독자가 실패한 프로세스를 감지해 보고하고, 가능하면 재시작합니다. 이 안티패턴은 개발자가 패턴 매칭과 가드를 통한 단정적(assertive) 스타일로 프로그래밍하는 대신, 계획되지 않은 잘못된 값을 반환할 수 있는 방어적이거나 부정확한 코드를 작성할 때 나타나요.
예시
get_value/2 함수는 URL 쿼리 문자열의 특정 키에서 값을 추출하려고 합니다. 패턴 매칭으로 구현되지 않았기 때문에, get_value/2는 호출에 파라미터로 전달된 URL 쿼리 문자열의 형식과 무관하게 항상 값을 반환합니다. 가끔은 반환된 값이 유효할 거예요. 하지만 예상 밖의 형식의 URL 쿼리 문자열이 호출에 사용되면, get_value/2는 그로부터 잘못된 값을 추출할 수 있어요:
defmodule Extract do
def get_value(string, desired_key) do
parts = String.split(string, "&")
Enum.find_value(parts, fn pair ->
key_value = String.split(pair, "=")
Enum.at(key_value, 0) == desired_key && Enum.at(key_value, 1)
end)
end
end
# URL query string with the planned format - OK!
iex> Extract.get_value("name=Lucas&university=UFMG&lab=ASERG", "lab")
"ASERG"
iex> Extract.get_value("name=Lucas&university=UFMG&lab=ASERG", "university")
"UFMG"
# Unplanned URL query string format - Unplanned value extraction!
iex> Extract.get_value("name=Lucas&university=institution=UFMG&lab=ASERG", "university")
"institution" # <= why not "institution=UFMG"? or only "UFMG"?
리팩터링
이 안티패턴을 없애기 위해 get_value/2는 패턴 매칭을 사용해 리팩터링할 수 있어요. 그러면 예상 밖의 URL 쿼리 문자열 형식이 사용될 때, 유효하지 않은 값을 반환하는 대신 함수가 크래시합니다. 아래에 보이는 이 동작은 클라이언트가 이런 오류를 어떻게 처리할지 결정하게 하고, 예상 밖의 값이 추출될 때 코드가 올바르게 동작하고 있다는 잘못된 인상을 주지 않습니다:
defmodule Extract do
def get_value(string, desired_key) do
parts = String.split(string, "&")
Enum.find_value(parts, fn pair ->
[key, value] = String.split(pair, "=") # <= pattern matching
key == desired_key && value
end)
end
end
# URL query string with the planned format - OK!
iex> Extract.get_value("name=Lucas&university=UFMG&lab=ASERG", "name")
"Lucas"
# Unplanned URL query string format - Crash explaining the problem to the client!
iex> Extract.get_value("name=Lucas&university=institution=UFMG&lab=ASERG", "university")
** (MatchError) no match of right hand side value: ["university", "institution", "UFMG"]
extract.ex:7: anonymous fn/2 in Extract.get_value/2 # <= left hand: [key, value] pair
iex> Extract.get_value("name=Lucas&university&lab=ASERG", "university")
** (MatchError) no match of right hand side value: ["university"]
extract.ex:7: anonymous fn/2 in Extract.get_value/2 # <= left hand: [key, value] pair
Elixir와 패턴 매칭은 알려진 경우를 처리하는 단정적 프로그래밍 스타일을 장려합니다. 예상 밖의 시나리오가 발생하면, 실용적인 예시에 따라 적절히 처리할지, 아니면 그 시나리오가 정말 유효하지 않아 예외가 바람직한 선택인지 결정할 수 있어요.
case/2는 Elixir에서 특정 패턴에 매칭해 단정적 코드를 작성하도록 돕는 또 하나의 중요한 구성 요소입니다. 예를 들어 함수가 {:ok, ...} 또는 {:error, ...}를 반환한다면, 두 패턴 모두에 명시적으로 매칭하는 것을 선호하세요:
case some_function(arg) do
{:ok, value} -> # ...
{:error, _} -> # ...
end
특히 아래처럼 _에만 매칭하는 것은 피하세요:
case some_function(arg) do
{:ok, value} -> # ...
_ -> # ...
end
_에 매칭하는 것은 의도가 덜 명확하고, some_function/1이 미래에 새 반환 값을 추가하면 버그를 숨길 수 있습니다.
추가 설명
이 안티패턴은 이전에 추측적 가정(Speculative assumptions)으로 알려져 있었어요.
비단정적 truthiness
문제
Elixir는 truthiness 개념을 제공합니다. nil과 false는 "falsy"로, 그 외의 모든 값은 "truthy"로 간주됩니다. &&/2, ||/2, !/1 같은 언어의 많은 구성 요소가 truthy와 falsy 값을 다룹니다. 그 연산자를 사용하는 것 자체는 안티패턴이 아니에요. 그러나 모든 피연산자가 boolean일 것으로 기대되는데 그 연산자를 사용하는 것은 안티패턴일 수 있어요.
예시
이 안티패턴이 나타나는 가장 단순한 시나리오는 조건문입니다:
if is_binary(name) && is_integer(age) do
# ...
else
# ...
end
&&/2의 두 피연산자가 모두 boolean이므로, 이 코드는 필요한 것보다 더 일반적이고 잠재적으로 불명확합니다.
리팩터링
이 안티패턴을 없애기 위해 &&/2, ||/2, !/1을 각각 and/2, or/2, not/1로 바꿀 수 있어요. 이 연산자들은 적어도 첫 번째 인자가 boolean임을 단정합니다:
if is_binary(name) and is_integer(age) do
# ...
else
# ...
end
이 기법은 Erlang 코드와 작업할 때 특히 중요할 수 있어요. Erlang에는 truthiness 개념이 없습니다. 그것은 nil을 결코 반환하지 않고, Elixir 개발자가 nil을 반환할 자리에 그 함수들은 :error나 :undefined를 반환할 수 있어요. 따라서 Erlang API와 연동할 때 :undefined나 :error를 실수로 truthy 값으로 해석하는 것을 피하려면, and/2, or/2, not/1만을 전용으로 사용하는 것을 선호할 수 있어요.
필드가 32개 이상인 구조체
문제
Elixir의 구조체는 미리 정의된 필드 수를 가진 컴파일 타임 맵으로 구현됩니다. 구조체에 필드가 32개 이상 있으면 Erlang 가상 머신에서의 내부 표현이 바뀌어, 부풀어 오르고 메모리 사용량이 높아질 수 있어요.
예시
필드가 32개 이상인 어떤 구조체든 문제가 됩니다:
defmodule MyExample do
defstruct [
:field1,
:field2,
...,
:field35
]
end
Erlang VM에는 맵에 대한 두 가지 내부 표현이 있습니다. flat 맵과 hash 맵이에요. flat 맵은 내부적으로 두 튜플로 표현되는데, 하나는 키를 담고 다른 하나는 값을 담습니다. flat 맵을 업데이트할 때마다 키 튜플이 공유되어 업데이트가 사용하는 메모리를 줄여줍니다. hash 맵은 더 복잡한 구조로, 많은 수의 키에 효율적이지만 키 공간을 공유하지 않습니다.
최대 32개 키까지의 맵은 flat 맵으로 표현됩니다. 그 외의 모든 것은 hash 맵이에요. 구조체 는 (__struct__라는 메타데이터 필드를 가진) 맵이므로 필드가 32개 미만인 어떤 구조체든 flat 맵으로 표현됩니다. 이 덕분에 여러 구조체 연산을 최적화할 수 있어요. 구조체에 필드를 추가하거나 제거하지 않고 단순히 업데이트하기 때문입니다.
나아가, 같은 모듈 안에서 "인스턴스화"된 같은 이름의 구조체들은 필드가 32개 미만이라면 컴파일 시점에 같은 "튜플 키"를 공유합니다. 예를 들어 다음 코드에서:
defmodule Example do
def users do
[%User{name: "John"}, %User{name: "Meg"}, ...]
end
end
모든 user 구조체는 컴파일 시점에 같은 튜플 키를 가리켜 %MyStruct{...} 표기법으로 구조체를 인스턴스화하는 메모리 비용도 줄여줍니다. 이 최적화도 구조체가 32개 이상의 키를 가질 때는 사용할 수 없습니다.
리팩터링
이 안티패턴을 제거하는 것은 요컨대 구조체의 필드가 32개 미만이 되도록 보장하는 것입니다. 적용할 수 있는 몇 가지 기법이 있어요:
- 구조체에 "선택적" 필드가 있다면(예: nil로 초기화되는 필드) 모든 선택적 필드를
:metadata,:optionals같은 다른 필드 안에 중첩할 수 있어요. 이렇게 하면nil값에 의존하는 대신 패턴 매칭으로 필드 존재 여부를 검사하는 이점을 얻을 수 있습니다 - 필드 안에 구조체를 저장하면서 구조체를 중첩할 수 있어요. 거의 읽거나 쓰지 않는 필드가 중첩 구조체로 옮기기 좋은 후보입니다
- 필드를 튜플로 중첩할 수 있어요. 예를 들어 두 필드가 항상 함께 읽히거나 업데이트된다면, 그것들을 튜플(또는 다른 복합 데이터 구조)로 옮길 수 있습니다
도전 과제는 위 변경들을 API 인체공학(ergonomics)과 균형 맞추는 일이에요. 특히 필드가 자주 읽히고 쓰일 때 말이죠.
더 알아보기
- 디자인과 관련된 안티패턴은 design-anti-patterns 문서에서 다룹니다.
- 문서 작성 규칙은 "문서 작성하기" 가이드에서 확인할 수 있어요.
- 패턴 매칭과 가드의 단정적 프로그래밍은 Elixir 기초 가이드에서 자세히 배울 수 있습니다.