11.1 왜 DI가 필요한가?
의존관계란 객체 혼자 모든 일을 처리하기 힘들기 때문에 내가 해야 할 작업을 다른 객체에게 위임하면서 발생한다.
즉, 내가 가지고 있는 책임과 역할을 다른 객체에 위임하는 순간 발생하는 것이다. (중략..)
DI는 객체 간의 의존관계를 어떻게 해결하느냐에 따른 새로운 접근 방식이다.
지금까지 우리는 의존관계에 있는 객체를 사용하기 위해 객체를 직접 생성하고, 사용하는 방식으로 구현했다. (중략..)
이 같은 방식으로 구현할 경우 유연한 개발을 하는데 한계가 있기 때문에 인스턴스를 생성하는 책임과 사용하는 책임을 분리하자는 것이다.
(중략..)
이처럼 객체 간의 의존관계에 대한 결정권을, 의존관계를 가지는 객체가 가지는 것이 아니라 외부의 누군가가 담당하도록 맡겨 버림으로써 좀 더 유연한 구조로 개발하는 것을 DI라고한다.
일반적으로 유연한 구조의 애플리케이션은 변화를 최소화하면서 확장하기도 쉽고, 테스트하기도 쉽다는 것을 의미한다.
11.3.1 싱글톤 패턴을 제거한 DI
앞서 Controller, Service, Repository 클래스의 인스턴스를 싱글톤으로 사용하던 방식을 모두 제거하고 아래와 같이 생성자 주입 방식으로 변경한다
public class QuestionService {
private QuestionDao questionDao;
private AnswerDao answerDao;
public QuestionService(QuestionDao questionDao, AnswerDao answerDao) {
this.questionDao = questionDao;
this.answerDao = answerDao;
}
}
LegacyHandlerMapping 리팩토링
initMapping() 실행시 의존관계를 직접 처리하여 주입할 수 있도록 변경한다
public class LegacyHandlerMapping implements HandlerMapping {
private static final Logger log = LoggerFactory.getLogger(LegacyHandlerMapping.class);
private Map<String, Controller> mapping = new HashMap<>();
public LegacyHandlerMapping() {}
public void initMapping() {
QuestionDao questionDao = new JdbcQuestionDao();
AnswerDao answerDao = new JdbcAnswerDao();
QuestionService questionService = new QuestionService(questionDao, answerDao);
mapping.put("/qna/show", new ShowController(questionDao, answerDao));
mapping.put("/qna/form", new CreateFormQuestionController());
mapping.put("/qna/create", new CreateQuestionController(questionDao));
mapping.put("/qna/updateForm", new UpdateFormQuestionController(questionDao));
mapping.put("/qna/update", new UpdateQuestionController(questionDao));
mapping.put("/qna/delete", new DeleteQuestionController(questionService));
mapping.put("/api/qna/addAnswer", new AddAnswerController(answerDao, questionDao));
mapping.put("/api/qna/deleteAnswer", new DeleteAnswerController(answerDao));
mapping.put("/api/qna/deleteQuestion", new ApiDeleteQuestionController(questionService));
log.info("Init Request Mapping!");
}
}
p385
싱글톤 패턴을 제거하는 단순한 작업으로 생각했는데 예상보다 수정할 부분이 많았다. 싱글톤 패턴을 사용하지 않으면서 인스턴스 하나를 사용하도록 하려면 컨트롤러에서 시작해서 꼬리에 꼬리를 물고 DI하는 구조로 구현해야 한다.
이 예제의 경우 의존 관계 깊이가 깊지 않아 다행이지만 의존관계의 깊이가 깊어지면 객체 간의 의존관계를 연결하는 작업이 만만치 않겠다.
11.3.2 Mockito를 활용한 테스트
데이터베이스가 없는 상태에서도 테스트 가능하도록 QuestionDao, AnswerDao에 대한 가짜(Mock)클래스를 구현한다
AnswerDao 인터페이스
public interface AnswerDao {
Answer insert(Answer answer);
Answer findById(long answerId);
List<Answer> findAllByQuestionId(long questionId);
void delete(long answerId);
int count(long questionId);
}
QuestionDao 인터페이스
public interface QuestionDao {
List<Question> findAll();
Question findById(long questionId);
Question insert(Question question);
void updateCountOfAnswer(long questionId);
void update(Question question);
void delete(long questionId);
}
MockAnswerDao 클래스
public class MockAnswerDao implements AnswerDao {
private Map<Long, Answer> answers = new HashMap<>();
@Override
public Answer insert(Answer answer) {
return answers.put(answer.getAnswerId(), answer);
}
@Override
public Answer findById(long answerId) {
return answers.get(answerId);
}
@Override
public List<Answer> findAllByQuestionId(long questionId) {
return answers.values().stream()
.filter(q -> q.getQuestionId() == questionId)
.collect(Collectors.toList());
}
@Override
public void delete(long answerId) {
answers.remove(answerId);
}
@Override
public int count(long questionId) {
return answers.size();
}
}
MockQuestionDao 클래스
public class MockQuestionDao implements QuestionDao {
private Map<Long, Question> questions = new HashMap<>();
@Override
public List<Question> findAll() {
return questions.values().stream().collect(Collectors.toList());
}
@Override
public Question findById(long questionId) {
return questions.get(questionId);
}
@Override
public Question insert(Question question) {
return questions.put(question.getQuestionId(), question);
}
@Override
public void updateCountOfAnswer(long questionId) {
Question question = findById(questionId);
question.setCountOfAnswer(question.getCountOfAnswer() + 1);
}
@Override
public void update(Question question) {
findById(question.getQuestionId()).update(question.getTitle(), question.getContents());
}
@Override
public void delete(long questionId) {
questions.remove(questionId);
}
}
테스트 코드
public class QuestionServiceTest {
private QuestionService questionService;
private MockQuestionDao questionDao;
private MockAnswerDao answerDao;
@BeforeEach
void setUp() {
questionDao = new MockQuestionDao();
answerDao = new MockAnswerDao();
questionService = new QuestionService(questionDao, answerDao);
}
@Test
void deleteQuestion_없는질문() {
assertThatThrownBy(() -> questionService.delete(1L, new User("userId")))
.isInstanceOf(CannotDeleteException.class)
.hasMessage("존재하지 않는 글입니다");
}
@Test
void deleteQuestion_다른_사용자() {
Question question = new Question(1L, "jinwoo");
questionDao.insert(question);
assertThatThrownBy(() -> questionService.delete(1L, new User("userId")))
.isInstanceOf(CannotDeleteException.class)
.hasMessage("다른 사용자가 쓴 글을 삭제할 수 없습니다");
}
@Test
void deleteQuestion_같은_사용자_답변없음() throws Exception {
Question question = new Question(1L, "jinwoo");
questionDao.insert(question);
questionService.delete(1L, new User("jinwoo"));
}
}
만약 QuestionService와 의존관계를 가지는 Controller를 테스트 한다면 어떻게 해야할까?
이 또한 QuestionService에 대한 Mock 클래스를 구현해야 한다. 이는 곧 테스트 코드를 작성하는데 있어 부담을 증가시킨다.
→ 그래서 Mock 테스트 프레임워크를 활용하도록 한다
pom.xml 의존성 추가
<!-- unit testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.8.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.23.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.8.2</version>
<scope>test</scope>
</dependency>
<!-- mockito -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>4.5.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>4.5.1</version>
<scope>test</scope>
</dependency>
QuestionServiceTest를 Mock 테스트로 변경
-Junit5 사용하고 있으므로, 클래스 레벨에 @ExtendWith(MockitoExtension.class) 추가해야 Mocking 가능
-@Mock : Mock 객체 , @InjectMocks : Mocking 객체를 주입할 대상
@ExtendWith(MockitoExtension.class)
public class QuestionServiceTest {
@Mock
private MockQuestionDao questionDao;
@Mock
private MockAnswerDao answerDao;
@InjectMocks
private QuestionService questionService;
@Test
void deleteQuestion_없는질문() {
when(questionDao.findById(1L)).thenReturn(null);
assertThatThrownBy(() -> questionService.delete(1L, new User("userId")))
.isInstanceOf(CannotDeleteException.class)
.hasMessage("존재하지 않는 글입니다");
}
@Test
void deleteQuestion_다른_사용자() {
Question question = new Question(1L, "jinwoo");
when(questionDao.findById(1L)).thenReturn(question);
assertThatThrownBy(() -> questionService.delete(1L, new User("userId")))
.isInstanceOf(CannotDeleteException.class)
.hasMessage("다른 사용자가 쓴 글을 삭제할 수 없습니다");
}
@Test
void deleteQuestion_같은_사용자_답변없음() throws Exception {
Question question = new Question(1L, "jinwoo");
when(questionDao.findById(1L)).thenReturn(question);
when(answerDao.findAllByQuestionId(1L)).thenReturn(Lists.newArrayList());
questionService.delete(1L, new User("jinwoo"));
}
}
*컨트롤러, 서비스와 같이 다른 클래스와 의존관계를 가지는 클래스를 테스트하는 경우 Mockito 프레임워크를 사용할 것을 추천한다
11.3.3 DI보다 우선하는 객체지향 개발
p391
"계층형 아키렉처 관점과 객체지향 설계 관점에서 핵심적인 비지니스 로직을 구현해야 하는 역할은 누가 담당해야 할까?"라는 질문에 대해 많은 개발들이 서비스 레이어를 담당하고 있는 QuestionService에서 처리해야 한다고 생각하고 있다.
하지만 이는 서비스 레이어의 역할에 맞지 않다. 핵심적인 비지니스 로직 구현은 도메인 객체가 하는 것이 맞다. 서비스 레이어의 핵심적인 역할은 도메인 객체들이 비즈니스 로직을 구현할 수 있도록 도메인 객체를 조합하거나, 로직처리를 완료했을 때의 상태 값을 DAO(Repository)를 활용해 데이터베이스에 영구 저장하는 등의 역할을 담당한다.
많은 개발자들이 QuestionService의 delete() 메서드와 같이 구현하는 것이 일반적인데 이는 대표적인 절차지향적인 개발 방법이다. 이와 같이 절차지향적으로 개발하면 서비스 레이어의 복잡도는 점점 더 증가하면서 유지보수와 테스트하기 힘든 상황이 발생한다. 또한 핵심 객체라 할 수 있는 도메인 객체는 사용자가 입력한 데이터를 DAO에 전달하거나 데이터베이스 데이터를 뷰에 전달 하는 역할 밖에 하지 않게 된다. 그렇다보니 도메인 객체가 값을 전달하는 getter, setter 메소드만 가지는 상황이 발생한다.
지금까지 이 같은 방식으로 개발했다면 지금부터라도 도메인 객체에 더 많은 일을 시켜보자.
(수정전) 절차 지향적 방식
(수정후) 객체 지향적 방식
*Question과 Answer 도메인 객체에 비즈니스 로직을 옮겨 서비스 레이어 로직이 간소화되었다
Question 클래스 일부
public boolean canDelete(User user, List<Answer> answers) throws CannotDeleteException {
if(!this.isSameUser(user)) {
throw new CannotDeleteException("다른 사용자가 쓴 글을 삭제할 수 없습니다");
}
for(Answer answer : answers) {
if(!answer.canDelete(user)) { // Answer 도메인 객체에 *.canDelete() 메소드 존재
throw new CannotDeleteException("다른 사용자의 답변이 존재하여 질문을 삭제할 수 없습니다");
}
}
return true;
}
p395~396
도메인 객체를 테스트하기 쉬운 코드로 만들려고 고민하다보면 자연스럽게 좀 더 객체지향적인 코드를 구현할 수 있게 된다
11.4 DI 프레임워크 실습
요구사항
① @Controller, @Service, @Repository 애노테이션을 설정한다
② 위 3개의 설정으로 생성된 각 인스턴스간의 의존관계는 @Inject 애노테이션을 사용한다.
③ @Controller, @Service, @Repository 설정에 의해 DI 프레임워크가 자동으로 생성한 인스턴스로 정의한다
④ 앞으로 DI 프레임워크에서 생성된 인스턴스는 빈(Bean)이라는 이름을 사용하자
1단계 힌트
① @Inject 애노테이션이 설정되어 있는 생성자를 통해 빈을 생성해야 한다.
② 다른 빈과 의존관계를 가지지 않는 빈을 찾아 인스턴스를 생성할 때 까지 "재귀"를 실행하는 방식으로 구현한다
③ 재귀를 통해 생성한 빈은 BeanFactory의 Map<Class<?>, Object>에 추가해 관리한다.
④ 인스턴스를 생성하기 전에 먼저 Map<Class<?>, Object>에 빈이 존재하는지 여부를 판단 후 존재하지 않는 경우 생성하는 방식으로 구현한다.(=일반적인 캐시 동작원리)
2단계 힌트
① 빈(Bean) 인스턴스를 생성하기 위한 재귀 함수를 지원하려면 Class에 대한 빈 인스턴스를 생성하는 메서드와 Construcotr에 대한 빈 인스턴스를 생성하는 메소드가 필요하다
② 재귀 함수의 시작은 instantiateClass()에서 시작한다.
③ @Inject 애노테이션이 설정된 생성자가 존재하면 instantiateConstructor() 메소드를 통해 인스턴스를 생성하고, 존재하지 않으면 기본 생성자로 인스턴스를 생성한다. (기본 생성자는 BeanUtils method 활용)
④ instantiateConstructor() 메소드는 생성자의 인자로 전달할 빈이 생성되어 Map<Class<?>, Object>에 이미 존재하면 해당 빈을 활용하고, 존재하지 않을 경우 instantiateClass() 메소드를 통해 빈을 생성한다. (ReflectionUtils method 활용)
① @Controller, @Service, @Repository, @Inject 정의
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Controller {
String value() default "";
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Service {
String value() default "";
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Repository {
String value() default "";
}
@Target({ElementType.TYPE, ElementType.CONSTRUCTOR})
@Retention(RetentionPolicy.RUNTIME)
public @interface Inject {
}
② BeanScanner Rename, 로직 리팩토링
- 인자로 basePackage 받아 Reflections 객체 초기화
- @Controller, @Service, @Repository 클래스 타입을 가변 인자로 전달하여 basePackage에 있는 모든 클래스 중 해당 애노테이션이 있는 클래스 타입 오브젝트를 찾아 반환
public class BeanScanner {
private Reflections reflections;
public BeanScanner(Object... basePackage) {
reflections = new Reflections(basePackage);
}
public Set<Class<?>> scan() {
return getTypesAnnotationWith(Controller.class, Service.class, Repository.class);
}
private Set<Class<?>> getTypesAnnotationWith(Class<? extends Annotation>... annotations) {
Set<Class<?>> preInstantiatedBeans = Sets.newHashSet();
for(Class<? extends Annotation> annotation : annotations) {
preInstantiatedBeans.addAll(reflections.getTypesAnnotatedWith(annotation));
}
return preInstantiatedBeans;
}
}
③ BeanFactory 리팩토링
@Inject 생성자가 없는 경우 기본 생성자로 빈을 생성, 있는 경우 instantiateConstructor() 메소드로 생성
ReflectionUtils API로 필요한 정보를 구한 뒤 BeanUtils API로 Bean 생성
이때 필요한 Bean이 생성되지 않은 경우(bean == null) instaniateClass() 메소드를 재귀 호출
BeanFactory 전체 코드
public class BeanFactory {
private static final Logger log = LoggerFactory.getLogger(BeanFactory.class);
private final Set<Class<?>> preInstantiatedBeans;
private Map<Class<?>, Object> beans = Maps.newHashMap();
public BeanFactory(Set<Class<?>> preInstantiatedBeans) {
this.preInstantiatedBeans = preInstantiatedBeans;
}
public void initialize() {
for(Class<?> clazz : preInstantiatedBeans) {
if(beans.get(clazz) == null) {
instantiateClass(clazz);
}
}
}
private Object instantiateClass(Class<?> clazz) {
Object bean = beans.get(clazz);
if(bean != null) {
return bean;
}
Constructor<?> injectedConstructor = BeanFactoryUtils.getInjectedConstructor(clazz);
if(injectedConstructor == null) {
bean = BeanUtils.instantiate(clazz);
beans.put(clazz, bean);
return bean;
}
log.debug("Constructor : {}", injectedConstructor);
bean = instantiateConstructor(injectedConstructor);
beans.put(clazz, bean);
return bean;
}
private Object instantiateConstructor(Constructor<?> constructor) {
Class<?>[] parameterTypes = constructor.getParameterTypes();
List<Object> args = Lists.newArrayList();
for(Class<?> clazz : parameterTypes) {
Class<?> concertedClass = BeanFactoryUtils.findConcreteClass(clazz, preInstantiatedBeans);
if(!preInstantiatedBeans.contains(concertedClass)) {
throw new IllegalStateException(clazz + "는 Bean 아닙니다");
}
Object bean = beans.get(concertedClass);
if(bean == null) {
bean = instantiateClass(concertedClass);
}
args.add(bean);
}
return BeanUtils.instantiateClass(constructor, args.toArray());
}
public <T> T getBean(Class<T> requiredType) {
return (T) beans.get(requiredType);
}
public void clear() {
beans.clear();
}
public Map<Class<?>, Object> getControllers() {
Map<Class<?>, Object> controllers = new HashMap<>();
for(Class<?> clazz : preInstantiatedBeans) {
if(clazz.isAnnotationPresent(Controller.class)) {
controllers.put(clazz, beans.get(clazz));
}
}
return controllers;
}
}
BeanFactoryUtils 전체코드
import static org.reflections.ReflectionUtils.*;
public class BeanFactoryUtils {
@SuppressWarnings({"unchecked", "rawtypes"})
public static Set<Constructor> getInjectedConstructors(Class<?> clazz) {
return getAllConstructors(clazz, withAnnotation(Inject.class));
}
/**
* 인자로 전달하는 클래스의 생성자 중 @Inject 애노테이션이 설정되어 있는 생성자를 반환
*
* @Inject 애노테이션이 설정되어 있는 생성자는 클래스당 하나로 가정한다.
* @param clazz
* @return
*/
@SuppressWarnings({"unchecked", "rawtypes"})
public static Constructor<?> getInjectedConstructor(Class<?> clazz) {
Set<Constructor> injectedConstructors = getInjectedConstructors(clazz);
if(injectedConstructors.isEmpty()) return null;
return injectedConstructors.iterator().next();
}
/**
* 만약 인자로 전달되는 injectedClazz가 일반 클래스라면 바로 반환
* 인터페이스인 경우 빈 클래스 후보 중에 해당 인터페이스를 구현한 클래스 찾아 반환
*
* @param injectedClazz
* @param preInstantiatedBeans
* @return
*/
public static Class<?> findConcreteClass(Class<?> injectedClazz, Set<Class<?>> preInstantiatedBeans){
if(!injectedClazz.isInterface()) { // 인터페이스가 아닌 일반 클래스의 경우
return injectedClazz;
}
for(Class<?> clazz : preInstantiatedBeans) {
// clazz 가지는 인터페이스 목록
Set<Class<?>> interfaces = Sets.newHashSet(clazz.getInterfaces());
// 인터페이스 목록에 injectedClazz 인터페이스가 있다면 해당 clazz 반환
if(interfaces.contains(injectedClazz)) {
return clazz;
}
}
throw new IllegalStateException(injectedClazz + "인터페이스를 구현하는 Bean이 존재하지 않습니다");
}
}
④ AnnotationHandlerMapping 리팩토링
-basePackage 정보를 가지고 BeanScanner 초기화 (Reflections 생성)
-beanScanner.scan() : basePackage 경로에 있는 빈 후보 클래스의 클래스 타입 정보를 구함, 그리고 BeanFactory 생성자 인자로 전달
-beanFactory.initialize() : 빈 후보 클래스의 클래스 타입 정보를 재귀 함수 호출하여 조회하면서 빈 생성, 저장과 같은 초기화 수행
-Map<Class<?>, Object> controllerMap : BeanFactory 초기화 후 Controller 애노테이션을 가지는 빈만을 뽑아 반환
나머지는 이전과 동일한 로직 (Map<HandlerKey, HandlerExecution> → Map<{요청URI, 요청 Method}, {인스턴스, 실제 실행 메서드}>)
public class AnnotationHandlerMapping implements HandlerMapping {
private Object[] basePackage;
private Map<HandlerKey, HandlerExecution> handler = Maps.newHashMap();
public AnnotationHandlerMapping(Object... basePackage) {
this.basePackage = basePackage;
}
public void initialize() {
BeanScanner beanScanner = new BeanScanner(basePackage);
BeanFactory beanFactory = new BeanFactory(beanScanner.scan());
beanFactory.initialize();
Map<Class<?>, Object> controllerMap = beanFactory.getControllers(); //@Controller Bean 만
Set<Method> methods = getRequestMappingMethod(controllerMap);
[..]
}
}
QNA 관련 Controller ~ Repository 클래스도 생성자 주입 및 애노테이션 방식으로 리팩토링하도록 한다(생략)
참고. 개인 저장소
https://github.com/ljw1126/my-jwp-basic/tree/chapter11_di
'독서 > 📚' 카테고리의 다른 글
만화로 배우는 리눅스 시스템 관리1 (3) | 2024.04.30 |
---|---|
[Next Step] 12장 확장성 있는 DI 프레임워크로 개선 (0) | 2023.11.23 |
[Next Step] 10장 새로운 MVC 프레임워크 구현을 통한 점진적 개선 (2) | 2023.11.20 |
[Next Step] 9장 두 번째 양파 껍질을 벗기기 위한 중간 점검 (0) | 2023.11.18 |
[Next Step] 8장 Ajax를 활용해 새로고침 없이 데이터 갱신하기 (0) | 2023.11.17 |
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!