BackEnd/Spring & Springboot Study

[토비의 스프링] 1.7 의존관계 주입(DI)

스프링을 IoC 컨테이너로 적용하는 방법과 싱글톤 저장소로서의 특징을 살펴봤다.

스프링의 IoC에 대해 좀 더 깊이 알아보자.

1. 제어의 역전(IoC)과 의존관계 주입

IoC는 소프트웨어에서 자주 발견할 수 있는 일반적인 개념

객체를 생성하고 관계를 맺어주는 등의 작업을 담당하는 기능을 일반화한 것이 스프링의 IoC 컨테이너

IoC라는 용어는 매우 느슨하게 정의돼서 폭넓게 사용되는 용어

스프링이 제공하는 IoC 방식을 핵심을 짚어주는 의존관계 주입(Dependency Injection)이라는, 좀 더 의도가 드러나는 이름을 사용

 

 

의존관계 주입, 의존성 주입, 의존 오브젝트 주입?

'Dependency Injection'은 여러 가지 우리말로 번역돼서 사용된다. 그중에서 가장 흔히 사용되는 용어가 의존성 주입이다. 하지만 의존성이라는 말은 DI의 의미가 무엇인지 잘 드러내 주지 못한다. 또한 의존(종속) 오브젝트 주입이라고도 부르기도 하는데, 이때는 DI가 일어나는 방법에 초점을 맞춘 것이다. 엄밀히 말해서 오브젝트는 다른 오브젝트에 주입할 수 있는게 아니다. 오브젝트의 레퍼런스가 전달될 뿐이다. DI는 오브젝트 레퍼런스를 외부로부터 제공(주입) 받고 이를 통해 여타 오브젝트와 다이나믹하게 의존관계가 만들어지는 것이 핵심이다. 용어는 동작방식(메커니즘)보다는 의도를 가지고 이름을 짓는 것이 좋다. 그런 면에서 의존관계 주입이라는 번역이 적절할 듯싶고, 이 책에서는 이를 사용한다. 하지만 DI가 무엇인지만 잘 인식하고 있다면 어떤 용어를 사용해도 상관없다. 의도를 강하게 드러내는 '의존관계 설정'이라는 용어도 나쁘지 않다고 생각한다.

 

2. 런타임 의존관계 설정

 

의존관계

의존 관계란 무엇인가

두 개의 클래스 또는 모듈이 의존관계에 있다고 말할 때는 항상 방향성을 부여해줘야 한다. 즉 누가 누구에게 의존하는 관계에 있다는 식이어야 한다. UML 모델에서는 두 클래스의 의존관계(dependency relationship)를 다음과 같이 점선으로 된 화살표로 표현한다.

클래스의 의존관계 다이어그램

의존한다는 건 의존대샹, 여기서는 B가 변하는 그것이 A에 영향을 미친다는 뜻

대표적인 예는 A가 B를 사용하는 경우, A에서 B에 정의된 메소드를 호출해서 사용하는 경우

의존관계에는 방향성이 있다. A가 B에 의존하기에 B의 변화에 A는 영향을 받지만 A의 변화에 B는 영향을 받지 않는다.

더보기

UserDao의 의존관계

지금까지 작업한 UserDao는 ConnectionMkaer에 의존하고 있는 형태다.

따라서 ConnectionMaker 인터페이스가 변한다면 그 영향을 UserDao가 직접적으로 받게된다.

하지만 ConnectionMaker 인터페이스를 구현한 클래스, 즉 DConnectionMaker등이 다른 것으로 바뀌거나 그 내부에서 사용하는 메소드의 변화가 생겨도 UserDao에 영향을 주지 않는다. 이렇게 인터페이스에 대해서만 의존관계를 만들어두면 인터페이스 구현 클래스와의 관계는 느슨해지면서 변화에 영향을 덜 받는 상태가 된다.

즉 결합도가 낮다. 인터페이스를 통해 의존관계를 제한하면 변경에서 자유로워 진다.

인터페이스를 통한 느슨한 결합을 갖는 의존관계

의존 관계 주입은 구체적인 의존 오브젝트와 그것을 사용할 주체,

보통 클라이언트라고 부르는 오브젝트를 런타임 시에 연결해 주는 작업을 말한다.

 

의존관계 주입이란 다음과 같은 세 가지 조건을 충족하는 작업을 말한다.

  • 클래스 모델이나 코드에는 런타임 시점의 의존관계가 드러나지 않는다. 그러기 위해서는 인터페이스에만 의존하고 있어야 한다.
  • 런타임 시점의 의존관계는 컨테이너나 팩토리 같은 제3의 존재가 결정한다.
  • 의존관계는 사용할 오브젝트에 대한 레퍼런스를 외부에서 제공(주입)해줌으로써 만들어진다.

