스프링(Spring)은 참 기능이 많은 프레임워크입니다. 공부를 하면 할수록 정말 개발에 조금이라도 필요하겠다 싶은 기능은 다 있다는 걸 느끼게 됩니다. 어떤 특정 코드가 한 줄 씩이라도 반복되면 그걸 줄이는 기능이 반드시 있을 정도입니다. 이런 스프링의 핵심이 되는 기술, Core Technologies(핵심 기술)라고 불리는 기술들이 있으며 다른 기능들은 이러한 핵심 기술 위에 구현이 되어있습니다. 이번에는 그 중에서도 중요한 IoC(Inversion of Control) Container와 Bean, 그리고 DI(Dependency Injection) 등의 개념에 대해 간단하게 정리해보겠습니다.
📢 해당 글은 IoC Container와 Bean의 자세한 코드 구현이나 기능은 담고 있지 않습니다. 대신 기능의 의도와 동작 원리에 초점을 맞추고 있습니다.
도대체 어디서부터 이야기를 시작하면 좋을지 많은 고민을 해본 결과 객체 지향(Object Oriented)의 개념을 잠시 다시 살펴보는게 좋을 것 같다는 생각이 들었습니다. 스프링을 선택하셨다면 자바(혹은 코틀린)을 선택하셨다는 것이고, 이는 객체 지향의 개념과 이점을 최대화하기 위한 기술을 택했다는 뜻이기 때문이며, 스프링 프레임워크가 곧 이러한 객체 지향적인 관점의 설계 원칙을 따르고 있기 때문입니다.
제가 다형성(Polymorphism)에 대해 들었던 설명 중 특히 기억에 남는 설명이 "변수 타입은 기능을, 객체 타입은 그 구현을 말한다." 라는 것입니다.
좀 더 자세히 얘기하면, "변수 타입은 무슨 메서드를 부를 수 있는지, 객체 타입은 불렀을 때 무슨 일이 일어나는지 말한다"라고 합니다. 이게 무슨 뜻이냐면, 객체 지향 프로그래밍에서 새로운 객체의 인스턴스를 생성할 때, 저 코드 한 줄을 가진 객체는 새로 만드는 객체의 기능과 그 기능이 어떻게 구현되어 있는지를 알고 있다는 뜻입니다.
객체 지향 프로그래밍과 디자인 패턴에 대해 처음 공부할 때 많이 헷갈렸던 부분이 바로 이 부분인데, 처음에는 잘 와닿지 않아 "알면 아는거지 그게 뭐 어쩌라고?" 생각을 했었습니다. 객체 지향 설계와 디자인 패턴은 모두 더 나은 소프트웨어를 만드는데 그 목적을 두고 있고, 그 목적을 위한 가장 중요한 수단 중 하나가 변화에 강한 코드를 쓰는 것입니다.
다시 '알고 있다'로 넘어가서, 위 예시와 같은 코드가 있다는 것은, 만약 공연으로 비유하자면 공연의 감독이 어떤 역할에 필요한 배우를 직접 뽑았다는 말과 비슷하다고 볼 수 있겠습니다.
어느 쪽이든 기능을 사용하는 쪽 코드를 클라이언트라고 부르는데, 클라이언트가 정확히 콕 집어서 내게 필요한 기능을 누가 어떻게 구현할 것인지까지 알고 있는 것이라고 할 수 있습니다. 클라이언트가 어떤 무언가를 알고 있다는 것을 의존한다 혹은 의존성(Dependency)을 가진다라고 표현합니다. 위 예시에서 감독은 현재 역할이라는 기능과, 어느 배우라는 그 구현 모두에 의존하고 있습니다.
그런데 이때 감독이 뽑은 배우가 아파서 몸져 눕는다면 어떨까요? 혹은 역할은 있는데 뽑을 사람이 아직 없다면? 배우를 교체해야 한다면?
소프트웨어 세상에서 변화는 필연적입니다. 요구사항이 항상 바뀌고, 기능이 추가되고, 테스트가 필요합니다. 그렇게되면 기존의 구현체를 다른 구현체로 바꾸거나 해야하는 상황이 반드시 오게 마련입니다. 설상가상으로 더 현실적으로 표현하자면, 저 동일한 기능이 수백군데서 호출되고 있고, 모든 호출이 저 한 구현체에 의존하고 있었다면, 수백군데를 고쳐야하는 상황이 생기게 됩니다.
이런 상황의 해결법은 클라이언트로 하여금 구현체를 모르게하는 것이며, 이를 위한 하나의 방법(패턴)이 제어의 역전(Inversion of Control)입니다.
제어의 역전(Inversion of Control, IoC)란 객체의 생성과 실행 흐름의 제어 권한을 개발자(클라이언트)가 아닌 프레임워크나 컨테이너에 맡기는 것을 의미합니다.
"inversion of control" refers to granting the framework control over the implementations of dependencies that are used by application objects
출처 - 위키피디아
클라이언트는 구현체가 아닌 기능(인터페이스)에만 의존하게 하여, 자신이 구현해야하는 비즈니스만 작성하도록 합니다(이는 또 SOLID 원칙에서 DIP에 해당합니다). 대신 해당 구현체가 어떤 구현체인지에 대한 관리를 외부에서 하도록 합니다.
감독은 계속해서 연극을 진행시키고, 특정 역할을 이 배우가 할지, 저 배우가 할지, 연습을 위해 임시 배우가 할지 등의 선택은 캐스팅 매니저가 한다고 생각할 수 있겠습니다.
이제 클라이언트가 몇 개든, 필요한 기능에 대한 구현체가 바뀌어도(변화가 일어나도) 클라이언트 코드를 수정하지 않아도 되게 되었습니다. 이러한 코드를 (비교적 더) 변화에 강한 코드라고 합니다.
방법(pattern)은 알았으니 이제 실제 구현을 해볼 시간입니다. IoC를 구현하는 방법(technique)들에는 여러가지가 있으나, 스프링에서 주로 사용되는 방식이자 가장 널리 쓰이는 방식은 의존성 주입(Dependency Injection)입니다.
우선 의존성 주입을 적용하기 전의 코드를 보겠습니다.
class Service {
void execute() {
System.out.println("Service executed");
}
}
class Client {
private final Service service = new Service(); // 직접 객체 생성 (강한 결합)
void run() {
service.execute();
}
}
public class Main {
public static void main(String[] args) {
Client client = new Client();
client.run();
}
}
위 코드를 보면 Service
가 제공하는 기능을 사용하기 위해 Client
는 Service
의 인스턴스를 직접 만들어 사용하고 있습니다. 앞서 말했다시피 이는 이후 변경이 힘들게 만들며, 강하게 결합되어 있다고도 표현합니다.
interface Service {
void execute();
}
class MyService implements Service {
public void execute() {
System.out.println("Service executed");
}
}
class Client {
private final Service service;
// 생성자를 통해 의존성 주입 (느슨한 결합)
public Client(Service service) {
this.service = service;
}
void run() {
service.execute();
}
}
public class Main {
public static void main(String[] args) {
Service service = new MyService();
Client client = new Client(service); // 외부에서 객체를 주입
client.run();
}
}
이제 Client
는 구체 클래스가 아니라 인터페이스에 의존하며, 인스턴스를 직접 생성하지 않습니다. 대신 생성자를 통해 자신이 이용할 Service
가 어떤 인스턴스인지 주입(inject) 받고 있습니다. Client
는 이제 어떤 구현체를 사용하는지 모르는 상태가 되며, Service
라는 인터페이스를 구현하고 있는 구현체라면 어떤 것이든 받아서 사용할 수 있습니다. 우리는 단순이 Client
를 생성할 때, 사용할 구현체를 생성자에 넣어주기만 하면 됩니다.
아직 유용성이 와닿지 않으시다면, 여기 코드로 다 표현하기는 무리가 있으므로, 첫번째 경우에서 Client
가 100개 가량 있고, Service
구현체는 아직 구현이 안 됐는데 테스트를 해야하는 상황이라, TestService
를 만들어 임시로 넣어놓았다고 한번 상상해보세요. 그런 다음 Service
의 개발이 완료되어 TestService
를 Service
로 교체해야 한다고 상상해보시면 좋을 것 같습니다.
위는 생성자를 통한 의존성 주입을 예로 들었습니다. 이 외에도 필드 주입과 Setter 주입 등이 있으나 이에 대한 설명은 생략하겠습니다. 생성자 주입은 다른 방식과 비교해서 불변성 유지와 순환 참조 방지 및 테스트 시 용이하다는 장점 덕분에 다른 방식에 비해 가장 흔하게 쓰입니다.
여기까지 오고나면 스프링의 IoC 컨테이너에 대해서도 예측이 가시리라 생각됩니다.
IoC 컨테이너(Inversion of Control Container)는 객체의 생성 및 생명 주기를 관리하고, 의존성을 자동으로 주입(DI) 해주는 Spring의 핵심 컴포넌트입니다.
이전까지 살펴본 제어의 역전 패턴에서 클라이언트로부터 역전받은 객체의 관리, 즉, 배우 캐스팅을 담당하고 필요한 위치에 주입해주는 것이 IoC 컨테이너입니다.
공식적인 설명으로는 스프링 Bean을 관리하는 주체라고 이야기합니다. IoC 컨테이너는 우리가 이전에 작성한 것처럼 비즈니스 로직이 담겨있는 자바 객체(Plain Old Java Obejct)와 Configuration Metadata라고 하는 것을 통해 Bean이라는 것을 만듭니다.
스프링부트 프로젝트를 실행시켜본 분들이라면 아래와 같은 main 메서드가 선언되어 있는 프로젝트 메인 클래스를 보신적 있으실 겁니다.
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
하지만 특수한 경우를 제외하고는 이곳에서 어떤 코드를 더 쓰는 일이 잘 없으셨을 겁니다. 이는 제가 위에 보인 DI 예시 코드와 차이가 있습니다. 이전에는 제가 직접 main 메서드에서 의존성 주입을 하고 객체를 생성해주었기 때문입니다. 반대로 여기서는 SpringApplication.run()
하나만 호출해주고 있습니다. 저 run()
의 코드를 다 보여드리긴 좀 그렇기 때문에 일부만 보여드리겠습니다. 바로 아래 코드가 나오진 않고 조금 타고 올라가다보면 다음과 나옵니다.
/**
* Run the Spring application, creating and refreshing a new
* {@link ApplicationContext}.
* @param args the application arguments (usually passed from a Java main method)
* @return a running {@link ApplicationContext}
*/
public ConfigurableApplicationContext run(String... args) {
// ... 생략 ...
context = createApplicationContext();
context.setApplicationStartup(this.applicationStartup);
prepareContext(bootstrapContext, context, environment, listeners, applicationArguments, printedBanner);
refreshContext(context);
// ... 생략 ...
return context;
}
코드를 살펴보면 시작과 동시에 ApplicationContext
라는 것을 생성하는 것을 알 수 있습니다. 이 ApplicationContext
가 바로 스프링의 IoC Container입니다. IoC Container는 구성 메타데이터가 설정되어있는 파일을 직접 확인하고 해당 설정에 따라 Bean을 생성해 관리합니다. 이제 그럼 Bean에 대해서 좀 더 자세히 이야기해보겠습니다.
🫘 스프링 빈(Bean)은 스프링 IoC Container가 관리하는 객체를 의미합니다.
Bean을 생성을 위해 구성 파일을 등록하는 방법은 3가지가 있습니다.
@beans.xml
)@Configuration
)@ComponentScan
)하지만 여기서는 프레임워크 없이하는 코드와 빠른 비교를 위해 어노테이션 기반 방식만 살펴보겠습니다.
interface Service {
void execute();
}
@Component // 자동으로 Bean 등록
class MyService implements Service {
public void execute() {
System.out.println("Service executed");
}
}
@Component
class Client {
private final Service service;
@Autowired // 생성자 주입
public Client(Service service) {
this.service = service;
}
public void run() {
service.execute();
}
}
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
우선 기존처럼 main 메서드에서 Client에 의존성 주입을 하지 않고, @Component
라는 어노테이션이라 부르는 문법이 각 클래스 위에 붙었습니다. main 메서드가 실행되고 스프링 애플리케이션이 시작되면 앞서 언급한 IoC 컨테이너인 ApplicationContext
가 생성되고, 이것이 프로젝트 내부(기본적으로 DemoApplication 클래스가 속한 패키지와 그 하위 패키지)에서 @Component
붙은 클래스를 모두 찾습니다.
이렇게 해서 찾은 클래스를 Client
에서 Service
를 필요로 하는 것처럼 필요한 곳에 주입해주기 위해 Bean으로 생성하는데 이 과정을 컴포넌트 스캔(Component Scan)이라고 합니다. 이렇게 Bean으로 생성된 클래스는 @Autowired
가 붙어있는 생성자에 ApplicationContext
에 의해 주입됩니다.
추가적을 이러한 Bean은 싱글톤(Singleton)이라는 패턴이 적용되어 하나의 인스턴스만 메모리에 적재되어 매번 같은 인스턴스가 반환되도록 구현되어 있습니다. 클래스의 반복되는 사용을 위해 매번 새롭게 인스턴스를 생성하지 않고 하나의 인스턴스를 돌려쓰도록 하므로써 보다 성능적으로 최적화되도록 하기 위함입니다.
이렇게 빠르고 가볍게(?) 스프링 IoC 컨테이너와 Bean의 개념에 대해서 소개해보았습니다. 전체적인 흐름과 쓰임새를 설명하는데 집중하기 위해 세부적인 구현을 많이 생략했는데, 어떻게 느낌이라도 전달이 되셨으면 합니다ㅎㅎㅎ. 사실 AOP에 대해 다뤄보려다가 Bean에 대한 내용을 짚고 넘어가야 할 것 같아서 주제를 바꾸게 되었습니다. 다음에는 스프링 핵심 기능 중 다른 하나인 AOP에 대해 정리해보려고 합니다(바뀔 수 있습니다).
🙇♂️ 잘못된 내용이 있다면 언제든 알려주시면 감사합니다!