2082 단어
10 분
[ Effective Java ] 의존 객체 주입

들어가면서#

소프트웨어 개발에서 코드의 유연성과 재사용성을 높이는 것은 매우 중요하다. 특히 객체 간의 의존성을 효과적으로 관리하는 방식은 코드의 유지보수성과 테스트 용이성에 큰 영향을 미친다. 이번 포스트에서는 **의존 객체 주입(Dependency Injection)**에 대해 살펴보고, 다양한 방식으로 이를 구현하는 방법을 알아보자.

다음은 스펠링을 확인해주는 SpellChecker와 사전 역할을 하는 Dictionary 클래스이다.

public class SpellChecker {  
  
    private static final Dictionary dictionary = new Dictionary();  
  
    private SpellChecker() {}  
  
    public static boolean isValid(String word) {  
        // TODO 여기 SpellChecker 코드  
        return dictionary.contains(word);  
    }  
  
    public static List<String> suggestions(String typo) {  
        // TODO 여기 SpellChecker 코드  
        return dictionary.closeWordsTo(typo);  
    }  
}

public class Dictionary {
	// 모두 예시입니다.
	public boolean contains(String word){
		return false;
	}

	public List<String> closeWordsTo(String typo){
		return null;
	}

}

위 코드에서 SpellCheckerDictionary를 정적 필드로 가지고 있으며, 단어가 올바른지 확인하거나 추천 단어 목록을 제공하는 기능을 한다. 하지만 현재 구조에서는 Dictionary 인스턴스를 직접 생성하고 있어, 유연성이 떨어지고 테스트가 어렵다는 문제가 있다. 테스트 코드를 보자.

  • 테스트 코드
class SpellCheckerTest {  
  
    @Test  
    void isValid() {  
        assertFalse(SpellChecker.isValid("test"));  
    }  
  
}

만약 Dictionary 를 불러오는데 많은 자원을 사용하게 된다고 가정하면, 다음 테스트 코드는 매우 비효율적일 것이다. 테스트를 진행할 때마다 SpellChecker에 있는 Dictionary를 계속 불러와야 하기 때문이다.

또한, 유연성과 재사용성이 떨어진다. 만약 한국, 미국 사전 두개를 사용한다고 생각해보자. 같은 코드를 포함하고 있는 클래스를 두개 만들어서 객체만 바꾸게 되면 일단 동작은 한다. 하지만, 다른 사전들도 추가되어 사전이 점점 많아진다면, 매우 매우 비효율적일 것이다.

그래서 이 문제를 해결할 수 있는 의존 객체 주입을 알아보자.

의존 객체 주입#

의존 객체 주입은 객체가 직접 의존 객체를 생성하지 않고 외부에서 주입받는 방식이다. 이를 통해 객체 간의 결합도를 낮추고, 테스트 코드 작성과 코드 재사용성을 높일 수 있다.

생성자 주입 방식#

가장 기본적인 방식은 생성자 주입 방식이다. 객체 생성 시 의존 객체를 생성자의 인자로 받아 필드에 할당하는 방식이다.

public class SpellChecker {  
  
    private final Dictionary dictionary;  
  
    public SpellChecker(Dictionary dictionary) {  
        this.dictionary = dictionary;  
    }  
    
    public boolean isValid(String word) {  
        // TODO 여기 SpellChecker 코드  
        return dictionary.contains(word);  
    }  
  
    public List<String> suggestions(String typo) {  
        // TODO 여기 SpellChecker 코드  
        return dictionary.closeWordsTo(typo);  
    }  
}

위와 같이 작성을 한다면, 모든 코드를 재사용할 수 있게 된다. 객체 생성시에 Dictionary를 주입해주기 때문이다. 대신 전제가 하나 더 있다. Dictionary가 인터페이스여야 한다. Dictionary가 인터페이스가 아니라면, 객체별로 정해진 함수가 없기 때문에 재사용하기 힘들 수 있다.

인터페이스 활용#

의존 객체가 특정 구현체에 종속되지 않도록 하기 위해 인터페이스를 활용한다. 인터페이스를 사용하면 다양한 구현체를 주입받아 재사용성과 유연성을 높일 수 있다.

public interface Dictionary {  
  
    boolean contains(String word);  
  
    List<String> closeWordsTo(String typo);  
}
  • 구현체
public class DefaultDictionary implements Dictionary{  
  
    @Override  
    public boolean contains(String word) {  
        return false;  
    }  
  
    @Override  
    public List<String> closeWordsTo(String typo) {  
        return null;  
    }  
}

그래서 다음과 같이 Dictionary 인터페이스를 구현하는 구현체가 있다면, 어떤 구현체든 SpellChecker에 사용할 수 있게 된다. 또한, 테스트코드를 작성할 때 다음과 같이 Mock 객체를 만들어 넣어줄 수도 있다.

class SpellCheckerTest {  
  
    @Test  
    void isValid() {  
        SpellChecker spellChecker = new SpellChecker(new MockDictionary());  
        spellChecker.isValid("test");  
    }  
  
}
  • 테스트를 위한 Mock 객체
public class MockDictionary implements Dictionary{  
    @Override  
    public boolean contains(String word) {  
        return false;  
    }  
  
    @Override  
    public List<String> closeWordsTo(String typo) {  
        return null;  
    }  
}

위 방법도 훌륭하지만, 추가적으로 활용할 수 있는 방법들도 있다.

심화1: 팩토리 패턴 활용#

이 패턴의 심화로, 생성자에 Factory를 넘겨줄 수도 있다. 팩토리 패턴은 객체 생성을 캡슐화하는 방식이다. 이를 활용하면 의존 객체를 중앙에서 관리할 수 있다.

