.NET Framework 4.X 에서 Option Pattern 사용하기

Option Pattern은 Ubiquitous design pattern 중 하나로 web.config와 같은 Application의 모든 환경 설정 넣어서 사용하는 파일을 목적에 맞는 클래스들로 분리 시키고 인터페이스로 추상화하여 사용할 수 있도록 해준다.

이러한 이유로, Option Pattern을 사용하면 소프트웨어 엔지니어링 원칙중 2가지를 따를 수 있게 된다. 하나의 기능에 대한 환경 설정을 위한 클래스로 분리해서 사용하기 때문에 SRP(Single Responsibility Principle)를 따르게 되며, 인터페이스를 통해서 클래스의 값을 주입 받기 때문에 ISP(Interface Segregation Principle)를 따르게 된다.

기존에 web.config에 설정된 환경 변수들을 사용할때는 다음과 같이 String 타입인 XML 값을 읽어와야 했다. 그리고 필요에 따라 String 타입을 필요한 타입에 맞추어 Cast 해서사용해야 했다.

string SecretName = ConfigurationManager.AppSettings["SecretName"]

하지만, Option Pattern을 사용하다면, 미리 정의된 클래스 속성에 맞추어 Injection 과정에서 Cast가 진행되기 때문에 바로 사용할 수 있는 편리함이 있다. 그리고 미리 정의된 클래스 속성 이름을 참조하여 사용하기 때문에 항상 동일한 이름을 일관되게 코드에 적용 할 수 있게 되며, 참조 추적을 통해 얼마나 많은 곳에 해당 환경 설정이 사용되고 있는지 영향도 파악을 할 수 있게 되는 장점을 얻을 수 있다.

Option Pattern은 .Net Core부터는 Option pattern이 기본 패키지로 제공되기 때문에 쉽게 사용할 수 있다. 하지만 점점 EOS(End Of Service)가 공지되고 있는 .Net Framework는 그렇지 않기 때문에 만들어 사용해야 한다.

아래 구현된 소스코드는 .Net Framework 4.8 LTS 기준으로 작성되었고 Injector는 Simple Injector를 사용하였다. 전체 소스는 Github를 참고하길 바란다.

Web.Config

<appSettings>    
  <!--Application Settings-->
  <add key="SecretName" value="Name" />
  <add key="SecretKey" value="p@ssWord!" />    
</appSettings>

Options Class

public class SecretOptions
{    
    [Option("SecretName")]
    public string secretName { get; set; }
               
    [Option("SecretKey")]
    public string secretKey { get; set; }             
}

Options Interface

public interface IOptions<T>
{
    T Value { get; }
}

Options Concrete

public class Options<T> : IOptions<T>
{
    private readonly T model;
    private readonly List<string> AppSettingNames;
    
    public Options()
    {      
        AppSettingNames = ConfigurationManager.AppSettings.AllKeys.ToList();           

        T instance = Activator.CreateInstance<T>();

        model = (T)SetConfig(instance.GetType());
    }

    private object SetConfig(Type type)
    {
        object instance = Activator.CreateInstance(type);

        var instanceProperties = instance.GetType().GetProperties();

        foreach (var property in instanceProperties)
        {
            //If it is a hierarchical option class, it is searched recursively.
            if (property.GetCustomAttribute<OptionObjectIgnoreAttribute>() != null)
            {
                property.SetValue(instance, SetConfig(property.PropertyType));
            }

            //If an attribute does not have an 'OptionAttribute', the value is searched by the attribute's original name. 
            //but if there is, the value is searched by the set name at 'OptionAttribute'
            var name = AppSettingNames.FirstOrDefault(m => property.GetCustomAttribute<OptionAttribute>() == null ?
                                                            property.Name.Equals(m, StringComparison.InvariantCultureIgnoreCase) :
                                                            property.GetCustomAttribute<OptionAttribute>().keyName.Any(n => n.Equals(m, StringComparison.InvariantCultureIgnoreCase)));

            var value = ConfigurationManager.AppSettings.Get(name);

            if (!value.Equals(String.Empty))
            {
                property.SetValue(instance, Convert.ChangeType(value, property.PropertyType));
            }
        }

        return instance;
    }

    public T Value
    {
        get
        {
            return model;
        }
    }
}

Global.asax

var container = new Container();

