Spring Core Technolohies 1. IoC Container (2) Dependency Injection

#Spring#IoC#DI#Bean

2023-04-16 14:03

대표 이미지

스프링 공식 Document를 기반으로 스프링 코어 기술들에 대해 정리하는 시리즈물로써, IoC, DI, AOP 세 가지 개념을 중심으로 간단한 예시와 함께 스프링 컨테이너를 이해하고 코드 작성 방법을 익혀봅니다. 두 번째 글에서는 스프링의 Inversion of Control (IoC) 구현 방법 중 하나인 DI에 대해 설명합니다.

개요


이전 시리즈를 읽었다면, 혹은 스프링에 대해 어느 정도 공부했다면 IoC에 대한 전반적인 이해는 하고 있을 것이라 생각한다. 이 글을 읽는 독자는 IoC에 대한 최소한의 개념은 이해하고 있다고 가정하고 작성하는 글이기에 IoC를 처음 들어본다면 이전 시리즈를 먼저 읽고 오는 것을 추천한다.

간략히 다시 이야기 해보자면 스프링에서는 IoC 컨테이너가 개발자 대신 객체의 생명주기와 의존성 관리를 담당하고 있다. DI는 IoC의 구현 방식 중 하나로, 의존성 주입이라고도 한다. DI는 객체 간의 의존성을 명시적으로 정의하고, IoC 컨테이너가 객체에 필요한 의존성을 주입하는 것을 의미한다. 이를 통해 객체들은 느슨하게 결합되고, 유연하게 대체하거나 재사용할 수 있게 된다.

이 두 개념은 서로 보완적이며, 스프링 프레임워크에서는 IoC 컨테이너를 통해 DI를 구현하여 객체의 생명주기와 의존성 관리를 편리하게 할 수 있도록 지원한다. 이번 장에서는 이 DI가 어떤 것인지 이해하는 것을 중점으로 설명하고 더 나아가 어떤식으로 작동하는지 파헤쳐보도록 한다. 더 나아가 상이한 빈 타입간 의존 관계를 가지는 경우 어떤 방식으로 이를 해결할 수 있는지 알아보자.

핵심 개념


본문으로 들어가기 전 이번 장에서 다룰 핵심 개념에 대해 알고 가자. 당장 이해할 필요는 없다. 하지만 본문에서 갑작스럽게 모르는 것이 등장하는 것보단 간단한 개념을 보고 가는 것이 훨씬 좋을 것이다.

Dependency Injection

  • 의존성 주입
  • 객체가 필요로 하는 다른 객체를 직접 생성하는 것이 아니라, 외부에서 생성된 객체를 전달하여 객체 간의 의존성을 관리하는 프로세스
  • 객체 간의 결합도를 낮추고 유연하고 재사용 가능한 코드를 작성할 수 있다.

Dependency Resolution

  • 의존성 해결
  • 특정 빈 객체가 필요로 하는 다른 빈 객체를 가져와서 해당 빈 객체의 인스턴스 변수나 세터 메서드를 통해 주입하여 해당 빈 객체가 정상적으로 동작할 수 있도록 한다.

Singleton Bean

  • Singleton은 스프링 컨테이너에서 사용하는 빈의 생성 방식 중 하나로, 어플리케이션 전역에서 한 번만 인스턴스화되는 빈을 의미한다. 즉, 여러 개의 빈이 같은 인스턴스를 공유하여 사용한다.
  • 해당 빈이 처음으로 요청될 때 인스턴스화되고 이후에는 컨테이너에 의해 생성된 동일한 인스턴스가 계속해서 반환된다.

왜 DI를 택했는가


이런 상황을 가정해보자. 당신은 새로운 프로젝트의 팀원으로써 여러 객체를 설계하고 이를 코드에 녹여내고 있다.

public class House {
  private Information information;
  
  ...
}

public class Information {
  ...
}

집에 대한 정보를 Information이라는 객체에 담아냈고 이를 House 객체의 필드로 가지게 하였다.

public void print() {
  this.information.printStatus();
}

House의 메소드인 print()에서는 information을 활용해 어떤 로직을 수행하고 있다.

