토비의 스프링 부트 - 이해와 원리 : 섹션5. 독립 실행형 스프링 애플리케이션 - Dependency Injection & 의존성 오브젝트 DI 적용

2025. 8. 7. 14:53FrameWork/SpringBoot

반응형

Dependency Injection

스프링 프레임워크에서 DI(Dependency Injection) 기능은 IoC 컨테이너를 통해 제공되며, 이 컨테이너를 흔히 Spring IoC 컨테이너 또는 DI 컨테이너라고 부른다.

이 컨테이너는 애플리케이션을 구성하는 객체(Bean)들의 생성과 의존성 주입을 전담한다.
덕분에 개발자는 객체를 직접 생성하거나 연결하는 대신 필요한 의존 객체를 컨테이너로부터 주입받아 사용할 수 있다.

 

예를 들어, 우리가 앞서 만들었던 구조를 보면

               HelloController --------------> SimpleHelloService

와 같이 형성되어 있다.

 

아니 언제 의존 관계를 만들었지? 

싶지만 사실 의존관계는 별게 아니라 HelloController는 SimpleHelloService가 변경이되면 영향을 받는다 

기능이 바뀐다거나 method의 이름을 변경해야한다거나 쓰고 싶은 SimpleHelloService와는 다른 클래스로 전환이 될 수 도 있다.

위 관계는 어떤 클래스의 변화가 다른 클래스의 변화를 주는 기준으로 그려지며 이게 runtime에서 영향을 주기도 하고 어떤 경우에는 소스코드를 고쳐야지만 컴파일 되고 동작할 수 있는 수준의 영향을 주기도 한다.

 

아무튼 이렇게 HelloController가 다른 클래스, 다른 오브젝트의 기능을 사용하게 되면 HelloController는 그 클래스에 의존하고 있다 라고 말할수 있다.

 

이러면 기존에 SimpleHelloService 대신에 다른 Service를 사용해보려고 하는데 이러면 SimpleHelloService를 위해 사용했던 Controller에서의 코드들을 변경해줘야 하는 일들이 생기게 된다.

이렇게 의존관계가 있으면 어떤 변경이 있을 때 마다 코드를 변경해야한다는 부담이 있게 된다.

 

이런것들을 문제를 해결하기 위해서 자바에서 많이 사용되는 소프트웨어 원칙이 있는데 아래 그림과 같이

         HelloController
               │
               ▼
       HelloService (interface)
         ▲             ▲
         │             │
SimpleHelloService  ComplexHelloService

구성이 되고 HelloController는 getSayHi()라는 메서드를 정의해둔 HelloService라는 인터페이스에 의존하고 HelloService를 구현해둔 SimpleHelloService와 ComplexHelloService를 만들어둔다.

 

이렇게 만들어두면 HelloController는 아무리 많이 HelloService를 구현한 구현 클래스들을 만들어도 HelloController의 코드를 수정할 필요가 없어진다.

 

근데 이러면 문제가 해결이 된건 아니다.

우리가 HelloController가 HelloService 인터페이스에만 의존하도록 코드를 바꿨다고 해도 문제가 완전히 해결된 것은 아니다.

그 때는 내가 어떤 클래스의 오브젝트를 사용할지에 대해서 결정이 되어 있어야만 한다.

코드 상으로는 분명 다음과 같이 느슨한 결합(loose coupling) 을 만들어낸다

public class HelloController {
    private final HelloService helloService;

    public HelloController(HelloService helloService) {
        this.helloService = helloService;
    }
}

이제 HelloController는 HelloService 인터페이스에만 의존하므로 직접적으로 어떤 구현체(SimpleHelloService, ComplexHelloService)인지 알 필요는 없다.

 

그러나 문제는 런타임(runtime) 시점이다

아무리 인터페이스에만 의존하더라도, 프로그램이 실제로 동작하려면 HelloService 인터페이스를 구현한 구체적인 클래스 인스턴스가 메모리에 존재해야 하고 그 객체가 HelloController에 주입되어야만 한다.

