Web/Spring

[DI 구현하기] 의존성 주입이 필요한 이유와 DI 컨테이너의 탄생

스프링을 사용하는 핵심이유중 하나는 DI (Dependency Injection) 의존성 주입입니다.

우리는 의존성 주입이 왜 필요한 것인지, 또 스프링이 의존성 주입을 어떻게 해주는지 제대로 이해하고 사용하고 있을까요?

 

간단한 UserService와 UserDao 예제로 의존성 주입이 왜 필요한 것인지 간단히 이해해보고, 나아가서 스프링이 제공하는 DI 컨테이너를 직접 만들어 보겠습니다.

 

 

 

1단계 : 생성자 주입(?)

 

현재, 사용할 UserDao 인스턴스생성자를 통해 외부에서 전달받는 UserService가 존재합니다.

 

UserService

class UserService {

    private final UserDao userDao;

    public UserService(UserDao userDao) {
        this.userDao = userDao;
    }

    public User join(User user) {
        userDao.insert(user);
        return userDao.findById(user.getId());
    }
}

 

UserDao

class UserDao {

    private static final Map<Long, User> users = new HashMap<>();

    private final JdbcDataSource dataSource;

    public UserDao() {
        final var jdbcDataSource = new JdbcDataSource();
        jdbcDataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;");
        jdbcDataSource.setUser("");
        jdbcDataSource.setPassword("");

        this.dataSource = jdbcDataSource;
    }