코드를 작성 할 때는 문제가 없었지만 막상 프로그램을 실행하고 House 객체의 print() 메소드를 호출하는 순간 에러가 발생했다. information 객체가 존재하지 않는다는 Null Point Exception이 발생한 것이다.

이런 경우, 흔히 “House 빈은 Information 빈에 의존적이다.”라고 표현할 수 있다. 아무리 열심히 만들었다 한들 House 빈에 Information 객체가 할당되어 있지않으면 House 빈은 정상적으로 작동할 수 없다.

이를 해결하기 위해서 어떤 방법을 쓰면 좋을까? 가장 간단한 방법은 House 빈을 생성할 때 this.information = New Information(); 과 같은 코드를 추가하는 것이다. 매우매우 간단한 방법이다.

public class House {
  private Information information;
  
  public House() {
    this.information = new Information();
  }
  
  ...
}

바로 이렇게 말이다. 그럼 코드를 좀 더 복잡하게 만들어보자.

public class Information {
  private DateTime createdAt;
  
  public Information(DateTime createdAt) {
    this.creatAt = createAt;
  }
}

InformationcreatedAt 필드는 필수값이기 때문에 기본 생성자 사용을 제한한다. House 생성자도 다음과 같이 바꿔줘야 할 것이다.

public class House {
  private Information information;
  
  public House(DateTime datetime) {
    this.information = new Information(datetime);
  }
  
  ...
}

public class HouseService {
  public House createHouse() {
    House myHouse = New House(new DateTime());
    ...
    
    returm myHouse;
  }
}

당연히 House를 생성하는 곳들도 모두 수정해야 할 것이다. 여기까진 나쁘지 않은데? 🤔 라고 생각할지도 모르겠다. 내가봐도 그렇다. 하지만 안타깝게도 기획서가 업데이트되면서 요구 사항이 늘어났다. 당신은 요구사항을 충족하기 위해 모델을 변경하고 의존성있는 객체를 생성하기 위한 모든 필드값들을 생성자에 추가한다.

public class Certification {
  private DateTime createdAt;
  private Long owner;
  ...
}

public class Information {
  private DateTime createdAt;
  private Type type;
  private Certification certification;
  
  public Information(DateTime createdAt, Type type, DateTime certifactionCreateAt, Long ownerId) {
    this.creatAt = createAt;
    this.type = type;
    this.cerification = new Certification(certifactionCreateAt, ownerId);
  }
}

public class House {
  private Information information;
  
  public House(DateTime informationCreateAt, Type type, DateTime certifactionCreateAt, Long ownerId) {
    this.information = new Information(informationCreateAt, type, certifactionCreateAt, ownerId);
  }
  
  ...
}

public class HouseService {
  public House createHouse(Type type, Long ownerId) {
    House myHouse = New House(
      new DateTime(),
      type,
      new DateTime(),
      ownerId
    );
    ...
    
    returm myHouse;
  }
}

Information에 필드가 하나 추가됐더니 House부터 House를 생성하는 모든 클래스에도 변경이 일어나고 있다. 예시로 간단히 작성한 코드라 세 클래스만 변경됐지 실제 프로젝트 코드였으면 어땠을까? 상상 이상으로 많은 곳의 코드를 일일히 고치고 있는 당신의 모습을 상상해 볼 수 있을 것이다. 계속해서 필드가 추가되고 사용처는 많아지고 여기저기서 참조하고, 연관관계를 맺을 수록 피로도는 기하급수적으로 올라간다.

이렇게 객체들이 서로에게 직접 의존하고, 의존하는 객체가 변경되면 의존되는 객체에도 영향을 미치는 상황을 강한 결합도(strong coupling)라고 일컫는다. 이는 위 예제에서 간접적으로 경험한 것과 같이 유지보수와 확장에 어려움을 초래할 수 있다.

이 강한 결합도를 끊어버리려면 어떻게 하면 좋을까? House의 생성자 내에서 Information을 생성하지 않는다면? Information의 생성자 내에서 Certification을 생성하지 않는다면?

public class Certification {
  private DateTime createdAt;
  private Long owner;
  ...
}