public class DictionaryFactory {  
    public static Dictionary get() {  
        return null;  
    }  
}
public class SpellChecker {  
  
    private Dictionary dictionary;  
  
    public SpellChecker(DictionaryFactory dictionaryFactory) {  
        this.dictionary = dictionaryFactory.getDictionary();  
    }  
    ...
}

Factory란 호출할 때마다 특정 타입의 인스턴스를 반복해서 만들어주는 객체를 말한다.

심화2: Supplier 활용#

Supplier는 자바의 함수형 인터페이스로, 필요 시 객체를 제공하는 역할을 한다. 위 예시에서 Factory와 Supplier<T> 인터페이스가 동일한 역할을 한다. 그래서 대체가능한데, 다음과 같이 작성할 수 있다.

public class SpellChecker {  
  
    private final Dictionary dictionary;  
  
    public SpellChecker(Dictionary dictionary) {  
        this.dictionary = dictionary;  
    }  
  
    public SpellChecker(Supplier<Dictionary> dictionarySupplier) {  
        this.dictionary = dictionarySupplier.get();  
    }  
    ...
}

테스트도 다음 코드처럼 작성하면 된다.

  • 테스트 코드
class SpellCheckerTest {  
  
    @Test  
    void isValid() {  
        SpellChecker spellChecker = new SpellChecker(MockDictionary::new);  
        spellChecker.isValid("test");  
    }  
  
}

Supplier의 인자는 함수이므로, 위 테스트 코드와 같이 자바8의 람다식을 활용해 작성할 수 있다.

와일드카드 활용#

여기서, 만약 Dictionary가 인터페이스가 아니라 클래스라면 추가적으로 와일드카드 타입을 활용하여 하위타입도 생성할 수 있게 설정해줄 수 있다.

public class Dictionary {
	public boolean contains(String word){
		return false;
	}

	public List<String> closeWordsTo(String typo){
		return null;
	}
}
public class SpellChecker {  
  
    private final Dictionary dictionary;  
  
    public SpellChecker(Dictionary dictionary) {  
        this.dictionary = dictionary;  
    }  
  
    public SpellChecker(Supplier<? extends Dictionary> dictionarySupplier) {  
        this.dictionary = dictionarySupplier.get();  
    }  
    ...
}

이렇게 작성하면 Dictionary의 하위 타입들도 모두 생성할 수 있게 된다.

여기까지 정리를 해보았는데, 사실 서버 개발을 하고 Spring을 사용하는 개발자라면, 굳이 이렇게까지 하지 않아도 된다. 스프링에서 이 역할을 대신 해주고 있기 때문이다. Spring IOC를 사용하는 방법에 대해서도 알아보자.

Spring IOC#

Spring 프레임워크에서는 IoC (Inversion of Control) 컨테이너를 활용하여 의존 객체를 자동으로 주입할 수 있다.

설정 파일 작성#

@Configuration  
@ComponentScan(basePackageClasses = AppConfig.class)  
public class AppConfig {  
  
}

위와 같이 스프링 설정 파일을 생성해준 후 어노테이션으로 이 클래스가 설정파일이고, 컴포넌트 스캔을 통하여 빈으로 등록시키겠다고 설정해준다.

컴포넌트 등록#

@Component  
public class SpellChecker {  
  
    private Dictionary dictionary;  
  
    public SpellChecker(Dictionary dictionary) {  
        this.dictionary = dictionary;  
    }  
  
    public boolean isValid(String word) {  
        // TODO 여기 SpellChecker 코드  
        return dictionary.contains(word);  
    }  
  
    public List<String> suggestions(String typo) {  
        // TODO 여기 SpellChecker 코드  
        return dictionary.closeWordsTo(typo);  
    }  
}
@Component  
public class SpringDictionary implements Dictionary {  
  
    @Override  
    public boolean contains(String word) {  
        System.out.println("contains " + word);  
        return false;  
    }  
  
    @Override  
    public List<String> closeWordsTo(String typo) {  
        return null;  
    }  
}

그리고 각 클래스에 어노테이션으로 컴포넌트임을 지정해준다. 이렇게 어노테이션을 달게 되면, 스프링에서 자동으로 이 클래스를 인식하고 빈으로 등록시켜 준다.

어플리케이션 실행#

import org.springframework.context.ApplicationContext;  
import org.springframework.context.annotation.AnnotationConfigApplicationContext;  
  
public class App {  
  
    public static void main(String[] args) {  
        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);  
        SpellChecker spellChecker = applicationContext.getBean(SpellChecker.class);  
        spellChecker.isValid("test");  
    }  
}

이후 ApplicationContext로 SpellChecker 클래스를 빈으로 가지고 와 사용하면 된다.

마치며#

의존 객체 주입은 객체 간의 결합도를 낮추고 코드의 유연성과 재사용성을 높이는 중요한 설계 기법이다. 자바에서는 생성자 주입, 팩토리 패턴, Supplier 인터페이스 등 다양한 방식으로 이를 구현할 수 있으며, Spring IoC 컨테이너를 활용하면 더욱 편리하게 의존 객체를 관리할 수 있다.

출처#

  • 이펙티브 자바 아이템 5: 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라
  • 백기선의 이펙티브 자바(인프런)
[ Effective Java ] 의존 객체 주입
https://blog-full-of-desire-v3.vercel.app/posts/java/effective-java/item5/dependency-injection/
저자
SpeculatingWook
게시일
2025-03-04
라이선스
CC BY-NC-SA 4.0