본문 바로가기
카테고리 없음

c# LogManager

by 공부봇 2025. 11. 6.
반응형

로그가 절대 안 밀린다/안 누락된다고 장담할 수 있는 시스템은 없습니다.
디스크 속도 < 로그 생성 속도 인 상황이 길게 이어지면 어느 쪽을 희생할 것인지(로그를 버릴지, 검사 쓰레드를 잠깐이라도 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 로그 보존” 사이의 트레이드오프를 조절할 수 있습니다.
반응형