public class Information {
  private DateTime createdAt;
  private Type type;
  private Certification certification;
  
  public Information(DateTime createdAt, Type type, Certification certication) {
    this.creatAt = createAt;
    this.type = type;
    this.cerification = certication;
  }
}

public class House {
  private Information information;
  
  public House(Information information) {
    this.information = information;
  }
  
  ...
}

public class HouseService {
  public House createHouse(Type type, Long ownerId) {
    Certification certification = createCertification();
    Information information = new Information(..);
    House myHouse = New House(information);
    ...
    
    return myHouse;
  }
}

이 코드에서는 Certification가 변경된다고 Information, House, HouseService에 변화가 생기지는 않을 것이다. 이것이 바로 느슨한 결합(loose coupling)이다. 이제 객체 간의 의존성을 최소화하여 객체들이 독립적으로 존재하고 변경에 유연하게 대처할 수 있을 것이다. 그리고 이 느슨한 결합이 DI(Dependency Injection)의 핵심 개념 중 하나이다.

Dependency Injection


정의

DI란 빈을 생성할 때 의존성이 있는 객체를 스프링이 자동으로 주입해주는 테크닉을 의미한다. IoC의 기본 원리에 걸맞게, 개발자가 코드 내에서 어떤 시점에 의존 객체를 주입할지 명시하지 않는다. 즉, 의존 객체를 통채로 전달받기 때문에 객체는 의존하는 다른 객체를 생성하거나 관리하지 않으므로 대상 객체는 의존 객체에 대해 전혀 알 필요가 없다. 빈의 메타데이터(생성, 의존성 등)는 스프링에서 지원하는 XML, Java Configuration, Annotation 등의 방식으로 설정할 수 있으며 이것들은 자동으로 BeanDefinition으로 추상화되어 Spring IoC 컨테이너에서 사용한다.

자동 주입 설정

DI 명시를 위해 생성자 기반 또는 Setter기반 설정을 사용할 수 있다.

생성자 기반 DI

@Component
public class MyComponent {
   private final MyDependency dependency;
   
   @Autowired
   public MyComponent(MyDependency dependency) {
       this.dependency = dependency;
   }
}

생성자 기반 DI는 의존 객체를 생성자의 파라미터로 전달받아서 주입하는 방식이다. 위의 예제에서는 MyComponent 클래스의 생성자는 MyDependency 객체를 파라미터로 받아서 주입한다.

세터 기반 DI

@Component
public class MyComponent {
   private MyDependency dependency;

   public MyComponent() {
       // 기본 생성자
   }

   @Autowired
   public void setDependency(MyDependency dependency) {
       this.dependency = dependency;
   }
}

세터 기반 DI는 빈 객체의 기본 생성자를 먼저 호출한 뒤에 해당 빈의 세터 메소드를 호출해서 의존성을 주입하는 방식이다. 위의 예제에서는 MyComponent 클래스는 setDependency()라는 세터 메소드를 가지고 있고, 이 메소드를 통해 MyDependency 객체를 주입받는다. 생성자 기반과 세터 기반으로 주입 방식을 굳이 나눠둔 이유는 무엇일까? 생성자 기반 DI는 필수적인 의존성을 명확하게 나타낼 수 있고, 런타임에 누락된 의존성을 빨리 감지할 수 있도록 도와준다. 또한 생성자를 호출할 때 검증 로직을 추가할 수 있어서 런타임 오류를 줄일 수 있는 장점이 있다. 반면에 세터 기반 DI는 필수적이지 않은 선택적인 의존성을 주입할 때 유용하다. 또한 추후에 재구성이나 재주입이 가능하고, 빈의 의존성이 변경될 가능성이 높은 상황에서 유연성을 제공한다. 세터 기반 DI를 사용하면 해당 값이 존재하는지에 대한 확신이 없기 때문에 사용할 때마다 null 체크를 통해 혹시 모를 오류에 대비해야하므로 스프링 팀에서는 일반적으로 생성자 기반 DI를 권장한다.

Dependency Resolution


의존성 해결