예를 들어, helloService.sayHi()를 호출한다고 하면 이 메서드는 인터페이스에 정의돼 있을 뿐 실제 실행은 구현 클래스의 코드가 담당한다.

그러면 자연스럽게 “HelloService를 구현한 클래스 중, 도대체 어떤 구현체를 HelloController에 넣어줘야 하지?” 가 떠오른다.

 

이 연결 작업을 해주는 것이 바로 DI (Dependency Injection)이다.

 

"HelloController는 HelloService가 필요하니까 적절한 구현체를 대신 선택해서 넣어줄게!"

 

우리가 흔히 말하는 DI(Dependency Injection, 의존성 주입)는 단순히 어떤 객체가 다른 객체를 사용한다란 개념이 아니라 그 의존 관계를 외부에서 대신 설정해주는 방식을 말한다

 

DI에는 제 3의 존재가 필요하고 이걸 우리는 Assembler라고 부른다.

HelloController는 HelloService를 구현한 어떤 클래스에 의존을 하는데 소스코드 레벨에서는 의존하고 싶지 않고 SimpleHelloService에서 ComplexHelloService로 바꿨다고 해서 소스코드를 바꾸고 싶지는 않다는 이야기다.

예를 들어 아래처럼 HelloController 내부에서 직접 객체를 생성하면

public class HelloController {
    private HelloService helloService = new SimpleHelloService();  // 강한 결합
}

SimpleHelloService를 ComplexHelloService로 바꾸려면 소스코드를 수정해야 하기에 유지보수에 매우 불리하고 유연성이 없다

 

하지만 Runtime에 필요하다면 SimpleHelloService의 오브젝트 대신 ComplexHelloService의 오브젝트를 사용하기로 결정했다면 누군가가 이걸 가능하도록 만들어줘야 하는데 그 방식 HelloController 가 사용한 오브젝트를 직접 new 키워드를 사용해서 만드는 대신에 외부에서 오브젝트를 만들어서 HelloController가 사용할 수 있도록 주입을 해주는 것이다.

즉 HelloController가 직접 객체를 생성하지 않고 외부에서 필요한 구현체(Bean)를 대신 만들어서 주입해주는 것이다.

 

이 객체들을 외부에서 생성하고 서로 연결해주는 역할을 하는 제3의 존재를 Assembler(조립자) 라고 불렀다.

Assembler가 오브젝트를 가져다가 하나의 커다란 레고를 만드는것 처럼 원래는 의존관계가 없는 클래스들의 오브젝트를 가져다가 서로 관계를 연결 시켜주고 서로 사용할 수 있게 해주는 역할을 해주는데 이 구조를 예전에는 자바 코드로 직접 Assembler 클래스를 만들어 구현했지만 스프링에서는 이 역할을 자동으로 해주는 컨테이너, Spring IoC Container가 그 역할을 대신한다.

앞에서 살펴 봤던 ApplicationContext 또는 GenericApplicationContext, AnnotationConfigApplicationContext 등은
모두 의존 객체들을 생성하고, 연결(주입)하고, 관리하는 조립자 역할을 해준다.

 

그니까 스프링 컨테이너는 우리가 전달한 메타정보(어노테이션, 자바 설정 클래스 등)를 가지고 클래스에 singleton 오브젝트를 만들고 이 오브젝트가 사용할 다른 의존 오브젝트가 있다면 그 오브젝트를 주입해주는 작업까지 수행을 해주는 것이다.

 

앞에서 Servlet Container는 servlet object로 만들어서 집어 넣는데 왜 스프링 컨테이너는 메타정보만 받아서 직접 object를 생성할까 이에 대한 이유가 여기에 있다.

 

HelloController만들고 나서 SimpleHelloService의 Object를 사용하기로 했다면 이 Object도 Spring Container가 관리하는 bean으로 등록하고 SimpleHelloService를 사용할 수 있도록 어떤 방식으로 주입을 해주는 것이다.

 