container.RegisterMvcControllers(Assembly.GetExecutingAssembly());

//Injection form Web.config settings to Option Model  
container.Register<IOptions<SecretOptions>, Options<SecretOptions>>(SimpleInjector.Lifestyle.Singleton);

DependencyResolver.SetResolver(new SimpleInjectorDependencyResolver(container));

Dependency Injection Service Lifetimes

DI(Dependency Injection)을 구성할때 lifetime을 정의해야한다. lifetime은 DI가 동작할때 서비스 인스턴스가 생성되는 조건을 정의 하는 것이다.
(참고로, DI에 대한 개념 설명은 “Dependency Injection 개념과 Ninject 사용법”이란 포스팅에 설명해 두었다. 추가적인 내용이 필요 하다면 참고하길 바란다.)

lifetime에는 다음과 같이 3가지가 있다.

  1. Transient – 모든 요청에 대해서 매번 새로 인스턴스를 생성한다.
  2. Scoped – scope 당 1회 인스턴스를 생성한다.
  3. Singleton – DI가 구성되고 특정 시점에 1회 인스턴스를 생성되고 나면, 모든 호출에 대해서 동일한 인스턴스를 재활용한다.

글로만 읽으면 잘 이해가 가지 않는다. 좀 더 쉽게 이해를 하기 위해서 간단히 코딩을 하여 알아보자.

.Net Core Console 프로젝트를 생성하고 Progrma.cs에 다음과 같이 코드를 넣어 보자.

namespace DILifetimesTest
{
    public interface IService
    {
        void Info();
    }

    public interface ISingleton : IService { }
    public interface IScoped : IService { }
    public interface ITransient : IService { }

    public abstract class Operation : ISingleton, IScoped, ITransient
    {
        private Guid _operationId;
        private string _lifeTime;

        public Operation(string lifeTime)
        {
            _operationId = Guid.NewGuid();
            _lifeTime = lifeTime;

            Console.WriteLine($"{_lifeTime} Service Created.");
        }

        public void Info()
        {
            Console.WriteLine($"{_lifeTime}: {_operationId}");
        }
    }

    public class SingletonOperation : Operation
    {
        public SingletonOperation() : base("Singleton") { }
    }
    public class ScopedOperation : Operation
    {
        public ScopedOperation() : base("Scoped") { }
    }
    public class TransientOperation : Operation
    {
        public TransientOperation() : base("Transient") { }
    }

    class Program
    {
        static void Main(string[] args)
        {
            var serviceProvider = new ServiceCollection()
               .AddTransient<ITransient, TransientOperation>()
               .AddScoped<IScoped, ScopedOperation>()
               .AddSingleton<ISingleton, SingletonOperation>()
               .BuildServiceProvider();


            Console.WriteLine("========== Request 1 ============");
            serviceProvider.GetService<ITransient>().Info();
            serviceProvider.GetService<IScoped>().Info();
            serviceProvider.GetService<ISingleton>().Info();
            Console.WriteLine("========== ========= ============\r\n");

            Console.WriteLine("========== Request 2 ============");
            serviceProvider.GetService<ITransient>().Info();
            serviceProvider.GetService<IScoped>().Info();
            serviceProvider.GetService<ISingleton>().Info();
            Console.WriteLine("========== ========= ============\r\n");
                        
            using (var scope1 = serviceProvider.CreateScope())
            {
                Console.WriteLine("========== Request 3 (scope1)============");
                scope1.ServiceProvider.GetService<ITransient>().Info();
                scope1.ServiceProvider.GetService<IScoped>().Info();                
                scope1.ServiceProvider.GetService<ISingleton>().Info();
                Console.WriteLine("========== ========= ============\r\n");

                Console.WriteLine("========== Request 4 (scope1)============");
                scope1.ServiceProvider.GetService<ITransient>().Info();
                scope1.ServiceProvider.GetService<IScoped>().Info();
                scope1.ServiceProvider.GetService<ISingleton>().Info();
                Console.WriteLine("========== ========= ============\r\n");
            }

            using (var scope2 = serviceProvider.CreateScope())
            {
                Console.WriteLine("========== Request 5 (scope2)============");
                scope2.ServiceProvider.GetService<ITransient>().Info();
                scope2.ServiceProvider.GetService<IScoped>().Info();
                scope2.ServiceProvider.GetService<ISingleton>().Info();
                Console.WriteLine("========== ========= ============\r\n");
            }

            Console.WriteLine("========== Request 6 ============");
            serviceProvider.GetService<ITransient>().Info();
            serviceProvider.GetService<IScoped>().Info();
            serviceProvider.GetService<ISingleton>().Info();
            Console.WriteLine("========== ========= ============\r\n");

            Console.ReadKey();
        }
    }
}

코드를 실행해 보면 다음과 같이 결과를 얻을 수 있다.

========== Request 1 ============
Transient Service Created.
Transient: 2abfdf9f-5223-4a2c-953f-1c7269745073
Scoped Service Created.
Scoped: ab819f16-7b39-4f61-b077-3f7c5e38025b
Singleton Service Created.
Singleton: 47e04473-f743-41ec-9f2a-e2b08e1a216e
========== ========= ============

========== Request 2 ============
Transient Service Created.
Transient: 883343bd-0f24-4416-a56c-04b1a7cba89f
Scoped: ab819f16-7b39-4f61-b077-3f7c5e38025b
Singleton: 47e04473-f743-41ec-9f2a-e2b08e1a216e
========== ========= ============

========== Request 3 (scope1)============
Transient Service Created.
Transient: 5eba1fb5-f53b-49cc-bab4-5e1e103b4e17
Scoped Service Created.
Scoped: a0cdf863-e10b-4925-8731-3fe0f2a085ff
Singleton: 47e04473-f743-41ec-9f2a-e2b08e1a216e
========== ========= ============

========== Request 4 (scope1)============
Transient Service Created.
Transient: d6124182-1fbc-402d-997f-fd9024fa5187
Scoped: a0cdf863-e10b-4925-8731-3fe0f2a085ff
Singleton: 47e04473-f743-41ec-9f2a-e2b08e1a216e
========== ========= ============

========== Request 5 (scope2)============
Transient Service Created.
Transient: 8a200580-965e-4d77-bfad-08cc722e6a13
Scoped Service Created.
Scoped: 72c67b06-6296-49da-b162-853c6f16e1d0
Singleton: 47e04473-f743-41ec-9f2a-e2b08e1a216e
========== ========= ============

========== Request 6 ============
Transient Service Created.
Transient: 0885afac-3b12-4c8a-8e60-0fb68b0f32a4
Scoped: ab819f16-7b39-4f61-b077-3f7c5e38025b
Singleton: 47e04473-f743-41ec-9f2a-e2b08e1a216e
========== ========= ============

출력된 결과를 살펴 보면 Transit은 매 서비스 요청마다 새로 인스턴스가 생성된 것을 확인 할 수 있다.

다음으로 Scoped는 1번 요청에서 생성된것이 2번 6번에서 재활용되었고, 그 외 Scope가 새로 할당 될 때는 새 인스턴스가 생성되고 해당 Scope안에서는 Scope가 종료될때까지 동일한 인스턴스가 재활용되는 것을 확인 할 수 있다.

마지막으로 Sigleton은 1회 인스턴스가 생성되면, 요청이나 Scope 할당과 상관없이 동일한 인스턴스가 재활용 되는 것을 확인 할 수 있다.

위와 같이 각각 lifetime 옵션에 따라 서비스 처리 특성이 다르기 때문에 서비스 요청 특성에 맞춰 알맞는 lifetime을 설정하는 것이 중요할 것 같다.

[출처] : https://www.c-sharpcorner.com/article/dependency-injection-service-lifetimes/

Dependency Injection 개념과 Ninject 사용법

소프트웨어를 잘 만들기 위해서 많은 디자인 패턴들이 사용되는데, 그중에서 DI(Dependency Injection, 의존성 주입)에 대한 개념과 .NET MVC에서 많이 사용되는 DI Framework들 중 하나인 Ninject(Open Source Project)에 대해서 알아보겠다.

DI(Dependency Injection) 란?

프로그래밍에서 사용되는 객체들 사이의 의존관계를 소스코드가 아닌 외부 설정파일 등을 통해 정의하게 하는 디자인 패턴이다. 개발자는 각 객체의 의존관계를 일일이 소스코드에 작성할 필요 없이 설정파일에 의존관계가 필요하다는 정보만 된다. 그러면 객체들이 생성될때, 외부로부터 의존관계를 주입 받아 관계가 설정 된다.