컨테이너는 다음과 같은 방식으로 Bean 의존성을 해결한다.

  • ApplicationContext는 모든 빈에 대한 configuration metadata를 이용해 빈들을 생성하고 초기화한다.
  • 빈이 생성될 때 종속적인 빈이 제공된다.
  • 각 프로퍼티나 생성자 인수는 스프링이 자동으로 형변환을 수행한다.

그렇다면 스프링은 복잡한 의존성을 가진 빈들을 어떻게 효율적으로 해결할까? 일반적으로 Spring IoC 컨테이너는 애플리케이션 구동 시점에서 모든 빈의 BeanDefinition을 먼저 읽어들인 뒤 각 빈의 의존성 정보를 분석하여 의존성 그래프를 구성하고, 이를 바탕으로 생성 순서를 정한다. 즉, BeanDefinition을 이용해 메타 정보를 이해하는 것과 실제 빈을 만드는 작업은 동시에 일어나지 않는다는 말이다. 의존성 그래프를 구성하는 방식은 Topological Sort 알고리즘을 사용하는 것이 일반적이라고 한다. 이 알고리즘은 노드 간의 의존 관계가 있는 방향 그래프에서, 모든 노드를 방향성에 거스르지 않도록 나열하는 알고리즘이다. Spring IoC 컨테이너에서도 이와 유사한 알고리즘이 사용되며 빈들의 의존성 그래프를 구성한 다음, 생성 순서를 정하게 된다. 이렇게 생성된 빈은 해당 빈이 필요한(호출되는) 런타임에서 생성된 순서대로 초기화된다.

BeanCurrentlyInCreationException이란 오류를 이따금 접해보았을 것으로 생각된다. 이는 빈 A가 생성자 주입을 통해 빈 B를 필요로 하고, 빈 B 또한 생성자 주입을 통해 빈 A를 필요로 하는 경우 순환 참조에 의해 발생하는 런타임 오류이다. 해결책으론 세터 기반 주입을 사용하는 것인데.. 애초에 순환 참조가 일어난 것 자체가 설계 미스라고 볼 수 있으니 설계부터 다시 하는 것을 추천한다.

스프링이 Bean 의존성을 해결하는 방법을 떠올리며 실제로 스프링이 구동될 때 어떤 식으로 작동하는지 짚고 넘어가도록 하자.

  1. 빌드 타임
    1. 어플리케이션을 빌드하고 빌드 결과물을 생성하는 시점
    2. 스프링은 XML 파일이나 어노테이션 등을 통해 설정 정보를 읽어들여 BeanDefinition을 생성한다.
  2. 런타임 전반
    1. ApplicationContext가 초기화되고 빈이 생성되는 시점
    2. 스프링은 컨테이너를 생성하고 싱글톤 빈 인스턴스들을 등록, 생성, 초기화하는 작업을 수행한다.
    3. 이 때 컨테이너에 등록되는 빈들은 어플리케이션 전체에서 공유된다.
  3. 런타임 후반
    1. 런타임 중 어플리케이션이 빈을 요청하고 의존성을 해결하는 시점
    2. 컨테이너에 등록된 빈들을 사용하게 되는 시점
    3. 빈을 사용하는 코드에서는 컨테이너에서 해당 빈을 가져와서 사용하며, 빈들간의 의존성도 런타임 시점에서 해결된다. 즉, 빈이 필요하여 가져오는 시점에 의존성 주입을 진행한다. → 😢 이 시점에 의존 객체가 준비되어 있지 않으면 런타임 에러가 발생할 수 있다.
    4. 처음 호출돼 정상적으로 의존성 주입을 마치면 이후 호출에서는 의존성 해결이 된 상태로 남아있다.

Lazy-initialized Beans

위의 2. 런타임 전반 > b를 보면 스프링은 ApplicationContext 초기화와 함께 빈을 일단 생성한단 것을 알 수 있다. 물론 의존성 주입은 실제로 빈이 필요하여 호출되는 시점에 진행하지만 아무튼 만들어는 둔단 것이다. Lazy-initialized. 즉 지연 초기화를 사용하면 런타임 전반에 빈이 생성되지 않고 빈을 필요로 하여 호출하는 시점에서 빈 인스텐스가 생성되며 의존성 주입 또한 함께 일어난다.   ex. 어노테이션 기반