여기서 주입을 해준다는 의미는 이란 간단히 말해 필요한 객체의 레퍼런스를 외부에서 넣어준다는 의미로 우리가 만든 코드에서는 SimpleHelloService의 레퍼런스를 넘겨주는 것이다.

그러면 어디서 넘겨줄까? 

여러가지 방법이 있는데 제일 대표적이고 쉬운 방법이며 권장 되는 방법으로는 생성자 주입(Constructor Injection)으로 HelloController를 만들때 생성자 파라미터로 SimpleHelloService의 Object를 집어 넣어주는것이다.

// 생성자 주입
public class HelloController {
    private final HelloService helloService;

	// 파라미터로 전달받는 객체에 따라 구현체가 달라짐
    public HelloController(HelloService helloService) { 
        this.helloService = helloService;
    }
}

물론 이때 그 파라미터의 타입은 이 클래스가 구현하고 있는 HelloService 라는 인터페이스 타입으로 되어 있다.

( 스프링 부트 4.3+ 부터는 생성자 하나면 @Autowired 생략 가능해졌다)

 

또 다른 방법으로는 팩토리메서드 같은걸로 bean을 만들게 하면서 파라미터를 넘기는 방법으로 팩토리 메서드 주입(@Bean을 통한 자동 주입)이라고 불리우는 방식도(@Configuration을 어노테이션으로 붙이는 스프링 설정 클래스와 같은 개념이 추가되어 나중에 보도록 하자) 있고 또 이전에 많이 사용하던 방법중에서는 HelloController 클래스에 prioperties를 정의해서 setter메서드를 통해 사용해야할 SimpleHelloService를 주입해주는 방법도 있다.

그 밖에 여러가지 방법이 있는데 이런 작업들을 Spring Container가 Object를 만들고 주입해주고 이런 류의 모든 작업을 수행해주는 컨테이너로써 동작을 해준다.

 

스프링 컨테이너를 사용해야하는 가장 중요한 이러한 기능을 해주기 때문이고  지금까지 예시는 단순한 구조였지만 현실의 애플리케이션은 수십, 수백 개의 클래스들이 서로 의존하고 연결되어 동작 하기에 복잡하고 규모가 커질수록 직접 객체를 만들고 연결하는 방식은 유지보수가 불가능해지기 때문이다.

 

의존성 오브젝트 DI 적용

현재 코드는 HelloController가 SimpleHelloService라는 클래스의 오브젝트를 직접 생성해서 사용하는 방식이였는데 이제 스프링의 bean으로 등록하고 스프링 컨테이너가 Assembler로써 DI, 즉 SimpleHelloService bean의 오브젝트를 HelloController가 새용할 수 있도록 주입해주는 작업까지 추가해보도록 하자.

 

기존에 SimpleHelloService는 인터페이스를 통해서 구현하는 방식을 사용하고 있진 않았다.

 

이번에는 인터페이스를 만들고 이 인터페이스를 구현하는 것으로 getSayHi 메서드를 오버라이딩 하여 구현하는 것으로 구현을 해보자.

이렇게 기존에 클래스에 존재하던 메서드를 인터페이스를 새로 만들어서 그 안에 정의하고 그걸 구현하는 방식으로 변경하는 것은 인텔리제이의 리팩토링 기능을 사용하면 편리하다.

 

함수명을 우클릭 > Refactor > Extract Interface...

그리고 이름을 변경해준 다음에 아래에 인터페이스에 넣을 메서드를 선택해주고 Refactor를 눌러주면 

자동으로 해당 메서드를 갖고 있는 클래스가 그 인터페이스를 구현한다고 변경되고

아래와 같이 인터페이스가 생성된다.

 

이젠 특정 클래스를 직접 인스턴스를 만드는 방식으로 사용하는게 아니라 스프링 컨테이너 HelloController 클래스의 오브젝트를 만들때 생성자 파라미터로 주입할 수 있도록 만들어 줘야 한다.

 

그래서 직접 오브젝트를 선언하는 부분은 지우고 

 

이제 주입 받을 클래스의 오브젝트는 변수에 저장을 해둬야만 그 이후에 HelloController에서 계속 사용할 수 있기 때문에 인터페이스 타입으로 변수를 하나 전역으로 생성해두자.

