반응형
로그가 절대 안 밀린다/안 누락된다고 장담할 수 있는 시스템은 없습니다.
디스크 속도 < 로그 생성 속도 인 상황이 길게 이어지면 어느 쪽을 희생할 것인지(로그를 버릴지, 검사 쓰레드를 잠깐이라도 Block할지) 선택해야 합니다.
그래서 아래 LogManager는:
- 고속 장비 환경까지 고려해서
- **큐 최대 길이 / 오버플로 정책(차단, 최신 드롭, 가장 오래된 것 드롭)**을 선택할 수 있고
- 여러 스레드에서 동시에 Log 호출해도 안전하고
- 카테고리별(Init/Inspection/Option/Error) + 연/월/일 폴더 구조로 기록하며
- 초보자도 이해할 수 있도록 주석을 많이 단 버전입니다.
이 파일 하나를 프로젝트에 추가해두고,
어느 프로젝트든 그대로 재사용하면 됩니다. (네임스페이스만 맞춰주면 됨)
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace A2_SOP_Linescan.Common
{
#region 로그 관련 열거형
/// <summary>
/// 로그 종류 (파일 분리 기준).
/// - Init : 초기화 관련 로그 (카메라/DB/AI 등)
/// - Inspection : 검사 진행/결과에 대한 로그
/// - Option : 설정/옵션 변경 로그
/// - System : 기타 시스템 상태 로그
/// - Error : 예외/에러 로그
/// </summary>
public enum LogCategory
{
Init,
Inspection,
Option,
System,
Error
}
/// <summary>
/// 로그 레벨.
/// </summary>
public enum LogLevel
{
Trace,
Debug,
Info,
Warn,
Error,
Fatal
}
/// <summary>
/// 로그 큐가 가득 찼을 때의 동작 정책.
/// </summary>
public enum LogOverflowPolicy
{
/// <summary>
/// 큐가 비워질 때까지 Log() 호출하는 쓰레드를 Block.
/// - 장점 : 로그 누락 최소화
/// - 단점 : 검사 쓰레드가 잠깐이라도 멈출 수 있음 (고속 장비에서는 주의)
/// </summary>
BlockCaller,
/// <summary>
/// 큐가 가득 찬 상태에서 들어온 "새로운 로그"를 버림.
/// - 장점 : 검사 쓰레드는 절대 Block되지 않음
/// - 단점 : 부하가 심할 때 일부 로그가 누락될 수 있음
/// </summary>
DropNewest,
/// <summary>
/// 큐가 가득 찬 상태에서 "가장 오래된 로그"를 버리고, 새 로그를 넣음.
/// - 장점 : 항상 최신 로그를 유지
/// - 단점 : 과거 몇 ms~수초간의 로그가 누락될 수 있음
/// </summary>
DropOldest
}
#endregion
#region 옵션 모델
/// <summary>
/// LogManager 동작 옵션.
/// - 생성 시 한 번 설정하고, 런타임 중에는 변경하지 않는 것을 권장.
/// </summary>
public sealed class LogManagerOptions
{
/// <summary>
/// 로그가 저장될 루트 폴더.
/// 예: "Logs" → 실행폴더\Logs\2025\11\05\Init_20251105.log
/// </summary>
public string BaseFolder { get; private set; }
/// <summary>
/// true 이면 연/월/일 폴더 구조를 사용.
/// 예: BaseFolder\2025\11\05\Init_20251105.log
/// false 이면 BaseFolder 아래에 바로 Init_20251105.log 형태로 생성.
/// </summary>
public bool UseDateFolders { get; set; }
/// <summary>
/// 로그 큐의 최대 길이 (항목 수 기준).
/// - 너무 작으면 로그가 자주 버려질 수 있고,
/// - 너무 크면 메모리 사용량이 커질 수 있음.
/// 기본값: 100,000
/// </summary>
public int MaxQueueLength { get; set; }
/// <summary>
/// 큐가 가득 찼을 때의 동작 정책.
/// 기본값: DropNewest (성능 우선)
/// </summary>
public LogOverflowPolicy OverflowPolicy { get; set; }
/// <summary>
/// 로그에 스레드 ID/스레드 이름을 포함할지 여부.
/// 기본값: true
/// </summary>
public bool IncludeThreadInfo { get; set; }
/// <summary>
/// 로그 시간 기록을 UTC로 할지 여부.
/// false 이면 LocalTime 사용.
/// 기본값: false
/// </summary>
public bool UseUtcTime { get; set; }
public LogManagerOptions(string baseFolder)
{
if (string.IsNullOrWhiteSpace(baseFolder))
{
throw new ArgumentException("로그 폴더 경로가 비어 있습니다.", "baseFolder");
}
BaseFolder = baseFolder;
UseDateFolders = true;
MaxQueueLength = 100000;
OverflowPolicy = LogOverflowPolicy.DropNewest;
IncludeThreadInfo = true;
UseUtcTime = false;
}
}
#endregion
#region 내부 로그 항목 구조
/// <summary>
/// 큐에 쌓이는 실제 로그 데이터 구조.
/// 파일에 쓰일 모든 정보가 들어있다.
/// </summary>
internal struct LogItem
{
public DateTime Timestamp;
public LogCategory Category;
public LogLevel Level;
public string Message;
public string ThreadName;
public int ThreadId;
}
#endregion
/// <summary>
/// 멀티스레드 환경에서도 안전하게 사용할 수 있는 범용 로그 매니저.
///
/// 주요 특징:
/// - 여러 쓰레드에서 동시에 Log() 호출 가능 (스레드 안전)
/// - 내부 큐에 쌓아두고, 백그라운드 쓰레드 1개가 파일에 순차적으로 기록
/// - 카테고리별 / 날짜별 파일 분리 (Init/Inspection/Option/Error)
/// - 옵션에 따라 연/월/일 폴더 구조 사용 가능 (Logs\2025\11\05\...)
/// - 큐 오버플로 정책 선택 가능 (BlockCaller / DropNewest / DropOldest)
///
/// 사용 방법:
/// var options = new LogManagerOptions(logFolder);
/// var logger = new LogManager(options);
/// logger.Log(LogCategory.Init, LogLevel.Info, "초기화 시작");
///
/// 보통은 Program.Main 또는 InitializationManager에서
/// 1개 생성 후 AppServices 등에 넣어서 전체에서 공유해서 사용한다.
/// </summary>
public sealed class LogManager : IDisposable
{
private readonly LogManagerOptions _options;
// BlockingCollection은 내부적으로 스레드 안전한 컬렉션(ConcurrentQueue 등)을 감싼다.
// 여기서는 BoundedCapacity를 사용하여 최대 큐 길이를 제한한다.
private readonly BlockingCollection<LogItem> _queue;
private readonly Task _workerTask;
private readonly object _writerLock = new object();
private readonly Dictionary<string, StreamWriter> _writers;
private bool _disposed;
/// <summary>
/// 로그 루트 폴더 (읽기 전용).
/// </summary>
public string BaseFolder
{
get { return _options.BaseFolder; }
}
public LogManager(LogManagerOptions options)
{
if (options == null)
{
throw new ArgumentNullException("options");
}
_options = options;
_writers = new Dictionary<string, StreamWriter>(StringComparer.OrdinalIgnoreCase);
// 로그 폴더가 없으면 생성
if (!Directory.Exists(_options.BaseFolder))
{
Directory.CreateDirectory(_options.BaseFolder);
}
// BoundedCapacity를 설정하여 큐의 최대 길이를 제한
_queue = new BlockingCollection<LogItem>(
new ConcurrentQueue<LogItem>(),
_options.MaxQueueLength);
// 백그라운드 로그 쓰기 worker 시작
_workerTask = Task.Factory.StartNew(
WorkerLoop,
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);
}
#region Public API - 기본 Log
/// <summary>
/// 간단 로그 기록 (레벨: Info).
/// 예:
/// logger.Log(LogCategory.Inspection, "Unit {0} 결과: {1}", unitId, result);
/// </summary>
public void Log(LogCategory category, string message, params object[] args)
{
Log(category, LogLevel.Info, message, args);
}
/// <summary>
/// 카테고리/레벨을 지정하여 로그 기록.
/// 이 메서드는 매우 자주 호출될 수 있으므로,
/// 내부에서는 단순히 큐에 항목을 쌓는 것 외에는 무거운 작업을 하지 않는다.
/// </summary>
public void Log(LogCategory category, LogLevel level, string message, params object[] args)
{
if (_disposed)
{
// 이미 Dispose된 후의 로그는 그냥 무시
return;
}
if (string.IsNullOrEmpty(message))
{
return;
}
string formatted = (args != null && args.Length > 0)
? string.Format(message, args)
: message;
DateTime now = _options.UseUtcTime ? DateTime.UtcNow : DateTime.Now;
var item = new LogItem
{
Timestamp = now,
Category = category,
Level = level,
Message = formatted,
ThreadId = Thread.CurrentThread.ManagedThreadId,
ThreadName = Thread.CurrentThread.Name ?? string.Empty
};
Enqueue(item);
}
/// <summary>
/// 예외를 포함한 에러 로그 기록용 헬퍼.
/// 예:
/// logger.LogException(LogCategory.Error, "DB 연결 실패", ex);
/// </summary>
public void LogException(LogCategory category, string message, Exception ex)
{
var sb = new StringBuilder();
sb.Append(message ?? "Exception");
if (ex != null)
{
sb.Append(" | ");
sb.Append(ex.GetType().FullName);
sb.Append(": ");
sb.Append(ex.Message);
sb.AppendLine();
sb.Append(ex.StackTrace);
}
Log(category, LogLevel.Error, sb.ToString());
}
#endregion
#region 내부: 큐에 넣기 + 오버플로 정책
/// <summary>
/// 큐에 로그 항목을 넣는다.
/// - OverflowPolicy에 따라 Block / DropNewest / DropOldest 로 동작.
/// </summary>
private void Enqueue(LogItem item)
{
if (_disposed)
{
return;
}
try
{
switch (_options.OverflowPolicy)
{
case LogOverflowPolicy.BlockCaller:
// 큐에 여유가 생길 때까지 현재 쓰레드가 대기.
_queue.Add(item);
break;
case LogOverflowPolicy.DropNewest:
// 큐가 꽉 찼다면 TryAdd가 실패 → 그냥 버림.
LogItem dummy;
if (!_queue.TryAdd(item))
{
// 필요하다면 여기서 "로그 유실" 카운팅 가능.
// (지금은 단순 무시)
}
break;
case LogOverflowPolicy.DropOldest:
// 큐가 꽉 차서 TryAdd 실패하면, 가장 오래된 항목을 하나 꺼내고 다시 시도.
if (!_queue.TryAdd(item))
{
LogItem removed;
_queue.TryTake(out removed); // 가장 오래된 것 제거 시도
_queue.TryAdd(item); // 다시 시도 (실패해도 추가 시도는 1회만)
}
break;
}
}
catch (InvalidOperationException)
{
// _queue.CompleteAdding() 호출된 후 Add/TryAdd 호출 시 발생 가능
// → 종료 과정에서 발생하는 것이므로 무시
}
}
#endregion
#region 내부: 워커 루프 및 파일 쓰기
/// <summary>
/// 백그라운드 워커.
/// 큐에 쌓인 로그 항목들을 순차적으로 파일에 기록한다.
/// </summary>
private void WorkerLoop()
{
try
{
foreach (var item in _queue.GetConsumingEnumerable())
{
WriteItem(item);
}
}
catch
{
// 로그 시스템 자체에서 예외가 터지면,
// 여기서 잡지 않으면 프로세스가 죽을 수 있으므로 침묵 처리.
// (필요하면 이벤트/콜백으로 알리는 구조 추가 가능)
}
finally
{
// 루프 종료 시 모든 writer 정리
CloseAllWriters();
}
}
/// <summary>
/// 큐에서 꺼낸 단일 로그 항목을 실제 파일에 한 줄로 기록.
/// </summary>
private void WriteItem(LogItem item)
{
string filePath = GetLogFilePath(item.Category, item.Timestamp);
StreamWriter writer = GetWriter(filePath);
string line;
if (_options.IncludeThreadInfo)
{
line = string.Format(
"{0:yyyy-MM-dd HH:mm:ss.fff} [{1}] [T{2}:{3}] {4}",
item.Timestamp,
item.Level.ToString().ToUpperInvariant(),
item.ThreadId,
string.IsNullOrEmpty(item.ThreadName) ? "-" : item.ThreadName,
item.Message);
}
else
{
line = string.Format(
"{0:yyyy-MM-dd HH:mm:ss.fff} [{1}] {2}",
item.Timestamp,
item.Level.ToString().ToUpperInvariant(),
item.Message);
}
writer.WriteLine(line);
// Flush는 AutoFlush=false 이므로 OS 버퍼에 쌓였다가
// 일정량 이상이 되면 알아서 디스크로 기록된다.
// 고속 장비에서는 Flush를 매번 하지 않는 것이 성능에 유리하다.
}
/// <summary>
/// 카테고리/시간에 따라 로그 파일 경로를 계산.
/// 예: BaseFolder\2025\11\05\Init_20251105.log
/// </summary>
private string GetLogFilePath(LogCategory category, DateTime timestamp)
{
string date = timestamp.ToString("yyyyMMdd"); // 20251105
string year = timestamp.ToString("yyyy"); // 2025
string month = timestamp.ToString("MM"); // 11
string day = timestamp.ToString("dd"); // 05
string name = category.ToString(); // Init / Inspection / ...
string fileName = string.Format("{0}_{1}.log", name, date);
string folder = _options.BaseFolder;
if (_options.UseDateFolders)
{
folder = Path.Combine(folder, year, month, day);
}
if (!Directory.Exists(folder))
{
Directory.CreateDirectory(folder);
}
return Path.Combine(folder, fileName);
}
/// <summary>
/// 특정 파일 경로에 대응되는 StreamWriter를 얻는다.
/// - 이미 열려 있으면 재사용
/// - 처음이면 새로 생성
/// </summary>
private StreamWriter GetWriter(string filePath)
{
lock (_writerLock)
{
StreamWriter writer;
if (_writers.TryGetValue(filePath, out writer))
{
return writer;
}
var stream = new FileStream(
filePath,
FileMode.Append,
FileAccess.Write,
FileShare.Read); // 다른 프로세스에서 읽기는 허용
writer = new StreamWriter(stream, Encoding.UTF8)
{
AutoFlush = false // 성능을 위해 AutoFlush는 끔
};
_writers[filePath] = writer;
return writer;
}
}
/// <summary>
/// 모든 로그 파일 writer를 닫고 정리.
/// </summary>
private void CloseAllWriters()
{
lock (_writerLock)
{
foreach (var kvp in _writers)
{
try
{
kvp.Value.Flush();
kvp.Value.Dispose();
}
catch
{
// 종료 중 오류는 무시
}
}
_writers.Clear();
}
}
#endregion
#region IDisposable
/// <summary>
/// 로그 시스템 종료.
/// - 더 이상 새로운 로그는 받지 않고
/// - 큐에 남아 있는 모든 로그가 파일에 기록될 때까지 대기 후 종료.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
// 더 이상 새로운 로그는 받지 않도록 표시
_queue.CompleteAdding();
try
{
// 워커가 큐를 비우고 종료될 때까지 최대 5초 대기
_workerTask.Wait(5000);
}
catch
{
// 강제 종료 시에는 그냥 넘긴다.
}
// 혹시 남은 writer 정리
CloseAllWriters();
_queue.Dispose();
}
#endregion
#region (선택) 전역 싱글톤으로 쓰고 싶을 때용 정적 멤버
// 필요하다면 아래 주석을 풀어서
// LogManager.Global.Log(...) 처럼 사용할 수도 있다.
//
// public static LogManager Global { get; private set; }
//
// public static void InitializeGlobal(string baseFolder)
// {
// if (Global != null)
// {
// throw new InvalidOperationException("Global LogManager는 이미 초기화되었습니다.");
// }
//
// var opts = new LogManagerOptions(baseFolder);
// Global = new LogManager(opts);
// }
#endregion
}
}
간단 사용 예 (프로그램 쪽 코드 예시)
// Program.Main 또는 InitializationManager에서
var exeDir = AppDomain.CurrentDomain.BaseDirectory;
var logRoot = Path.Combine(exeDir, "Logs");
var logOptions = new LogManagerOptions(logRoot)
{
// 고속 장비에서 로그 유실을 어느 정도 허용하고 검사 성능을 우선하려면:
OverflowPolicy = LogOverflowPolicy.DropNewest, // 또는 DropOldest
MaxQueueLength = 200000
};
var logger = new LogManager(logOptions);
// AppServices 같은 곳에 넣어서 전체에서 공유
var services = new AppServices(setting, rule, db, camera, aiManager, logger);
// 초기화 중
logger.Log(LogCategory.Init, LogLevel.Info, "카메라 초기화 시작");
logger.Log(LogCategory.Init, LogLevel.Info, "DB 연결 OK");
// 검사 중
logger.Log(LogCategory.Inspection, "Unit {0} 검사 완료 - 결과: {1}", unitId, outcome);
// 옵션 변경
logger.Log(LogCategory.Option, "GAIN {0} -> {1} (User={2})", oldGain, newGain, userId);
이 구조면:
- 고속 장비에서도 Log() 호출 자체는 매우 가볍게 돌아가고
- 큐가 감당 가능한 수준이면 누락 없이 기록되고
- 만약 로그가 장기간 디스크 속도를 초과하는 상황이라면
→ 옵션에서 BlockCaller / DropNewest / DropOldest 중 어떤 전략을 쓸지 선택해서
“검사 속도 vs 로그 보존” 사이의 트레이드오프를 조절할 수 있습니다.
반응형