프로그래밍을 하다보면, 하나의 객체가 다른 객체를 포함하는 경우가 많이 있다.
public class Car {
private final Engine engine = new Engine();
}
예를 들어, Car
클래스 내부에 Engine
클래스를 필드로 가지고 있는 경우를 생각해보자.
Car
클래스 내부 메서드는 Engine
클래스의 속성을 가져와서 쓰고 있다면 Car 클래스는 Engine 클래스에 의존한다. 라고 말할 수 있다.
객체간의 의존
관련 용어에 대해 알고싶다면 결합도와 응집도 관련 내용을 참고해보면 좋을 것 같다.
이번 내용은 이러한 의존 객체를 초기화 하는 작업인
의존 객체 주입 (Dependency Injection, DI)
에 대해서 다뤄볼 예정이다.
💡 *의존 객체 주입 (Dependency Injection, DI) 이란?
객체 지향 프로그래밍에서 한 객체가 다른 객체에 의존성을 직접 생성하는 것이 아니라, 외부에서 의존 객체를 주입받아 사용하는 디자인 패턴이다.
public class SpellChecker
{
private static final Lexicon dictionary = new Dictionary();
private SpellChecker() {}
public static boolean isValid(String word) {...}
public static List<String> suggestions(String type) {...}
}
public class SpellChecker {
private static final Lexion dictionary = new Dictionary();
private SpellChecker() {}
public static SpellChecker INSTANCE = new SpellChecker();
public boolean isValid() {...}
public List<String> suggestions(String typo) {...}
}
다음과 같이 맞춤법 검사 기능을 지원해주는 SpellChecker
클래스를 유틸리티 클래스 와 싱글톤 (Singleton) 패턴 으로 예시를 들어보자.
해당 클래스들은 맞춤법이 맞는지 확인할 사전 Lexicon
이 필요하기에 내부에 의존 객체로써 포함하려고 한다.
따라서, private 생성자
를 사용하므로 의존 객체 초기화를 미리 해둔 상태이다.
겉보기에는 아무런 문제가 없어보이는 코드지만,
실제로 맞춤법 검사를 수행하는데 있어서는 어떨까?
각 나라별 사전이나 특수 어휘용 사전 등 다양한 사전을 활용하여 다양한 경우에서 맞춤법 검사를 해야하지 않을까?
물론, 해당 예제 코드 클래스가 단 하나의 경우 에서만 사용할 것이라면,
해당 코드는 아무런 문제가 없다.
하지만, 확장성을 고려해야 하는 객체지향적 코드로 봤을 때,
해당 코드는 결코 잘 짜여진 코드라고 볼 수 없다.
그렇다면, Lexicon
필드의 final
키워드를 제거하고 static method
를 이용해 외부에서 Lexicon
을 바꿀 수 있게 변경하면 어떨까?
public class SpellChecker
{
private static Lexicon dictionary = ...;
private SpellChecker()
{
}
public static void changeDictionary(Lexicon dictionary) {
this.dictionary = dictionary;
}
public static boolean isValid(String word)
{
...
{
public static List<String> suggestions(String type)
{
...
}
}
싱글톤 (Singleton) 방식과 비슷하니 유틸리티 클래스 예제 코드만 수정했다.
이렇게 코드를 수정하면 외부에서 changeDictionary()
메서드를 호출하여 사전을 필요에 따라 바꿀 수 있으므로 다양한 사전을 넣어줄 수 있다.
하지만, 이렇게 되면 유틸리티 클래스는 결국 상태
를 가지게 된다.
즉, 다음과 같이 외부에서 dictionary
를 변경할 수 있게 되버리면 해당 유틸리티 클래스의 기능을 이용하는 모든 클래스는 변경된 상태가 공유된다는 말이 된다.
따라서, 멀티 스레드 환경에서는 동시성 문제를 야기할 수 있어 동시성 처리를 따로 해줘야 한다는 단점이 있다.
다음과 같은 이유 때문에 상태
를 가지면 안되는 클래스에는 의존 객체
사용하면 안된다는 사실을 알 수 있었다.
📑 정리
- 상태를 가지면 안되는 클래스 에는 의존 객체를 사용하면 확장성 에 좋지 않다.
- 의존 객체를 외부에서 변경할 수 있게 바꾸면 상태 를 가지게 되어 동시성 문제를 야기할 수 있다.
public class SpellChecker {
private final Lexicon dictionary = ...;
public SpellChecker(Lexicon dictionary) {
this.dictionary = dictionary;
}
public boolean isValid() {...}
public List<String> suggestions(String typo) {...}
}
다음과 같이 객체를 생성할 때마다 내부 의존 객체를 주입하는 방식은 여러가지 이점이 있다.
첫 번째로, 확장성이 높아진다.
public interface Lexicon {
void printText();
}
public class EnglishDictionary implements Lexicon{
@Override
public void printText() {
System.out.println("ENGLISH");
}
}
public class KoreanDictionary implements Lexicon{
@Override
public void printText() {
System.out.println("KOREAN");
}
}
public class Main {
public static void main(String[] args) {
SpellChecker koreanSpellChecker = new SpellChecker(new KoreanDictionary());
SpellChecker englishSpellChecker = new SpellChecker(new EnglishDictionary());
}
}
다음과 같이 Lexicon
인터페이스에서 하위 타입을 구현해 놓으면 여러 하위 타입을 넣고 객체를 만들어 사용이 가능하다.
두 번째로, 테스트에 용이하다.
import org.junit.Test;
import static org.mockito.Mockito.*;
public class SpellCheckerTest {
@Test
public void testSpellCheckerWithEnglishDictionary() {
// given
Lexicon englishDictionary = mock(EnglishDictionary.class);
SpellChecker spellChecker = new SpellChecker(englishDictionary);
// when
spellChecker.performSpellCheck();
// then
verify(englishDictionary).printText();
}
@Test
public void testSpellCheckerWithKoreanDictionary() {
// given
Lexicon koreanDictionary = mock(KoreanDictionary.class);
SpellChecker spellChecker = new SpellChecker(koreanDictionary);
// when
spellChecker.performSpellCheck();
// then
verify(koreanDictionary).printText();
}
}
다음과 같이 의존하는 객체를 외부에서 생성하여 직접 넣어줄 수 있기 때문에 각각의 하위 타입에 대한 테스트 코드를 짜기가 수월해진다.
📑 정리
- 의존 객체를 생성자로 넘겨주게 되면 확장성 이 높아진다.
- 외부에서 객체를 제공하기 때문에 테스트 가 용이해진다.
패턴을 응용해서 자원 팩토리 를 생성자에 넘겨주는 방식이 있다.
아래는 자바8 에서부터 사용가능한 Supplier<T>
인터페이스가 팩토리를 표현한 예이다.
import java.util.function.Supplier;
public class SpellChecker {
private final Lexicon dictionary;
public SpellChecker(Supplier<? extends Lexicon> dictionaryFactory) {
this.dictionary = dictionaryFactory.get();
}
public void print() {
dictionary.printText();
}
}
import java.util.function.Supplier;
public class Main {
public static void main(String[] args) {
Type type = Type.ENGLISH;
Supplier<Lexicon> dictionaryFactory = () -> {
Lexicon dictionary = null;
switch (type) {
case KOREAN: dictionary = new KoreanDictionary(); break;
case ENGLISH: dictionary = new EnglishDictionary(); break;
}
return dictionary;
};
SpellChecker spellChecker = new SpellChecker(dictionaryFactory);
spellChecker.print();
}
}
참고로, Supplier<T>
인터페이스는 함수형 인터페이스 이므로, 람다식
으로 표현이 가능하다.
public SpellChecker(Supplier<? extends Lexicon> dictionaryFactory) {
this.dictionary = dictionaryFactory.get();
}
주로, 팩토리 방식 은 한정적 와일드 카드 타입
을 사용하여 팩터리의 타입 매개 변수를 제한한다. 타입을 제한하면 사용자는 명시된 타입의 하위 타입이면 무엇이든 팩토리를 만들어 넘길 수 있게 된다.
물론 해당 방식은 좋지만,
의존성이 매우 높은 실제 프로젝트 에서는 각각 팩토리로 만들거나 생성자 매개변수로 넘겨주면 프로그래머 입장에서는 큰 부담이 된다.
이러한 부담을 줄여주기 위해서 실전에서는 의존 객체 프레임워크 를 활용하여 해결한다.
예를 들어, 스프링 프레임워크 에서는 @Autowired
와 같은 Annotation
을 활용하여 의존성을 주입해주는 방식을 지원해준다.
💡 의존 객체 프레임워크란?
의존성 주입을 지원하는 소프트웨어 프레임워크로, 이러한 프레임워크는 객체 간의 의존성을 효과적으로 관리하고 주입할 수 있는 방법을 제공하여 코드의 유연성과 유지보수성을 향상시켜준다.