Ver2.5 Elixir로 찍어내는 실시간 IoT 플랫폼: 데이터 수집부터 분석까지, 이게 되네?

Elixir로 찍어내는 실시간 IoT 플랫폼: 데이터 수집부터 분석까지, 이게 되네?
세상 모든 사물이 인터넷으로 연결되는 시대,
수억 개의 디바이스가 쏟아내는 데이터를 실시간으로 처리해야 한다면?
바로 그때, Elixir가 '치트키'가 되어주는 이유를 알려줄게.
#프롤로그 왜 하필 Elixir야? IoT랑 무슨 상관인데?
안녕! 코딩하는 친구들. 오늘은 좀 힙하고, 강력하고, 심지어 우아하기까지 한 언어, Elixir(엘릭서)에 대해 이야기해볼까 해. 그냥 언어 소개만 하면 재미없잖아? 그래서 요즘 가장 핫한 분야 중 하나인 **IoT(사물인터넷)**랑 엮어서, 'Elixir로 실시간 데이터 플랫폼을 어떻게 만드는지'에 대한 거대한 빌드업을 시작해보려고 해.
생각해봐. 우리 주변은 이미 스마트 기기로 가득 차 있어. 스마트 워치, 스마트 냉장고, 스마트 팩토리의 센서들, 심지어 길거리의 가로등까지. 이 모든 것들이 1초에도 수십, 수백 번씩 데이터를 뿜어내고 있어. "지금 온도는 24도", "공장 라인 3번 컨베이어 벨트 속도 정상", "심박수 75bpm" 같은 시그널을 말이야.
문제는 이 어마어마한 양의 데이터를 *안정적으로*, 그리고 *실시간으로* 받아내고 처리하는 거야. 만약 스마트 팩토리 센서 데이터가 1분만 늦게 도착해서 불량을 못 잡아내면? 수천만 원의 손실이 발생할 수도 있어. 자율주행차가 주변 차량 데이터를 0.1초만 늦게 받아도 대형 사고로 이어질 수 있지. 바로 이 지점에서 대부분의 기술 스택이 땀을 뻘뻘 흘리기 시작해.
핵심 질문: 수백만 개의 디바이스가 동시에 접속해서 쉬지 않고 데이터를 보내는 상황을 어떻게 감당할까?
하나의 연결이 끊기거나 에러가 나도 전체 시스템은 멀쩡하게 돌아가야 하는데, 그건 어떻게 보장하지?
이 질문에 대한 가장 우아하고 현실적인 답변 중 하나가 바로 **Elixir**야. 왜냐고? Elixir는 태생부터 '대규모 동시성'과 '안정성'이라는 두 가지 키워드를 DNA에 새기고 태어났거든. 이건 그냥 하는 말이 아니라, Elixir가 돌아가는 기반인 **BEAM(Erlang VM)** 이라는 가상 머신 덕분이야.
이 글은 Elixir 문법을 하나하나 가르쳐주는 튜토리얼은 아니야. 그보다는 더 큰 그림을 보여줄 거야. Elixir와 그 기반 기술인 OTP가 왜 IoT 플랫폼에 이상적인지, 그리고 실제로 어떤 아키텍처로 실시간 데이터 수집 및 분석 시스템을 구축할 수 있는지에 대한 청사진을 제시하는 거지. 이 글을 다 읽고 나면, "아, Elixir로 이런 걸 만들 수 있구나. 이런 철학으로 접근하는구나" 하는 감을 잡게 될 거야. 그럼, 이제부터 진짜 이야기를 시작해볼까?
PART 1 Elixir와 BEAM: IoT 플랫폼의 숨겨진 심장
자, Elixir가 IoT에 좋다는 건 알겠어. 근데 '왜' 좋은지를 알려면 그 속을 좀 들여다봐야 해. Elixir의 모든 마법은 **BEAM**이라는 가상머신과 **OTP**라는 프레임워크에서 나오거든. 이걸 이해하면 "아, 이건 사기캐잖아?" 소리가 절로 나올 거야.
BEAM VM: 동시성의 신세계를 열다
BEAM은 원래 통신 장비, 특히 전화 교환기를 만들려고 개발된 Erlang(얼랭)이라는 언어를 위해 만들어진 가상머신이야. Elixir는 이 Erlang 위에서 돌아가는, 좀 더 현대적이고 개발자 친화적인 문법을 가진 언어지. 중요한 건 Elixir가 BEAM의 모든 능력을 그대로 물려받았다는 거야.
전화 교환기를 생각해봐. 수십, 수백만 명이 동시에 전화를 걸고, 받고, 끊어. 특정 통화 하나에 문제가 생겼다고 교환기 전체가 멈추면? 대재앙이지. BEAM은 바로 이런 문제를 해결하기 위해 설계됐어.
**1. 미친듯이 가벼운 '프로세스'**
우리가 보통 생각하는 OS의 프로세스나 스레드는 무거워. 하나 만드는 데 메모리도 꽤 쓰고, 컨텍스트 스위칭 비용도 비싸지. 그래서 수만 개씩 띄우는 건 상상도 못 해.
하지만 BEAM의 프로세스는 달라. 이건 OS 프로세스가 아니라, BEAM이 직접 관리하는 *아주 가벼운 실행 단위*야. 만드는 데 걸리는 시간은 마이크로초 단위고, 메모리도 겨우 몇 KB만 차지해. 그래서 서버 한 대에서 **수십만, 수백만 개를 동시에 띄우는 게 가능해.**
이게 IoT에서 왜 중요할까? IoT 디바이스 하나하나를 독립된 프로세스 하나에 할당하는 전략을 쓸 수 있기 때문이야. 센서 A가 연결되면 '센서 A 전용 프로세스'를 하나 띄우고, 스마트 워치 B가 연결되면 '워치 B 전용 프로세스'를 띄우는 거지. 각 프로세스는 자기 할 일만 하고, 다른 프로세스에 영향을 주지 않아. 수백만 개의 디바이스가 연결돼도 그냥 프로세스 수백만 개를 띄우면 그만이야. 이게 Elixir 동시성의 핵심이야.
**2. 간섭 없는 세상: '메시지 패싱'**
이렇게 많은 프로세스들이 어떻게 서로 데이터를 주고받을까? 보통 다른 언어들은 '공유 메모리'를 쓰고 락(Lock)을 걸어서 데이터 경쟁을 막으려고 해. 이건 버그의 온상이고 시스템을 복잡하게 만드는 주범이지.
BEAM의 프로세스들은 메모리를 공유하지 않아. 각자 자기만의 고립된 메모리 공간을 가져. 그럼 소통은? 오직 **메시지를 주고받는 방식(Message Passing)**으로만 가능해. 마치 각 프로세스가 자기 우편함을 가지고 있고, 다른 프로세스에게 편지를 보내는 것과 같아. 이 방식은 'Side Effect'를 원천적으로 차단해서 코드를 훨씬 예측 가능하고 안전하게 만들어줘.
OTP: 고장나도 괜찮아, "Let it Crash" 철학
OTP(Open Telecom Platform)는 BEAM 위에서 돌아가는 거대한 라이브러리이자 프레임워크 모음이야. 안정적인 시스템을 만들기 위한 온갖 도구와 디자인 패턴이 들어있지. 그중에서도 핵심은 **Supervisor(감독관)**야.
일반적인 프로그래밍에서는 에러가 나면 어떻게든 그걸 잡아서(try-catch) 프로그램을 계속 돌리려고 애를 써. 하지만 OTP의 철학은 정반대야. **"죽게 내버려 둬 (Let it Crash)"**
이게 무슨 무책임한 소리냐고? 아니, 이게 바로 궁극의 안정성을 만드는 비결이야.
생각해봐. 어떤 프로세스가 알 수 없는 이유로 상태가 꼬여버렸어. 이 상태를 억지로 복구하려고 하면 더 큰 문제를 일으킬 수 있어. 차라리 그냥 그 프로세스를 죽여버리고, *깨끗한 초기 상태로 다시 시작하는 게* 훨씬 안전하고 간단하다는 거야.
이 '죽이고 다시 살리는' 역할을 하는 게 바로 **Supervisor**야. 개발자는 'Worker' 프로세스(실제 일을 하는 프로세스)를 만들고, 이 Worker들을 감시하는 Supervisor를 만들어. 만약 Worker가 에러를 내고 죽으면? Supervisor가 즉시 알아채고, 정해진 정책(예: 즉시 재시작, 몇 번 실패하면 포기 등)에 따라 Worker를 새롭고 깨끗한 상태로 되살려내. 이 모든 게 자동으로 이뤄져.
IoT 플랫폼에서 이게 어떤 의미일까? 센서 100만 개 중 하나가 이상한 값을 보내서 그 센서를 담당하던 프로세스가 죽었다고 가정해보자. 다른 999,999개의 센서와 전체 시스템은? 아무런 영향을 받지 않고 멀쩡히 돌아간다. 그리고 죽었던 프로세스는 Supervisor에 의해 즉시 깨끗한 상태로 되살아나서 다시 일할 준비를 마친다. 이게 바로 Elixir/OTP가 제공하는 궁극의 안정성, **Fault Tolerance(장애 감내성)**다.
이런 특성들 때문에 WhatsApp은 단 50명의 엔지니어로 수억 명의 사용자를 감당했고, Discord는 수백만 개의 동시 음성 채팅을 Elixir로 처리하고 있어. IoT 플랫폼이 요구하는 조건과 정확히 일치하지?
PART 2 IoT 플랫폼 아키텍처 설계: 큰 그림 그리기
자, 이제 Elixir가 가진 무기들을 알았으니, 이걸로 어떻게 IoT 플랫폼이라는 성을 지을지 설계도를 그려볼 차례야. 아키텍처는 복잡할 수 있지만, 핵심적인 흐름만 이해하면 돼. 보통 이런 식으로 구성돼.
**[디바이스] -> [게이트웨이/브로커] -> [데이터 인제스천] -> [데이터 처리/분석] -> [데이터 저장소] -> [API/대시보드]**
여기서 Elixir는 특히 **[게이트웨이/브로커]** 와 **[데이터 인제스천]**, **[데이터 처리/분석]** 단계에서 미친 존재감을 뽐내.
1. 통신 프로토콜 선택: IoT의 언어, MQTT
수많은 디바이스와 서버가 대화하려면 공통된 언어가 필요해. IoT 세계의 '국룰' 같은 프로토콜이 바로 **MQTT(Message Queuing Telemetry Transport)**야.
왜 MQTT일까?
- **경량(Lightweight):** 저사양, 저전력 디바이스에서도 부담 없이 돌아갈 수 있도록 헤더가 아주 작고 프로토콜 자체가 단순해.
- **Publish/Subscribe 모델:** 디바이스가 특정 '토픽(Topic)'에 데이터를 발행(Publish)하면, 이 토픽을 구독(Subscribe)하는 서버나 다른 디바이스가 데이터를 받아보는 구조야. 서로 직접 알 필요가 없어서 매우 유연하고 확장성이 좋아. 예를 들어, `home/living_room/temperature` 라는 토픽에 온도 센서가 값을 발행하면, 이 토픽을 구독하는 서버, 스마트폰 앱, 에어컨이 모두 그 값을 받을 수 있어.
- **QoS(Quality of Service):** 메시지가 최소 한 번은 전달되도록(At least once), 최대 한 번만 전달되도록(Exactly once) 보장하는 옵션이 있어서 데이터 유실에 민감한 환경에 적합해.
물론 CoAP, WebSockets 같은 다른 프로토콜도 있지만, 대부분의 범용 IoT 환경에서는 MQTT가 가장 이상적인 선택이야. 우리 플랫폼도 MQTT를 중심으로 설계할 거야.
2. Elixir 기반 아키텍처 구상
이제 Elixir를 각 단계에 배치해보자.
**a) 인제스천 레이어 (Ingestion Layer): MQTT 브로커**
여기가 최전선이야. 수백만 디바이스로부터의 MQTT 연결을 받아주는 곳. 우리는 Elixir로 직접 MQTT 브로커를 만들거나, `Tortoise` 같은 검증된 Elixir 라이브러리를 사용해서 이 부분을 구현할 거야.
앞서 말했듯이, 디바이스 연결 하나당 Elixir 프로세스 하나를 할당할 거야. Supervisor는 이 연결 프로세스들을 감시하고. 이렇게 하면 어떤 연결 하나가 문제를 일으켜도 다른 연결에는 전혀 영향을 주지 않아. 수백만 개의 동시 연결을 가볍게 처리하는 거지.
**b) 처리 및 분석 레이어 (Processing & Analysis Layer)**
MQTT 브로커가 받은 데이터는 날것(raw data) 그대로일 가능성이 높아. 이걸 의미 있는 정보로 가공하는 단계가 필요해.
- **파싱 및 유효성 검사:** JSON, MessagePack, 혹은 커스텀 바이너리 포맷으로 들어온 데이터를 파싱하고, 값이 유효한 범위 내에 있는지 검사해.
- **데이터 보강 (Enrichment):** `device_id`만 가지고 들어온 데이터에 디바이스의 위치, 소유자, 모델 정보 등을 추가해.
- **실시간 분석:** 특정 시간 동안의 평균값을 계산하거나, 온도가 갑자기 10도 이상 치솟는 등의 이상 징후(Anomaly)를 실시간으로 탐지해.
이런 데이터 처리 파이프라인을 구축하는 데는 Elixir의 **Broadway**나 **GenStage** 같은 라이브러리가 엄청난 위력을 발휘해. 이 라이브러리들은 데이터 흐름을 효율적으로, 그리고 병렬로 처리할 수 있게 도와주는 도구야. 뒤에서 더 자세히 다룰게.
**c) 데이터 저장 및 API**
- **저장소(Storage):** 처리된 데이터는 최종적으로 데이터베이스에 저장돼. IoT 데이터는 '시계열(Time-series)' 데이터인 경우가 많아서, **TimescaleDB**나 **InfluxDB** 같은 시계열 데이터베이스가 찰떡궁합이야. Elixir의 데이터베이스 라이브러리인 **Ecto**를 사용하면 이런 데이터베이스와도 쉽게 연동할 수 있어.
- **API 서버:** 저장된 데이터를 외부(웹 대시보드, 모바일 앱 등)에 제공하기 위한 API 서버가 필요해. 이때는 Elixir의 웹 프레임워크인 **Phoenix**를 사용해. Phoenix는 특히 **Channels**라는 기능으로 실시간 웹소켓 통신을 아주 쉽게 구현할 수 있어서, 대시보드에 실시간으로 데이터 변화를 뿌려주는 기능을 만들 때 최고야.
이 아키텍처의 아름다움은 각 부분이 독립적으로 확장될 수 있다는 거야. 디바이스가 늘어나면 인제스천 레이어 서버만 증설하면 되고, 데이터 처리가 복잡해지면 프로세싱 레이어 서버를 늘리면 돼. Elixir의 분산(Distribution) 기능을 이용하면 여러 서버를 마치 하나의 거대한 시스템처럼 운영할 수도 있어. 이건 정말 강력한 기능이지.
PART 3 실시간 데이터 수집: 코드로 보는 Elixir의 마법
자, 이제 이론은 충분해. 직접 코드를 보면서 Elixir가 어떻게 일을 처리하는지 감을 잡아보자. 여기서 모든 코드를 다 짜볼 순 없지만, 핵심적인 부분들을 통해 "아, 이런 느낌이구나!" 할 수 있도록 도와줄게.
먼저, Elixir 프로젝트를 시작해볼까? 터미널에 이렇게 입력하면 돼.
mix new iot_platform --sup
`mix`는 Elixir의 빌드 도구야. `new iot_platform`은 'iot_platform'이라는 새 프로젝트를 만들라는 뜻이고, `--sup` 옵션은 애플리케이션의 최상위 Supervisor를 포함한 기본 구조를 자동으로 만들어줘. 시작부터 '감독관'을 대동하는 거지. 이게 바로 OTP의 철학이 녹아있는 부분이야.
1. GenServer: 모든 디바이스를 위한 개인 비서
**GenServer**는 OTP의 가장 핵심적인 구성 요소 중 하나야. 'Generic Server'의 줄임말로, 상태를 가지고 메시지를 주고받으며 특정 작업을 수행하는 프로세스를 아주 쉽게 만들 수 있게 해주는 템플릿이야.
우리는 IoT 디바이스 하나하나를 GenServer 프로세스 하나에 매칭시킬 거야. 즉, 디바이스가 우리 서버에 연결되면, 그 디바이스만을 위한 GenServer가 하나 뿅! 하고 생겨나는 거지. 이 GenServer는 해당 디바이스의 상태(예: 마지막으로 연결된 시간, 보낸 메시지 수 등)를 관리하고, 디바이스로부터 오는 메시지를 처리하는 역할을 해.
간단한 디바이스 핸들러 GenServer를 만들어보자. `lib/iot_platform/device_handler.ex` 파일을 만들고 아래처럼 코드를 작성해봐.
defmodule IotPlatform.DeviceHandler do
use GenServer
# --- Client API ---
def start_link(device_id) do
# GenServer를 시작하고 Supervisor에 연결해줘.
# 이름으로 프로세스를 찾을 수 있게 등록하는 것도 좋은 방법이야.
GenServer.start_link(__MODULE__, device_id, name: via_tuple(device_id))
end
def handle_data(device_id, data) do
# 특정 디바이스 프로세스에 데이터를 비동기적으로 보내.
GenServer.cast(via_tuple(device_id), {:handle_data, data})
end
# --- Server Callbacks ---
@impl true
def init(device_id) do
# GenServer가 처음 시작될 때 호출돼.
# 여기서 초기 상태를 설정해.
IO.puts("Device #{device_id} handler started. PID: #{inspect(self())}")
state = %{
device_id: device_id,
message_count: 0,
last_seen: NaiveDateTime.utc_now()
}
{:ok, state}
end
@impl true
def handle_cast({:handle_data, data}, state) do
# handle_data/2 함수를 통해 메시지를 받으면 여기가 실행돼.
IO.puts("Received data from #{state.device_id}: #{inspect(data)}")
# 여기서 데이터 처리 로직을 수행하면 돼.
# 예를 들어, 데이터베이스에 저장하거나 다른 프로세스로 전달.
new_state =
state
|> Map.put(:message_count, state.message_count + 1)
|> Map.put(:last_seen, NaiveDateTime.utc_now())
{:noreply, new_state}
end
# --- Private Functions ---
defp via_tuple(device_id) do
# 프로세스를 쉽게 찾기 위한 이름 튜플 생성
{:via, Registry, {IotPlatform.DeviceRegistry, device_id}}
end
end
코드가 좀 있어 보이지만, 핵심은 간단해.
- `start_link/1`: 이 GenServer 프로세스를 시작시키는 함수야. Supervisor가 이 함수를 호출해서 워커를 만들어.
- `init/1`: 프로세스가 처음 만들어질 때 딱 한 번 실행돼. 디바이스 ID, 메시지 카운트 같은 초기 상태를 설정하지.
- `handle_cast/2`: 비동기 메시지를 처리하는 곳이야. 디바이스로부터 실제 데이터가 들어오면 이 함수가 호출되고, 여기서 데이터를 처리한 뒤 상태를 업데이트해.
여기서 `Registry`를 사용한 부분이 보일 거야. 이건 Elixir에 내장된 프로세스 등록소인데, `device_id` 같은 동적인 이름으로 프로세스의 PID(프로세스 ID)를 쉽게 찾을 수 있게 해줘. "device-123"이라는 ID를 가진 디바이스의 GenServer를 찾고 싶을 때 아주 유용해.
2. Supervisor: 죽지 않는 군단의 지휘관
자, 이제 일꾼(GenServer)은 만들었어. 근데 이 일꾼이 갑자기 죽으면 어떡하지? 바로 그때 **Supervisor**가 등장해.
우리는 `DeviceHandler`들을 관리할 전용 Supervisor를 만들 거야. 만약 어떤 `DeviceHandler`가 에러로 죽으면, 이 Supervisor가 그걸 감지하고 자동으로 재시작시켜주는 거지.
`lib/iot_platform/device_supervisor.ex` 파일을 만들어보자.
defmodule IotPlatform.DeviceSupervisor do
use Supervisor
def start_link(init_arg) do
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
# Supervisor를 위한 API
def start_device_handler(device_id) do
# Supervisor에게 동적으로 자식 프로세스를 추가해달라고 요청해.
Supervisor.start_child(__MODULE__, [device_id])
end
@impl true
def init(_init_arg) do
children = [
# 이 Supervisor는 DeviceHandler 타입의 워커들을 관리할 거야.
# restart: :transient -> 정상 종료되면 놔두고, 비정상 종료 시에만 재시작.
%{
id: IotPlatform.DeviceHandler,
start: {IotPlatform.DeviceHandler, :start_link, []},
restart: :transient
}
]
# one_for_one: 자식 하나가 죽으면 그 하나만 재시작.
opts = [strategy: :one_for_one]
Supervisor.init(children, opts)
end
end
이 Supervisor는 `DynamicSupervisor`를 쓰는 게 더 적합하지만, 이해를 돕기 위해 기본 Supervisor로 설명했어. 핵심은 `init/1` 함수야. 여기서 어떤 종류의 자식 프로세스(`children`)를 관리할지, 그리고 어떤 전략(`strategy`)으로 관리할지를 정의해.
- `children`: `DeviceHandler`를 자식으로 삼겠다고 명시했어.
- `strategy: :one_for_one`: 자식 중 하나가 죽으면 딱 그 하나만 재시작시키는 전략이야. IoT 플랫폼처럼 각 연결이 독립적일 때 완벽한 전략이지. (만약 하나가 죽으면 모두 재시작해야 한다면 `:one_for_all`을 사용해)
- `restart: :transient`: 프로세스가 정상적으로 종료되면(예: 디바이스가 정상적으로 로그아웃) 굳이 재시작하지 않고, 에러로 비정상 종료됐을 때만 재시작하라는 옵션이야.
이제 애플리케이션의 메인 Supervisor (`lib/iot_platform/application.ex`)가 이 `DeviceSupervisor`를 자식으로 등록해주면, 우리 앱이 시작될 때 이 모든 구조가 자동으로 실행되는 거야. 마치 군대처럼, 최상위 사령관이 각 부대 지휘관을 임명하고, 지휘관들이 병사들을 관리하는 완벽한 지휘 체계가 만들어지는 거지.
이런 구조 덕분에 개발자는 개별 디바이스의 에러 처리에 골머리를 앓을 필요가 없어. 그냥 비즈니스 로직에만 집중하면 돼. 에러가 나면? OTP가 알아서 처리해줄 테니까. 이게 바로 "Let it Crash" 철학의 실체야.
물론, 실제 플랫폼에서는 MQTT 라이브러리(예: `Tortoise`)가 연결을 수락하면, 그 콜백 함수 안에서 `DeviceSupervisor.start_device_handler(device_id)`를 호출해서 각 디바이스를 위한 GenServer를 띄워주는 로직이 들어가게 될 거야.
PART 4 데이터 처리와 분석: 흐르는 강물을 다루는 기술
자, 이제 수많은 디바이스로부터 데이터가 콸콸 쏟아져 들어오기 시작했어. 이 데이터들을 그냥 쌓아두기만 하면 단순한 창고일 뿐이야. 진짜 가치는 이 데이터의 흐름 속에서 의미를 찾아내는 데 있어. Elixir는 이 '데이터 파이프라인'을 구축하는 데에도 아주 강력한 도구를 제공해.
Broadway: 데이터 파이프라인계의 어벤져스
**Broadway**는 데이터 처리 파이프라인을 쉽게 만들 수 있도록 도와주는 Elixir 라이브러리야. RabbitMQ, Amazon SQS, Google Cloud Pub/Sub 같은 메시지 큐에서 데이터를 가져와서, 여러 단계에 걸쳐 병렬로 처리하고, 최종적으로 어딘가에 저장하거나 다른 작업을 수행하는 일련의 과정을 아주 우아하게 처리해줘.
Broadway의 구조는 크게 세 부분으로 나뉘어.
- **Producer:** 데이터 소스야. 메시지 큐에서 데이터를 가져오는 역할을 해.
- **Processor:** 실제 데이터 처리 로직이 들어가는 부분. 여러 개의 Processor가 동시에 돌아가면서 Producer가 가져온 데이터를 나눠서 처리해. 병렬 처리의 핵심이지.
- **Batcher:** 처리된 데이터들을 묶어서(batch) 한 번에 처리하는 역할을 해. 예를 들어, 데이터 100개가 모일 때마다 데이터베이스에 한 번에 `INSERT`하는 식으로 효율을 높일 수 있어.
우리의 IoT 플랫폼에서는 MQTT 브로커를 통해 들어온 데이터를 내부적인 메시지 큐(예: Elixir 프로세스를 이용한 간단한 큐 또는 RabbitMQ)로 보낸 다음, Broadway가 이 큐에서 데이터를 꺼내 처리하는 구조를 만들 수 있어.
간단한 Broadway 파이프라인 예시를 보자. 들어온 데이터를 파싱하고, 간단한 검증을 거쳐 데이터베이스에 저장하는 파이프라인이야.
defmodule IotPlatform.DataPipeline do
use Broadway
alias Broadway.Message
# 1. Producer 설정: 여기서는 테스트용 GenStage를 사용
def start_link(_opts) do
Broadway.start_link(__MODULE__,
name: __MODULE__,
producer: [
module: {Broadway.Producer.GenStage,
producer: IotPlatform.DataProducer} # 데이터를 공급하는 모듈
],
processors: [
default: [
concurrency: 50 # 50개의 프로세서가 동시에 데이터를 처리!
]
],
batchers: [
db_batcher: [
batch_size: 100, # 100개씩 묶어서 처리
batch_timeout: 2000 # 2초가 지나면 100개가 안돼도 처리
]
]
)
end
# 2. Processor 로직
@impl true
def handle_message(_, %Message{data: raw_data} = message, _) do
# JSON 파싱 & 데이터 변환
case Jason.decode(raw_data) do
{:ok, data} ->
# 데이터 변환 및 보강 로직
transformed_data = transform(data)
Message.put_data(message, transformed_data)
{:error, _} ->
# 파싱 실패 시, 메시지를 실패 처리
Message.failed(message, "json_parsing_failed")
end
end
# 3. Batcher 로직
@impl true
def handle_batch(:db_batcher, messages, _, _) do
# 성공적으로 처리된 메시지들만 추출
data_to_insert =
for %Message{data: data, status: :ok} <- messages do
data
end
# 데이터베이스에 한 번에 저장 (Bulk Insert)
case IotPlatform.Repo.insert_all(IotPlatform.Schema.Reading, data_to_insert) do
{_count, _} ->
# 성공하면 원래 메시지들을 그대로 반환
messages
{:error, _} ->
# 실패하면 모든 메시지를 실패 처리
for message <- messages, do: Message.failed(message, "db_insert_failed")
end
end
defp transform(data) do
# 들어온 데이터에 처리 시각을 추가하는 등의 변환 작업
Map.put(data, :processed_at, DateTime.utc_now())
end
end
이 코드의 강력함이 느껴져? `concurrency: 50` 이 한 줄만으로 50개의 프로세스가 동시에 데이터를 병렬로 처리하게 돼. 만약 서버 CPU 코어가 더 많다면 이 숫자를 더 늘려서 처리량을 극대화할 수 있어. `batch_size: 100` 설정은 데이터베이스의 부하를 획기적으로 줄여줘. 100번의 `INSERT` 쿼리를 보내는 대신, 한 번의 `INSERT` 쿼리로 100개의 데이터를 저장하니까.
이런 파이프라인을 여러 개 만들어서 복잡한 데이터 처리 로직을 구현할 수 있어. 예를 들어, 첫 번째 파이프라인은 데이터 정제를, 두 번째 파이프라인은 이상 징후 탐지를, 세 번째 파이프라인은 통계 계산을 담당하게 하는 식으로 말이야.
Ecto와 시계열 데이터베이스: 데이터의 영구적인 집
처리된 데이터는 어딘가에 저장해야겠지. **Ecto**는 Elixir의 데이터베이스 래퍼이자 쿼리 언어야. ORM(Object-Relational Mapper)과 비슷하지만, 더 명시적이고 함수형 프로그래밍 스타일에 잘 맞아. Ecto를 사용하면 PostgreSQL, MySQL 같은 관계형 데이터베이스는 물론, 어댑터만 있다면 다른 종류의 데이터베이스와도 상호작용할 수 있어.
특히 IoT 데이터는 '언제' 발생했는지가 매우 중요한 **시계열 데이터(Time-series Data)**야. "오후 3시 15분 21초의 온도", "오후 3시 15분 22초의 온도" 처럼 시간에 따라 기록되는 데이터지. 이런 데이터는 일반적인 관계형 데이터베이스보다 시계열 데이터베이스(TSDB)에 저장하고 조회하는 것이 훨씬 효율적이야.
대표적인 TSDB로는 **TimescaleDB**와 **InfluxDB**가 있어.
- **TimescaleDB:** PostgreSQL의 확장(extension) 형태라서, 기존 PostgreSQL의 모든 기능과 안정성을 그대로 사용하면서 시계열 데이터 처리 성능을 극대화한 DB야. Ecto와 아주 자연스럽게 통합돼서 많은 Elixir 개발자들이 선호해.
- **InfluxDB:** 처음부터 시계열 데이터 처리를 위해 만들어진 DB로, 데이터 저장 및 쿼리 성능이 매우 뛰어나.
Ecto와 TimescaleDB를 사용한다고 가정해보자. 먼저 `mix.exs` 파일에 Ecto와 PostgreSQL 드라이버 의존성을 추가하고, 데이터 구조를 정의하는 '스키마(Schema)'를 만들어.
defmodule IotPlatform.Schema.Reading do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
schema "readings" do
field :time, :utc_datetime_usec, primary_key: true
field :device_id, :string, primary_key: true
field :temperature, :float
field :humidity, :float
end
def changeset(reading, attrs) do
reading
|> cast(attrs, [:time, :device_id, :temperature, :humidity])
|> validate_required([:time, :device_id])
end
end
TimescaleDB에서는 `time`과 `device_id`를 복합 기본 키로 사용하는 'Hypertable'을 만들어서 성능을 최적화해. Ecto 스키마에서도 `@primary_key false`와 `primary_key: true` 옵션으로 이걸 표현할 수 있어.
이제 앞서 만든 Broadway의 `handle_batch/2` 함수에서 `IotPlatform.Repo.insert_all(IotPlatform.Schema.Reading, data_to_insert)` 코드를 통해 처리된 데이터를 TimescaleDB에 효율적으로 저장할 수 있게 되는 거야.
이렇게 Elixir는 데이터 수집부터 처리, 저장까지 전 과정에 걸쳐 안정적이고 확장 가능한 솔루션을 제공해. 이런 전문적인 기술 스택을 활용하고 싶지만 혼자서 막막하다면, **재능넷** 같은 플랫폼에서 Elixir 전문가의 도움을 받거나 프로젝트를 함께할 동료를 찾아보는 것도 좋은 방법이 될 수 있어.
PART 5 데이터 시각화: Phoenix LiveView로 만드는 실시간 대시보드
데이터를 모으고 분석까지 마쳤다면, 이제 그 결과를 눈으로 볼 차례야. 관리자는 실시간으로 공장 설비 상태를 모니터링하고 싶을 거고, 사용자는 자기 집 스마트 온도계의 변화를 그래프로 보고 싶을 거야. 이럴 때 필요한 게 바로 **실시간 대시보드**지.
Elixir 생태계에는 이 분야의 끝판왕, **Phoenix Framework**가 있어. Phoenix는 Elixir로 만들어진 웹 프레임워크인데, 특히 실시간 웹 애플리케이션 제작에 있어서는 타의 추종을 불허해.
Phoenix Channels: 실시간 통신의 고속도로
Phoenix의 핵심 기능 중 하나는 **Channels**야. 이건 서버와 클라이언트(웹 브라우저) 사이에 지속적인 양방향 연결(주로 웹소켓을 통해)을 만들어주는 기술이야. 서버는 언제든지 클라이언트에게 데이터를 밀어줄(push) 수 있고, 클라이언트도 서버로 메시지를 보낼 수 있어.
우리 IoT 플랫폼에서는 이렇게 활용할 수 있어:
- 데이터 처리 파이프라인(Broadway)에서 특정 디바이스의 이상 징후(예: 온도 급상승)를 감지한다.
- 파이프라인은 Phoenix의 **PubSub(Publish/Subscribe)** 시스템을 통해 `dashboard:anomalies` 같은 토픽으로 이상 징후 이벤트를 발행(broadcast)한다.
- 대시보드 페이지에 접속한 모든 클라이언트의 Channel 프로세스들은 이 토픽을 구독하고 있다가, 이벤트가 발행되면 즉시 메시지를 받는다.
- Channel은 받은 메시지를 웹소켓을 통해 클라이언트(브라우저)로 푸시한다.
- 브라우저의 JavaScript 코드는 이 메시지를 받아서 화면에 "경고! 3번 라인 온도 급상승!" 같은 알림을 띄우거나 차트 색깔을 빨갛게 바꾼다.
이 모든 과정이 수 밀리초 안에 일어나. 사용자는 거의 실시간으로 데이터 변화를 눈으로 확인할 수 있는 거지.
Phoenix LiveView: 자바스크립트 없이 만드는 인터랙티브 UI
한 걸음 더 나아가면 **Phoenix LiveView**라는 혁신적인 기술이 있어. 이건 서버 사이드 렌더링의 장점과 싱글 페이지 애플리케이션(SPA)의 동적인 사용자 경험을 합친 거야.
LiveView를 사용하면, 개발자는 복잡한 JavaScript 코드를 거의 작성하지 않고도 실시간으로 업데이트되는 인터랙티브한 UI를 만들 수 있어. 사용자가 버튼을 클릭하거나 폼에 입력하면, 그 이벤트가 웹소켓을 통해 서버의 LiveView 프로세스로 전달돼. LiveView 프로세스는 상태를 변경하고, 변경된 부분만 포함된 HTML 조각을 다시 렌더링해서 클라이언트로 보내줘. 그러면 브라우저는 받은 HTML 조각으로 페이지의 해당 부분만 지능적으로 업데이트해.
IoT 대시보드를 LiveView로 만든다고 상상해봐.
- 서버의 PubSub을 통해 새로운 온도 데이터를 받으면, LiveView 프로세스가 자신의 상태(온도 값)를 업데이트한다.
- 상태가 바뀌었으니 LiveView는 자동으로 다시 렌더링되고, 변경된 HTML(예: `
온도: 25.5도
`)이 클라이언트로 전송된다. - 브라우저는 자바스크립트 코드 한 줄 없이도 온도 표시 부분을 새로운 값으로 업데이트한다.
개발자는 그냥 Elixir 코드만으로 서버 로직과 UI 업데이트 로직을 모두 작성하면 돼. 생산성이 엄청나게 올라가고, 프론트엔드와 백엔드 간의 복잡한 API 연동 과정이 상당 부분 사라져. 실시간 데이터 시각화가 필요한 IoT 대시보드에 이보다 더 좋을 순 없지.
물론 복잡한 차트나 그래픽은 `Chart.js` 같은 자바스크립트 라이브러리의 도움이 필요하지만, LiveView는 이런 라이브러리와도 잘 연동될 수 있도록 'Hooks'라는 기능을 제공해. Elixir로 핵심 로직을 처리하고, 시각적인 표현만 자바스크립트에 맡기는 환상적인 조합이 가능해.
PART 6 확장과 안정성: 분산 Elixir의 힘
지금까지 만든 플랫폼은 서버 한 대에서 아주 잘 돌아갈 거야. 하지만 디바이스가 수백만 개에서 수천만 개로 늘어나면? 서버 한 대로는 감당이 안 되겠지. 이때 필요한 게 바로 **수평 확장(Horizontal Scaling)**, 즉 서버를 여러 대로 늘리는 거야.
많은 기술 스택에서 수평 확장은 아주 골치 아픈 문제야. 여러 서버의 상태를 어떻게 동기화할지, 특정 서버에 장애가 생겼을 때 어떻게 대처할지 등을 고민해야 하거든.
하지만 Elixir는 태생부터 '분산 시스템'을 염두에 두고 만들어졌어. Erlang/OTP가 원래 여러 통신 장비 노드들이 서로 협력해서 하나의 거대한 교환 시스템을 이루도록 설계됐기 때문이야.
분산 Elixir: 여러 서버를 하나처럼
Elixir는 여러 서버(노드)를 아주 쉽게 하나의 클러스터로 묶을 수 있어. 클러스터로 묶인 노드들은 서로를 인식하고, 다른 노드에 있는 프로세스에게 마치 같은 노드에 있는 것처럼 메시지를 보낼 수 있어. 프로세스의 위치가 투명(Location Transparency)한 거야.
예를 들어, 노드 A에 있는 `DeviceHandler` 프로세스가 노드 B에 있는 `DataProcessor` 프로세스에게 데이터를 보내고 싶다면, 그냥 그 프로세스의 이름이나 PID로 메시지를 보내면 돼. Elixir 런타임이 알아서 네트워크를 통해 메시지를 전달해줘. 개발자는 이게 같은 서버 안에서 일어나는 일인지, 다른 대륙에 있는 서버로 전달되는 건지 신경 쓸 필요가 없어.
이런 특성을 이용하면 IoT 플랫폼을 아주 유연하게 확장할 수 있어.
- **지역 기반 분산:** 미국 디바이스는 미국 데이터센터의 Elixir 노드에, 유럽 디바이스는 유럽 노드에 연결되도록 해서 지연 시간(latency)을 줄일 수 있어. 하지만 이 노드들은 하나의 클러스터로 묶여서 데이터를 서로 주고받을 수 있지.
- **기능 기반 분산:** 어떤 노드들은 MQTT 연결을 받는 인제스천 역할만 전문으로 하고, 다른 노드들은 데이터 처리 파이프라인만 전문으로 돌리는 식으로 역할을 분리해서 부하를 분산시킬 수도 있어.
이런 분산 환경을 쉽게 구축하고 관리할 수 있도록 도와주는 `libcluster` 같은 라이브러리도 있어서, Kubernetes나 클라우드 환경에서 노드들을 자동으로 발견하고 클러스터를 구성하는 것도 매우 간단해.
궁극의 안정성: 분산 환경에서는 Supervisor의 힘이 더욱 막강해져. 한 노드 전체가 다운되더라도, 클러스터의 다른 노드들은 계속해서 정상적으로 동작해. 심지어 특정 노드의 작업을 다른 노드가 이어받아 처리하도록 설계할 수도 있어. 이런 수준의 장애 감내성은 일반적인 웹 프레임워크나 언어에서는 구현하기 매우 어려운 고급 기술이야.
#에필로그 Elixir, IoT 시대를 위한 준비된 언어
자, 지금까지 Elixir와 함께 IoT 플랫폼을 구축하는 긴 여정을 함께했어. 어때? Elixir가 왜 이 분야에서 '치트키'라고 불리는지 조금은 감이 와?
정리해볼게. Elixir가 IoT에 강력한 이유는:
- **대규모 동시성:** BEAM의 가볍고 독립적인 프로세스 덕분에 수백만 개의 디바이스 연결을 동시에, 그리고 안정적으로 처리할 수 있어.
- **궁극의 안정성 (Fault Tolerance):** "Let it Crash" 철학과 Supervisor 덕분에, 시스템의 일부에서 장애가 발생해도 전체 시스템은 멈추지 않고 계속 동작해.
- **뛰어난 확장성:** 분산 Elixir 기능을 통해 여러 서버를 하나의 시스템처럼 묶어 수평적으로 쉽게 확장할 수 있어.
- **높은 생산성:** 함수형 프로그래밍, 깔끔한 문법, 그리고 Phoenix LiveView 같은 강력한 도구 덕분에 개발자는 비즈니스 로직에만 집중하며 빠르고 즐겁게 개발할 수 있어.
물론 Elixir가 만병통치약은 아니야. 함수형 프로그래밍과 OTP의 개념에 익숙해지는 데 시간이 좀 걸릴 수 있고, 다른 주류 언어에 비해 생태계의 크기가 작다고 느낄 수도 있어. 하지만 대규모 실시간 시스템이라는 명확한 목표가 있다면, Elixir는 그 어떤 선택지보다 빛나는 해답이 될 수 있을 거야.
이 글을 통해 Elixir라는 새로운 세계에 대한 호기심이 생겼다면, 혹은 지금 구상하고 있는 프로젝트에 Elixir가 딱 맞을 것 같다는 생각이 들었다면, 주저하지 말고 도전해봐. Elixir School 같은 좋은 한글 자료도 많고, 커뮤니티도 아주 활발해. 때로는 **재능넷**과 같은 플랫폼을 통해 전문가의 조언을 구하거나 프로젝트를 함께 진행하는 것도 성장의 지름길이 될 수 있지.
세상의 모든 것이 연결되는 미래, 그 중심에서 데이터를 다루는 강력하고 우아한 방법을 찾고 있다면, Elixir는 분명 너의 가장 든든한 무기가 되어줄 거야. Happy coding!
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.