DI 컨테이너 구현하기

디우·2022년 10월 12일
2

스프링을 사용하다 보면 자연스럽게 DI 라는 용어를 접할 수 있고, 스프링은 DI 컨테이너라는 말을 들어보게 된다.
그런데 막상 DI 가 뭐야? DI 컨테이너가 뭐야? 라고 물어보면 제대로 답변하기 힘들다.

그래서 이번에는 스프링 프레임워크의 핵심 기술인 DI 컨테이너를 직접 구현해보면서 스프링 DI에 대한 이해도를 높여보려고 한다.

혹시 DI에 대한 개념이 부족하다면 DI와 서비스 로케이터를 비교하며 학습하였던 내용인 DI(Dependency Injection)와 서비스 로케이터 를 참고하면 도움이 될 것 같다.


DI 컨테이너란?

스프링을 IoC 컨테이너, DI 컨테이너라고 부르는데 DI 컨테이너란 무엇일까?
그 전에 DI, 즉 의존성 주입이란 무엇일까? 위키에 따르면 프로그램 디자인이 결합도를 느슨하게 되도록하고 의존관계 역전 원칙(DIP)와 단일 책임 원칙(SRP)를 따르도록 클라이언트의 생성에 대한 의존성을 클라이언트의 행위로부터 분리하는 것이다.

예시를 보자.

public Class Client {
	private final Service service;
    
    public Client() {
    	this.service = new ConcreteService;
    }
}

위와 같이 구체(Concrete) 클래스를 직접 생성해서 사용하고 있게 되면 의존 역전 원칙을 위반하게 되고 결국 개방 폐쇄 원칙(OCP)를 지키지 어렵게 된다. 이 말은 즉 유연하지 못한 코드가 된다. 만약 서비스 해주는 구체 클래스를 변경하고 싶다면 Client 코드를 직접 변경해주어야 한다. 하지만 아래와 같이 구현한다면 외부에서 의존성을 주입해주기 때문에 변경이 있다면 Client 코드는 수정하지 않아주어도 된다.

public Class Client {
	private final Service service;
    
    public Client(final Service service) {
    	this.service = service;
    }
}

그럼 이제 본격적으로 DI 컨테이너란 무엇이고, 스프링은 이것을 왜 제공할까?
우선 스프링을 DI 컨테이너라고 하는 이유는 간단하게 위와 같은 의존성 주입을 제공해주는 컨테이너이기 때문이다 라고 생각할 수 있다. 그럼 여기서 궁금한 점은 왜 이런 기능을 제공할까 이다. 스프링 프레임워크는 엔터프라이즈급의 애플리케이션을 만들기 위한 용도의 프레임워크이다. 즉, 굉장히 규모 있는 코드를 작성하기 위한 도구이다. 따라서 적절하게 책임과 관심을 분리하고 서로 영향을 주지 않도록 다양한 추상화를 도입해야만이 관리가 가능한 애플리케이션이 될 수 있다. 이를 위한 핵심 기술로써 스프링은 DI를 제공하는 것이다. 만약 이러한 의존성 주입을 제공해주지 않는다면 우리는 스프링을 통해 큰 규모의 코드를 작성하고 관리하는 것이 어렵게 되며 코드 사이의 결합도 또한 높아지게 될 것이다.

구체적으로 스프링은 어떤 식으로 DI 를 제공할까? 스프링은 빈이라고 하는 객체를 관리한다. 여기서 관리라고 하면 객체에 대한 생성과 제어를 담당하는 것을 말하며 스프링은 이렇게 빈이라고 하는 객체를 빈 팩터리(bean factory)를 통해서 관리하게 된다. 빈 팩터리는 빈을 등록하고, 생성하며 조회, 반환하는 등의 빈과 관련된 작업을 수행한다.
그리고 이렇게 객체에 대한 제어를 개발자가 하는 것이 아니라 스프링 컨테이너(DI 컨테이너)가 하게 됨으로써 우리는 제어 권한을 개발자가 아닌 다른 대상, 즉 스프링에게 위임하게 되었다. 이렇게 제어의 역전이 일어나게 되므로 DI 컨테이너를 IoC 컨테이너 라고도 한다.