@Component
@Lazy
public class MyComponent {
   //...
}

ex. Java 기반

@Configuration
public class AppConfig {
    
    @Bean
    @Lazy
    public MyComponent myComponent() {
        return new MyComponent();
    }

    //...
}

이미 빈을 호출할 때 의존성 주입을 함으로써 성능적인 이점을 가져가고 있는 스프링인데 왜 지연 초기화 같은 것을 사용하는 것일까?

Lazy-initialized를 통해 얻을 수 있는 장점은 두 가지로 요약할 수 있다. 첫째로, 애플리케이션 시작 시간을 단축시킬 수 있다. 모든 빈을 미리 생성하는 것이 아니라 필요한 빈만 생성하므로 애플리케이션 구동 속도가 빨라진다. 둘째로, 자원을 효율적으로 관리할 수 있다. 자원이 많이 필요한 빈이나 DB 커넥션과 같은 경우, 생성자를 통해 필요한 시점에 빈을 생성하면 자원을 효율적으로 활용할 수 있다. 필요하지 않은 경우에는 빈을 생성하지 않기 때문에 자원 낭비를 줄일 수 있다.

당연히 단점도 존재하는데 우선 빈을 사용하기 직전에 생성되므로, 애플리케이션의 성능이 느려질 수 있다. 특히, 매번 빈을 사용할 때마다 생성하는 경우에는 오버헤드가 크다. 또한 빈이 필요할 때마다 생성하므로, 빈이 생성되는 시점이 예측하기 어려울 수 있다.

이렇듯 Lazy-initialized는 자원을 효율적으로 관리하고 애플리케이션 시작 시간을 단축시킬 수 있는 장점이 있지만, 성능 저하와 예측 어려움이라는 단점도 고려해야 한다. 자원이 많이 필요한 빈, 예를 들면 DB 커넥션 같은 경우에는 자원 절약 효과를 높이기 위해 lazy-initialized를 사용하는 것을 추천하며 애플리케이션 구동 시간을 줄이기 위해 모든 빈을 한번에 생성하지 않고 필요한 빈만 생성하는 경우에도 사용할 수 있을 것이다.

lazy-initialized를 사용하는 이유는 대부분의 애플리케이션에서 필요하지 않은 빈들까지 초기화하는 비용을 줄이기 위해서이지만 대부분의 빈은 미리 생성하는 것이 좋다. 해당 빈이 사용되는 시점에서 생성되므로 런타임 오류가 발생할 가능성이 커지고 런타임에서 해당 의존성이 해결되기 전까지 다른 빈의 생성 및 초기화 작업도 지연될 가능성이 있기 때문이다.

lazy-initialized는 신중히 사용하도록 하자.

서로 다른 라이플 사이클을 가진 빈


빈은 다양한 타입이 존재하는데, 이 빈 타입에 따라서 라이프사이클이 상이해진다. 라이프사이클이 다른 두 빈의 경우 주입 시에 문제가 발생할 수 있는데 다음과 같은 상황을 생각해보자.

@Component
@Scope("prototype")
public class MyPrototypeBean {
    private String name;
    
    public void setName(String name) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
}

@Component
public class MySingletonBean {
    private MyPrototypeBean prototypeBean;
    
    @Autowired
    public void setPrototypeBean(MyPrototypeBean prototypeBean) {
        this.prototypeBean = prototypeBean;
    }
    
    public void doSomething() {
        // MyPrototypeBean을 사용하는 로직
        MyPrototypeBean myPrototypeBean = prototypeBean;
        System.out.println(myPrototypeBean.getName());
    }
}

싱글톤 빈인 MySingletonBean은 프로토타입 빈인 MyPrototypeBean에 의존성을 가진다. 생성자를 통해서 의존성 주입을 사용하는데, 싱글톤 빈의 특성상 이 생성자는 단 한 번 만 호출 될 것이다. 개발자는 의도적으로 MySingletonBean.doSomething에서 매번 새로운 인스턴스를 사용해야하지만 불가능하게 된다.

