엣지 애플리케이션 개발
완전한 엣지 IoT 애플리케이션을 개발합니다.
🎯 학습 목표
- 엣지 애플리케이션 아키텍처 설계
- 실시간 모니터링 대시보드
- 로컬 데이터 처리 및 분석
- 클라우드 연동
예상 소요 시간: 60분
📋 사전 요구사항
- 데이터 파이프라인 튜토리얼 완료
- Blazor 또는 ASP.NET Core 기본 지식
- Docker
Step 1: 프로젝트 생성
# 엣지 애플리케이션 프로젝트 생성
cafe init --name EdgeApp --template edge-full
cd EdgeApp
생성된 구조:
EdgeApp/
├── src/
│ ├── EdgeApp.csproj
│ ├── Program.cs
│ ├── Services/
│ │ ├── DataCollectionService.cs
│ │ └── MonitoringService.cs
│ ├── Pages/
│ │ ├── Index.razor
│ │ └── Dashboard.razor
│ └── appsettings.json
├── docker-compose.yml
└── Dockerfile
Step 2: 데이터 수집 서비스
Services/DataCollectionService.cs:
using Caffeine.Client;
using Caffeine.Core.Models;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace EdgeApp.Services;
public class DataCollectionService : BackgroundService
{
private readonly CaffeineClient _client;
private readonly ILogger<DataCollectionService> _logger;
private readonly List<string> _tagNames;
public event Action<TagValue>? OnTagChanged;
public DataCollectionService(
CaffeineClient client,
ILogger<DataCollectionService> logger)
{
_client = client;
_logger = logger;
_tagNames = new List<string>
{
"Equipment1.Temperature",
"Equipment1.Pressure",
"Equipment1.Flow",
"Equipment1.Status"
};
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("데이터 수집 서비스 시작");
// 서버 연결
await _client.ConnectAsync(stoppingToken);
// 실시간 구독
foreach (var tagName in _tagNames)
{
await _client.SubscribeTagAsync(tagName, (tagValue) =>
{
_logger.LogDebug("태그 변경: {TagName} = {Value}",
tagValue.TagName, tagValue.Value);
OnTagChanged?.Invoke(tagValue);
}, stoppingToken);
}
// 서비스 실행 유지
await Task.Delay(Timeout.Infinite, stoppingToken);
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("데이터 수집 서비스 종료");
await _client.DisconnectAsync();
await base.StopAsync(cancellationToken);
}
}
Step 3: 모니터링 서비스
Services/MonitoringService.cs:
using Caffeine.Core.Models;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace EdgeApp.Services;
public class MonitoringService : BackgroundService
{
private readonly DataCollectionService _dataService;
private readonly ILogger<MonitoringService> _logger;
private readonly Dictionary<string, List<TagValue>> _history = new();
public MonitoringService(
DataCollectionService dataService,
ILogger<MonitoringService> logger)
{
_dataService = dataService;
_logger = logger;
// 태그 변경 이벤트 구독
_dataService.OnTagChanged += OnTagValueChanged;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("모니터링 서비스 시작");
while (!stoppingToken.IsCancellationRequested)
{
// 주기적 분석 (1분마다)
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
AnalyzeData();
}
}
private void OnTagValueChanged(TagValue tagValue)
{
// 히스토리 저장 (최근 100개)
if (!_history.ContainsKey(tagValue.TagName))
{
_history[tagValue.TagName] = new List<TagValue>();
}
_history[tagValue.TagName].Add(tagValue);
if (_history[tagValue.TagName].Count > 100)
{
_history[tagValue.TagName].RemoveAt(0);
}
// 실시간 알람 체크
CheckAlarms(tagValue);
}
private void CheckAlarms(TagValue tagValue)
{
if (tagValue.TagName == "Equipment1.Temperature" &&
tagValue.Value is double temp)
{
if (temp > 80)
{
_logger.LogWarning("⚠️ 고온 경고: {Temp}°C", temp);
}
else if (temp < 10)
{
_logger.LogWarning("⚠️ 저온 경고: {Temp}°C", temp);
}
}
}
private void AnalyzeData()
{
foreach (var (tagName, values) in _history)
{
if (values.Count < 10) continue;
var numericValues = values
.Where(v => v.Value is double or int)
.Select(v => Convert.ToDouble(v.Value))
.ToList();
if (numericValues.Any())
{
var avg = numericValues.Average();
var min = numericValues.Min();
var max = numericValues.Max();
_logger.LogInformation(
"{TagName} 통계 - 평균: {Avg:F2}, 최소: {Min:F2}, 최대: {Max:F2}",
tagName, avg, min, max);
}
}
}
public Dictionary<string, List<TagValue>> GetHistory() => _history;
}
Step 4: Blazor 대시보드
Pages/Dashboard.razor:
@page "/dashboard"
@using Caffeine.Core.Models
@using EdgeApp.Services
@inject DataCollectionService DataService
@inject MonitoringService MonitoringService
@implements IDisposable
<PageTitle>실시간 대시보드</PageTitle>
<h1>🎛️ 실시간 모니터링</h1>
<div class="row">
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5>온도</h5>
<h2 class="@GetTemperatureClass()">@_temperature°C</h2>
<small>@_lastUpdate</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5>압력</h5>
<h2>@_pressure kPa</h2>
<small>@_lastUpdate</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5>유량</h5>
<h2>@_flow L/min</h2>
<small>@_lastUpdate</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5>상태</h5>
<h2>@_status</h2>
<small>@_lastUpdate</small>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-body">
<h5>온도 추세</h5>
<canvas id="temperatureChart"></canvas>
</div>
</div>
</div>
</div>
@code {
private double _temperature;
private double _pressure;
private double _flow;
private string _status = "대기 중";
private string _lastUpdate = "-";
protected override void OnInitialized()
{
DataService.OnTagChanged += OnTagChanged;
}
private void OnTagChanged(TagValue tagValue)
{
InvokeAsync(() =>
{
switch (tagValue.TagName)
{
case "Equipment1.Temperature":
_temperature = Convert.ToDouble(tagValue.Value);
break;
case "Equipment1.Pressure":
_pressure = Convert.ToDouble(tagValue.Value);
break;
case "Equipment1.Flow":
_flow = Convert.ToDouble(tagValue.Value);
break;
case "Equipment1.Status":
_status = tagValue.Value.ToString() ?? "알 수 없음";
break;
}
_lastUpdate = DateTime.Now.ToString("HH:mm:ss");
StateHasChanged();
});
}
private string GetTemperatureClass()
{
return _temperature switch
{
> 80 => "text-danger",
< 10 => "text-primary",
_ => "text-success"
};
}
public void Dispose()
{
DataService.OnTagChanged -= OnTagChanged;
}
}
Step 5: 프로그램 구성
Program.cs:
using EdgeApp.Services;
using Caffeine.Client;
var builder = WebApplication.CreateBuilder(args);
// Blazor 서비스
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
// Caffeine Client
builder.Services.AddSingleton(sp =>
{
var options = new CaffeineClientOptions
{
ServerUrl = builder.Configuration["Caffeine:ServerUrl"] ?? "https://localhost:5001",
UseSignalR = true
};
return new CaffeineClient(options);
});
// 백그라운드 서비스
builder.Services.AddSingleton<DataCollectionService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<DataCollectionService>());
builder.Services.AddSingleton<MonitoringService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<MonitoringService>());
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.MapBlazorHub();
app.MapFallbackToPage("/_Host");
app.Run();
Step 6: Docker 배포
Dockerfile:
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 80
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["EdgeApp.csproj", "./"]
RUN dotnet restore
COPY . .
RUN dotnet build -c Release -o /app/build
FROM build AS publish
RUN dotnet publish -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "EdgeApp.dll"]
docker-compose.yml:
version: '3.8'
services:
edge-app:
build: .
ports:
- "8080:80"
environment:
- Caffeine__ServerUrl=https://caffeine-server:5001
depends_on:
- redis
- influxdb
redis:
image: redis:7-alpine
ports:
- "6379:6379"
influxdb:
image: influxdb:2.7
ports:
- "8086:8086"
# Docker 빌드 및 실행
docker-compose up -d
# 로그 확인
docker-compose logs -f edge-app
🎓 배운 내용
1. 엣지 아키텍처
2. 백그라운드 서비스
IHostedService구현- 이벤트 기반 통신
- 서비스 생명주기 관리
3. 실시간 UI
- Blazor Server
- SignalR 통합
- 반응형 업데이트
🚀 다음 단계
다음 튜토리얼:
- 프로덕션 배포 - 운영 환경 배포
추가 학습:
완료 시간: 약 60분
난이도: ⭐⭐⭐ 고급
이전: ← 데이터 파이프라인 | 다음: 프로덕션 배포 →