그럼 DI 컨테이너 실습을 통해서 조금 더 자세하게 알아보자.


DI 컨테이너 실습

Stage 0

class UserDao {

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

    public static void insert(User user) {
        users.put(user.getId(), user);
    }

    public static User findById(long id) {
        return users.get(id);
    }
}
class UserService {

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

위와 같은 Dao 클래스와 Service 클래스가 있다고 하자.

여기서 Service 클래스의 join 메소드는 정적(static) 메소드로 제공되며 UserDao 와 결합도가 높게 구현되어 있다. 만약 여기서 UserDao 가 아닌 다른 방식으로 DB를 사용하려고 하면 Service가 수정되어야만 한다.

class Stage0Test {

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

        final var actual = UserService.join(user);

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

정리해보면 현재 Service 클래스는 전혀 객체지향스럽지 않은 코드이며, DB와는 별개로 서비스단의 비즈니스 로직을 테스트하기가 어렵게 된다.(현재 예시는 Map 을 사용하는 메모리에 저장하는 방식이지만 실제 DB와 연동되는 Dao 라고 생각한다면) 또한 만약 DB에 대한 접근 기술의 변경 등 Dao 가 변경되게 되면 Service 코드를 수정해주어야하는 문제점이 존재하게 된다.

Stage 1

Stage 0 에서 만난 DAO 객체 변경에 따른 Service 코드 수정의 문제를 우리는 생성자 주입을 통해서 개선해줄 수 있다.

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());
    }
}
class Stage1Test {

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

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

        final var actual = userService.join(user);

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

하지만 아직 문제가 존재한다. UserDao 라는 구체 클래스에 여전히 의존하고 있는 것이다.
따라서 우리는 인터페이스를 도입함으로써 이러한 결합도 문제를 해결해줄 수 있다.

Stage 2

interface UserDao {

    void insert(User user);

    User findById(long id);
}

위와 같은 인터페이스를 도출하고, Service에서는 해당 인터페이스에 의존하도록 구현한다.

그리고 구체(Concrete) 클래스에서는 아래와 같이 MemberUserDao 로 구현해줄 수도 있고,

class MemoryUserDao implements UserDao {

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

	@Override
    public static void insert(User user) {
        users.put(user.getId(), user);
    }

	@Override
    public static User findById(long id) {
        return users.get(id);
    }
}

만약 실제 DB와 연결되는 Dao 가 필요하다면 JdbcTemplate 을 사용하는 JdbcUserDao 를 만들고, UserDao 를 implements(구현)하도록 구성해줄 수도 있다.

하지만 여전히 문제가 존재한다. 인터페이스를 통해서 Service 와 Dao 사이의 결합도를 낮춰주는데에는 성공했지만, 여전히 누군가는 Service 생성자에서 주입해줄 Dao 의 구현 객체를 결정해주어야 한다는 것이다.
즉, 우리는 객체를 생성하고 연결해주는 역할이 필요하게 된다.

Stage 3

class DIContainer {

    private final Set<Object> beans;

    public DIContainer(final Set<Class<?>> classes) {
        this.beans = createBeans(classes);
        this.beans.forEach(this::setFields);
    }

    private Set<Object> createBeans(final Set<Class<?>> classes) {
        Set<Object> beans = new HashSet<>();
        for (Class<?> aClass : classes) {
            beans.add(createInstance(aClass));
        }
        return beans;
    }

    private static Object createInstance(final Class<?> aClass) {
        try {
            final Constructor<?> constructor = aClass.getDeclaredConstructor();
            constructor.setAccessible(true);
            return constructor.newInstance();
        } catch(InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
            throw new RuntimeException();
        }
    }

    private void setFields(final Object bean) {
        final Field[] fields = bean.getClass().getDeclaredFields();

        for (Field field : fields) {
            setBeanField(bean, field);
        }
    }

