춤추는 개발자

[Spring] 스프링 IoC의 동작원리인 의존성 주입 DI 본문

Developer's_til/스프링 프레임워크

[Spring] 스프링 IoC의 동작원리인 의존성 주입 DI

Heon_9u 2021. 8. 19. 18:32
728x90
반응형

 

 

 

 이전 포스팅에서는 어플리케이션 컨텍스트를 적용한 UserDao와 DaoFactory를 작성하며 스프링 IoC에 대해 알아보았습니다. 특히, 코드를 분리하고 확장하는 과정(객체지향적인 설계, 디자인 패턴 등)에서 자연스럽게 IoC를 적용하거나 그 원리로 동작하는 기술을 사용해보았습니다.

 

 이번에는 스프링의 IoC에 대해 좀 더 깊게 들어가서 대표적인 동작원리인 의존성 주입(DI)에 대해 알아보는 시간을 갖겠습니다.

 

 의존성 주입(DI)이란 오브젝트 레퍼런스를 외부로부터 제공(주입)받고, 이를 통해 다른 오브젝트와 동적인 의존 관계가 만들어지는 것이 핵심입니다.

 

 

✅ 런타임 의존관계 설정

1. 의존관계

 먼저 2개의 클래스가 의존관계에 있다고 가정할 때, 항상 방향성을 부여해줘야 합니다. 아래 그림은 두 클래스의 의존관계를 나타내는 UML 모델로 A가 B에 의존하고 있음을 의미합니다.

 

 의존한다는 건 의존 대상, 여기서는 만약, B가 변한다면, A에 영향을 미친다는 뜻이 됩니다. B의 기능이 추가되거나 변경되면 그 영향이 A로 전달되는 것입니다.

 

 대표적으로 A에서 B에 정의된 메서드를 호출해서 사용하는 경우입니다. 만약 B에 새로운 메서드가 추가되거나 기존 메서드가 변경된다면, 메서드 형식에 따라 A에도 수정이 필요합니다. 또는 B의 형식은 그대로지만 내부적으로 기능이 변경된다면, A의 기능이 수행되는데에 영향을 미칠 수 있습니다.

 

 이렇게 사용 관계에 있는 경우, A와 B는 의존관계에 있다고 말할 수 있습니다. A가 B에 의존하지만, 반대로 B는 A에 의존하지 않습니다.

 

[ UserDao의 의존관계 ]

 앞에서 작업해왔던 UserDao는 ConnectionMaker의 인터페이스를 사용하며 의존하는 형태입니다. 만약, ConnectionMaker 인터페이스가 변한다면, UserDao도 영향받게 됩니다. 하지만, ConnectionMaker 인터페이스를 구현한 클래스가 바뀌는 경우 UserDao에 영향을 주지 않습니다.

 

 

 이렇게 모델이나 코드에서 드러나는 모델링 시점의 의존관계가 아닌 런타임 시, 오브젝트사이에서 만들어지는 의존관계도 존재합니다. 프로그램이 시작되고 UserDao 오브젝트가 만들어지고 나서 런타임 시에 의존관계를 맺는 대상, 즉 실제 사용 대상인 오브젝트를 의존 오브젝트라고 말합니다.

 

 의존관계 주입은 구체적인 의존 오브젝트와 그것을 사용할 주체를 런타임 시에 연결해주는 작업을 말합니다. 정리하면 의존 관계 주입이란 3가지 조건을 충족하는 작업을 말합니다.

 

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

 의존관계 주입의 핵심은 위에서 2번째로 설계 시점에는 알지 못했던 두 오브젝트의 관계를 맺도록 도와주는 제3의 존재가 있다는 것입니다. DI에서 말하는 제3의 존재는 관계 설정 책임을 가진 코드를 분리해서 만들어진 오브젝트라고 볼 수 있습니다.

 앞에서 만들었던 DaoFactory도 외부에서 오브젝트 사이의 런타임 관계를 맺어주는 책임을 지닌 제3의 존재라고 볼 수 있습니다.

 

처음 관계 설정의 책임을 분리하기 전에 UserDao의 생성자는 아래와 같았습니다.

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

 이 코드의 문제는 이미 런타임 시의 의존관계가 코드 속에 미리 결정되어 있다는 것입니다. 만약 ConnectionMaker의 생성자 파라미터가 추가된다면, UserDao의 생성자도 수정이 필요하게 됩니다.

 

 

 이러한 상황을 해결하고자 제3의 존재인 DaoFactory 클래스를 만들어서 런타임 의존관계 결정 권한을 위임합니다. 그래서 런타임 시점에 UserDao가 사용할 ConnectionMaker 타입의 오브젝트를 결정하고, 이를 생성한 후에 UserDao의 생성자 파라미터로 주입해서 오브젝트끼리 런타임 의존관계를 맺게 해줍니다.

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

 

 결국, DaoFactory는 두 오브젝트 사이의 의존관계 주입을 주도하는 역할을 하며, IoC 방식으로 작업을 수행하는 DI 컨테이너가 되는 것입니다. DI 컨테이너는 UserDao를 만드는 시점에서 생성자의 파라미터로 이미 만들어진 ConnectionMaker의 오브젝트를 전달합니다. 즉, ConnectionMaker 오브젝트의 레퍼런스가 전달되는 것입니다.

 

 DI는 자신이 사용할 오브젝트에 대한 선택과 생성 제어권을 외부로 넘기고, 자신은 수동적으로 주입받은 오브젝트를 사용한다는 점에서 IoC의 개념에 어울립니다. 그렇다 보니, 스프링 컨테이너의 IoC는 주로 DI라는데 초점이 맞춰져있습니다.

 