의존관계 주입의 핵심을 설계 시점에는 알지 못했던 두 오브젝트의 관계를 맺도록 도와주는 제 3자가 있다는 것.

UserDao의 의존관계 주입

UserDao에 적용된 의존관계 주입 기술을 다시 살펴보자.

UserDao와 ConnectionMaker 구현 클래스 간에 의존관계를 느슨하게 만들었지만 UserDao가 사용할 구체적인 클래스를 알고 있어야 한다는 점이다. 관계설정의 책임을 분리하기 전에 UserDao 클래스의 생성자는 다음과 같다.

public UserDao() {
	connectionMaker = new DConnectionMaker();
}

 

이 코드의 문제점은 이미 런타임시의 의존관계가 코드 속에 다 미리 결정되어 있다는 점이다.

그래서 IoC 방식을 써서 UserDao로부터 런타임 의존관계를 드러내는 코드를 제거하고, 제3의 존재에 런타임 의존관계 결정 권한을 위임한다. 그래서 최종적으로 만들어진것이 DaoFactory다.

 

DaoFactory는 런타임 시점에 UserDao가 사용할 ConnectionMaker 타입의 오브젝트를 결정하고 이를 생성한 후에 UserDao의 생성자 파라미터로 주입해서 UserDao가 DConnectionMaker의 오브젝트와 런타임 의존관계를 맺게 해준다. 따라서 의존관계 주입의 세 가지 조건을 모두 충족한다고 볼 수 있고 이미 DaoFactory를 만든 시점에서 의존관계 주입(DI)을 이용한 셈이다.

클래스/코드 레벨의 의존관계

런타임 시점의 의존관계를 결정하고 만드려면 제3의 존재가 필요하다. 이 역할을 DaoFactory가 담당한다고 하자. DaoFactory는 여기서 두 오브젝트 사이의 런타임 의존관계를 설정해주는 의존관계 주입 작업을 주도하는 존재이며, 동시에 IoC방식으로 오브젝트의 생성과 초기화, 제공 등의 작업을 수행하는 컨테이너다. 따라서 의존관계 주입을 담당하는 컨테이너라고 볼 수 있고, 줄여서 DI 컨테이너라고 불러도 된다. 보통 DI는 그 근간이 되는 개념인 IoC와 함께 사용해서 IoC/DI 컨테이너라는 식으로 함께 사용되기도 한다. 아무튼 DaoFactory는 그래서 DI 컨테이너다.

DI 컨테이너는 UserDao를 만드는 시점에서 생성자의 파라미터로 이미 만들어진 DConnectioMaker의 오브젝트를 전달한다.


DI 컨테이너가 자신이 결정한 의존관계를 맺어줄 클래스의 오브젝트를 만들고 이 생성자의 파라미터로 오브젝트의 레퍼런스를 전달해주는 코드 

public Class UserDao {
	private ConnectionMaker connectionMaker;
    
    public UserDao(ConnectionMaker connectionMaker) {
    	this.connectionMaker = connectionMaker;
    }
}

 이렇게 두 개의 오브젝트 간에 런타임 의존관계가 만들어졌다.

이렇게 DI 컨테이너에 의해 런타임 시에 의존 오브젝트를 사용할 수 있도록 그 레퍼런스를 전달받는 과정이 마치 메소드(생성자)를 통해 DI 컨테이너가 UserDao에 주입해 주는 것과 같다고 해서 이를 의존관계 주입이라고 부른다.

 

DI는 자신이 사용할 오브젝트에 대한 선택과 생성 제어권을 외부로 넘기고 자신은 수동적으로 주입받은 오브젝트를 사용한다는 점에서 IoC의 개념에 잘 들어맞는다. 스프링 컨테이너의 IoC는 주로 의존관계 주입 또는 DI라는 데 초점이 맞춰져 있다. 그래서 스프링을 IoC 컨테이너 외에도 DI 컨테이너 또는 DI 프레임워크라고 부르는 것이다.

 

3. 의존관계 검색과 주입

의존관계를 맺는 방법이 외부로부터의 주입이 아닌 스스로 검색을 이용하기 때문에 의존관계 검색(dependency lookup)이라고 불리는 것도 있다. 의존관계 검색은 의존 오브젝트를 능동적으로 찾는다.

런타임시 의존관계를 맺을 오브젝트를 결정하는 것과 오브젝트 생성작업은 외부 컨테이너에게 IoC로 맡기고, 이를 가져올 때 메소드나 생성자를 통한 주입 대신 스스로 컨테이너에게 요청하는 방법을 사용한다.

 

더보기