물론 IoC를 포기하면 편하다! 그치만 그러자고 쓰는 스프링이 아니기에 … 😅 새로운 대책을 몇 가지 내놓았다.

이 글에서 알아볼 방법은 총 두 가지인데 하나는 컨테이너에서 관리되는 빈의 메서드를 오버라이드하고, 다른 이름을 가진 빈의 조회 결과를 반환하는 Lookup Method Injection이며 다른 하나는 빈의 메서드를 동적으로 대체하고 실행 결과를 조작하는 Arbitrary Method Replacement이다. 이 두 가지 기능은 스프링 프레임워크에서 제공하는 고급 기능으로, 동적인 빈의 생성 및 메서드 실행을 가능하게 한다. 하지만 이 두 기능은 서로 다른 목적과 사용 시점을 가지고 있다.

lookup Method Injection


컨테이너에서 관리되는 빈의 메소드를 오버라이드하고, 다른 이름을 가진 빈의 lookup 결과를 반환할 수 있는 기능이다. 몇 가지 주의 사항을 먼저 알아보고 넘어가자!

  • 스프링 빈 컨테이너가 서브 클래스화하는 클래스는 final이 아니어야하며, 오버라이드 할 메소드도 final이면 안된다.
  • 추상 메소드를 포함하는 클래스를 유닛 테스트하는 경우 해당 클래스를 직접 오버라이딩한 뒤 추상 메소드의 stub을 제공해야한다.
  • 컴포넌트 스캐닝 시 인스턴스화 가능한 클래스와 메소드가 필수이다.
  • 룩업 메소드는 팩토리 메소드, 특히 configuration 클래스의 @Bean 메소드와 함께 작동하지 않는다.

@Configuration으로 정의한 클래스에서 @Bean 메소드를 사용하는 경우 컨테이너는 인스턴스를 직접 생성하지 않는다. 이는 @Bean 메소드를 사용하면 개발자가 직접 객체를 생성하고 반환하는 것이 가능해지기 때문에 컨테이너는 인스턴스를 직접 생성하지 않고 개발자가 정의한 메소드를 통해 반환된 객체를 사용하게 된다. 따라서 서브 클래스(상속 클래스)를 런타임에서 생성할 수 없게 되어 룩업 메소드가 작동하지 않는다. 따라서 팩토리 메소드나 @Bean 메소드를 사용하여 객체를 사용하는 경우에는 다른 방법을 사용해야한다.

다음으로 코드를 보면서 작동방식을 이해해보도록 한다.

@Component
@Scope("prototype")
public class MyCommand {

    private String state;

    public void setState(String state) {
        this.state = state;
    }

    public String getState() {
        return "State: " + state;
    }
}

@Component
public abstract class CommandManager {

    public Object process(Object commandState) {
        MyCommand command = createCommand();
        command.setState(commandState.toString());
        return command;
    }

    @Lookup
    protected abstract MyCommand createCommand();
}

MyCommand은 프로토타입으로 선언된 빈 객체. 즉 싱글톤 타입의 빈에 주입될 때 매번 새로운 빈이 생성되어야할 주체이다. CommandManager는 빈으로 선언된 추상 클래스 @Lookup 어노테이션을 사용하는 createCommand() 메소드를 포함한다. 이 메소드는 호출될 때 마다 새로운 MyCommand를 반환할 것이다. process 메소드에서는 전달받은 commandState 값을 이용하여 createCommand()로 새로 생성된 MyCommand 객체의 state값을 set해준다.   MyCommand를 사용하고자 하는 Client 객체는 아래와 같이 구현된다.

@Component
public class Client {

    private final CommandManager commandManager;

    @Autowired
    public Client(CommandManager commandManager) {
        this.commandManager = commandManager;
    }

    public String getMyCommentState(String state) {
        MyCommand myCommand = (MyCommand) commandManager.process(state);
        return myCommand.getState();
    }
}