✅ 의존관계 검색과 주입

 의존관계 검색은 주입하는 방식과 다르게 자신이 필요로 하는 의존 오브젝트를 능동적으로 찾습니다. 의존관계 검색은 런타임 시, 의존관계를 맺을 오브젝트를 가져올 때 메서드나 생성자를 통한 주입 대신 스스로 컨테이너에게 요청하는 방법을 사용합니다.

 

 스프링의 IoC 컨테이너인 어플리케이션 컨텍스트에서 getBean()이라는 메서드가 의존관계 검색에 사용됩니다.

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

 

 물론, 위와 같은 코드보다 의존관계 주입으로 작성한 코드가 훨씬 단순하고 깔끔합니다. 의존관계 검색 방법은 오브젝트 팩토리 클래스나 스프링 API가 나타나고, 관심사의 분리가 어색해지기 때문에 의존관계 주입 방식을 사용하는 편이 낫습니다.

 

 하지만, 의존관계 검색 방법을 사용해야할 때가 있습니다. 테스트 코드를 담당하는 UserDaoTest에서는 이미 getBean()을 사용합니다. 또한, static 메서드인 main()에서는 DI할 방법이 없기 때문에 의존관계 검색 방법을 한 번은 사용해서 오브젝트를 가져와야 합니다.

 

 의존관계 검색(DL)과 의존관계 주입(DI)의 차이점은 빈의 유무에 있습니다. DL 방식에서는 검색하는 오브젝트 자신이 스프링의 빈일 필요가 없습니다. 반면에, DI는 반드시 자신의 오브젝트가 빈 오브젝트여야 합니다.

 

✅ 의존관계 주입의 응용

 이번에는 특정 서비스를 개발하고 배포하는 상황을 기반으로 의존관계 주입의 장점에 대해 알아보겠습니다.

만약, 특정 서비스를 개발 중에는 로컬 DB를 사용하고, 실제 서버를 배포할 때는 서버가 제공하는 특별한 DB를 사용한다고 가정하겠습니다. 즉, 개발 중에는 LocalDBConnectionMaker라는 클래스를 사용하고, 서버 운영 중에는 ProductionDBConnectionMaker라는 클래스를 사용하는 것입니다.

 

 이 상황에서 DI 방식을 사용하지 않았다면 어떻게 될까요? 매번 서비스를 개발하고, 배포할 때마다 DB 커넥션을 가져오는 코드를 바꿔가며 작업해야 합니다. 만약 Dao가 100개라면 100군데의 코드를 수정해야 한다는 뜻입니다.

 

 반면에, 아래 코드처럼 DI 방식을 사용한다면? 모든 Dao는 생성 시점에 ConnectionMaker 타입의 오브젝트를 컨테이너로부터 제공받습니다.

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

 

 이제 개발이 끝나고 서버에 배포할 때는 DAO 클래스의 코드 수정없이, 단지 코드 한줄만 변경하면 됩니다.

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

 

 이렇게 개발환경과 운영환경에서 DI의 설정정보에 해당하는 DaoFactory만 다르게 만들어두면 나머지 코드에는 전혀 손대지 않고, 개발 시와 운영 시에 각각 다른 런타임 오브젝트에 의존관계를 갖게 해줘서 문제를 해결할 수 있습니다.

 

[ 부가기능 추가 ]

 또 다른 상황을 가정해보겠습니다. 만약, Dao가 DB를 얼마나 많이 연결해서 사용하는지 파악하길 원한다면, 단순히 모든 Dao에 makeConnection() 메서드를 호출하는 부분에 카운트를 중가시키는 코드를 넣을 수 있습니다. 하지만, 분석 작업이 끝나면 다시 코드를 지우고 수정하는 불필요한 작업을 하게됩니다.

 

 이러한 상황에서도 DI 컨테이너를 사용합니다. Dao 컨테이너와 DB 커넥션을 만드는 오브젝트 사이에 연결 횟수를 카운팅하는 오브젝트를 하나 추가하는 것입니다. 즉, 컨테이너가 사용하는 설정 정보만 수정해서 런타임 의존관계만 새롭게 정의해주면 됩니다.

 

 아래와 같이 ConnectionMaker 인터페이스를 구현해서 만든 CountingConnectionMaker라는 클래스를 구성합니다. 이는 Dao가 의존할 대상이 됩니다.

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 += 1;
        return realConnectionMaker.makeConnection();
    }

    public int getCounter() {
        return this.counter;
    }
}

 

 이렇게 되면 UserDao가 DB 커넥션을 가져올 때마다 CountingConnectionMaker의 makeConnection() 메서드라 실행되고 카운터는 하나씩 증가하게 됩니다.

 대신, CountingConnectionMaker에는 DB 커넥션을 생성하는 메서드가 없고, 생성자에서 connectionMaker 오브젝트를 주입받기 때문에 ConnectionMaker를 호출하도록 만들어야 합니다. 이러한 흐름에 따라 구성된 런타임 의존관계는 아래와 같습니다.

 

 새로운 의존관계를 컨테이너가 사용할 설정정보를 이용해 만들어보겠습니다. 어플리케이션 컨텍스트를 이용해 Dao Factory에 어노테이션을 추가했듯이 CountingDaoFactory라는 이름의 설정용 클래스를 만들어보겠습니다.