DaoFactory를 이용하는 생성자 코드

public UserDao() {
	DaoFactory daoFactory = new DaoFactory();
    this.connectionMaker = daoFactory.connectionMaker();
}

 

의존관계 검색을 이용하는 UserDao 생성자 코드

public UserDao() {
	AnnotationConfigApplicationContext context =
    	new AnnotationConfigApplicationContext(DaoFactory.class);
    this.connectionMaker = context.getBean("ConnectionMaker", ConnectionMaker.class);
}

의존관계 검색과 의존관계 주입 방법중 어떤것이 나은가?

의존관계 검색의 경우 코드 안에 오브젝트 팩토리 클래스나 스프링 API가 나타난다. 애플리케이션 컴포넌트가 컨테이너와 같이 성격이 다른 오브젝트에 의존하게 되는 것이므로 그다지 바람직하지 않다.

따라서 대개는 의존관계 주입 방식을 사용하는 편이 낫다.

 

의존관계 검색과 의존관계 주입을 적용시 중요한 차이점이 하나 있다.

의존관계 검색 방식에서는 검색하는 오브젝트는 자신이 스프링의 빈일 필요가 없다는 점이다.

반면 의존관계 주입에서는 UserDao와 ConnectionMaker 사이에 DI가 적용되려면 UserDao도 반드시 컨테이너가 만드는 빈오브젝트여야 한다. 컨테이너가 UserDao에 ConnectionMaker 오브젝트를 주입해주려면 UserDao에 대한 생성과 초기화 권한을 갖고 있어야 하고, 그러려면 UserDao는 IoC 방식으로 컨테이너에서 생성되는 오브젝트, 즉 빈이어야 한다.

이러한 점에서 DI와 DL(의존관계 검색의 약자)은 적용 방법에 차이가 있다.

 

DI 받는다

DI의 동작방식은 이름 그대로 외부로부터의 주입이다. 하지만 단지 외부에서 파라미터로 오브젝트를 넘겨줬다고 해서, 즉 주입해줬다고 해서 다 DI가 아니라는 점을 주의해야 한다. 주입받는 메소드 파라미터가 이미 특정 클래스 타입으로 고정되어 있다면 DI가 일어날 수 없다. DI에서 말하는 주입은 다이내믹하게 구현 클래스를 결정해서 제공받을 수 있도록 인터페이스 타입의 파라미터를 통해 이뤄져야 한다.
그래서 이 책에서는 DI 원리를 지키며 외부에서 오브젝트를 제공받는 방법을 단순히 '주입받는다'라고 하는 대신 'DI 받는다'라고 표현하기도 할 것이다. 좀 어색한 표현일지는 모르겠지만, 단순한 오브젝트 주입이 아니라 DI 개념을 따르는 주입임을 강조하는 것이라고 이해해보자.

 

 

4. 의존관계 주입의 응용

DI의 장점은 코드에는 런타임 클래스에 대한 의존관계가 나타나지 않고, 인터페이스를 통해 결합도가 낮은 코드를 만드므로, 다른 책임을 가진 의존관계에 있는 대상이 바뀌거나 변경되더라도 자신은 영향을 받지 않으며, 변경을 통한 다양한 확장 방법에는 자유롭다

 

이런 다양한 장점을 가지는 DI의 응용사례를 접해보자

더보기

기능 구현의 교환

로컬 DB를 운영 서버로 배치해 사용한다고 가정

DI 미적용시

로컬 DB와 연결을 위한 LocalDBConnectionMaker .... DAO에서 해당 maker new를 통한 인스턴스 생성
new LocalDBConnectionMaker()라는 코드가 모든 DAO에 들어있음
이를 ProductionDBConnectionMaker라는 클래스로 변경해줘야 하는 필요성이 생김
DAO가 100개면 최소 100군데의 코드 수정 -> 하나라도 빼먹거나 잘못 고치면 서버 오류 -> 지옥

DI 적용시

모든 DAO는 생성 시점에 ConnectionMaker 타입의 오브젝트를 컨테이너로 부터 제공받는다.

구체적인 사용 클래스의 이름은 컨테이너가 사용할 설정정보에 들어 있다.

@Configuration이 붙은 DaoFactory를 사용한다고 하면 개발자 PC에서는 다음과 같이 사용

 

개발용 ConnectionMaker 생성 코드

@Bean
public ConnectionMaker connectionMaker() {
	return new LocalDBConnectionMaker();
}

 

개발자 PC에서 서버 배포를 위한 운영 환경으로 변경시 다음과 같이 한 줄의 코드 변경이면 완료된다.

 

 운영용 ConnectionMaker 생성 코드

