본문으로 건너뛰기

모범 사례

Caffeine Framework 개발 및 운영 시 권장되는 모범 사례입니다.

📋 개요

프로덕션 환경에서 안정적이고 효율적인 시스템을 구축하기 위한 가이드라인입니다.


🏗️ 아키텍처 설계

1. 계층 분리

권장:

Presentation Layer (UI/API)

Business Logic Layer (Services)

Data Access Layer (Repositories)

Infrastructure Layer (Drivers, DB)

예제:

// ❌ 나쁜 예 - 모든 로직이 한 곳에
public class DriverController
{
public async Task<IActionResult> GetTag(string tagName)
{
var tcpClient = new TcpClient();
await tcpClient.ConnectAsync("192.168.1.100", 502);
// ... 직접 통신 로직
}
}

// ✅ 좋은 예 - 계층 분리
public class DriverController
{
private readonly IDriverService _driverService;

public async Task<IActionResult> GetTag(string tagName)
{
var result = await _driverService.ReadTagAsync(tagName);
return Ok(result);
}
}

2. 의존성 주입 (DI) 활용

권장:

// Startup.cs or Program.cs
services.AddSingleton<IDriverRepository, DriverRepository>();
services.AddScoped<IDriverService, DriverService>();
services.AddTransient<ITagValidator, TagValidator>();

// 사용
public class DriverService
{
private readonly IDriverRepository _repository;
private readonly ILogger<DriverService> _logger;

public DriverService(
IDriverRepository repository,
ILogger<DriverService> logger)
{
_repository = repository;
_logger = logger;
}
}

피해야 할 것:

// ❌ 직접 생성
public class DriverService
{
private readonly DriverRepository _repository = new DriverRepository();
}

🔧 드라이버 개발

1. 연결 관리

권장사항:

  • 연결은 재사용 (매번 새로 생성하지 않기)
  • 타임아웃 설정
  • 연결 상태 모니터링
  • 재연결 로직 구현

예제:

public class ModbusDriver : IDriver, IDisposable
{
private TcpClient? _tcpClient;
private bool _isConnected;
private readonly SemaphoreSlim _connectionLock = new(1, 1);

public async Task<bool> ConnectAsync(CancellationToken ct = default)
{
await _connectionLock.WaitAsync(ct);
try
{
if (_isConnected) return true; // 이미 연결됨

_tcpClient = new TcpClient();
await _tcpClient.ConnectAsync(_config.Host, _config.Port, ct);
_isConnected = true;

return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Connection failed");
return false;
}
finally
{
_connectionLock.Release();
}
}

public void Dispose()
{
_tcpClient?.Dispose();
_connectionLock?.Dispose();
}
}

2. 에러 처리

권장:

public async Task<TagValue?> ReadTagAsync(string tagName, CancellationToken ct = default)
{
try
{
var value = await ReadFromDeviceAsync(tagName, ct);
return new TagValue(tagName, value, TagQuality.Good);
}
catch (TimeoutException ex)
{
_logger.LogWarning(ex, "Read timeout: {TagName}", tagName);
return new TagValue(tagName, 0, TagQuality.Uncertain);
}
catch (IOException ex)
{
_logger.LogError(ex, "Communication error: {TagName}", tagName);
return new TagValue(tagName, 0, TagQuality.Bad);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error: {TagName}", tagName);
return new TagValue(tagName, 0, TagQuality.Bad);
}
}

피해야 할 것:

// ❌ 모든 예외를 무시
try
{
// ...
}
catch { }

// ❌ 구체적이지 않은 로깅
catch (Exception ex)
{
_logger.LogError(ex, "Error"); // 정보 부족
}

3. 배치 처리

권장: 여러 태그를 한 번에 읽기

