본문으로 건너뛰기

엣지 애플리케이션 개발

완전한 엣지 IoT 애플리케이션을 개발합니다.

🎯 학습 목표

  • 엣지 애플리케이션 아키텍처 설계
  • 실시간 모니터링 대시보드
  • 로컬 데이터 처리 및 분석
  • 클라우드 연동

예상 소요 시간: 60분


📋 사전 요구사항


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분
난이도: ⭐⭐⭐ 고급
이전: ← 데이터 파이프라인 | 다음: 프로덕션 배포 →