2025년 8월 16일 토요일

C# 로거 만들기..

관심사의 분리니 뭐니 해서..
옛날에는 그냥 static으로 WriteLog를 처리 했었다..
그런데 요즘 트렌드는 요로코롬 안한다.
유행을 무시해도 되지만 아발로니아를 비롯해 asp.net 에서도 ILogger를 
생성자로 DI해서  사용한다.. 
그래서 그냥 이참에 나만의 파일로거를 만들어야 것다
뭐 언젠가는 해야하것지만..

일단 나의 전략은 LogLevel에서 Trace와 Debug까지는 콘솔에만 출력하고
Infomation이상은 콘솔출력 + 파일출력 (날짜별 폴더)
가 목표이다..  그리고 카테고리 이름별로 별도의 폴더를 만들어야 하는경우를
고려하여 만들었다.  예를들어 AAA라는 클래스에서 주입받은 아이는 별도의
AAA폴더에 파일을 저장하고 아닌경우는 그냥 로그 폴더에 저장함.

이제 시작~!!

먼저 DI와 Logging 패키지를 받아야한다..
Asp.net은 둘다 설치 되어 있으니까 건너뛰고 안되어 있는 프로젝트만 설치 고고싱

dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Logging

이렇게 2개

그다음 2개의 클래스를 만들어야 하는데 하나는 ILogger구현체이고 
나머지는 이.. 구현체클래스를 제공하는 클래스이다..
하나로 합치지.. 에휴.. 뭐 어째뜬 이렇게 만든 이유는 카테고리별로
로그를 별도 관리하기 때문이다.   특정카테고리로 전달되면 해당카테고리 이름으로
구분하여 로거를 여러게 만든다. 그렇기 때문에 프로바이더와 실제로거가 분리되어 있다.

1.  ILogger 구현체


/// <summary>
/// 파일로거 본체
/// </summary>
public class FileLogger : ILogger
{
//다른 로그폴더에 대상이 되는 클래스 이름 문자들 (소문자)
private Dictionary<string, string> _typeToDicStr = new Dictionary<string, string>()
{
{ "aaa", "AAA" },
{ "bbb", "비비비" },
};


private readonly string _category;
public FileLogger(string categoryName)
{
string[] last_name = categoryName.Split(".");
string last_cate = last_name[last_name.Length - 1];
_category = last_cate.ToLower();
}

//
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => default;
//모든 로그레벨 허용 여부
public bool IsEnabled(LogLevel logLevel)
{
return true;
}

//로그본체
private static object log_sync = new object();
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
try
{
DateTime nowtime = DateTime.Now;
string write_msg = nowtime.ToString("[HH:mm:ss]: (") + $"{logLevel}) {formatter(state, exception)}" + Environment.NewLine;
Trace.Write(write_msg);
//
if (logLevel > LogLevel.Debug)
{
lock (log_sync)
{
string log_dic_name = "로그";
if (_typeToDicStr.ContainsKey(_category)) {
log_dic_name = _typeToDicStr[_category].ToString();
}                        

//
string date_word = nowtime.ToString("yyyy-MM-dd");
string target_dir = Path.Combine(AppContext.BaseDirectory, log_dic_name, date_word);
if (!Directory.Exists(target_dir))
{
Directory.CreateDirectory(target_dir);
}
//
string target_file = Path.Combine(target_dir, $"{date_word}.log");
using (StreamWriter sw = new StreamWriter(target_file, true, EUCKR.GetEncoding))
{
sw.Write(write_msg);
}
}
}
} catch (Exception ex) {
Trace.WriteLine(ex.Message);
}
}
}


2. 프로바이더 구현체

/// <summary>
 /// 로거 프로바이더
 /// </summary>
 public class FileLoggerProvider : ILoggerProvider
 {
     public ILogger CreateLogger(string categoryName)
     {
         return new FileLogger(categoryName);
     }
     public void Dispose() { }
 }


요로 코롬 만들어 놓고
App수준의 .cs 파일에서  다음과 같이 처리함

public static IServiceProvider Service { get; private set; } = null;

이후 초기화 함수등에서

var services = new ServiceCollection();
services.AddLogging(builder => 
{
    builder.SetMinimumLevel(LogLevel.Trace);
    builder.AddProvider(new FileLoggerProvider());
});
Services = services.BuildServiceProvider();

