Item 5. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라

다람·2025년 2월 21일

Effective Java

목록 보기
5/13
post-thumbnail

1. 정적 유틸리티 클래스와 싱글턴은 적절하지 않다.

자원이 여러 개 필요할 때 정적 유틸리티 클래스싱글턴을 사용하는 것은 좋지 않다.

정적 유틸리티 클래스와 싱글턴의 문제점

  1. 필요한 자원이 여러 개일 수 있다

    • 하나의 자원으로 모든 경우를 처리할 수 있다고 가정하는 것은 바람직하지 않다.
    • 맞춤법 검사기에서 필요한 사전은 언어별 사전일 수도 있고, 특수 어휘용 사전일 수도 있고, 테스트용 사전이 필요할 수 있다.
  2. 필드에서 final 한정자를 제거하고 자원을 변경하는 메서드를 추가한다면?

    public class SpellChecker {
        private Dictionary dictionary;
        public void setDictionary(Dictionary dictionary) { this.dictionary = dictionary; }
    }
    • 멀티스레드 환경에서 안전하지 않다
      • 여러 스레드가 setDictionary()를 동시에 호출하면 데이터 불일치 문제가 발생할 수 있다.
        - 한 스레드에서 setDictionary(new KoreanDictionary())를 호출한 후,
        - 동시에 다른 스레드에서 setDictionary(new EnglishDictionary())를 호출하게 된다면
        - dictionary 필드가 KoreanDictionary일 수도 있고, EnglishDictionary일 수도 있게되버린다.
        - 이런 경우 데이터 불일치가 발생하여 정확한 사전이 사용되지 않을 수 있다.
    • 오류를 내기 쉬운 코드
      • dictionarynull이 될 가능성이 높아 NullPointerException 발생 위험이 생긴다.
        - final을 사용하면 dictionary를 생성자에서 반드시 초기화해야 하므로 오류가 발생할 위험이 없어지는데 final을 없애버린다면
        - setDictionary()를 통해서 값을 주입하지 않고 다른 메서드를 사용하게 되면 dictionarynull이 될 수 있는 것이다.

2. 의존 객체 주입 (Dependency Injection, DI)을 사용하자

생성자에서 필요한 자원을 주입하는 방식

public class SpellChecker {
    private final Dictionary dictionary;

    // 생성자 주입
    public SpellChecker(Dictionary dictionary) {
        this.dictionary = Objects.requireNonNull(dictionary);
    }
}
  • 유연성 증가 : 다양한 Dictionary 구현체를 주입할 수 있..
  • 불변성 보장 : final로 선언하여 dictionary가 변경될 위험이 없다.
  • 테스트 용이성 증가 : 테스트 시 MockDictionary 등을 주입해서 테스트가 가능하다.

의존 객체 주입은 의존 관계가 많아도 잘 동작한다.

public class TextProcessor {
    private final SpellChecker spellChecker;
    private final GrammarChecker grammarChecker;

    public TextProcessor(SpellChecker spellChecker, GrammarChecker grammarChecker) {
        this.spellChecker = spellChecker;
        this.grammarChecker = grammarChecker;
    }
}
  • 의존 객체 주입을 사용하면 의존 객체가 많아도 관계없이 코드가 잘 동작한다.
  • 생성자뿐만 아니라 정적 팩터리, 빌더 모두 의존 객체 주입을 적용할 수 있다.
// 정적 팩터리에 의존 객체 주입 적용
public class SpellChecker {
    private final Dictionary dictionary;

    // private 생성자: 직접 생성 막음
    private SpellChecker(Dictionary dictionary) {
        this.dictionary = Objects.requireNonNull(dictionary);
    }

    public static SpellChecker from(Dictionary dictionary) {
        return new SpellChecker(dictionary); // 의존 객체 주입
    }
}
// 사용
Dictionary koreanDictionary = new KoreanDictionary();
SpellChecker spellChecker = SpellChecker.from(koreanDictionary);
// 빌더에 의존 객체 주입 적용
public class SpellChecker {
    private final Dictionary dictionary;
    private final GrammarChecker grammarChecker;

    private SpellChecker(Builder builder) {
        this.dictionary = Objects.requireNonNull(builder.dictionary);
        this.grammarChecker = Objects.requireNonNull(builder.grammarChecker);
    }

    public static class Builder {
        private Dictionary dictionary;
        private GrammarChecker grammarChecker;

        public Builder dictionary(Dictionary dictionary) {
            this.dictionary = dictionary;
            return this;
        }

        public Builder grammarChecker(GrammarChecker grammarChecker) {
            this.grammarChecker = grammarChecker;
            return this;
        }