@Configuration
public class CountingDaoFactory {

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

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

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

 

 위 코드는 DaoFactory와 달리 connectionMaker에서 CountingConnectionMaker 오브젝트를 생성합니다. 그럼 realConnectionMaker() 메서드가 호출되며 ConnectionMaker 구현 클래스를 호출하기 때문에 위에 작성한 런타임 의존관계와 똑같은 구조가 만들어집니다.

 

 실제 DB 커넥션 카운팅을 위한 실행 코드는 UserDaoTest에서 UserDao 오브젝트를 생성했듯이, 어플리케이션 컨텍스트의 getBean() 메서드를 활용하는 DL방식을 사용합니다.

 이후, makeConnection() 메서드를 호출하는 add(), get() 메서드 등을 여러번 호출하여 CountingConnectionMaker의 counter를 출력한다면, Dao가 DB를 몇 번 연결해서 사용했는지 알 수 있습니다.

 

 Dao가 수십, 수백개여도 CountingConnectionMaker를 이용하면 DB 연결 횟수를 알 수 있습니다. 해당 작업이 모두 끝났다면 다시 CountingConnectionMaker 설정 클래스를 DaoFactory로 변경하거나, connectionMaker() 메서드를 수정하면 이전 상태로 돌아올 수 있게 됩니다.

 

✅ 메서드를 이용한 의존관계 주입(DI)

 지금까지 UserDao의 생성자를 사용하여 의존관계를 주입했지만, 생성자가 아닌 일반 메서드를 사용해서도 의존관계를 주입할 수 있습니다. 이러한 방식에는 2가지가 있습니다.

 

  1. 수정자 메서드를 이용한 주입
    수정자 메서드는 Setter 메서드라고도 불리며, 항상 set으로 시작합니다. 외부에서 오브젝트 내부의 속성값을 변경하려는 용도로 주로 사용됩니다. 부가적으로 입력 값에 대한 검증이나 그 밖의 작업을 수행할 수 있습니다.
  2. 일반 메서드를 이용한 주입
    한 번의 여러 개의 파라미터를 갖는 일반 메서드를 DI용으로 사용할 수 있습니다. 물론, 생성자로 여러 개의 파라미터를 받을 수 있지만, 적절한 개수의 파라미터를 가진 여러 개의 일반 메서드가 한 번에 모든 필요한 파라미터를 받아야만 하는 생성자보다 나을 때도 있습니다.

 

마지막으로 UserDao도 생성자 대신 수정자 메서드를 이용해 DI 해보도록 하겠습니다. 기존 생성자를 제거하고 아래처럼 Setter 메서드를 만들어서 connectionMaker를 파라미터로 받겠습니다.

public class UserDao {
    private ConnectionMaker connectionMaker;

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

 

 UserDao에 ConnectionMaker 오브젝트를 주입하는 방식이 바꼈기 때문에 DaoFactory의 코드도 함께 수정합니다. 

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

 

 의존관계를 주입하는 시점과 방법이 달라졌을 뿐, 결과는 동일합니다. 실제로 스프링은 생성자, 수정자 메서드, 일반 메서드 외에도 다양한 의존관계 주입 방법을 지원합니다. 이는 다른 포스팅에서 다뤄보도록 하겠습니다.

 

🙋‍♂️ 느낀점

 IoC하면 항상 따라오는 단어 DI에 대해 알아봤다. 프로젝트를 진행할 때마다 자연스럽게 해왔던 작업으로 DI를 하기 전과 후의 코드를 비교하여 정확하게 이해할 수 있었다.

 

 의존성 주입이라하면, 막연하게 외부에서 레퍼런스를 제공하고 결합도를 낮추는 것만 생각해왔다. 하지만, 의존 관계를 충족하는 3가지 조건을 보며 더욱 명확한 DI가 필요함을 느꼈다.

 그리고 상황에 따라 다양한 방법으로 DI 방식을 수행하며 코드 변화의 폭을 최소한으로 만드는 것이 스프링을 가장 잘 활용하는 것임을 알 수 있었다.

 

 

728x90
반응형