    private void setBeanField(final Object bean, final Field field) {
        try {
            injectField(bean, field);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }

    private void injectField(final Object bean, final Field field) throws IllegalAccessException {
        field.setAccessible(true);
        final Class<?> fieldType = field.getType();
        for (Object o : beans) {
            if (fieldType.isAssignableFrom(o.getClass())) {
                field.set(bean, o);
            }
        }
    }

    // 빈 컨텍스트(DI)에서 관리하는 빈을 찾아서 반환한다.
    @SuppressWarnings("unchecked")
    public <T> T getBean(final Class<T> aClass) {
        return (T) beans.stream()
                .filter(bean -> aClass.isAssignableFrom(bean.getClass()))
                .findFirst()
                .orElseThrow(IllegalArgumentException::new);
    }
}

앞서 언급한 문제를 위와 같은 DI 컨테이너를 통해서 해결해줄 수 있다. 즉, DI 컨테이너는 객체를 생성하고 연결해주는 역할을 하게 된다. DIContainer 클래스의 생성자에 우리가 빈으로 등록할 클래스 정보들을 넘겨준다.
그럼 Reflection을 이용해서 클래스 정보를 통해서 인스턴스를 생성하고 필드인 beans 에 저장해둔다.
(만약 Reflection에 대한 개념이 약하다면, 자바 Reflection 을 참고하길 바란다.)

그리고 인스턴스 생성과 함께 필드들에 대한 초기화를 진행해준다. 이 때 우리가 주목해야할 것은 injectField() 메소드인 것 같다. 즉, 빈들 중에서 할당 가능한 빈이 있을 경우 해당 빈을 등록해주는 것이다.
조금 더 구체적인 이해를 위해서 에시를 들어보자.

class Stage3Test {

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

        final var diContainer = createDIContainer();

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

        final var actual = userService.join(user);

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