        public SpellChecker build() {
            return new SpellChecker(this); // 의존 객체 주입
        }
    }
}
// 사용
SpellChecker spellChecker = new SpellChecker.Builder()
    .dictionary(new KoreanDictionary())
    .grammarChecker(new BasicGrammarChecker())
    .build();

4. 의존 객체 주입 패턴의 변형 : 팩터리 사용

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

Supplier<T> 인터페이스를 사용한 의존 객체 주입

public class SpellChecker {
    private final Supplier<Dictionary> dictionarySupplier;

    public SpellChecker(Supplier<? extends Dictionary> dictionarySupplier) {
        this.dictionarySupplier = dictionarySupplier;
    }

    public void checkSpelling() {
        Dictionary dictionary = dictionarySupplier.get(); // 필요할 때 사전 가져오기
    }
}

한정적 와일드카드 타입(? extends T)을 사용

  • Supplier<? extends Dictionary>와 같이 한정적 와일드 카드 타입을 사용해서 Dictionary를 **상속하는 모든 객체(Supplier 등)를 받을 수 있도록 허용한다.
  • 이를 통해 다양한 종류의 Dictionary를 다룰 수 있다.

Supplier<T>란?

  • 호출될 때마다 새로운 객체를 반환하는 함수형 인터페이스
  • 매번 새로운 객체를 만들거나 미리 생성된 객체를 제공하는 데 유용

5. 의존 객체 주입의 장점

앞에서 계속 설명한 것처럼 의존 객체 주입을 사용하면 클래스의 유연성, 테스트 용이성이 증가한다.

  1. 유연성 증가
  • 다른 Dictionary 구현체를 쉽게 교체할 수 있게되어서 다양한 환경에서 활용이 가능해진다.
  1. 테스트 용이
    SpellChecker spellChecker = new SpellChecker(new MockDictionary());
  • 실제 데이터베이스 없이도 가짜 객체를 이용해서 테스트가 가능하다.
    (테스트 환경에서 빠르고 안정적인 실행 가능)
  1. 단일 책임 원칙(SRP)을 유지할 수 있다
  • 추가적인 장점은 바로 객체지향 원칙 중 단일 책임 원칙이다.
    단일 책임 원칙이란 클래스는 단 하나의 책임만 가져야 한다는 원칙이다. 만약에 SpellChecker의 생성자에 Dictionary를 직접 생성한다면 맞춤법 검사 뿐만아니고 객체 생성과 교체 기능까지 책임을 지게 된다. Dictionary를 변경하려면 내부 코드를 변경해야 되는 것이다.
    하지만 의존 객체 주입 방식을 사용하게 된다면 책임이 분리되어서 맞춤법 검사만 신경 쓰면 되는 것이다.
  • SpellCheckerDictionary가 어떤 구현체인지 신경 쓰지 않고, 오직 맞춤법 검사만 담당하게 되어서 단일 책임 원칙이 유지된다.

6. 큰 프로젝트에서는 의존 객체 주입을 하게되면 오히려 복잡해진다

“코드를 어지럽게 만든다”는 말의 의미가 뭘까?
프로젝트가 커질수록 의존성이 많아지면 매번 new 키워드로 객체를 생성하고 주입하는 것이 번거롭고 관리하기 어렵다라는 뜻인것 같다.

해결 방법

  • Spring, Guice, Dagger 같은 의존 객체 주입 프레임워크를 사용하면 이런 복잡한 의존 관계를 자동으로 관리할 수 있다.

아래는 Spring 프레임워크 코드 예시이다.

@Component
public class SpellChecker {
    private final Dictionary dictionary;

    @Autowired
    public SpellChecker(Dictionary dictionary) {
        this.dictionary = dictionary;
    }
}

💡 Spring이 Dictionary를 주입하는 원리

  1. @Component 애너테이션
    • SpellChecker@Component로 등록되어 있어서 Spring 컨테이너가 관리하는 빈(Bean)이 된다.
  2. 생성자 주입 + @Autowired
    • @Autowired가 붙은 생성자는 Spring이 자동으로 매개변수에 알맞은 빈을 찾아서 주입해준다.
  3. Spring 컨테이너에서 Dictionary 객체를 찾는다.
    (Dictionary도 빈으로 등록되어 있어야한다)
    - Spring은 현재 등록된 빈 중에서 Dictionary 타입의 객체를 찾는다.

7. 결론

  1. 자원을 직접 명시하지 말고 생성자를 통한 의존 객체 주입을 사용하자.
  2. 의존 객체 주입을 사용하면 클래스의 유연성, 재사용성, 테스트 용이성이 개선된다.
  3. 팩터리(Supplier<T>)를 활용하면 더 유연한 객체 생성이 가능하다.
  4. 의존 객체 주입 프레임워크(Spring, Guice, Dagger 등)를 사용해 코드 관리를 쉽게 할 수 있다.
profile
개발하는 다람쥐

0개의 댓글