    public void insert(User user) {
        try (final var connection = dataSource.getConnection()) {
            users.put(user.getId(), user);
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    public User findById(long id) {
        try (final var connection = dataSource.getConnection()) {
            return users.get(id);
        } catch (SQLException e) {
            return null;
        }
    }
}

 

 

 

위와 같은 코드의 문제점은 무엇일까요?

 

개발자가 위의 코드를 이용해 UserServiceTest를 작성하려고 합니다.

그런데, DB 없이 돌아가는 테스트를 작성하기 위해서 어떻게 해야할까요?

 

UserDao가 Jdbc에 직접 의존하고 있기 때문에 현재 구조에서는 방법이 없습니다.

DB에 연결하지 않고 테스트를 짤 수 없습니다.

 

떠오르는 하나의 방법은,

Jdbc에 의존하지 않는 DAO를 새로 생성해 UserService 코드상 에서 교체해준뒤, 외부에서도 새로운 DAO를 전달해주는 방법이 있을 수 있습니다.

 

그런데 이 방법은 문제점이 존재합니다.

 

UserDao의 변경을 위해 UserService의 코드를 변경해주어야 합니다.

 

UserDao를 사용하는 클래스가 수백개 있다고 가정하면 UserDao를 교체하기 위해 수백개의 클래스를 직접 다 수정해주어야 합니다.

 

 

위와 같은 문제점을 해결하기 위해서는 객체지향의 5가지 원칙중 하나인 DIP (구현 클래스에 의존하지 않고 인터페이스에 의존해라)를 만족하게 하면 됩니다.

 

 

UserService가 UserDao 인터페이스에 의존하도록 바꿔보겠습니다.

 

 

 

 

2단계 : 인터페이스에 의존하기

 

구현체들이 사용할 메서드 명세만 작성해놓은 UserDao interface를 만듭니다.

 

UserDao interface

interface UserDao {

    void insert(User user);

    User findById(long id);
}

 

UserService는 UserDao 인터페이스에만 의존하도록 구현합니다.

또한, 외부에서 사용할 UserDao의 구현체를 주입받습니다.

 

UserService

class UserService {

    private final UserDao userDao;

    public UserService(UserDao userDao) {
        this.userDao = userDao;
    }

    public User join(User user) {
        userDao.insert(user);
        return userDao.findById(user.getId());
    }
}

 

 

1단계와 똑같이 위의 코드로 DB 없이 돌아가는 UserServiceTest를 작성하려고 합니다.

 

이제 UserService가 UserDao 인터페이스에만 의존하고 있기 때문에 외부에서 DB없이 돌아가는 구현체를 주입해주면 됩니다.

 

UserServiceTest

InMemoryUserDao() 라는 메모리 상에서 돌아가는 DB를 구현한뒤 UserService에 주입해줍니다.

    @Test
    void joinTest() {
        final var user = new User(1L, "ash");

        final UserDao userDao = new InMemoryUserDao();
        final var userService = new UserService(userDao);

        final var actual = userService.join(user);

        assertThat(actual.getAccount()).isEqualTo("ash");
    }

 

 

인터페이스에만 의존하도록 수정했더니 UserDao의 변경이 UserService에게 영향을 미치지 않게 되었습니다.

 

 

그런데 아직도 문제가 존재합니다.

UserDao 구현체를 변경하기 위해서는 UserService를 사용하는 어딘가에서 구현체를 결정하고 주입해주어야 한다는 것입니다.

 

UserController

UserController에서 UserDao의 구현체를 결정해주고 있습니다.

@Controller
public class UserController {
    private UserService userService;

    @PostMapping("/api/user")
    public String join(final Long id, final String name) {
        final var user = new User(1L, "gugu");
        
        userService = new UserService(new InMemoryUserDao());
        userService.join(user);
        return "index.html";
    }
}

 

분명히 인터페이스에 의존하게 변경했는데도 어딘가에서는 UserDao의 변경에 영향이 가는 곳이 존재합니다.

 

왜 이런 문제가 발생한 것일까요?

 

 

 

관계설정 책임

UserService를 사용하는 곳에서는 자신과는 상관없는 UserDao 구현체를 결정해준다는 책임이 숨어있습니다.

 

UserController

UserController에서 UserDao의 구현체를 결정해주고 있습니다.

@Controller
public class UserController {
    private UserService userService;

    @PostMapping("/api/user")
    public String join(final Long id, final String name) {
        final var user = new User(1L, "ash");
        
        userService = new UserService(new InMemoryUserDao());
        userService.join(user);
        return "index.html";
    }
}

 

UserController에게 UserDao 구현체를 결정해줘야된다는 책임이 필요할까요?

 

이 관계설정의 관심사를 분리하지 않으면 결국에는 UserDao가 확장 가능한 클래스라고 볼 수는 없습니다.

 

 

 

 

3단계 : DI Container

 

관계 설정의 관심사(책임)을 분리하기 위해서는 어떻게 해야 할까요?

 

전혀 다른 외부에서 책임을 가지도록 구현하면 됩니다. (제어의 역전. IoC)

관계설정만 하는 책임을 가지는 클래스로 분리를 하면 됩니다.

 

그리고 이런 객체를 생성하고 연결해주는 역할을 하는 클래스를 DI Container 라고 합니다.

 

객체가 사용할 구현체를 능동적으로 결정하는 것이 아니라 외부의 다른 객체에게 제어권한을 넘깁니다.

 

DI Container가 객체를 생성하고 관계를 설정하고,

UserService가 DI Container에게 모든 제어 권한을 위임하도록 구현해보겠습니다.

 

UserService는 DIContainer로 부터 사용할 인스턴스를 제공받습니다.

이 인스턴스 안에는 사용할 UserDao 구현체도 결정되어있죠.

    @Test
    void stage3() {
        final var user = new User(1L, "ash");

        final var diContainer = createDIContainer();

        final var userService = diContainer.getBean(UserService.class);

        final var actual = userService.join(user);

        assertThat(actual.getAccount()).isEqualTo("gugu");
    }

    /**
     * DIContainer가 관리하는 객체는 빈(bean) 객체라고 부른다.
     */
    private static DIContainer createDIContainer() {
        var classes = new HashSet<Class<?>>();
        classes.add(InMemoryUserDao.class);
        classes.add(UserService.class);
        return new DIContainer(classes);
    }

 

DI Container

 

제가 구현한 DI Container의 원리는 간단하게 아래와 같습니다.

  1. 생성자를 통해 컨테이너가 관리할 빈을 등록합니다.
  2. 컨테이너는 빈을 전달받을 클래스를 인스턴스화해서 가지고 있습니다.
  3. 그리고 한번 인스턴스화 된 빈을 계속 사용합니다.
  4. 빈으로 등록될 객체가 들고있는 필드빈으로 등록되어있으면 걔네들도 주입합니다.

 

/**
 * 스프링의 BeanFactory, ApplicationContext에 해당되는 클래스
 */
class DIContainer {

    private final Set<Object> beans = new HashSet<>();

    // TODO: 같은 클래스로 여러 빈 등록 불가하게 수정해야한다.
    public DIContainer(final Set<Class<?>> classes) {
        // 클래스를 인스턴스화 해서 Set에 저장
        initBean(classes);
        // 빈의 필드에 빈으로 등록할 수 있는 필드 등록
        beans.forEach(this::setFields);
    }

    private void initBean(final Set<Class<?>> classes) {
        try {
            for (Class<?> aClass : classes) {
                addBeans(aClass);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private void setFields(final Object bean) {
        // 필드가 빈으로 등록되어있으면 주입한다.
        final List<Field> fields = List.of(bean.getClass().getDeclaredFields());
        fields.forEach(field -> setField(bean, field));
    }

    private void setField(final Object obj, final Field field) {
        field.setAccessible(true);
        beans.forEach(bean -> setFieldValue(obj, field, bean));
    }

    private void setFieldValue(final Object obj, final Field field, final Object bean) {
        final Class<?> fieldType = field.getType();
        if (fieldType.isInstance(bean)) {
            try {
                field.set(obj, bean);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    private void addBeans(final Class<?> aClass)
            throws NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException {
        final Constructor<?> constructor = aClass.getDeclaredConstructor();
        constructor.setAccessible(true);
        final Object instance = constructor.newInstance();
        beans.add(instance);
    }

    @SuppressWarnings("unchecked")
    public <T> T getBean(final Class<T> aClass) {
        return (T) beans.stream()
                .filter(aClass::isInstance)
                .findFirst()
                .orElseThrow(() -> new NoSuchElementException("등록된 빈이 아닙니다."));
    }
}

기본생성자로 리플렉션을 이용해 인스턴스를 생성하는 부분,

필드들을 읽어서 해당필드의 타입이 빈으로 등록된 인스턴스중 같은 타입이면 필드에도 주입해주는 부분

들이 이해하기 어려울 수 있지만 여러번 읽어보면 또 쉽게 이해할 수 있습니다.

 

 

 

 

4단계 : 어노테이션 기반 DI 컨테이너

지금도 DI 컨테이너는 자신의 책임에 맞게 잘 동작하고 있습니다.

하지만 개발자는 매번 빈을 직접 등록해주는 일도 번거롭습니다.

그리고 어떤 클래스가 빈으로 등록되는 클래스인지 알기 위해서는 DI컨테이너까지 가봐야 알 수 있습니다.

 

저희는 스프링을 사용할 때 어노테이션 기반으로 빈들을 등록하고 있습니다.

클래스 위에 어노테이션으로 이 클래스는 빈으로 등록된다는 것을 명시합니다.

그리고 이것은 InterviewService의 @Service에 해당하는 부분입니다.

 

Service 어노테이션 클래스 안에는 @Component 가 있습니다.

사실 @Component 가 빈으로 등록된다는 것을 명시하는 어노테이션 입니다.

 

 

이처럼 어노테이션 기반의 DI컨테이너를 만들면 개발자는 어떤 설정파일에 직접 빈을 등록할 필요 없이 클래스 위에 어노테이션을 붙이는 것 만으로도 객체를 빈으로 등록해 관리할 수 있습니다.

 

어노테이션 기반의 DI 컨테이너를 직접 구현해보겠습니다.

 

저희는 간단하게 @Service, @Repository 어노테이션이 붙은 클래스, 더해서 @Inject 어노테이션이 붙은 필드의 의존성을 자동으로 주입해보겠습니다.

(@Inject 는 @Autowired에 해당하는 어노테이션 이라고 봐도 무방합니다.)

 

 

DIContainer

  1. createContainerForPakage 에서는 전달받은 rootPakageName을 루트로 가지는 모든 클래스들 가져오고
  2. 이 클래스들 중 @Service, @Repository 어노테이션이 붙은 클래스를 추출하고,
  3. 추출된 클래스들을 빈으로 등록합니다.
    ...
    public static DIContainer createContainerForPackage(final String rootPackageName) {
        // @Service 가 붙은 클래스들의 인스턴스를 bean으로 등록하자.
        final Set<Class<?>> allClasses = ClassPathScanner.getAllClassesInPackage(rootPackageName);
        final Set<Class<?>> injectClasses = allClasses.stream()
                .filter(DIContainer::isComponent)
                .collect(Collectors.toSet());
        return new DIContainer(injectClasses);
    }

    private static boolean isComponent(final Class<?> aClass) {
        return aClass.isAnnotationPresent(Service.class)
                || aClass.isAnnotationPresent(Repository.class);
    }
    ...

 

 

setField 에서는 모든 필드의 의존성을 주입하는 것이 아닌, @Inject 가 붙어있는 필드에만 의존성을 주입합니다.

...
// @Inject 붙은 필드의 의존성을 주입해준다.
    private void setFields(final Object bean) {
        final List<Field> fields = List.of(bean.getClass().getDeclaredFields());
        final List<Field> injectFields = fields.stream()
                .filter(field -> field.isAnnotationPresent(Inject.class))
                .collect(Collectors.toList());
        injectFields.forEach(field -> setField(bean, field));
    }
    ...

 

UserService

UserDao 인터페이스를 필드로 가지고 있지만 구현체를 할당하는 코드가 존재하지 않습니다.

하지만 DI 컨테이너에서 @Inject 가 붙은 필드에 의존성을 주입해주기 때문에 런타임에는 구현체를 가지고 있습니다.

@Service
class UserService {

    @Inject
    private UserDao userDao;

    public User join(final User user) {
        userDao.insert(user);
        return userDao.findById(user.getId());
    }

    private UserService() {}
}

 

 

 

마무리

 

토비의 스프링 1권 p.378~379

그래서 적절하게 책임과 관심이 다른 코드를 분리하고, 서로 영향을 주지 않도록 다양한 추상화 기법을 도입하고, 애플리케이션 로직과 기술/환경을 분리하는 등의 작업은 갈수록 복잡해지는 엔터프라이즈 애플리케이션에는 반드시 필요하다. 이를 위한 핵심적인 도구가 바로 스프링이 제공하는 DI다. 스프링의 DI가 없었다면 인터페이스를 도입해서 나름 추상화를 했더라도 적지 않은 코드 사이의 결합이 남아있게 된다.

스프링의 의존관계 주입 기술인 DI는 모든 스프링 기술의 기반이 되는 핵심 엔진이자 원리이며, 스프링이 지지하고 지원하는, 좋은 설계와 코드를 만드는 모든 과정에서 사용되는 가장 중요한 도구다. 스프링을 DI 프레임워크라고 부르는 이유는 외부 설정정보를 통한 런타임 오브젝트 DI라는 단순한 기능을 제공하기 때문이 아니다. 오히려 스프링이 DI에 담긴 원칙과 이를 응용하는 프로그래밍 모델을 자바 엔터프라이즈 기술의 많은 문제를 해결하는 데 적극적으로 활용하고 있기 때문이다. 또, 스프링과 마찬가지로 스프링을 사용하는 개발자가 만드는 애플리케이션 코드 또한 이런 DI를 활용해서 깔끔하고 유연한 코드와 설계를 만들어낼 수 있도록 지원하고 지지해주기 때문이다.

 

 

스프링 프레임워크의 핵심 기술인 어노테이션 기반의 DI 컨테이너를 직접 구현하면서 스프링 DI에 대한 이해도를 높여보았습니다.

저희가 스프링을 사용하지 않았다면 결국엔 직접 구현체를 넣어주거나 DI 컨테이너를 따로 구현해주어야 했을 것입니다.

스프링이 DI를 해주는 덕분에 저희는 애플리케이션 코드에만 집중할 수 있고 더 깔끔하고 유연한 설계를 할 수 있었습니다.

 

고마워 최고스프링아