그리고 이건 해당 클래스 내부에서만 사용될 것으로 외부에 공개될 필요가 없으니 private로 접근제한자를 설정해주자.

추가로 해당 변수는 재할당 될일도 될 필요도 없기에 재할당 되지 않도록 하기 위해서 final키워드 까지 붙여주자.

여기서 붉은 밑줄로 경고가 나오는 이유는 

위와 같이 클래스 내부에서 final 키워드를 붙인 static이 붙지않은 전역 변수의 경우는 생성 시점에 초기화를 하거나 생성자에서 초기화를 해줘야만 하기 때문이다.

 

인텔리제이라면 빨간줄이 떠있는 곳에 커서를 올리고 

F2를 누르면

위처럼 경고가 뜨고 여기서 alt+enter를 눌러주면 

이렇게 어떤 대처를 자동으로 해줄까 라고 하면서 화면을 보여준다

 

더 단순하게는 빨간줄 위에 커서를 올리고 alt+enter만 해도 해당 자동 완성 도구를 보여준다.

 

여기서 Add constructor parameter를 눌러 생성자를 생성해주자.

 

그러면 이전에 스프링 컨테이너한테 HelloController 클래스의 정보를 전달하면서 해당 클래스를 bean으로 등록해주세요라고 했었는데 

이번엔 HelloService타입의 클래스도 결국 bean으로 등록해줘야만 컨테이너가 이 클래스를 인식하고, 오브젝트로 만들고 필요하다면 다른 오브젝트를 만들때 생성자로 주입을 해서 의존 관계를 runtime에 맺어줄 수 있게 된다.

 

그래서 위에 bean을 등록하는 코드를 복사하여 HelloService클래스의 메타 데이터를 전달해주자.

인터페이스를 전달하면 안됨...!

그런데 이렇게만 넣으면 안된다.

왜냐면 이건 인터페이스지 클래스가 아니기 때문에 스프링이 구성정보를 만들때는 정확하게 어떤 클래스를 가지고 bean을 만들것인지에 대해서 지정을 해줘야 하기 때문에 클래스 타입을 넣어줘야한다.

쉽게 말해 구현체가 될 클래스를 전달해줘야한다는 것이다.

 

그런데 SimpleHelloService는 HelloService인터페이스 타입이고 이걸 HelloController의 생성자로 받아서 사용을 하게 하면 되는게 이걸 registerBean을 통해서 오브젝트를 만들어 bean을 만들어 getBean으로 돌려준다는건 전에 봤었는데 SimpleHelloService bean을 HelloController에 주입을 해주는 부분은 어떻게 스프링 컨테이너가 알 수 있을까?

초창기에는 스프링에서 이런 것들을 XML을 통해서 bean을 정의 할 때 class 명도 지정하고 생성자에 어떤 bean을 주입할지에 대한 정보도 다 작성을 해줬어야 했었다.

 

근데 요즘 개발 추세는 많은 부분을 생략하고 관례와 몇가지 룰만으로 알아서 자동으로 해주는 것을 요구하기에 simple해진 것이다.

 

스프링 컨테이너는 HelloController를 오브젝트로 만들때 생성자를 호출해야하는데 생성자에 주입해야할 파라미터의 타입이

HelloService 인터페이스 타입임을 확인한 후에 컨테이너에 bean 등록 정보를 모두 조회해서 해당 인터페이스를 구현하고 있는 구현한 클래스가 있는지 찾는다.

그때 SimpleHelloService가 bean으로 등록되어 있기에 해당 클래스가 HelloController의 생성자에 주입(매개변수 타입이 HelloService라)할 수 있겠다고 인식하면서 해당 bean을 생성자로 전달하면서 HelloController bean을 생성해주는 과정을 거친다.

 

이렇게 까지하면 DI까지 완성된 것으로

이 부분만 helloService변수로 변경해준 다음에

서버를 켜서보면 

잘 되는 것을 볼 수 있다.

 

반응형