=================
== The Archive ==
=================

Phoenix + LiveView 전역 Rate Limit 적용기

|

개요

Phoenix 데모 서비스를 공개하면 가장 먼저 맞닥뜨리는 문제는 “인증이 없는데도 서비스가 버틸 수 있느냐"이다.

이번 글에서는 단일 홈서버/단일 컨테이너 환경에서 실제로 적용한 전역 rate limit 방식을 정리해보겠다. 핵심 목표는 다음 3가지이다.

왜 Plug만으로는 부족한가

Phoenix의 HTTP 요청은 Plug 파이프라인을 지난다. 하지만 LiveView의 handle_event/3는 웹소켓 연결 이후 이벤트이기 때문에 Plug 체인을 다시 타지 않는다.

즉, 전역 보호를 하려면 다음과 같이 구성해야 한다.

두 레이어를 같이 적용해야 한다.

1) 공통 토큰 버킷: ETS 기반 RateLimiter

인메모리 데모 서비스라면 ETS 토큰 버킷이 가장 단순하고 빠르다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
defmodule Playground.RateLimiter do
  @table :playground_rate_limiter

  def allow?(key, opts \\ []) when is_binary(key) do
    capacity = Keyword.get(opts, :capacity, 10)
    refill_per_second = Keyword.get(opts, :refill_per_second, 5.0)
    now_ms = Keyword.get(opts, :now_ms, System.monotonic_time(:millisecond))

    ensure_table!()

    case :ets.lookup(@table, key) do
      [] ->
        :ets.insert(@table, {key, capacity - 1.0, now_ms})
        :ok

      [{^key, tokens, last_refill_ms}] ->
        elapsed_ms = max(now_ms - last_refill_ms, 0)
        replenished = min(capacity * 1.0, tokens + elapsed_ms * refill_per_second / 1000.0)

        if replenished >= 1.0 do
          :ets.insert(@table, {key, replenished - 1.0, now_ms})
          :ok
        else
          :ets.insert(@table, {key, replenished, now_ms})
          {:error, :rate_limited}
        end
    end
  end
end

2) 클라이언트 IP 해석 유틸 (HTTP/LiveView 공통)

중요 포인트는 신뢰 프록시(로컬 Nginx)에서 들어온 요청일 때만 X-Forwarded-For를 신뢰하는 것이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
defmodule PlaygroundWeb.RateLimit.ClientIp do
  def from_conn(conn) do
    peer_ip = conn.remote_ip |> :inet.ntoa() |> to_string()
    forwarded_ip =
      conn
      |> Plug.Conn.get_req_header("x-forwarded-for")
      |> List.first()
      |> first_forwarded_ip()

    resolve(peer_ip, forwarded_ip)
  end

  def from_live_connect_info(peer_data, x_headers) do
    peer_ip = case peer_data do
      %{address: address} -> address |> :inet.ntoa() |> to_string()
      _ -> nil
    end

    forwarded_ip =
      Enum.find_value(x_headers || [], fn
        {"x-forwarded-for", value} -> first_forwarded_ip(value)
        _ -> nil
      end)

    resolve(peer_ip, forwarded_ip) || "unknown"
  end

  defp resolve(peer_ip, forwarded_ip) do
    if trusted_proxy_source?(peer_ip) and forwarded_ip, do: forwarded_ip, else: peer_ip
  end

  defp trusted_proxy_source?("127." <> _), do: true
  defp trusted_proxy_source?("::1"), do: true
  defp trusted_proxy_source?(_), do: false
end

3) HTTP 전역 적용: Router 파이프라인 Plug