이렇게 등록하고 

사용할때는 생성자에서 (ILogger<T> logger)로 받아서 멤버에 등록해서 쓰거나
아니면 명시적으로 (App수준의 cs파일에서 static 멤버로 등록했으니까)

var mylogger = App.Services.GetService<ILogger<T>>();

이렇게 받아서 사용하면 됨..


음.. 기왕지사 DI 얘기가 나온김에 한술 더 떠보자..
결론적으로 저 Services에 등록된 애들끼리만 생성자에서 DI를 받을 수 있지 않은가??

그래서 원칙적으로는 DI가 필요한 클래스 전부 저걸 일일이 다 등록해야한다.

그러면 짜치자너??

이럴때는 DI로 등록될 클래스를 어트리뷰트로 만들어서 쓰는게 좋다.
예를 들어 MyDevice라는 클래스가 있는데 이 녀셕을 DI로 쓸껀데..
싱글톤으로 하고 싶다면 클래스 선언 앞에
[DIObject(ServiceLifttime.Singleton)]
이런식으로 어트리뷰트를 달아서 쓰고 이 어트리뷰트가 달린 모든 클래스를
확장메서드로 한번에 불러와서 서비스에 담으면 된다..

3. DIObjectAttribute.cs  파일 추가

public class DIObjectAttribute : Attribute
{
    public ServiceLifetime Lifetime { get; }

    public DIObjectAttribute(ServiceLifetime lifetime = ServiceLifetime.Transient)
    {
        Lifetime = lifetime;
    }        
}


4. 그 다음 서비스가 사용할 확장 메서드

public static class MyExtentions
{
    public static IServiceCollection AddConventionBasedServices(this IServiceCollection services)
    {
        var assembly = Assembly.GetExecutingAssembly();
        var typesWithAttribute = assembly.GetTypes()
            .Where(t => t.IsClass && t.GetCustomAttribute<DIObjectAttribute>() != null);

        //
        foreach (var type in typesWithAttribute)
        {
            //클래스에 있는 속성을 읽어옴
            var attribute = type.GetCustomAttribute<DIObjectAttribute>();

            //속성에 지정된 Lifetime에 따라 적설한 등록 메서드를 호출함
            switch (attribute.Lifetime)
            {
                case ServiceLifetime.Singleton:
                    services.AddSingleton(type);
                    break;
                case ServiceLifetime.Scoped:
                    services.AddScoped(type);
                    break;
                case ServiceLifetime.Transient:
                    services.AddTransient(type);
                    break;
                default:
                    break;
            }
        }

        //
        return services;
    }
}


이렇게 확장메서드를 등록햇으니까
아까 앱수준의 Service 초기화 구문에서 다음 구문만 추가하면 된다.

//서비스 컬렉션을 사용하는 프로그램
var services = new ServiceCollection();
services.AddLogging(builder => 
{
    builder.SetMinimumLevel(LogLevel.Trace);
    builder.AddProvider(new FileLoggerProvider());
});

//여기에 DIObject 있는 클래스들 등록
services.AddConventionBasedServices();
Services = services.BuildServiceProvider();


//일반적인 Asp.net에서 로거만 추가하려는 경우는  다음처럼
builder.Services.AddSingleton<ILoggerProvider>(sp => {
    return new FileLoggerProvider();
});
 
요로코롬 사용하면 나중에
[DIObject(ServiceLifetime.Singleton)]
뭐이런 식으로 적혀있는 클래스는 서비스에 자동등록됨..
 
요로코롬 사용하면 지들끼리 생성자에서 북치고 장구치구가 됨..


그리고 DI는 sw엔지니어링의 기법이다. 따라서 무조건 맹신적으로 
사용하여 모든 클래스를 인터페이스화 하여 주입받는 행동은
지양해야한다. DI가 필요한 부분 즉 의존성 교체나 단위테스트 대상이
되는 클래스를 DI용으로 설계해야지
단발성으로 사용되고 버려지는 객체나 단순 데이터를 담을 용도로
사용되어지는 객체들까지 DI를 위한 처리가 들어가 버리면
말도안되는거다..  그런 상황에서는 기존 방식대로 new를 쓰는게 
당연하다.

ㅇㅇ

댓글 없음:

댓글 쓰기