이전에 작성했던 빈들과 다르게 MyCommand에 직접적인 의존성을 가지지않고 CommandManager에 대한 의존성만 가지고 있다. getMyCommentState를 호출하면 commandManager.process 내부에서 새로운 MyCommand를 가져오기 때문에 클라이언트에서는 매번 다른 MyCommandstate값을 가져올 수 있다.

@SpringBootTest
public class ClientTest {

    @Autowired
    private Client client;

    @Test
    public void testExecuteCommand() {
        String state = "Hello World";
        String result = client.getMyCommentState(state);
        assertEquals("State: " + state, result);
    }
}

처음에 프로토타입 빈의 name 값을 출력하는 싱글톤 빈 예제가 있었는데, 이를 lookup method injection으로 수정해보면 다음과 같다.

@Component
@Scope("prototype")
public class MyPrototypeBean {
    private String name;
    
    public void setName(String name) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
}

@Component
public abstract class CommandManager {

    public Object process(Object commandState) {
        MyPrototypeBean command = createPrototypeBean();
        command.setName(commandState.toString());
        return command;
    }

    @Lookup
    protected abstract MyPrototypeBean createPrototypeBean();
}

@Component
public class MySingletonBean {
    private final CommandManager commandManager;

    @Autowired
    public MySingletonBean(CommandManager commandManager) {
        this.commandManager = commandManager;
    }
    
    public void doSomething() {
        MyPrototypeBean myPrototypeBean = (MyPrototypeBean) commandManager.process("my_name");
        System.out.println(myPrototypeBean.getName());
    }
}

Arbitrary Method Replacement


Arbitrary Method Replacement(임의 메서드 대체)은 스프링의 AOP 기능 중 하나로, 빈의 메서드를 동적으로 대체하고 실행 결과를 조작하는 기능이다. 이는 스프링 컨테이너가 빈의 메서드 실행을 가로채어 대체 구현체인 MethodReplacer의 메서드를 실행하도록 하는 것을 의미한다.

AOP는 여러 객체에 걸쳐 반복적으로 적용되는 공통 기능을 한 곳에서 관리하고 적용할 수 있는 프로그래밍 패러다임이다.

즉, 원하는 메소드 대신 오버라이딩된 다른 메소드를 호출할 수 있다. 한마디로 기존 메소드를 다른 메소드로 대체하는 것이다.

@Component
@Scope("prototype")
public class PrototypeBean {
    public String getMessage() {
        return "Hello from PrototypeBean!";
    }
}

@Component
public class SingletonBean {
    private PrototypeBean prototypeBean;

    public String getMessage() {
        return prototypeBean.getMessage();
    }

    @Autowired
    public void setPrototypeBean(PrototypeBean prototypeBean) {
        this.prototypeBean = prototypeBean;
    }

    public void resetPrototypeBean() {
        this.prototypeBean = null;
    }

    public boolean isPrototypeBeanInitialized() {
        return prototypeBean != null;
    }
}

public class CustomMethodReplacer implements MethodReplacer {

    @Override
    public Object reimplement(Object obj, Method method, Object[] args) throws Throwable {
        return "Hello from CustomMethodReplacer!";
    }
}

SingletonBeanPrototypeBean에 대한 종속성을 가지며, resetPrototypeBean 메소드를 호출하면 PrototypeBean의 인스턴스를 다시 초기화할 수 있다. CustomMethodReplacerMethodReplacer 인터페이스를 구현하고, reimplement() 메소드에서 대체하고자 하는 메소드의 구현을 제공한다.   CustomMethodReplacerPrototypeBeangetMessage() 메소드를 대체하게 하기 위해서 아래와 같은 설정이 필요하다.

@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class AppConfig {

    @Bean
    public CustomMethodReplacer customMethodReplacer() {
        return new CustomMethodReplacer();
    }

    @Bean
    public BeanFactoryPostProcessor prototypeReplacer() {
        return new BeanFactoryPostProcessor() {
            @Override
            public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
                //메소드를 대체하고 싶은 프로토타입 빈을 가져온다.
                BeanDefinition prototypeBeanDefinition = beanFactory.getBeanDefinition("prototypeBean");
                //PrototypeBean의 getMessage 메소드를 바로 위에서 빈으로 정의한 CustomMethodReplacer로 변경한다.
                prototypeBeanDefinition.setMethodOverrides(
                        Collections.singletonMap("getMessage", new ReplaceOverride("getMessage", customMethodReplacer())));
            }
        };
    }

}

