Process-related anti-patterns
Process-related anti-patterns (프로세스 관련 안티패턴)
이 문서는 프로세스와 프로세스 기반 추상화와 관련된 잠재적인 안티패턴을 정리한 자료예요. 어떤 코드가 "안티패턴"인지 알아두면 프로세스를 더 현명하게 쓰고, 시스템이 병목이 되거나 유지보수가 어려워지는 것을 피할 수 있어요.
본문
코드를 프로세스 단위로 구성하기 (Code organization by process)
문제
이 안티패턴은 불필요하게 프로세스 단위로 조직된 코드를 말해요. 프로세스 자체는 안티패턴이 아니지만, 런타임 속성(동시성, 공유 자원 접근, 오류 격리 등)을 모델링하는 데만 쓰여야 합니다. 코드 조직에 프로세스를 쓰면 시스템에 병목이 생길 수 있어요.
예시
아래와 같이 GenServer 프로세스로 산술 연산(예: add, subtract)을 구현한 모듈이 이 안티패턴의 예시입니다. 이 단일 프로세스에 대한 호출 수가 늘어나면 이 코드 조직이 시스템 성능을 해쳐 병목이 될 수 있어요.
defmodule Calculator do
@moduledoc """
Calculator that performs basic arithmetic operations.
This code is unnecessarily organized in a GenServer process.
"""
use GenServer
def add(a, b, pid) do
GenServer.call(pid, {:add, a, b})
end
def subtract(a, b, pid) do
GenServer.call(pid, {:subtract, a, b})
end
@impl GenServer
def init(init_arg) do
{:ok, init_arg}
end
@impl GenServer
def handle_call({:add, a, b}, _from, state) do
{:reply, a + b, state}
end
def handle_call({:subtract, a, b}, _from, state) do
{:reply, a - b, state}
end
end
iex> {:ok, pid} = GenServer.start_link(Calculator, :init)
{:ok, #PID<0.132.0>}
iex> Calculator.add(1, 5, pid)
6
iex> Calculator.subtract(2, 3, pid)
-1
리팩터링
Elixir에서 코드 조직은 다음처럼 모듈과 함수로만 해야 해요. 가능하면 라이브러리는 사용자에게 특정 동작(예: 병렬화)을 강요하지 않아야 합니다. 이런 동작 결정은 클라이언트 개발자에게 위임하는 편이 좋고, 그래야 라이브러리의 코드 재사용 가능성이 높아져요.
defmodule Calculator do
def add(a, b) do
a + b
end
def subtract(a, b) do
a - b
end
end
iex> Calculator.add(1, 5)
6
iex> Calculator.subtract(2, 3)
-1
흩어진 프로세스 인터페이스 (Scattered process interfaces)
문제
Elixir에서 Agent, GenServer, 그 밖의 프로세스 추상화를 쓰는 것 자체는 안티패턴이 아니에요. 하지만 프로세스와 직접 상호작용하는 책임이 시스템 전체에 퍼져 있으면 문제가 될 수 있습니다. 이런 나쁜 관행은 코드 유지보수를 어렵게 하고 버그가 생기기 쉬운 코드를 만들 수 있어요.
예시
다음 코드는 이 안티패턴을 보여주려는 예시입니다. Agent와 직접 상호작용하는 책임이 네 개의 서로 다른 모듈(A, B, C, D)에 퍼져 있어요.
defmodule A do
def update(process) do
# Some other code...
Agent.update(process, fn _list -> 123 end)
end
end
defmodule B do
def update(process) do
# Some other code...
Agent.update(process, fn content -> %{a: content} end)
end
end
defmodule C do
def update(process) do
# Some other code...
Agent.update(process, fn content -> [:atom_value | content] end)
end
end
defmodule D do
def get(process) do
# Some other code...
Agent.get(process, fn content -> content end)
end
end
이렇게 책임이 흩어지면 중복 코드가 생기고 유지보수가 어려워질 수 있어요. 게다가 공유 데이터의 형식에 대한 통제가 없어서 복잡한 합성 데이터가 공유될 수 있습니다. 어떤 데이터 형식이든 쓸 수 있는 이런 자유는 위험하며, 개발자가 버그를 도입하게 유도할 수 있어요.
# 빈 리스트의 초기 상태로 agent 시작
iex> {:ok, agent} = Agent.start_link(fn -> [] end)
{:ok, #PID<0.135.0>}
# 여러 데이터 형식(예: List, Map, Integer, Atom)이
# 시스템 전체에 퍼진 직접 접근을 통해 결합됨
iex> A.update(agent)
iex> B.update(agent)
iex> C.update(agent)
# 공유 정보의 상태
iex> D.get(agent)
[:atom_value, %{a: 123}]
GenServer와 다른 behaviour의 경우, GenServer와의 모든 상호작용을 한 곳에 캡슐화하지 않고 GenServer.call/3과 GenServer.cast/2 호출을 여러 모듈에 흩어 놓을 때 이 안티패턴이 드러납니다.
리팩터링
Agent같은 프로세스 추상화에 대한 직접 접근을 코드의 여러 곳에 퍼뜨리는 대신, 프로세스와 상호작용하는 책임을 단일 모듈에 집중시키는 것으로 리팩터링하는 게 좋아요. 이 리팩터링은 중복 코드를 제거해 유지보수성을 높이고, 공유 데이터의 허용 형식을 제한해서 버그를 줄여 줍니다. 아래처럼 Foo.Bucket 모듈이 Agent와 상호작용하는 책임을 집중시키고 있어요. 공유 데이터에 접근해야 하는 코드의 다른 어떤 곳도 이제 이 동작을 Foo.Bucket에 위임해야 합니다. 또한 Foo.Bucket은 이제 데이터가 Map 형식으로만 공유되게 허용합니다.
defmodule Foo.Bucket do
use Agent
def start_link(_opts) do
Agent.start_link(fn -> %{} end)
end
def get(bucket, key) do
Agent.get(bucket, &Map.get(&1, key))
end
def put(bucket, key, value) do
Agent.update(bucket, &Map.put(&1, key, value))
end
end
다음은 (Agent가 제공하는) 공유 데이터 접근을 Foo.Bucket에 위임하는 방법의 예시입니다.
# `Foo.Bucket`를 통해 agent 시작
iex> {:ok, bucket} = Foo.Bucket.start_link(%{})
{:ok, #PID<0.114.0>}
# 키 `milk`와 `beer`에 공유 값 추가
iex> Foo.Bucket.put(bucket, "milk", 3)
iex> Foo.Bucket.put(bucket, "beer", 7)
# 특정 키의 공유 데이터 접근
iex> Foo.Bucket.get(bucket, "beer")
7
iex> Foo.Bucket.get(bucket, "milk")
3
추가 설명
이 안티패턴은 이전에 Agent obsession으로 알려져 있었어요.
불필요한 데이터 보내기 (Sending unnecessary data)
문제
프로세스에 메시지를 보내는 것은 메시지가 충분히 크면 비용이 클 수 있어요. 그 메시지가 받는 프로세스에 완전히 복사되어 CPU와 메모리를 많이 쓸 수 있기 때문이죠. 이는 Erlang의 "share nothing(공유 없음)" 아키텍처 때문인데, 각 프로세스가 자신만의 메모리를 가져서 가비지 컬렉션을 단순화하고 빠르게 합니다.
이는 send/2, GenServer.call/3, 또는 GenServer.start_link/3의 초기 데이터를 쓸 때 더 분명해요. 특히 spawn/1, Task.async/1, Task.async_stream/3 등을 쓸 때도 마찬가지입니다. 여기서는 더 미묘한데, 이 함수들에 전달하는 익명 함수가 참조하는 변수를 캡처하고, 캡처된 모든 변수가 복사되기 때문이에요. 이렇게 하다 보면 실제로 필요한 것보다 훨씬 더 많은 데이터를 프로세스에 실수로 보낼 수 있습니다.
예시
애플리케이션에 요청을 보낸 IP 주소를 간단히 리포트하는 코드를 구현한다고 상상해 보세요. 비동기로 처리하고 싶어서 처리를 막지 않으려 한다면 spawn/1을 쓰기로 할 거예요. 나중에 더 많은 데이터가 필요할 수도 있으니 connection 전체를 넘기는 게 좋은 생각처럼 보일 수 있어요. 하지만 connection을 넘기면 요청 본문, 파라미터 등 불필요한 데이터를 많이 복사하게 됩니다.
# log_request_ip가 ip를 어떤 외부 서비스로 보냄
spawn(fn -> log_request_ip(conn) end)
이 문제는 관련 부분만 접근할 때도 발생합니다.
spawn(fn -> log_request_ip(conn.remote_ip) end)
이 역시 conn 전체를 복사하게 돼요. conn 변수가 spawn된 함수 안에서 캡처되고 있기 때문이죠. 함수는 그다음 remote_ip 필드를 추출하지만, conn 전체가 복사된 후에 그렇게 됩니다.
send/2와 GenServer API도 메시지 전달에 의존합니다. 아래 예시에서 conn은 다시 한번 기저의 GenServer에 복사됩니다.
GenServer.cast(pid, {:report_ip_address, conn})
리팩터링
이 안티패턴에는 여러 잠재적 해결책이 있어요.
- 구조체 전체를 보내지 말고 절대적으로 필요한 최소 데이터로 보내세요. 예를 들어 필요한 게 몇 개의 필드뿐이라면
conn구조체 전체를 보내지 마세요. - 데이터가 필요한 유일한 프로세스가 보내는 대상이라면, 프로세스가 그 데이터를 직접 가져오게 하는 것을 고려해 보세요(전달 대신).
:persistent_term같은 일부 추상화는 데이터가 자주 바뀌지 않는 한 프로세스 간에 데이터를 공유하게 해 줍니다.
우리 경우에는 입력 데이터를 제한하는 것이 합리적인 전략이에요. 지금 필요한 게 IP 주소뿐이라면 그것만 다루고, 클로저에 IP 주소만 전달하도록 합시다.
ip_address = conn.remote_ip
spawn(fn -> log_request_ip(ip_address) end)
또는 GenServer의 경우:
GenServer.cast(pid, {:report_ip_address, conn.remote_ip})
감독 없는 프로세스 (Unsupervised processes)
문제
Elixir에서 감독 트리(supervision tree) 밖에 프로세스를 만드는 것 자체는 안티패턴이 아니에요. 하지만 오래 실행되는 프로세스를 감독 트리 밖에서 많이 spawn하면 그 프로세스들의 가시성·모니터링이 어려워져 개발자가 수명 주기를 완전히 제어하지 못하게 될 수 있어요.
예시
다음 코드 예시는 Agent 프로세스로 숫자 Counter를 유지하는, 감독 트리 밖에서 동작하는 라이브러리를 보여주려는 것입니다.
defmodule Counter do
@moduledoc """
Global counter implemented as an Agent.
"""
use Agent
@doc "Starts a counter process."
def start_link(opts \\ []) do
initial_state = Keyword.get(opts, :initial_value, 0)
name = Keyword.get(opts, :name, __MODULE__)
Agent.start_link(fn -> initial_state end, name: name)
end
@doc "Gets the current value of the given counter."
def get(name \\ __MODULE__) do
Agent.get(name, fn state -> state end)
end
@doc "Bumps the value of the given counter."
def bump(name \\ __MODULE__, value) do
Agent.get_and_update(fn state -> {state, value + state} end)
end
end
감독 트리 밖에서 프로세스를 시작하는 것이 가능합니다:
iex> Counter.start_link()
{:ok, #PID<0.115.0>}
iex> Counter.bump(13)
0
iex> Counter.get()
13
하지만 이런 프로세스는 관찰하고 수명 주기를 제어하기가 더 어려워요. 예를 들어 위 Counter에 의존하는 다른 프로세스가 있다면, 그것들이 순서대로 초기화되도록 임시 메커니즘(ad-hoc)이 필요합니다. 게다가 애플리케이션이 종료될 때 언제 종료될지 보장되지 않아요.
리팩터링
사용된 프로세스 수와 각 프로세스의 수명에 관계없이 라이브러리 클라이언트가 자기 시스템을 완전히 제어할 수 있게 하려면, 모든 프로세스가 감독 트리 안에서 시작되어야 해요. 아래 코드는 Supervisor를 감독 트리로 사용합니다.
defmodule SupervisedProcess.Application do
use Application
@impl true
def start(_type, _args) do
children = [
# counter와 name의 기본값 사용
Counter,
# counter, name, 그리고 커스텀 ID 지정
Supervisor.child_spec(
{Counter, name: :other_counter, initial_value: 15},
id: :other_counter
)
]
Supervisor.start_link(children, strategy: :one_for_one, name: App.Supervisor)
end
end
감독 트리는 프로세스가 시작되는 결정적 순서를 가질 뿐 아니라, 역순으로 종료되는 것도 보장해서 종료 중에 필요한 정리를 수행할 수 있게 해 줍니다. 또한 감독 전략(supervision strategy)으로 예상치 못한 실패 시 프로세스가 정확히 어떻게 동작해야 하는지 설정할 수 있어요.
마지막으로, 애플리케이션과 감독 트리는 Phoenix.LiveDashboard나 Erlang 내장 observer 같은 도구로 내부를 들여다볼 수 있습니다.
더 알아보기
Agent— 상태를 감싸는 프로세스 추상화GenServer— 서버를 구현하는 behaviourSupervisor— 감독 트리를 구성하는 모듈Task— 비동기·병렬 작업:persistent_term— 자주 안 바뀌는 데이터를 프로세스 간 공유