    private static DIContainer createDIContainer() {
        var classes = new HashSet<Class<?>>();
        classes.add(MemoryUserDao.class);
        classes.add(UserService.class);
        return new DIContainer(classes);
    }
}

위와 같은 테스트 코드가 있다고 하자. 그러면 현재 우리는 DIContainer 에 MemoryUserDao 와 UserService 클래스 정보를 주었으므로 해당 정보를 가지고 인스턴스를 생성하고, beans 필드에 저장하게 될 것이다. 그리고 나서 해당 클래스들의 필드 정보를 채우게 될 텐데, Service 클래스를 보면 private UserDao userDao 필드를 확인해볼 수 있다. 그런데 여기서 beans 에 MemberUserDao 가 저장되어 있을 것이고, 따라서 DIContainerinjectField() 메소드에서 빈으로 등록한 MemoryUserDao 가 필드로 주입될 것이다.
즉, 지금 우리가 DIContainer 에 등록한 빈들 간에 의존성 주입을 DI 컨테이너가 알아서 해주고 있다.

Stage 4

위의 DIContainer 에서 만족할 수도 있지만, DI 컨테이너에 등록할 클래스들, 즉 빈들을 직접 명시해주어야 한다는 단점이 존재한다. 그런데 스프링에서는 우리가 직접 이렇게 등록할 빈들을 직접 명시해주지 않는다. 어떻게 해결하면 좋을까? 이 또한 Reflection을 활용해볼 수 있다.
Service 클래스에 대해서는 @Service 어노테이션을, DAO 클래스에 대해서는 @Repository 어노테이션을 붙여 주고, 주입할 필드는 @Inject 어노테이션을 붙여서 주입할 것임을 알려주는 것이다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Service {
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Repository {
}
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Inject {
}

위와 같은 어노테이션을 만들고, Service, Dao 클래스에 위의 어노테이션을 붙여준다.

@Service
class UserService {

    @Inject
    private UserDao userDao;

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

    private UserService() {}
}
@Repository
class MemoryUserDao {

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

    public static void insert(User user) {
        users.put(user.getId(), user);
    }

    public static User findById(long id) {
        return users.get(id);
    }
}

그리고 나서 DI Container는 ClassPathScanner 를 이용해서 특정 패키지 아래에 존재하는 Repository, Service, Inject 어노테이션이 붙어 있는 모든 클래스들을 찾아주고, 이를 이용해서 빈을 등록해주도록 구성해주면 된다.

public class ClassPathScanner {

    public static Set<Class<?>> getAllClassesInPackage(final String packageName) {
        Set<Class<?>> classes = new HashSet<>();
        Reflections reflections = new Reflections(packageName);

        final Set<Class<?>> repositoryClasses = reflections.getTypesAnnotatedWith(Repository.class);
        final Set<Class<?>> serviceClasses = reflections.getTypesAnnotatedWith(Service.class);
        final Set<Class<?>> injectClasses = reflections.getTypesAnnotatedWith(Inject.class);

        classes.addAll(repositoryClasses);
        classes.addAll(serviceClasses);
        classes.addAll(injectClasses);

        return classes;
    }
}

또한 빈들의 필드를 주입할 때에는 hasInjectAnnotation() 메소드를 활용해서 @Inject 어노테이션이 붙어 있는 필드에 대해서만 주입을 진행해줄 수 있도록 수정해준다.

class DIContainer {

    private final Set<Object> beans;

    public DIContainer(final Set<Class<?>> classes) {
        this.beans = createBeans(classes);
        this.beans.forEach(this::setFields);
    }

    public static DIContainer createContainerForPackage(final String rootPackageName) {
        final Set<Class<?>> allClassesInPackage = ClassPathScanner.getAllClassesInPackage(rootPackageName);
        return new DIContainer(allClassesInPackage);
    }

    private Set<Object> createBeans(final Set<Class<?>> classes) {
        Set<Object> beans = new HashSet<>();
        for (Class<?> aClass : classes) {
            beans.add(createInstance(aClass));
        }
        return beans;
    }

    private static Object createInstance(final Class<?> aClass) {
        try {
            final Constructor<?> constructor = aClass.getDeclaredConstructor();
            constructor.setAccessible(true);
            return constructor.newInstance();
        } catch(InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
            throw new RuntimeException();
        }
    }

    private void setFields(final Object bean) {
        final Field[] fields = bean.getClass().getDeclaredFields();

        for (Field field : fields) {
            setBeanField(bean, field);
        }
    }

    private void setBeanField(final Object bean, final Field field) {
        try {
            injectField(bean, field);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }

    private void injectField(final Object bean, final Field field) throws IllegalAccessException {
        field.setAccessible(true);
        if (hasInjectAnnotation(field)) {
            field.set(bean, getBean(field.getType()));
        }
    }

    private boolean hasInjectAnnotation(final Field field) {
        return field.isAnnotationPresent(Inject.class);
    }

    @SuppressWarnings("unchecked")
    public <T> T getBean(final Class<T> aClass) {
        return (T) beans.stream()
                .filter(bean -> aClass.isAssignableFrom(bean.getClass()))
                .findFirst()
                .orElseThrow(IllegalArgumentException::new);
    }
}

이와 같이 구현해주고 나면 스프링과 같이 특정 패키지 아래에 속해있는 클래스들 중에 어노테이션을 기반으로 해서 선언적으로 빈을 등록하고, 주입해줄 수 있는 DI Container를 완성하게 된다.

class Stage4Test {

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

        final var diContext = createDIContainer();
        final var userService = diContext.getBean(UserService.class);

        final var actual = userService.join(user);

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

    private static DIContainer createDIContainer() {
        final var rootPackageName = Stage4Test.class.getPackage().getName();
        return DIContainer.createContainerForPackage(rootPackageName);
    }
}

위의 총 Stage0 부터 Stage4 까지 4번의 걸친 개선을 통해서 DIContainer 를 구현해보았고, 이를 통해서 스프링의 DI Container 개념에 대해서 학습해보았다.

이 글에서 최대한 핵심이 되는 코드는 모두 실었지만, 분량상 생략한 코드가 존재해 이해하기 어렵다면 DI 컨테이너 실습한 Repository 에서 커밋기록들을 보며 학습해보면 좋을 것 같다.

profile
꾸준함에서 의미를 찾자!

2개의 댓글

comment-user-thumbnail
2023년 1월 12일

잘보고갑니당~

답글 달기
comment-user-thumbnail
2023년 12월 7일

엄청난 글입니다.. 많이 배우고 갑니다!!

답글 달기