모범 사례
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