// ✅ 좋은 예 - 배치 읽기
public async Task<Dictionary<string, TagValue>> ReadTagsAsync(
string[] tagNames,
CancellationToken ct = default)
{
// 연속된 주소는 하나의 요청으로
var groups = GroupConsecutiveAddresses(tagNames);

var results = new Dictionary<string, TagValue>();

foreach (var group in groups)
{
var values = await ReadMultipleRegistersAsync(
group.StartAddress,
group.Count,
ct);

for (int i = 0; i < group.TagNames.Length; i++)
{
results[group.TagNames[i]] = values[i];
}
}

return results;
}

// ❌ 나쁜 예 - 개별 읽기
foreach (var tag in tagNames)
{
await ReadTagAsync(tag); // N개 요청 = 느림
}

💾 데이터 관리

1. Redis 캐싱 전략

권장:

  • 최신 값만 캐싱
  • TTL 설정 (예: 60초)
  • 태그별 키 네이밍 규칙

예제:

public class TagCacheService
{
private readonly IConnectionMultiplexer _redis;
private readonly TimeSpan _ttl = TimeSpan.FromSeconds(60);

public async Task SetTagValueAsync(TagValue tagValue)
{
var db = _redis.GetDatabase();
var key = $"tag:{tagValue.TagName}";

var json = JsonSerializer.Serialize(tagValue);
await db.StringSetAsync(key, json, _ttl);
}

public async Task<TagValue?> GetTagValueAsync(string tagName)
{
var db = _redis.GetDatabase();
var key = $"tag:{tagName}";

var value = await db.StringGetAsync(key);
return value.HasValue
? JsonSerializer.Deserialize<TagValue>(value!)
: null;
}
}

2. InfluxDB 데이터 저장

권장:

  • 배치 쓰기 (10초마다 또는 1000개마다)
  • 측정값(measurement) 네이밍 규칙
  • 태그(tag)와 필드(field) 구분

예제:

public class InfluxDbWriter
{
private readonly Queue<TagValue> _buffer = new();
private readonly Timer _flushTimer;
private const int BatchSize = 1000;

public InfluxDbWriter()
{
_flushTimer = new Timer(FlushCallback, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
}

public void Write(TagValue tagValue)
{
lock (_buffer)
{
_buffer.Enqueue(tagValue);

if (_buffer.Count >= BatchSize)
{
FlushAsync().GetAwaiter().GetResult();
}
}
}

private async Task FlushAsync()
{
TagValue[] batch;

lock (_buffer)
{
if (_buffer.Count == 0) return;

batch = _buffer.ToArray();
_buffer.Clear();
}

var points = batch.Select(tv => PointData
.Measurement("tag_values")
.Tag("tag_name", tv.TagName)
.Field("value", tv.Value)
.Field("quality", (int)tv.Quality)
.Timestamp(tv.Timestamp, WritePrecision.Ms));

await _writeApi.WritePointsAsync(points);
}
}

🔒 보안

1. Secrets 관리

권장:

# ✅ 개발 환경 - User Secrets
dotnet user-secrets set "ConnectionStrings:PostgreSQL" "Host=localhost;..."

# ✅ 프로덕션 - 환경 변수
export ConnectionStrings__PostgreSQL="Host=prod-db;..."

# ✅ Kubernetes - Secrets
kubectl create secret generic caffeine-secrets \
--from-literal=db-password=secret123

피해야 할 것:

// ❌ 코드에 하드코딩
var connectionString = "Host=localhost;Password=secret123;";

// ❌ appsettings.json에 평문
{
"Password": "secret123"
}

2. API 보안

권장:

// ✅ API Key 검증
[ApiKey]
[HttpGet("tags/{tagName}")]
public async Task<IActionResult> GetTag(string tagName)
{
// ...
}

// ✅ HTTPS 강제
app.UseHttpsRedirection();
app.UseHsts();

// ✅ CORS 설정
app.UseCors(policy => policy
.WithOrigins("https://trusted-domain.com")
.AllowAnyMethod()
.AllowAnyHeader());

📊 성능 최적화

1. 비동기 프로그래밍

권장:

// ✅ async/await 사용
public async Task<TagValue> ReadTagAsync(string tagName)
{
await _semaphore.WaitAsync();
try
{
var value = await _driver.ReadAsync(tagName);
return value;
}
finally
{
_semaphore.Release();
}
}

// ❌ 동기 블로킹
public TagValue ReadTag(string tagName)
{
return _driver.ReadAsync(tagName).GetAwaiter().GetResult(); // 데드락 위험
}

2. 리소스 관리

권장:

// ✅ using 문 사용
using (var stream = await _driver.GetStreamAsync())
{
await stream.WriteAsync(data);
}

// ✅ IDisposable 구현
public class MyDriver : IDriver, IDisposable
{
public void Dispose()
{
_tcpClient?.Dispose();
_semaphore?.Dispose();
}
}

📝 로깅

1. 구조화된 로깅

권장:

// ✅ 구조화된 로깅
_logger.LogInformation(
"Tag read: {TagName}, Value: {Value}, Quality: {Quality}, Duration: {Duration}ms",
tagName, tagValue.Value, tagValue.Quality, duration);

// ❌ 문자열 보간
_logger.LogInformation($"Tag read: {tagName}, Value: {tagValue.Value}");

2. 로그 레벨

권장 사용:

  • Trace: 매우 상세한 디버깅 (개발만)
  • Debug: 디버깅 정보
  • Information: 일반 정보 (태그 읽기, 연결 등)
  • Warning: 경고 (재시도, 품질 저하)
  • Error: 에러 (연결 실패, 읽기 실패)
  • Critical: 치명적 오류 (서버 다운)

예제:

_logger.LogDebug("Attempting to connect to {Host}:{Port}", host, port);
_logger.LogInformation("Connected successfully");
_logger.LogWarning("Connection unstable, retrying...");
_logger.LogError(ex, "Connection failed");

🧪 테스트

1. 단위 테스트

권장:

public class ModbusDriverTests
{
private readonly Mock<ITcpClient> _mockTcpClient;
private readonly ModbusDriver _driver;

[Fact]
public async Task ReadTagAsync_ShouldReturnGoodQuality_WhenConnectionSucceeds()
{
// Arrange
_mockTcpClient
.Setup(x => x.ReadAsync(It.IsAny<byte[]>()))
.ReturnsAsync(new byte[] { 0x01, 0x03, 0x04, 0x00, 0x0A });

// Act
var result = await _driver.ReadTagAsync("HR:100");

// Assert
Assert.NotNull(result);
Assert.Equal(TagQuality.Good, result.Quality);
Assert.Equal(10, result.Value);
}
}

2. 통합 테스트

권장:

public class DriverIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task EndToEnd_ReadTag_ShouldReturnValue()
{
// Arrange
using var testServer = new TestServerBuilder()
.WithModbusSimulator()
.Build();

// Act
var tagValue = await testServer.Client.ReadTagAsync("Equipment1.Temperature");

// Assert
Assert.NotNull(tagValue);
Assert.InRange(tagValue.Value, 20, 30);
}
}

🚀 배포

1. Docker 이미지 최적화

권장:

# ✅ Multi-stage build
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app

FROM mcr.microsoft.com/dotnet/runtime:10.0-alpine
WORKDIR /app
COPY --from=build /app .

# Non-root user
RUN addgroup -g 1000 caffeine && \
adduser -D -u 1000 -G caffeine caffeine
USER caffeine

ENTRYPOINT ["dotnet", "Caffeine.Server.dll"]

2. Health Checks

권장:

services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("database")
.AddCheck<RedisHealthCheck>("redis")
.AddCheck<DriverHealthCheck>("drivers");

app.MapHealthChecks("/health");
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});

📚 참고 자료


작성일: 2026-01-28
버전: 2.0