핵심은 BeanFactoryPostProcessor에서 재정의 되는 postProcessBeanFactory메소드이다.

Collections.singletonMap 의 키값은 대체할 메소드의 이름과 동일해야하고 동일하지 않을 시 원래 메소드가 호출되니 주의하자.

CustomMethodReplacerPrototypeBeangetMessage() 메소드를 대체하고, SingletonBeanPrototypeBean을 사용하는 동안 PrototypeBeangetMessage() 메소드를 대신 호출한다.

@SpringJUnitConfig(classes = AppConfig.class)
@ContextConfiguration(classes = AppConfig.class)
class SingletonBeanTest {

    @Autowired
    private SingletonBean singletonBean;

    @BeforeEach
    void setUp() {
        //테스트 코드가 호출될 때 마다 SingletoneBean이 PrototypeBean에 대해 의존성을 새로운 인스턴스로 설정하도록 유도함.
        singletonBean.resetPrototypeBean();
    }

    @Test
    void testGetMessage() {
        String message = singletonBean.getMessage();
        assertEquals("Hello from CustomMethodReplacer!", message);
        assertTrue(singletonBean.isPrototypeBeanInitialized());
    }

}

Lookup Method Injection vs. Arbitrary Method Replacement

Lookup Method Injection은 주로 프로토타입 스코프의 빈을 사용할 때 유용하다. 일반적으로 스프링은 싱글톤 스코프로 빈을 관리하는데, 이는 빈이 애플리케이션 전체에서 하나의 인스턴스만을 공유한다는 의미이다. 하지만 프로토타입 스코프의 빈은 매번 새로운 인스턴스를 생성하여 사용해야 한다. Lookup Method Injection은 이러한 프로토타입 스코프의 빈을 매번 새로운 인스턴스로 가져와야 할 때 사용된다. 즉, 매번 호출될 때마다 새로운 빈의 인스턴스를 반환하는 역할을 한다.

반면에 Arbitrary Method Replacement은 빈의 메서드를 동적으로 대체하여 실행 결과를 조작하는 기능이다. 이 기능은 특정한 메서드의 동작을 변경하거나 확장해야 할 때 사용된다. 예를 들어, 특정 메서드 호출 시 로깅을 추가하거나 보안 검사를 수행하는 등의 작업을 할 수 있다. Arbitrary Method Replacement은 메서드 호출 시 원래의 메서드를 대체하여 새로운 동작을 수행하도록 한다.

두 가지 모두 서로 다른 라이프사이클을 가진 빈 사이에 의존성을 해결할 때 유용하게 사용될 수 있다.

맺음말


이번 포스팅에서는 스프링의 핵심 개념인 DI(Dependency Injection)에 대해 다뤄보았다.

간단히 복습해보자면, 스프링에서는 IoC 컨테이너가 개발자 대신 객체의 생명주기와 의존성 관리를 담당하며 DI는 객체 간의 의존성을 명시적으로 정의하고, IoC 컨테이너가 필요한 의존성을 객체에 주입하는 것을 의미한다. 이를 통해 객체 간의 결합도를 낮추고 유연성과 재사용성을 높일 수 있다.

두 가지 개념은 서로 보완적이며, 스프링 프레임워크는 IoC 컨테이너를 통해 DI를 구현하여 객체의 생명주기와 의존성 관리를 편리하게 지원한다.

다음 글에서는 빈 스코프와 다양한 방법으로 빈을 정의하는 방법에 대해서 다뤄보고자 한다. 이번 시리즈를 통해 스프링의 핵심 개념들에 대해 이해하고, 코드 작성 방법을 익히는 데 도움이 되었기를 바란다. 다음 글에서도 쉽고 명확한 설명으로 빈에 대한 이해도를 높여보도록 하겠다.

참고


https://docs.spring.io/spring-framework/reference/core/beans/dependencies.html