HTTP 요청은 Plug 하나로 전역 커버할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
defmodule PlaygroundWeb.Plugs.RateLimit do
  @moduledoc false

  import Plug.Conn
  alias PlaygroundWeb.RateLimit.ClientIp

  @behaviour Plug

  @http_capacity 60
  @http_refill_per_second 20.0

  @impl true
  def init(opts), do: opts

  @impl true
  def call(conn, _opts) do
    key = rate_limit_key(conn)

    case Playground.RateLimiter.allow?(key,
           capacity: @http_capacity,
           refill_per_second: @http_refill_per_second
         ) do
      :ok ->
        conn

      {:error, :rate_limited} ->
        conn
        |> put_status(:too_many_requests)
        |> put_resp_content_type(response_content_type(conn))
        |> resp(429, response_body(conn))
        |> halt()
    end
  end

  defp rate_limit_key(conn) do
    ip = ClientIp.from_conn(conn)
    "http:#{ip}:#{conn.method}:#{conn.request_path}"
  end

  defp response_content_type(conn) do
    if json_request?(conn), do: "application/json", else: "text/plain"
  end

  defp response_body(conn) do
    if json_request?(conn), do: ~s({"error":"rate_limited"}), else: "rate_limited"
  end

  defp json_request?(conn) do
    Enum.any?(get_req_header(conn, "accept"), &String.contains?(&1, "json"))
  end
end

Router에 연결:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
pipeline :browser do
  plug :accepts, ["html"]
  plug PlaygroundWeb.Plugs.RateLimit
  ...
end

pipeline :api do
  plug :accepts, ["json"]
  plug PlaygroundWeb.Plugs.RateLimit
end

4) LiveView 전역 적용: on_mount Hook

LiveView는 on_mount에서 이벤트 훅을 붙여 전역 처리한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
defmodule PlaygroundWeb.Live.RateLimitHook do
  import Phoenix.Component, only: [assign: 3]
  import Phoenix.LiveView, only: [attach_hook: 4, get_connect_info: 2]
  alias PlaygroundWeb.RateLimit.ClientIp

  @live_capacity 20
  @live_refill_per_second 8.0

  def on_mount(:default, _params, _session, socket) do
    rate_limit_ip =
      ClientIp.from_live_connect_info(
        get_connect_info(socket, :peer_data),
        get_connect_info(socket, :x_headers)
      )

    socket =
      socket
      |> assign(:rate_limit_ip, rate_limit_ip)
      |> attach_hook(:global_rate_limit, :handle_event, &handle_event/3)

    {:cont, socket}
  end

  defp handle_event(event, _params, socket) do
    key = "live:#{socket.assigns.rate_limit_ip}:#{inspect(socket.view)}:#{event}"

    case Playground.RateLimiter.allow?(key,
           capacity: @live_capacity,
           refill_per_second: @live_refill_per_second
         ) do
      :ok -> {:cont, socket}
      {:error, :rate_limited} ->
        {:halt, Phoenix.LiveView.put_flash(socket, :error, "요청이 너무 빠릅니다. 잠시 후 다시 시도하세요.")}
    end
  end
end

그리고 모든 LiveView에 자동 적용:

1
2
3
4
5
6
7
def live_view do
  quote do
    use Phoenix.LiveView
    on_mount(PlaygroundWeb.Live.RateLimitHook)
    ...
  end
end

추가로 get_connect_info/2로 IP/헤더를 읽으려면 Endpoint 소켓 설정에 아래가 필요하다.

1
2
3
socket("/elixir/live", Phoenix.LiveView.Socket,
  websocket: [connect_info: [session: @session_options, peer_data: true, x_headers: true]]
)

5) Nginx 설정 체크포인트

리버스 프록시에서 아래 헤더 전달은 필수이다.

1
2
3
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

여기에 앱 측에서 “신뢰된 프록시일 때만 XFF 사용” 규칙을 지켜야 스푸핑 리스크를 줄일 수 있다.

6) 버킷 키 분리 전략

이번 적용에서 키를 다음처럼 분리했다.

이렇게 하면 /healthz 폭주가 /article/*를 막거나, 특정 LiveView 이벤트 폭주가 다른 이벤트를 잠그는 문제를 줄일 수 있다.

7) 데모 환경에서의 현실적인 결론

단일 홈서버 + 단일 컨테이너 + 인메모리 서비스라면 아래 조합이 꽤 실용적이다.

운영 트래픽이 커지면 Redis 기반 분산 rate limit으로 옮기는 게 맞다. 다만 데모 공개 목적에서는 ETS 방식이 구현 복잡도 대비 효과가 좋다고 판단했다.

8) 운영 주의사항

Categories:

Tags: