ABOUT ME

Contact.
Email:yj.anthonyjo@gmail.com
Introduce : CS Student.

Today
-
Yesterday
-
Total
-
  • 객체지향 - DI Container 구현 및 분석
    프로그래밍/객체지향 2022. 3. 4. 20:55
    반응형

    0. 개요

    IoC의 구현방법 중 하나인 DI 컨테이너를 직접 구현해본다.

    0.1. 사전지식

    객체지향, SOLID원칙, IoC, DI, 리플렉션(개념만), 자바(annotation 등)

    0.2. 코드

    https://dev.to/jjbrt/how-to-create-your-own-dependency-injection-framework-in-java-4eaj
    위 사이트의 코드를 분석하였다.

    0.3. 기본지식

    • Dependency Injection이란?
      IoC를 구현하는 디자인패턴
    • IoC 디자인 원칙란?
      프레임워크가 object의 생성과 할당을 관리하는 것.
      사람이 관리하던일을 프레임워크가 관리한다는 측면에서 Inverse of Control, IoC라고 부른다.
      싱글톤 방식을 사용해도 OCP, DIP원칙에 위배된다. 스프링은 이 문제를 IoC로 해결했다.

    0.3.1 용어

    • client class : DI 받는 클래스(의존성을 주입받는 클래스), dependent class
      • client 클래스를 변경하지 않도록 하는 것이 DI의 목적이다.
    • service class : DI의 대상이 되는 클래스, 즉 주입해줄 대상이다. dependency class
    • injector class : DI가 필요한 client class와 DI의 대상인 service class를 각각 검색하고, client class에 service class의 object를 주입한다.(IoC이기에, injector class가 service class의 object 생성까지 맡는다.)
    • Interfaces : 클라이언트가 서비스를 사용하는 방법을 정의.
    • Injection : auto wire이라고도 불리며, client에 service(dependency)를 주입하는 것을 의미한다.

    0.3.2 작동원리 개요

    https://dev.to/jjbrt/how-to-create-your-own-dependency-injection-framework-in-java-4eaj

    • Client class가 UserService와 AccountService 객체를 요구한다.
    • 하지만 client class는 UserServiceImpl과 AccountServiceImpl을 직접적으로 접근하지 않는다.
    • 대신, Injector class가 객체를 생성하고 Client에 inject한다.
    • 이는 object들이 어떻게 만들어지는지와 client가 무관하도록 만든다.

    0.3.3 Dependency Injection의 타입

    • Constructor Injection : 생성자를 이용해 주입하는 방식이다.
    • Property Injection : client class의 public 프로퍼티들에 dependency를 전달한다. 이 경우 @Autowire이 variable 정의부에 붙는다.
    • Setter method Injection : the client class implements an interface that declares the method(s) to supply the service (dependency) and the injector uses this interface to supply the dependency to the client class.

    여기서는 두번째방법을 사용하는 듯하다.

    1. 작동 순서

    1. root 패키지부터 모든 client를 탐색
    2. 클라이언트의 instance를 생성
    3. client class에서 사용중인 모든 services를 탐색
    4. 서비스 안에 정의된 서비스를 모두 탐색한다.(재귀적으로)
    5. 3, 4 단계에서 얻어진 결과(서비스들)의 instance를 생성한다.
    6. 5단계에서 만들어진 instance를 inject한다.
      7. Create Map all the client classes Map
    7. Bean과 Service를 받을 수 있도록 API를 노출한다.
    8. interface에 대한 구현이 되어있는지, 또 중복 구현은 아닌지 확인한다.
    9. 중복 구현의 경우, Qualifier를 사용하거나, type에 맞게 autowire한다.

    2. 코드분석

    코드는 https://github.com/YJ-AnthonyJo/DI-Container 에서 확인할 수 있다.
    이는 원본 코드에 이해를 돕기 위한 주석을 추가한 형태이다.

     

    2.0. 간략한 프로그램 설명

    이 코드는 사용자를 User와 Account로 나누어(?) 관리하는 코드이다. 표현이 그렇지.. 나누어 관리한다고 거창한게 아니다.

    사용되는 기능은 두가지이다. interface를 보면 알 수 있다.

    • UserService interface : 사용자의 이름을 가져온다.
    • AccountService interface : 사용자의 이름을 바탕으로 AccountNumber를 가져온다.

    왜 이렇게 나누어 관리하고자 하는지는 의문이다. 심지어 정보도 저장하고 가져오는 것도 아니고, 인터페이스 implement클래스를 보면 리턴하는 형태이다..

    엔티티를 (username, accountnumber)이렇게 구성하면 될텐데..

    아무튼 중요한 것은 DI이다. 프로그램의 동작은 크게 중요하지 않다. 넘어가자.

    프로그램 실행결과는 다음과 같다.

    2.1. com.useraccount : UserAccountApplication

    이 프로그램을 실행하는 부분이다.

    • Injector의 startApplication을 실행한다.
    • 구현된 DI Container에서 UserAccountClientComponent의 instance를 받아와 그 instance의 displayUserAccount()를 실행한다.
    • 위 동작을 실행하는데 걸린 시간을 표시한다.

    Injector의 startApplication을 살펴보기 전에 다른 기본이되는 것들을 알아보자.

    2.2. com.useraccount : UserAccountClientComponent

    Client class 코드 부분이다.

    • private UserService userService와 private AccountService accountService가 보인다.
      이 두가지 필드에 DI시켜주어야한다. (주입받는 대상)
      여기 @Autowired, @Qualifier 어노테이션이 보인다. 아래에서 살펴본다.
    • displayUserAccount() : username과 accountNumber을 얻고 출력한다.

    2.3. org.di.framework.annotations

    2.3.1. Autowired : @Autwired

    Client class에서 "DI 되어야하는 Service"에 해당하는 필드(변수)들에 사용되어야하는 것이다.

     

    위 2.2의 Client class코드 부분을 보면 바로 이해가 된다.

    DI되어야하는 accountService, userService에 딱 붙어있다.

    각각 AccountService의 구현체(AccountService interface's implementation class),

    UserService의 구현체(UserService interface's implementation class)의 object가 들어와야하는 곳이다.

    2.3.2. Component : @Component

    원본 코드 주석에는 Client class가 사용해야한다고 되어있으나, service class도 사용한다..

     

    원본 코드 주석을 무시하고 분석한 결과를 통해 설명한다.

    이 어노테이션이 붙은 것을 injector가 모두 찾아내어 오브젝트를 생성하고, {class : class's object}형태의 map에 저장해둔다.

     

    2.3.3. Qualifier : @Qualifier

    인터페이스를 구현한 클래스가 여러개있을 때 어떤 것을 선택할지 정하게 해주는 어노테이션이다.

    이 어노테이션이 없는 상태에서 여러개가 존재하여 충돌이 난 경우에 대해서는 아래 설명한다.

     

    2.4. org.di.framework : Injector

    이제 본격적으로 DI의 과정을 알아보자.

    2.4.1. startApplication

    2.1의 UserAccountApplication에서 Injector.startApplication을 호출했다.

    injector가 없으면 initFramework를 호출한다. 인자로는 본인이 전달받은 mainClass를 전달한다.

    mainClass는 UserApplication.class였다.

    2.4.2. initFramework

    사실상 이 메서드에서 DI의 대부분이 일어난다.(아니, 모두 일어난다고 보아도 무방할 듯.)

    코드길이가 길다. 조금씩 나누어보자.

    여담..-------------------

    여기서부터 조금씩 힘들어졌다. burningwave라는 자바 리플렉션 라이브러리를 사용했기 때문이다.

    Burningwave는 내가 못찾은 것인지.. 레퍼런스가 거의 존재하지 않는다. 심지어 공식 사이트에도, github에도 method에 대한 설명이 존재하지 않는다..

    그저 공식 사이트에서 제공해주는 example들을 보며 대략적으로 이해하는 것이 최선이었다.

    -------------------

    for문 들어가기 전이다.

    • Class<?> classes = getClasses(mainClass.getPackage().getName(), True);
      내가 옆에 주석에 써두었다 싶이, mainClass(즉 UserAccountApplication클래스)가 존재하는 패키지의 하위에 존재하는 모든 클래스를 검색해서 반환한다. getClasses는 굳이 설명하지 않는다.(직관적으로 이해하자)
    • ComponentContainer componentContainer =... 와 그 다음 줄인 ClassHunter...
      이 부분은 Burningwave에서 제공하는 ClassHunter를 사용하기 위한 작업이다. 그리고 ClassHunter는 이름에서 느낄 수 있듯이 Class들을 탐색하는 역할을 한다.
    • String packageRelPath ...
      .으로 구분된 자바의 패키지를 /으로 구분하도록한다. 단순히 Burningwave를 사용해주기 위한 규약을 맞추어주는 것이다.
    • try(Class...) ~ Collection<Class<?>> types = result.getClasses();
      우선 try 안에 들어가는 것이 궁금하다면 다음을 참고한다. 링크
      한 줄로 정리해서, types에 @Component가 있는 모든 클래스를 저장한다는 것이다.

    for문 ~ 끝 부분이다

     

    diMap과 applicationScope가 나온다. 둘다 Map형태이다. 필자의 추정을 써본다.

    2.4.2.1. diMap과 applicationScope

    DI를 하는 두가지 경우를 생각해보자.

    //--주입할 대상이 interface의 구현체인 경우--//
    //필드의 타입이 ExInterface, 즉 인터페이스이다. -> 인터페이스의 구현체의 오브젝트가 들어와야한다.
    @Autowired
    private ExInterface exInterface;
    
    //--주입할 대상이 class인 경우--//
    //필드의 타입이 ExClass, 즉 클래스이다. -> 클래스의 오브젝트가 들어와야한다.
    @Autowired
    private ExClass exclass;

    물론 IoC와 객체지향에 더 어울리는 것은 첫번째의 경우이다. 하지만, 두번째 경우도 없는 것은 아니다.

    # 첫 번째 경우, injector는 인터페이스의 구현체 클래스를 찾고 그 클래스의 오브젝트를 찾아 주입해야한다.

    구현체를 찾기 위해 diMap을 사용한다. diMap은 {인터페이스:구현체클래스} 들을 담고있다.

     

    # 두 번째의 경우, injector는 구현체를 검색할 필요없이 그 클래스의 오브젝트를 찾아 주입하면된다.

    하지만 이 소스코드의 저자는, 첫번째 경우와 두번째 경우 모두를 하나의 코드 로직 처리하기를 원했던 것 같다.

    이 코드에서 두 번째 경우도, injector는 클래스를 검색하고, 그 클래스의 오브젝트를 찾아 주입한다.

    이를 구현하는 방법은 간단하다. diMap에 {클래스:클래스}를 담으면 된다. (이때 key와 value로 들어가는 클래스는 동일하다.)

    파이썬적인 예제로 보자면 다음과 같다.

    diMap = {ExInterface : ExInterfaceImplClass, ExClass : ExClass}

     

    이제 클래스를 찾았으니, 해당 클래스의 오브젝트를 찾아 주입해야한다. 이를 위해 코드에서는 applicationScope를 사용하였다. applicationScope는 {클래스:클래스의 오브젝트}들을 담고있다.

     

    즉 injector는 필드의 타입으로 주입할 대상을 찾아 주입하는데, 다음의 과정을 거친다.

    직관적인 이해를 위해 파이썬 문법을 통해 나타내본다.

    # target : 주입할 대상의 "필드 타입"
    def doDI(target):
    	return applicationScope[ diMap[target] ]

     

    이제 다시 코드 분석으로 돌아오자.

     

    2.4.2.2. 코드 분석(두개의 for문)

    다시 보고있던 코드로 돌아오자.

    • 첫번째 for문 : diMap에 {인터페이스(or클래스):(구현)클래스}를 추가한다.
    • 두번째 for문 : @Component가 존재하는 모든 클래스의 instance를 생성하여, applicationScope에 {클래스:인스턴스}를 추가한다.
      추가적으로 InjectionUtil.autowire를 호출한다. 이는 2.4.3.3에서 알아보자.
      인자는 (방금 인스턴스를 생성한 클래스, 그 클래스의 인스턴스)이다.

    물론.. 왜 굳이 try문에서 @Component가 있는 클래스를 필터링했으면서 그것을 이용하지 않고 모든 클래스에서 isAnnotationPresent로 다시 필터링했는지는 모르겠다..

     

    2.4.2.3. InjectionUtil.autowire

    2.4.3.2의 두번째 for문에서 인스턴스를 생성했다.

    이 autowire 메서드는 생성된 인스턴스의 필드에 @Autowired가 있는 것을 찾아내고, 이 필드에 DI를 해준다.

    이 또한 코드 길이가 길다.(사실 필자가 작성한 주석이 길다..) 두 부분으로 캡쳐해서 올린다.

    주석 그 자체다..

    인자로 (인스턴스를 생성한 클래스, 그 클래스의 인스턴스)를 받았다.

    • Collection<Field> fields = ..
      전달받은 클래스에서 @Autowired가 존재하는 모든 필드를 찾고 접근가능하게 만든다.(어떻게 가능한지는 모르겠다. private인데 접근 가능해진다.)
    • for문
      • String qualifier = ...
        필드의 타입을 바탕으로 DI를 한다. 이때 필드가 인터페이스이고, 이것의 구현체가 두개이면 충돌이 발생하기에 이 충돌을 없앨 수 있도록 생성한 @Qualifier가 있는지 탐색한다. 없으면 null로 놔둔다.
      • Object fieldInstance = ...
        필드에 집어넣어야할 인스턴스를 injector에서 찾는다.  getBeanInstance()를 사용한다. 
        getBeanInstance()는 밑(2.4.3.4)에서 알아본다.
      • Fields.setDirect(classInstance, field, fieldInstance)
        레퍼런스가 없다.. 직접 분석했고 위 주석은 필자의 추정이다. 지금은 이 호출이 어떤 역할을 하는지만 적어본다. setDirect는 Field에 직접 DI를 수행하는 것으로 보인다. 클래스 인스턴스(classInstance)의 필드(field)에 fieldInstance를 주입하는 것으로 보인다.
      • autowire(injector...)
        fieldInstance의 클래스에 DI해줄 필요가 없을 때까지 계속 재귀함수로 호출하며 모든 필드를 DI해준다.
        즉, Aclass의 필드가 Bclass였는데 Bclass가 Cclass필드를 가지고 있고 DI가 필요하다면 이것까지 해준다는 것이다. 

    2.4.2.4. injector.getBeanInstance();

    • Class<?> implementationClass = getImplimentationClass(interfaceClass, fieldNmae, qualifier);
      interfaceClass의 구현체 클래스를 구하여 implementationClass에 저장한다.
      getImplimentationClass() 아래(2.4.2.5)에서 알아본다.
    • if문
      applicationScope({클래스:오브젝트} 자료)에 구해진 구현체클래스가 존재하는지, 즉 구한 구현체클래스의 오브젝트가 생성되어있는지 확인한다. 
      만일 있다면, 해당 오브젝트를 반환한다.
    • sync...블럭
      applicationScope에 구현체클래스의 오브젝트가 존재하지 않는다면, 새로운 객체를 생성해서 applicationScope에 넣고 생성된 객체를 반환한다.

    2.4.2.5. getImplimentationClass()

    분석하면서 이런 로직이 맞는가 싶었다..

    이유는 구현체클래스가 여러개일 때 중복하는 로직이 상당히 부정확해보였기 때문이다.

    • Set<Entry<Class<?>, Class<?>>> implementationClasses = diMap.entrySet().stream()... 
      diMap에서 interfaceClass의 implementationClass를 모두 받아온다.
    • if문 : implementationClass가 없으면 에러를 표현하면서 종료한다.(예외)
    • else if 문
      implementationClass가 1개라면(중복이 없다면), 해당 implementationClass를 반환한다.
    • else문
      implementationClass가 2개 이상, 중복인 경우이다. 
      이때 로직은 다음과 같다.
      1. qualifier정의 
        • qualifier가 정의되어있으면, 해당 qualifier를 사용한다.
          이때 qualifier라 함은, @Qualifier()에 인자로 들어간 String값이다.
        • qualifier가 정의되어있지 않으면, 필드의 이름을 qualifier로 지정한다.
          필드의 이름이다.. `private InterfaceA classA`이면 `classA`..
          내가 주석에도 써 두었듯이, 이 코드의 개발자는 field의 이름을 implementationClass의 이름으로 짓는다고 기대(?) 추측(?)하는 것 같다.
      2. 이후 해당 qualifier와 동일한 이름을 가진 class를 검색한다. (대소문자 무시)
        자세한 것은 필자가 단 주석을 확인하자.
      3. 만일 클래스가 검색되었다면 그 클래스를 반환한다.
        그렇지 않다면, 에러를 표현하면서 종료한다.(예외)

     

    이렇게 UserAccountApplication에서 호출한 startApplication에 대해 분석을 마쳤다.

    이 startApplication을 통해 이 코드를 짠 개발자는 DI Container와 DI를 모두 구현했다.

    실제로 스프링의 DI Container도 이렇게 작동할까?

    이는 공부해보고 추후 포스팅하도록 하겠다.

     

    다시 처음으로 돌아와 UserAccountApplication의 다음 동작들을 해석하며 마무리짓는다.

    2.5. UserAccountApplication

    • Injector.getService(...)
      Injector객체(개념상 DI Container)에서 UserAccountClientComponent의 instance를 받아오고, 그 instance의 displayUserAccount()를 호출한다.
      해당 코드 아래 두 줄의 주석과 동일한 역할이다.(처음 보았을 때 조금 헷갈려서ㅎㅎ 적어보았다..)
    • endTime...
      종료시간을 측정한다.
    • System.out.println(...);
      걸린시간을 출력한다.

     

    아주 단편적으로, 이해하기 쉬운 DI의 장점을 적어본다.

    interface를 implement하는 class를 변경하여도, client class의 코드를 변경하지 않아도 된다.

     

    마무리

    사실 객체지향을 오랫동안 공부해본 것은 아니다. 작년 9월 쯤부터 무작정 스프링으로 개발을 해보고자 했다.

    이해가 안되는 것 천지였다. 다른 예제를 이해는 했어도, 왜 그런 형식으로 짜야하는지, 또 내 프로젝트에는 어떻게 적용할 수 있는지 몰랐다. 그때까지는 절차지향적인 사고가 내 뇌를 지배했기 때문에, 굳이 저렇게 길게 코드를 쓸 필요가 있나?라는 질문이 나를 붙잡고 늘어졌다. 절차지향으로 하면 너무나 짧아지는 코드가 객체지향으로 바뀌니 너무나 길어졌다. 그렇게 비효율적인 시간이 흘러가던 중, 학교 선배에게 객체지향 강의를 듣고, 틱택토 구현을 통해 객체지향에 입문하였다. 지속적인 피드백을 받으며, 이전에는 이해가 안되었던 SOLID나 인터페이스의 역할 등등을 이해할 수 있었다.

    그리고 그렇게 틱택토 코드를 발전시켜나가는 와중, 스프링의 IoC와 DI, Bean이 내 짧은 지식으로는 객체지향을 완벽히 구현할 수 있도록 하는 도구라는 것을 느꼈고, 직접 DI Container를 개발해보고 싶어졌다. 그렇게 이 코드에 대한 분석을 시작했다. 물론 중간에 많이 놀러다니는 탓에 거의 한달 가까운 시간이 소요되었다.(변명을 하자면,, 이것만 한건 아니다ㅎㅎ 인공지능, 알고리즘 등등 많이했다..ㅎㅎ) 이제 그 분석의 마무리다.

    자바라는 언어에 대해 많이 친숙해질 수 있었다. 또 객체지향에 대해서도 많이 알 수 있었다.

    리플렉션, 어노테이션 등등... 얕게만 알고 있었던 것들도 직접 코드를 보며 친해질 수 있었다.

    이제 스프링으로 진행하던 프로젝트를 마무리할 것이다. 이번 달 안에 마무리함을 목표로한다.

    그리고 스프링의 DI와 더 기본적이지만 어렵고, 또 신비한 객체지향과 개발자들의 노하우들을 공부할 것이다.

    여담으로, 이렇게 DI Container를 분석해보니 드는 생각이 있다.

    본래 취지가 DI Container를 틱택토에 구현하여 적용하는 것이었는데, 이거를 내 손으로 구현하지는 못해먹겠다😂

    잘 만들어진 것이 있다면 잘 응용할줄 알자ㅎㅎ

     

    잘못된 내용에 대한 지적은 언제나 환영합니다.

    궁금한 것에 대한 토의도 환영합니다.

    함께 알아갔으면 좋겠습니다. 🙂

    반응형

    댓글

Designed by Tistory.