@Bean
public ConnectionMaker connectionMaker() {
	return new ProductionDBConnectionMaker();
}

 

부가기능 추가

DAO가 DB를 얼마나 많이 연결하는지 사용량 파악하는 등의 부가기능이 필요시 대응

연결횟수 카운팅 기능이 있는 클래스 

package springbook.user.dao;

import springbook.user.domain.ConnectionMaker;

import java.sql.Connection;
import java.sql.SQLException;

public class CountingConnectionMaker implements ConnectionMaker {
    int counter = 0;
    private ConnectionMaker realConnectionMaker;

    public CountingConnectionMaker(ConnectionMaker realConnectionMaker) {
        this.realConnectionMaker = realConnectionMaker;
    }

    public Connection makeConnection() throws ClassNotFoundException, SQLException {
        this.counter++;
        return realConnectionMaker.makeConnection();
    }

    public int getCounter() {
        return this.counter;
    }
}
CountingConnectionMaker 적용 전 런타임 오브젝트 의존관계
CountingConnectionMaker를 적용 후 런타임 오브젝트 의존관계

 

CountingConnectionMaker 의존관계가 추가된 DI 설정용 클래스

package springbook.user.dao;

@Configuration	// -> 애플리케이션 컨텍스트 또는 빈 팩토리가 사용할 설정정보라는 표시
public class CountingDaoFactory {

    @Bean	// 오브젝트 생성을 담당하는 IoC용 메소드라는 표시
    public UserDao userDao() {
        return new UserDao(connectionMaker());
    }
// => 모든 DAO는 여전히 connectionMaker()에서 만들어지는 오브젝트를 DI 받는다.
    @Bean
    public ConnectionMaker connectionMaker() {
        return new CountingConnectionMaker(realConnectionMaker());
    }

    @Bean
    public ConnectionMaker realConnectionMaker() {
        return new DConnectionMaker();
    }
}

 

CountingConnectionMaker에 대한 테스트 클래스

package springbook.user.dao;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import springbook.user.domain.UserDao;

import java.sql.SQLException;

public class UserDaoConnectionCountingTest {
    public static void main(String[] args) throws ClassNotFoundException, SQLException {
        AnnotationConfigApplicationContext context =
                new AnnotationConfigApplicationContext(CountingConnectionMaker.class);
        UserDao dao = context.getBean("userDao", UserDao.class);

        //
        // DAO 사용 코드
        //

        CountingConnectionMaker ccm = context.getBean("connectionMaker", CountingConnectionMaker.class);
        System.out.println("Connection counter : " + ccm.getCounter());
    }
}

DAO가 수십, 수백개여도 상관없다.

DI의 장점은 관심사의 분리(SoC)를 통해 얻어지는 높은 응집도에서 나온다.

모든 DAO가 직접 의존해서 사용할 ConnectionMaker 타입 오브젝트는 connectionMaker() 메소드에서 만든다.

따라서 CountingConnectionMaker의 의존관계를 추가하려면 이 메소드만 수정하면 그만이다.

또한 CountingConnectionMaker를 이용한 분석 작업이 모두 끝나면, 다시 CountingDaoFactory 설정 클래스를 DaoFactory로 변경하거나 connectionMaker() 메소드를 수정하는 것만으로 DAO의 런타임 의존관계는 이전 상태로 복구된다. 

 

 

5. 메소드를 이용한 의존관계 주입

 

생성자가 아닌 일반 메소드를 이용해 의존 오브젝트와의 관계를 주입해주는 방법에 대해 알아보자

더보기
  • 수정자(setter) 메소드를 이용한 주입
    • 파라미터로 전달된 값을 내부의 인스턴스 변수에 저장하는 수정자 메소드를 이용
    • set으로 시작하고 한 번에 한 개의 파라미터만 가질 수 있다는 제약이 있다.
  • 일반 메소드를 이용한 주입
    • 한 번에 여러개의 파라미터를 받을 수 있다.
    • 임의의 초기화 메소드를 이용하는 DI는 적절한 개수의 파라미터를 가진 여러 개의 초기화 메소드를 만들 수도 있기 때문에 한 번에 모든 필요한 파라미터를 다 받아야 하는 생성자보다 낫다.

수정자 메소드 DI 방식을 사용한 UserDao Example Code

public class UserDao {
	private ConnectionMaker connectionMaker;
    
    public void setConnectionMaker(ConnectionMaker connectionMaker) {
    	this.connectionMaker = connectionMaker;
    }
}

 

수정자 메소드 DI를 사용하는 팩토리 메소드

@Bean
public UserDao userDao() {
	UserDao userDao = new UserDao();
    userDao.setConnectionMaker(connectionMaker());
    return userDao;
}