공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.
DI 개념 및 장점
DI (Dependency Injection)
- 의존성 주입
- 한 객체가 다른 객체를 사용할 때 의존성이 있다고 한다.
- 런타임 시 의존 관계를 맺는 대상을 외부에서 결정하고 주입해 주는 것이다.
- 스프링 프레임워크는 DI 기능을 지원해 주는 프레임워크이다.
장점
- 의존성 주입을 인터페이스 기반으로 설계하면, 코드가 유연해진다.
- 느슨한 결합도(loose coupling)
- 변경에 유연해진다.
- 결합도가 낮은 객체끼리는 부품을 쉽게 갈아끼울 수 있다.
- 테스트하기 좋은 코드가 된다.
DI 프레임워크 만들기
DI(Dependency Injection) 프레임워크의 핵심은 객체 간의 의존성을 관리하고 주입하는 것이다. 이를 위해 어노테이션을 사용하여 특정 클래스를 자동으로 인식하고 의존성을 주입하는 메커니즘을 구현할 것이다.
1. 어노테이션 정의
어노테이션을 사용하여 프레임워크가 자동으로 처리할 수 있는 클래스를 지정한다.
여기서는 @Controller, @Service, @Inject 세 가지 어노테이션을 사용한다.
// Controller.java
package org.example.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Controller {
}
// Service.java
package org.example.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Service {
}
// Inject.java
package org.example.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.CONSTRUCTOR, ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Inject {
}
- @Controller: 컨트롤러 역할을 하는 클래스를 지정한다.
- @Service: 서비스 역할을 하는 클래스를 지정한다.
- @Inject: 의존성을 주입할 필드나 생성자, 메서드를 지정한다.
2. 클래스 구현
이제 @Controller와 @Service 어노테이션을 사용하여 클래스를 구현한다. 여기서 UserController 클래스는 UserService 클래스에 의존성을 가지며, 이는 @Inject 어노테이션을 통해 주입된다.
// UserController.java
package org.example.controller;
import org.example.annotation.Controller;
import org.example.annotation.Inject;
import org.example.service.UserService;
@Controller
public class UserController {
private final UserService userService;
@Inject
public UserController(UserService userService) {
this.userService = userService;
}
public UserService getUserService() {
return userService;
}
}
// UserService.java
package org.example.service;
import org.example.annotation.Service;
@Service
public class UserService {
}
- @Controller 어노테이션이 붙은 UserController 클래스가 UserService 객체를 주입받아 사용한다.
- @Inject 어노테이션을 통해 생성자 주입 방식으로 의존성을 설정하고 있다.
3. BeanFactory 클래스(★중요★)
BeanFactory 클래스는 어노테이션 기반으로 객체를 자동으로 생성하고 관리한다. 여기서 각 클래스의 인스턴스를 생성하고, 의존성을 주입하는 기능을 구현한다.
package org.example.di;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.*;
public class BeanFactory {
private final Set<Class<?>> preInstantiatedClazz;
private Map<Class<?>, Object> beans = new HashMap<>();
// 생성자: 초기화 및 객체 생성
public BeanFactory(Set<Class<?>> preInstantiatedClazz) {
this.preInstantiatedClazz = preInstantiatedClazz;
initialize();
}
// preInstantiatedClazz에 있는 클래스들의 인스턴스를 생성하여 beans 맵에 저장
private void initialize() {
for (Class<?> clazz : preInstantiatedClazz) {
Object instance = createInstance(clazz);
beans.put(clazz, instance);
}
}
// 클래스의 인스턴스를 생성, 생성자 파라미터에 맞는 객체를 주입
private Object createInstance(Class<?> clazz) {
//UserController의 생성자를 찾는다.
Constructor<?> constructor = findConstructor(clazz);
List<Object> parameters = new ArrayList<>();
for (Class<?> typeClass : constructor.getParameterTypes()) {
//UserController는 UserService 인스턴스를 필요로 하므로 getParameterByClass가 호출된다.
parameters.add(getParameterByClass(typeClass));
}
try {
//★★★2. UserService 인스턴스를 주입받은 UserController 인스턴스 생성★★★
return constructor.newInstance(parameters.toArray());
} catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}
// 클래스의 생성자 중 @Inject 어노테이션이 붙은 것을 찾고 없으면 기본 생성자를 사용
private Constructor<?> findConstructor(Class<?> clazz) {
Constructor<?> constructor = BeanFactoryUtils.getInjectedConstructor(clazz);
if (Objects.nonNull(constructor)) {
return constructor;
}
return clazz.getConstructors()[0];
}
// 주어진 타입의 객체를 반환, 없으면 새로 생성
private Object getParameterByClass(Class<?> typeClass) {
Object instanceBean = getBean(typeClass);
if (Objects.nonNull(instanceBean)) {
return instanceBean;
}
// 1. 여기서 Service인스턴스 먼저 생성
return createInstance(typeClass);
}
// beans 맵에서 주어진 타입의 객체를 반환
public <T> T getBean(Class<T> requiredType) {
return (T) beans.get(requiredType);
}
}
- 필드
- preInstantiatedClazz: DI 컨테이너가 관리할 클래스의 집합이다. 이 클래스들은 어노테이션을 기반으로 사전에 인스턴스화된다.
- beans: 생성된 객체를 저장하는 맵으로, 클래스 타입을 키로 사용하여 해당 클래스의 인스턴스를 관리한다.
- 생성자
- BeanFactory(Set<Class<?>> preInstantiatedClazz): 생성자는 preInstantiatedClazz를 받아 초기화하고, initialize 메서드를 호출하여 해당 클래스들의 인스턴스를 생성하여 beans 맵에 저장한다.
- 주요 메서드
- initialize(): preInstantiatedClazz에 있는 클래스들을 순회하며 각각의 인스턴스를 생성하고 beans 맵에 저장한다.
- createInstance(Class<?> clazz): 주어진 클래스의 인스턴스를 생성하는 메서드이다. 이 메서드는 먼저 해당 클래스의 생성자를 찾고, 생성자의 파라미터 타입에 맞는 인스턴스를 준비하여 객체를 생성한다.
- findConstructor(Class<?> clazz): 주어진 클래스에서 @Inject 어노테이션이 붙은 생성자를 찾고, 없으면 기본 생성자를 반환한다.
- getParameterByClass(Class<?> typeClass): 생성자의 파라미터로 사용될 객체를 반환한다. 이 객체가 이미 beans 맵에 존재하면 이를 반환하고, 없으면 새로운 인스턴스를 생성한다.
- getBean(Class<T> requiredType): beans 맵에서 주어진 타입의 인스턴스를 반환한다.
4. BeanFactoryUtils 클래스
이 클래스는 주로 유틸리티 기능을 제공하며, 특정 어노테이션이 적용된 생성자를 찾는 기능을 담당한다.
package org.example.di;
import org.example.annotation.Inject;
import org.reflections.ReflectionUtils;
import java.lang.reflect.Constructor;
import java.util.Set;
public class BeanFactoryUtils {
// 주어진 클래스에서 @Inject 어노테이션이 붙은 생성자를 찾음
public static Constructor<?> getInjectedConstructor(Class<?> clazz) {
Set<Constructor> injectedConstructors = ReflectionUtils.getAllConstructors(clazz, ReflectionUtils.withAnnotation(Inject.class));
if (injectedConstructors.isEmpty()) {
return null;
}
return injectedConstructors.iterator().next();
}
}
- BeanFactoryUtils는 유틸리티 클래스로, 주로 어노테이션 기반의 생성자를 찾는 역할을 한다.
- DI 프레임워크는 이 클래스의 도움을 받아 @Inject 어노테이션이 붙은 생성자를 우선적으로 사용한다.
- getInjectedConstructor(Class<?> clazz)
- 이 메서드는 주어진 클래스에서 @Inject 어노테이션이 붙은 생성자를 검색한다.
- 만약 해당 생성자가 존재하지 않으면 null을 반환하고, 존재할 경우 해당 생성자를 반환한다.
5. DI 프레임워크 테스트
DI 프레임워크가 제대로 작동하는지 확인하기 위해 테스트를 작성한다. 테스트에서는 BeanFactory를 통해 UserController와 UserService가 올바르게 생성되고 주입되는지 확인한다.
package org.example.di;
import org.example.annotation.Controller;
import org.example.annotation.Service;
import org.example.controller.UserController;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.reflections.Reflections;
import java.lang.annotation.Annotation;
import java.util.HashSet;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class BeanFactoryTest {
private Reflections reflections;
private BeanFactory beanFactory;
// 테스트 실행 전 BeanFactory 및 Reflections 초기화
@BeforeEach
void setUp() {
reflections = new Reflections("org.example");
Set<Class<?>> preInstantiatedClazz = getTypesAnnotatedWith(Controller.class, Service.class);
beanFactory = new BeanFactory(preInstantiatedClazz);
}
// 주어진 어노테이션이 붙은 클래스들을 반환
private Set<Class<?>> getTypesAnnotatedWith(Class<? extends Annotation> ... annotations) {
Set<Class<?>> beans = new HashSet<>();
for (Class<? extends Annotation> annotation : annotations) {
beans.addAll(reflections.getTypesAnnotatedWith(annotation));
}
return beans;
}
// BeanFactory가 의존성 주입을 올바르게 수행하는지 테스트
@Test
void diTest() {
UserController userController = beanFactory.getBean(UserController.class);
assertThat(userController).isNotNull();
assertThat(userController.getUserService()).isNotNull();
}
}
- 필드
- reflections: org.reflections.Reflections 라이브러리를 사용하여 클래스 경로를 스캔하고 어노테이션이 붙은 클래스를 찾는다.
- beanFactory: 테스트 대상인 BeanFactory 인스턴스이다.
- 주요 메서드
- setUp(): 테스트 실행 전에 호출되며, Reflections 인스턴스를 초기화하고 BeanFactory를 설정한다.
- getTypesAnnotatedWith(Class<? extends Annotation>... annotations): 주어진 어노테이션이 붙은 클래스들을 검색한다.
- diTest(): BeanFactory가 UserController와 그 의존성인 UserService를 올바르게 인스턴스화하고 주입하는지 검증한다.
전체흐름
1. 초기화 (Initialization)
- Input: 어노테이션이 붙은 클래스들의 집합 (preInstantiatedClazz)
- Process: BeanFactory 생성자에서 initialize() 메서드를 호출
2. 클래스 인스턴스화 (Creation)
- Input: preInstantiatedClazz에 포함된 각 클래스 (UserController, UserService)
- Process: createInstance(Class<?> clazz) 메서드 호출
- Output: 클래스 인스턴스
3. 의존성 주입 (Dependency Injection)
- Input: 생성자의 파라미터 타입 (UserService)
- Process: getParameterByClass(Class<?> typeClass) 메서드를 통해 의존성 객체 검색 또는 생성
- Output: 필요한 의존성 객체 주입
4. 객체 저장 (Storage)
- Process: beans 맵에 클래스와 인스턴스 매핑 저장
- Output: 맵에 저장된 객체들
5. 객체 검색 (Retrieval)
- Input: 필요한 클래스 타입 (UserController.class)
- Process: getBean(Class<T> requiredType) 메서드를 통해 beans 맵에서 객체 검색
- Output: 요청된 클래스의 인스턴스
이 과정에서 BeanFactory는 preInstantiatedClazz에 포함된 클래스들을 인스턴스화하고, 필요한 의존성을 주입하며, 이러한 인스턴스를 beans 맵에 저장하여 관리한다. 이후 getBean 메서드를 통해 필요한 객체를 검색하고 반환한다. 예를 들어, UserController를 요청하면, UserService가 주입된 상태로 UserController 인스턴스를 반환한다.
의존성 주입의 중요성
의존성 주입(Dependency Injection)은 소프트웨어 설계의 중요한 패턴 중 하나로, 코드의 유연성, 테스트 가능성, 유지보수성을 크게 향상시킨다. UserService가 UserController에 주입되는 경우와 그렇지 않은 경우의 차이점을 구체적으로 설명한다.
1. 의존성 관리 측면
UserService가 주입되는 경우
- 의존성 주입 (Dependency Injection): UserController는 외부에서 UserService를 주입받는다. 이 과정에서 BeanFactory와 같은 DI 컨테이너가 자동으로 UserService 객체를 생성하고 주입한다.
- 낮은 결합도: UserController와 UserService 간의 결합도가 낮아진다. UserController는 UserService의 구체적인 생성 방법에 대해 알 필요가 없으며, 오직 인터페이스와 기능에만 의존한다.
@Controller
public class UserController {
private final UserService userService;
@Inject
public UserController(UserService userService) {
this.userService = userService;
}
// UserService를 사용하는 메서드들
}
UserService가 주입되지 않는 경우
- 직접 생성: UserController가 UserService의 인스턴스를 직접 생성한다. 이는 클래스 간 결합도를 높이며, UserController가 UserService의 생성 책임까지 가지게 된다.
- 높은 결합도: 모든 변경 사항이 UserController에 직접적인 영향을 미칠 수 있어, 코드의 유연성과 유지보수성이 떨어진다.
@Controller
public class UserController {
private final UserService userService = new UserService();
// UserService를 사용하는 메서드들
}
2. 기능 사용 및 코드 구조
UserService가 주입되는 경우
- 기능 분리: UserService는 비즈니스 로직을 담당하고, UserController는 사용자 요청을 처리하는 역할을 수행한다. 이는 각 클래스가 자신의 역할에 집중할 수 있게 해준다.
- 테스트 용이성: 의존성 주입을 통해 UserService를 모의 객체(mock)로 대체할 수 있어, UserController의 테스트가 더욱 간편해진다.
UserService가 주입되지 않는 경우
- 기능 통합: 비즈니스 로직과 사용자 요청 처리가 UserController에 혼합될 수 있다. 이는 코드의 복잡성을 증가시키고, 유지보수를 어렵게 만든다.
- 테스트 어려움: UserService의 인스턴스가 고정되어 있어 테스트 시에 모의 객체로 대체하기 어렵다.
결론
UserService와 같은 의존성을 주입하는 방식은 코드의 결합도를 낮추고, 각 클래스가 자신의 역할에 충실하도록 해준다. 이는 코드의 가독성과 유지보수성을 높이며, 테스트를 용이하게 한다. 반면, 의존성을 직접 관리하는 방식은 결합도가 높아져 코드의 유연성을 떨어뜨리고, 변경에 취약하게 만든다.
의존성 주입을 통해 더 유연하고 관리하기 쉬운 코드를 작성하는 것은 현대 소프트웨어 개발의 중요한 원칙 중 하나이다. 이를 통해 더 나은 구조의 애플리케이션을 구축할 수 있다.
'BackEnd > Project' 카테고리의 다른 글
[Board] Ch01. 깃허브 프로젝트 관리 (0) | 2024.08.05 |
---|---|
[Board] Ch01. 프로젝트 기획 (0) | 2024.08.04 |
[MVC] Ch06. MVC 프레임워크(4) (0) | 2024.08.01 |
[MVC] Ch06. MVC 프레임워크(3) (0) | 2024.08.01 |
[MVC] Ch06. MVC 프레임워크(2) (0) | 2024.07.31 |