스프링 IoC와 DI
스프링프레임워크의 제일 핵심이자 기초인 IoC와 DI에 대해 알아보고 다양한 방식으로 DI를 테스트해보자
IoC와 DI에 대해 알아보기 전에 우선 스프링 프레임워크(Spring Framework)의 구조에 대해 살펴보자
위의 그림은 스프링 프레임워크를 구성하는 모듈을 나타낸 것이다. 스프링 프레임워크는 약 20개의 모듈로 구성되어 있으며, 필요한 모듈을 가져다 사용하는 구조이다.
이를 좀 더 간단한 구조로 표현하면,
이렇게 총 6개의 주요 모듈로 나눌 수 있다. JEE를 제외한 각각의 모듈에 대한 대략적인 설명은 아래와 같다.
- Core : IoC와 DI 기능을 제공한다.
- DAO : 이름에서 알 수 있듯, JDBC 추상계층을 제공한다.
- ORM : JPA와 같은 ORM API를 적용할 수 있는 기능을 제공한다.
- AOP : AOP(Aspect Oriented Programming)는 관점지향프로그래밍을 말하며, 해당 모듈에서 이를 위한 기능을 제공한다. 즉, 어떤 로직을 핵심 관점과 부가적 관점을 나눈 다음 이를 기준으로 모듈화하는 것이다.
- Web : Spring Web MVC와 같이 웹 어플리케이션의 구현에 도움되는 기능을 제공한다. 대표적으로 서블릿, JSP 관련 기능이 여기에 속한다.
이번 포스팅에서 다룰 IoC와 DI는 바로 Core 모듈에서 제공된다. 그만큼 스프링 프레임워크의 핵심인 것이다. Ioc와 DI가 무엇인지 순서대로 살펴보도록 하자.
1. IoC(Inversion of Control / 제어의 역전)
스프링을 쓰기 이전까지 자바 기반의 개발은 개발자가 프로그램의 흐름을 직접 제어하는 과정의 연속이었다. 좀 더 쉽게 얘기하면 코드를 직접 개발자가 작성하고, 이를 통해 클래스의 인스턴스(ex. new를 통한 선언)를 선언하는 등의 과정이 모두 직접 수행된 것이다. 좀 더 있어보이게 얘기하자면, 이를 객체의 생성주기를 개발자가 관리한다고 표현할 수 있다.
하지만 스프링을 사용하게 되면 프로그램의 흐름을 스프링 프레임워크가 주도하게 된다. 따라서 객체의 생성주기를 프레임워크가 관리하기 떄문에 이를 두고 '제어의 역전'(Inversion of Control)이라고 표현하는 것이다.
이렇게 넘어온 객체의 생명주기는 컨테이너(Container)라고 불리는 곳에서 관리된다. 컨테이너에 의한 객체 관리가 처음 들으면 매우 생소할 것이다. 위의 그림에서 알 수 있는 서블릿 컨테이너(ex. 톰캣)가 서블릿의 생명주기를 관리해주는 것이 바로 대표적인 컨테이너에 의한 관리의 예이다.
(2020/12/12 - [네트워크&서버] - 서버와 클라이언트, 웹서버 참고)
IoC에 따라 스프링 프레임워크 하에서 객체가 만들어지고 실행되는 과정은 아래와 같다.
- 최초 객체의 생성
- 의존성 객체의 주입 -> 제어권이 프레임워크에 위임되어 프레임워크에서 생성된 객체가 주입되는 것
- 의존성 객체 메소드의 호출
그렇다면 의존성 객체가 어떻게 주입되는지 살펴보도록 하자.
2. DI(Dependency Injection / 의존성 주입)
DI는 Dependency Injection의 줄임말로 말 그대로 '의존성 주입'으로 이해할 수 있다. IoC에서 언급했듯 스프링 프레임워크는 매번 new를 통해 개발자가 인스턴스를 생성하지 않고, 의존성 객체라는 프레임워크에 의해 생성된 객체를 주입시키는 방식을 취한다.
1) 의존성의 의미?
DI에 대해 본격적으로 논하기 전에, '의존'이라는 단어의 대해서 살펴보도록 하자. 보통 '의존'이라고 하면, 한 클래스가 다른 클래스의 메소드를 실행하는 것을 두고 두 객체간에 의존 관계가 존재한다고 한다. 아래의 코드를 통해 살펴보자.
Class School{
Classroom classroom;
Student student;
Teacher teacher;
}
위와 같이 School이라는 클래스의 인스턴스로 Classroom, Student, Teacher이라는 인스턴스가 존재한다고 가정해보자. 여기서 School이라는 클래스가 온전하게 제 기능을 하기 위해서는, 인스턴스로 두고 있는 세개의 클래스 모두가 각자의 속성과 메소드를 갖춰야 할 것이다.
이로 인해 School 클래스와 Classroom, Student, Teacher 클래스들은 서로 '의존관계'에 있다고 할 수 있는 것이다.
그렇다면 여기서 의존성을 주입하는 것은 어떻게 이루어지는지 살펴보자.
Class School{
Classroom classroom;
Student student;
Teacher teacher;
public School(){
this.classroom = new Classroom();
this.student = new Student();
this.teacher = new Teacher();
}
}
인스턴스 변수를 필드에 선언한 후, 이를 초기화하기 위한 생성자를 선언하는 방식이다. 이런 식으로 인스턴스 변수에 객체를 할당하는 것이 의존관계 형성의 대표적인 예이다.
하지만 이러한 의존성의 주입은 다음과 같은 문제를 지닌다.
- 인스턴스 변수에 new를 통해 객체를 할당하면서 두 객체 간에는 긴밀한 결합(tight coupling)이 생긴다.
- 하지만 이러한 긴밀한 결합으로 인해, 만일 하나의 인스턴스에 변화가 생길 경우에는 의존 관계에 있는 다른 클래스의 속성에도 변화가 생긴다.
- 따라서 하나의 모듈이 바뀌면, 다른 모듈까지 다 변경해야 하기 때문에 이로 인해 코드의 유지/보수가 어려워진다.
이러한 문제점을 극복하려면, 외부로부터 생성된 객체를 받음으로써 객체 간의 긴밀한 결합을 줄이면 된다.(느슨한 결합)
Class School{
Classroom classroom;
Student student;
Teacher teacher;
public School(Classroom classroom, Student student, Teacher teacher){
this.classroom = classroom;
this.student = student;
this.teacher = teacher;
}
}
앞서 살펴본 코드를 위의 코드로 바꿔보았다. 여기서는 객체 내에서 새로운 객체를 생성하지 않고, 외부에서 생성된 객체를 생성자의 매개변수로 주입받았다.
(이 외에도 setter메소드를 통해 외부 객체를 주입할 수도 있다.)
2) 스프링에서의 의존성 주입
앞서 객체 간의 의존성에 대해 살펴보았다. IoC도 결국 스프링 프레임워크에서 느슨한 결합을 통해 DI를 실현하는 방식이라 볼 수 있다. 다시 말해, IoC를 DI를 통해 실현하는 것이다.
(말장난 같지만 그만큼 둘은 서로 밀접한 개념이라고 이해하자)
스프링에서의 DI는 외부로부터 생성된 객체를 주입하는 과정을 IoC에 따라 개발자가 아닌 프레임워크게 대신 처리해준다.(마지막으로 살펴본 코드도 결국 생성자나 setter 메소드를 직접 선언하여 의존성을 주입시키는 방식이다. )
개발자는 사전 설정을 위한 xml 파일이나 @ 어노테이션을 통해 미리 설정해두면 나머지 과정은 컨테이너가 알아서 처리하게 된다.
스프링에서 의존성을 주입하는 방식은 세 가지로 나눌 수 있다.
- Constructor Injection : 생성자를 통한 주입
- Setter Injection 혹은 Method Injection : setter 메소드를 통한 주입
- Field Injection : 필드상의 인스턴스 변수를 통한 주입
이 중 스프링에서 권장하는 방식은 생성자를 통한 주입방식이다. 필드와 setter 메소드를 통한 주입방식은 보통 Bean을 생성한 후에, 어노테이션이 붙은 필드를 보고 이에 해당 Bean을 찾아서 주입해주는 방식인 반면, 생성자 주입 방식은 객체를 생성하는 시점에 Bean을 주입한다.(즉 Bean을 먼저 생성하는 것이 아니다.)
이러한 차이는 순환 참조라는 잘못된 객체 설계방식을 방지하는 데 있어서 효과적이다. 순환참조는 쉽게 말하면 객체 A가 B의 인스턴스이면서, 동시에 B가 A의 인스턴스이기도 하는 구조를 말하는데 객체 지향 프로그래밍에 있어서 지양된다.
결론부터 이야기하자면, 순환참조가 일어날 경우 필드 주입방식에서는 StackOverFlowError과 같은 예외가 발생하더라도 어플리케이션이 정상적으로 구동되지만, 생성자 주입 방식에서는 BeanCurrentlyInCreationException과 같은 예외가 발생하면서 어플리케이션이 구동되지 않는다.
따라서 이러한 맥락에서 순환 참조로 인한 문제를 사전에 방지하기 위해서는 문제가 발생하면 바로 동작을 멈춰버리는 생성자 주입방식이 더욱 권장된다 할 수 있다.
3. 코드로 DI 직접 구현해보기
아래의 세 가지 방식을 모두 살펴볼 것이다.
- xml 파일에 Bean 등록
- java config 클래스 활용
- @Component 어노테이션 활용
1) xml 파일 통해 DI 테스트해보기
편의상 Maven 프로젝트로 진행하였다.
먼저 컨테이너가 만들어줄 Bean 클래스를 생성하도록 하자.
public class UserBean {
private String name;
private int age;
public UserBean() {
} //기본생성자
public UserBean(String name, int age) {
super();
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
예시로 UserBean이라는 클래스를 선언하였다. Bean 클래스는 아래의 3가지 규칙을 지켜서 선언해야 한다.
- 기본 생성자를 포함한다.
- 필드는 private로 선언한다.
- getter/setter 메소드를 선언한다.(외부에서 필드에 직접적으로 접근하는 것을 방지)
Bean 클래스가 완성되었다면, 해당 클래스를 스프링 컨테이너가 Bean으로 인식하고 필요시 만들어낼 수 있도록 사전설정을 해주도록 한다.(모든게 다 자동화된다고 착각하지 말자)
이를 위해 우선 pom.xml에 아래와 같이 컨테이너 사용을 위한 스프링 dependency를 추가한다.
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
<!-- properties에서 4.3.14로 버전 지정해놓고 쓰는 것 -->
</dependency>
(spring.version의 경우 본문에서는 언급하지 않았지만 <properties>에서 지정한 스프링 버전을 의미한다. 위에서 한 번 설정해두면, 아래에 버전이 바뀔때마다 아래의 스프링 관련 dependency들의 설정을 일일히 바꿔야 할 필요가 없다. )
컨테이너 사용을 위한 라이브러리 추가가 끝났다면, 다음에는 컨테이너에 UserBean의 정보를 알려야 한다. 이를 위해 별도로 applicationContext.xml를 만들어 아래와 같이 Bean을 등록한다.
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<!-- 스프링 컨테이너에 빈 객체 생성시 어떻게 생성할 지 정보를 미리 알려주기 -->
<bean id="userBean" class="com.junu.spring.diexam.UserBean"></bean> <!-- Bean 정보 등록해서, 태그에 있는 아이디만 가지고 자동으로 class 속성값으로 지정한 객체를 생성할 수 있음 -->
</beans>
<bean> 태그 내의 id는 객체 생성시 사용될 변수명, class는 실제 사용할 클래스를 각각 의미한다고 보면 된다. 컨테이너는 싱글톤(Singleton) 패턴에 따라 이런 식으로 클래스 하나당 Bean 객체를 하나씩 생성해서 보유한다.
설정이 완료되었다면, 이제는 설정을 읽어들일 객체를 생성해야 한다.
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;
public class ApplicationContextExam01 {
public static void main(String[] args) {
ApplicationContext ac = new ClassPathXmlApplicationContext("classpath:applicationContext.xml"); //classpath로 context파일 정보 매개변수로 주는 것
UserBean userBean = (UserBean)ac.getBean("userBean");
UserBean userBean2 = (UserBean)ac.getBean("userBean");
}
우선 컨테이너로 기능할 ApplicationContext 클래스를 선언하였다. BeanFactory 클래스로도 선언할 수 있지만, 보통은 IoC/DI외에도 다른 여러 기능을 포함하는 ApplicationContext 클래스를 선언하는 것이 일반적이다. 이후 할당할 객체의 매개변수에는 기존에 만든 설정파일명인 applicationContext.xml을 넣어 해당 파일의 Bean 설정을 읽어들이도록 한다.
(ApplicationContext는 인터페이스이기 때문에, 이를 구현하기 위한 구현체의 종류는 다양하다. 여기서는 그중 하나로 ClassPathXmlApplicationContext를 사용하였다.)
이후, UserBean 객체의 인스턴스로 userBean, userBean2를 각각 생성하였는데(형변환 주의) 위에서 설명한대로 getBean() 메소드를 통해 의존성 객체를 주입받았음을 알 수 있다.
(따라서 ==으로 동일성 검증을 해보면 두 객체는 같은 인스턴스이기 때문에 true를 반환하게 된다.)
2) config 파일을 통해 DI 테스트해보기
여기서는 xml 파일을 통해 Bean 객체를 관리하는 것을, config파일과 어노테이션(@)을 통해 관리하는 방식을 살펴보도록 하자. 참고로 어노테이션의 적용은 JDK1.5 버전 이상에서 사용 가능하다.
먼저 Bean으로 사용할 Car, Engine 클래스를 아래와 같이 생성하였다.
public class Car {
private Engine v8;
public Car() {
System.out.println("Car 기본 생성자");
}
public void setEngine(Engine e) {
this.v8 = e;
}
public void run() {
System.out.println("엔진을 이용하여 달립니다.");
v8.exec();
}
}
public class Engine {
public Engine() {
System.out.println("Engine 기본 생성자");
}
public void exec() {
System.out.println("엔진 동작");
}
}
이후에는 ApplicationContext 구현체가 읽어들일 자바 Config 클래스를 생성해준다.
@Configuration //해당 어노테이션을 읽고 해당 클래스가 config 파일임을 인지(스프링 설정 클래스)
public class ApplicationConfig {
/*
* @Bean 어노테이션이 붙은 메소드(생성자)를 실행해서,
* 리턴값으로 받은 객체들을 자동으로 싱글톤으로 관리해줌
* pom.xml에서 일일히 등록하는 것보다 더욱 편리!!
*
* */
@Bean
public Car car(Engine e) { //클래스명과 실행파일에서의 getBean()의 매개변수를 서로 맞춰주도록 함
Car c = new Car();
//c.setEngine(e);
return c;
}
@Bean
public Engine engine() {
return new Engine();
}
}
위의 코드에서 @Configuration 어노테이션은 해당 클래스가 컨테이너로 기능할 것임을 나타낸다. 필드에 선언된 @Bean 어노테이션은 가리키는 클래스가컨테이너에 의해 관리되는 의존성 객체임을 선언하는 역할을 한다.
이후 실행 메소드에서 컨테이너를 생성해서 Bean을 주입받도록 해보자.
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ApplicationContextExam03 {
public static void main(String[] args) {
ApplicationContext ac = new AnnotationConfigApplicationContext(ApplicationConfig.class);
Car car = (Car)ac.getBean("car");
car.run();
}
}
이번에는 컨테이너의 구현체로 AnnotationConfigApplicationContext클래스를 사용하였고, 매개변수로 앞서 config 파일로 지정한 ApplicationConfig 클래스를 넣어주었다.
AnnotationConfigApplicationContext는 config 파일을 읽어 IoC와 DI를 적용하며, 여기서 컨테이너 클래스 내에서 @Bean 어노테이션이 붙은 메소드들이 리턴하는 객체(Bean)들을 싱글톤으로 관리하게 된다.
위의 코드에서 getBean() 메소드의 매개변수로 넣은 car과 일치하는 @Bean이 붙은 메소드를 찾아, 해당 메소드가 리턴하는 객체가 주입받은 의존성 객체가 될 것이다.
한편, (Car)ac.getBean("car") 외에도, 아래와 같이 매개변수로 클래스 타입을 지정해서 Bean을 찾도록 할 수도 있다.
Car car = ac.getBean(Car.class);
3) @ComponentScan, @Component 활용하기
마지막으로 @ComponentScan 어노테이션을 통해 DI를 테스트해보도록 하자.
우선 이 방식 역시 자바 Config를 활용하기 때문에 ApplicationContext의 구현체로 쓰일 Config 클래스를 생성하도록 한다. @Configuration 어노테이션과 함께 컴포넌트 스캔을 하겠다는 의미로 @ComponentScan 어노테이션을 같이 명시해주도록 한다.
import org.springframework.context.annotation.*;
@Configuration
@ComponentScan("kr.or.connect.diexam01") //컴포넌트 스캔을 진행할 패키지명을 명시
public class ApplicationConfig2 {
}
이 때, @ComponentScan의 ()안에는 스캔을 진행할 패키지명을 반드시 명시해야 한다.(이 역시 스프링이 정말로 모든걸 자동화한다고 착각하지 말아야 하는 부분이다.)
Config클래스 설정이 끝났다면 이번에는 기존에 만들어둔 Car과 Engine 클래스에 스캔할 Component 대상이라는 의미로 @Component 어노테이션을 아래와 같이 명시해주도록 한다.
@Component
public class Car {
@Autowired
private Engine v8;
public Car() {
System.out.println("Car 기본 생성자");
}
public void setEngine(Engine e) {
this.v8 = e;
}
public void run() {
System.out.println("엔진을 이용하여 달립니다.");
v8.exec();
}
}
@Component
public class Engine {
public Engine() {
System.out.println("Engine 기본 생성자");
}
public void exec() {
System.out.println("엔진 동작");
}
}
Car 객체의 경우 내부의 Engine 타입의 인스턴스를 가지고 있기 때문에, @Autowired 어노테이션을 명시해주면 컨테이너가 자동으로 해당 인스턴스에 Engine타입의 Bean 객체를 주입해준다.
(따라서 이 경우 아래의 setEngine 메소드는 사실 굳이 필요하지 않게 된다.)
설정이 끝났으면 다른 방식과 마찬가지로 컨테이너 생성 후 Bean을 주입받아 메소드를 호출해보도록 한다.
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ApplicationContextExam04 {
public static void main(String[] args) {
ApplicationContext ac = new AnnotationConfigApplicationContext(ApplicationConfig2.class);
Car car = ac.getBean(Car.class);
car.run();
}
}
동일하게 AnnocationConfigApplicationContext를 통해 컨테이너를 구현하였는데, 내부적인 동작을 살펴보면 매개변수로 받은 ApplicationConfig2 클래스에서 @ComponentScan 어노테이션을 확인 후, 스캔할 패키지에서 @Component어노테이션이 명시된 클래스들을 확인하여 이를 메모리에 올리고 DI를 주입하는 방식이다.
만일 여기서 @Component 어노테이션이 명시되어있지 않은 객체는 별도로 @Bean 어노테이션을 통해 객체를 직접 생성하는 방식으로 동시에 관리할 수 있다.
참고
velog.io/@gillog/Spring-DIDependency-Injection
galid1.tistory.com/493?category=769011
ko.wikipedia.org/wiki/%EC%9D%98%EC%A1%B4%EC%84%B1_%EC%A3%BC%EC%9E%85
댓글