DI를 적용하면, 의존관계 설정이 컴파일시 고정 되는 것이 아니라 실행시 설정파일을 통해 이루어져 모듈간의 결합도(Coupling)을 낮출 수 있다.
결합도가 낮아지면, 코드의 재사용성이 높아져서 모듈을 여러 곳에서 수정 없이 사용할 수 있게 되고, 모의 객체를 구성하기 쉬워지기 때문에 단위 테스트에 용이해 진다.

이제, .NET에서 MVC 프로젝트를 만들때 DI를 구현하기 위해 가장 많이 사용하는 Open Source인 Ninject에 대해서 알아보자.

NInject 알아보기

(공식 페이지 : http://www.ninject.org/)

간단히 이름부터 살펴보면, NInject는, N(Ninja) + Inject로 대표 이미지로도 Ninja로 되어 있다.

홈페이지 대문에 보면 “Be fast, be agile, be precise”라는 슬로건이 있는데 닌자처럼 빠르고 민첩하며 정확하게 프로그램을 만들수 있게 하겠다는 정신이 담겨있는 것 같다.

(Nate Kohari라는 소프트웨어 엔지니어가 최초 개발을 했는데, 개인적인 생각으로는 N을 중의적인 의미로 사용한게 아닌가 싶다. 참고 : https://www.infoq.com/articles/ninject10-released/)

NInject는 Open Source 라이브러리로 Apache License 2.0에 따라 배포되었으며,
2007년 부터 .NET 어플리케이션의 DI를 구현하기 쉽게 해주도록 지원하고 있다.

이제, 실제로 사용해 보자

NInject 사용해 보기

  1. Package Install
    • Visual Studio에서 제공하는 Nuget Package Installer를 사용하여 다음 Package들을 설치한다.
      – Ninject
      – Ninject.Web.WebApi
      – Ninject.Web.Common
      – Ninject.Web.Common.WebHost
  2. Edit Ninject.Web.Common.cs
    • 위 Package 설치가 완료되면, 프로젝트 최상단에서 App_Start 폴더에 Ninject.Web.Common.cs 파일이 생성된 것을 확인 할 수 있다.
    • 해당 파일을 열어 보면, CreateKernel이라는 method가 있는데 다음 코드를 추가한다. 그러면 NInject가 controller의 의존성 주입을 구성해 줄 수 있게 된다.
RegisterServices(kernel);
GlobalConfiguration.Configuration.DependencyResolver = new NinjectDependencyResolver(kernel);
return kernel;
  1. Register Service
    • 스크롤을 조금 내려 보면, RegisterServices라는 method를 확인 할 수 있는데, 실제로 의존관계를 설정(bind)하는 곳이다. 의존성을 주입할 객체들의 관계를 다음과 같이 추가한다.
private static void RegisterServices(IKernel kernel)
{
    kernel.Bind<ICommonStore>().To<CommonStore>();
}
  1. Use it on controller
    • 이제 의존성 주입을 위한 IoC 설정과, 의존관계 설정(bind)작업을 모두 하였으니 Controller에서 사용해보자.
public class CommonStoreController : Controller
{    
    public CommonStoreController(ICommonStore common)
    {
        this.commonStore = common;
    }

    private ICommonStore commonStore;

    public int GetItemCount(string id)
    {
        return commonStore.Add(id);
    }
}

Design Pattern 중 DI(Dependency Injection, 의존성 주입)이라는 패턴에은 표준 프로그래밍을 할 때 중요한 요소이긴하지만 무조건 사용해야 하는 것은 아니다. 간단한 프로그램이나 객체간의 결합이 명확하여 구분하지 않아도 되는 경우 굳이 프로젝트를 무겁게(?) 만들 필요없다.

그리고 Ninject는 .NET에서 많이 사용되는 Open Source DI Framework 중 하나로 DI를 할 때 쉽게 구현할 수 있어서 선호되는 편이지만 다양한 DI Open Source Framework들이 있으며, 성능 면에서도 훨씬 더 좋은 것들이 있으니 확인하고 사용하길 바란다.
(참고 : https://www.claudiobernasconi.ch/2019/01/24/the-ultimate-list-of-net-dependency-injection-frameworks/)