dependency-injection | spring | java

Spring DI - Field Injection보다 Constructor Injection을 써야 하는 이유

Spring에서 의존성 주입(DI)이 필요한 이유를 Google Guice 예제로 설명하고, Field/Setter Injection 대신 Constructor Injection을 권장하는 5가지 이유를 정리합니다.

Mimul
MimulNovember 15, 2017 · 12 min read · Last Updated:

재사용성, 테스트 용이성, 코드 단순화, 낮은 결합도 등의 장점 덕분에 DI는 다양한 언어와 프레임워크에서 널리 활용된다. 이 글에서는 DI가 왜 필요한지를 Google Guice 예제로 살펴보고, Spring에서 제공하는 세 가지 주입 방식 중 Constructor Injection이 권장되는 이유를 정리해본다.

왜 DI(의존성 주입)가 필요한가?

DI가 필요한 이유에 대한 좋은 해답으로 Google Guice Motivation 페이지에서 잘 설명해 주어서 인용해 본다.

동기요인

관련된 모든 객체들을 밀결합하는 것은 애플리케이션 개발에서 까다로운 문제가 된다. 애플리케이션에서 서비스, 데이터, 프리젠테이션 클래스들을 연결하는 방법에는 여러 가지가 있다. 이러한 접근 방법을 비교하기 위해 피자 주문에 관련된 빌링 코드를 작성할 것이다.

public interface BillingService {
  Receipt chargeOrder(PizzaOrder order, CreditCard creditCard);
}

구현과 함께 단위 테스트를 작성할 것이다. 테스트는 실제 신용 카드로 결제가 되지 않도록 가짜 카드 결제를 구현한 FakeCreditCardProcessor 클래스가 필요하다.

생성자 함수 직접 호출

다음 코드는 카드 결제 클래스(CreditCardProcessor)와 트랜잭션을 기록하는 클래스(TransactionLog)를 new 연산자로 인스턴스화하는 경우이다.

public class RealBillingService implements BillingService {
  public Receipt chargeOrder(PizzaOrder order, CreditCard creditCard) {
    CreditCardProcessor processor = new PaypalCreditCardProcessor();
    TransactionLog transactionLog = new DatabaseTransactionLog();
    try {
      ChargeResult result = processor.charge(creditCard, order.getAmount());
      transactionLog.logChargeResult(result);

      return result.wasSuccessful()
          ? Receipt.forSuccessfulCharge(order.getAmount())
          : Receipt.forDeclinedCharge(result.getDeclineMessage());
     } catch (UnreachableException e) {
      transactionLog.logConnectException(e);
      return Receipt.forSystemFailure(e.getMessage());
    }
  }
}

이 코드는 모듈화와 테스트 가능성에 문제가 있다. 진짜 신용 결제를 할 클래스(CreditCardProcessor)로 컴파일 시점에 직접적으로 의존하고 있기 때문에 테스트를 하면 카드에 청구되어 버린다. 또한 결제가 실패했을 때나 서비스가 중지상태가 되면 테스트하기가 힘들어진다.

Factory 클래스

Factory 클래스가 클라이언트와 서비스 구현을 분리해준다. 간단한 Factory 클래스에서 인터페이스를 구현한 mock getter와 setter로 사용할 수 있다. Factory 클래스는 아래와 같이 일부 boilerplate 코드와 함께 구현되어 있다.

public class CreditCardProcessorFactory {
  private static CreditCardProcessor instance;
  public static void setInstance(CreditCardProcessor processor) {
    instance = processor;
  }

  public static CreditCardProcessor getInstance() {
    if (instance == null) {
      return new SquareCreditCardProcessor();
    }

    return instance;
  }
}

클라이언트 코드에서는 new 호출하는 대신에 Factory 메소드로 변경하면 된다.

public class RealBillingService implements BillingService {
  public Receipt chargeOrder(PizzaOrder order, CreditCard creditCard) {
    CreditCardProcessor processor = CreditCardProcessorFactory.getInstance();
    TransactionLog transactionLog = TransactionLogFactory.getInstance();

    try {
      ChargeResult result = processor.charge(creditCard, order.getAmount());
      transactionLog.logChargeResult(result);

      return result.wasSuccessful()
          ? Receipt.forSuccessfulCharge(order.getAmount())
          : Receipt.forDeclinedCharge(result.getDeclineMessage());
     } catch (UnreachableException e) {
      transactionLog.logConnectException(e);
      return Receipt.forSystemFailure(e.getMessage());
    }
  }
}

Factory를 사용하면, 표준 단위 테스트를 작성할 수 있게 된다.

public class RealBillingServiceTest extends TestCase {
  private final PizzaOrder order = new PizzaOrder(100);
  private final CreditCard creditCard = new CreditCard("1234", 11, 2010);

  private final InMemoryTransactionLog transactionLog = new InMemoryTransactionLog();
  private final FakeCreditCardProcessor processor = new FakeCreditCardProcessor();

  @Override public void setUp() {
    TransactionLogFactory.setInstance(transactionLog);
    CreditCardProcessorFactory.setInstance(processor);
  }

  @Override public void tearDown() {
    TransactionLogFactory.setInstance(null);
    CreditCardProcessorFactory.setInstance(null);
  }

  public void testSuccessfulCharge() {
    RealBillingService billingService = new RealBillingService();
    Receipt receipt = billingService.chargeOrder(order, creditCard);

    assertTrue(receipt.hasSuccessfulCharge());
    assertEquals(100, receipt.getAmountOfCharge());
    assertEquals(creditCard, processor.getCardOfOnlyCharge());
    assertEquals(100, processor.getAmountOfOnlyCharge());
    assertTrue(transactionLog.wasSuccessLogged());
  }
}

이 코드에도 문제가 있다. 글로벌 변수가 mock 구현을 보유하기 때문에 setUp과 tearDown에 세심한 주의가 필요하다. tearDown 메소드가 실패하면 글로벌 변수는 테스트 인스턴스를 계속 참조하게 된다. 이렇게 되면 다른 테스트에 문제를 야기할 수 있고, 여러 테스트를 병행할 수 없게 된다.

그러나 가장 큰 문제는 의존성이 코드 속에 숨겨져 있다는 것이다. 만약 CreditCardFraudTracker 의존성을 추가했다고 하자. 테스트가 실패했을 때, 어떤 클래스에 문제가 있었는지 파악하려면 테스트를 다시 실행해야 한다. 운영 서비스에서 Factory 초기화를 잊으면 과금이 발생할 때까지 문제를 알 수 없다. 애플리케이션이 비대해질수록 세심한 주의가 필요한 Factory 클래스는 생산성을 떨어지게 하는 원인이 된다.

품질 문제는 QA나 인수 테스트에서 발견할 수 있다. 지금으로도 충분할지도 모르지만, 확실히 더 나은 방법이 있다.

의존성 주입(DI)

Factory처럼 DI도 디자인 패턴이다. 핵심 원칙은 행태와 의존성 해결을 분리하는 것이다. 여기 예제에서 보면, RealBillingService는 TransactionLog와 CreditCardProcessor를 lookup하는 책임을 지지 않는다. 대신 그들은 생성자를 인수로 전달한다.

public class RealBillingService implements BillingService {
  private final CreditCardProcessor processor;
  private final TransactionLog transactionLog;

  public RealBillingService(CreditCardProcessor processor,
      TransactionLog transactionLog) {
    this.processor = processor;
    this.transactionLog = transactionLog;
  }

  public Receipt chargeOrder(PizzaOrder order, CreditCard creditCard) {
    try {
      ChargeResult result = processor.charge(creditCard, order.getAmount());
      transactionLog.logChargeResult(result);

      return result.wasSuccessful()
          ? Receipt.forSuccessfulCharge(order.getAmount())
          : Receipt.forDeclinedCharge(result.getDeclineMessage());
     } catch (UnreachableException e) {
      transactionLog.logConnectException(e);
      return Receipt.forSystemFailure(e.getMessage());
    }
  }
}

더 이상 Factory는 필요가 없다. 게다가, boilerplate 코드인 setUp과 tearDown를 없애서 테스트 케이스가 훨씬 간결해졌다.

public class RealBillingServiceTest extends TestCase {

  private final PizzaOrder order = new PizzaOrder(100);
  private final CreditCard creditCard = new CreditCard("1234", 11, 2010);

  private final InMemoryTransactionLog transactionLog = new InMemoryTransactionLog();
  private final FakeCreditCardProcessor processor = new FakeCreditCardProcessor();

  public void testSuccessfulCharge() {
    RealBillingService billingService
        = new RealBillingService(processor, transactionLog);
    Receipt receipt = billingService.chargeOrder(order, creditCard);

    assertTrue(receipt.hasSuccessfulCharge());
    assertEquals(100, receipt.getAmountOfCharge());
    assertEquals(creditCard, processor.getCardOfOnlyCharge());
    assertEquals(100, processor.getAmountOfOnlyCharge());
    assertTrue(transactionLog.wasSuccessLogged());
  }
}

이렇게 되면 언제 의존성을 추가하거나, 삭제하고자 하면 어떤 테스트를 수정하면 좋을지 컴파일러가 알려준다. 의존성은 API의 시그너처내에서 노출되게 된다.

그러나 불행히도, BillingService의 클라이언트는 의존성을 스스로 찾아야 한다. 이런 문제는 DI 패턴을 통해 다시 해결책을 찾을 수 있다. BillingService에 의존하고 있는 클래스는 생성자에서 BillingService를 받을 수 있다. 그러나 최상위 클래스를 위해서는 프레임 워크가 있는 편이 유리하다. 그렇지 않으면, 서비스 사용이 필요할 때 재귀적으로 의존 관계를 구축하게 될 경우도 있다.

public static void main(String[] args) {
    CreditCardProcessor processor = new PaypalCreditCardProcessor();
    TransactionLog transactionLog = new DatabaseTransactionLog();
    BillingService billingService
        = new RealBillingService(processor, transactionLog);
    ...
  }

결론적으로 DI는 코드의 모듈화와 테스트 가능성을 향상시켜 주기 때문에 필요하다.

다음으로, Spring에서 제공하는 세 가지 DI 방식을 살펴보고 Spring 팀이 Constructor Injection을 권장하는 이유를 정리한다.

Spring의 Dependency Injection

1. Constructor Injection

Spring 4.3에서 단일 생성자의 경우 Autowired가 필요가 없다.

@Component
public class ConstructorInjection {
     private final LoginService loginService;
     private final SignupService signupService;

    @Autowired
    public ConstructorInjection(LoginService loginService,
                SignupService signupService) {
         this.loginService = loginService;
         this.signupService = signupService;
    }
}

2. Field Injection

@Component
public  class FieldInjection {
    @Autowired
    private LoginService loginService;
    @Autowired
    private SignupService signupService;
}

3. Setter Injection

@Component
public  class SetterInjection {
     private LoginService loginService;
     private SignupService signupService;

    @Autowired
    public  void setLoginService(LoginService loginService) {
         this.loginService = loginService;
    }

    @Autowired
    public  void setSignupService(SignupService signupService) {
         this.signupService = signupService;
    }
}

왜 Constructor Injection을 권장하나?

1. 단일 책임의 원칙

생성자의 인자가 많을 경우 코드량도 많아지고, 의존관계도 복잡해져 단일 책임의 원칙에 위배된다. Constructor Injection을 사용하면 의존관계와 복잡성을 한눈에 알 수 있어 리팩토링의 단초를 제공하게 된다.

2. 테스트 용이성

DI 컨테이너에서 관리되는 클래스는 특정 DI 컨테이너에 의존하지 않고 POJO여야 한다. DI 컨테이너를 사용하지 않고도 인스턴스화 할 수 있고, 단위 테스트도 가능하며, 다른 DI 프레임 워크로 전환할 수도 있게 된다.

3. Immutability

Constructor Injection에서는 필드는 final로 선언할 수 있다. 불변 객체가 가능한데 비해 Field Injection은 final는 선언할 수 없기 때문에 객체가 변경 가능한 상태가 된다.

4. 순환 의존성

Constructor Injection에서는 멤버 객체가 순환 의존성을 가질 경우 BeanCurrentlyInCreationException이 발생해서 순환 의존성을 알 수 있게 된다.

5. 의존성 명시

의존 객체 중 필수는 Constructor Injection을 옵션인 경우는 Setter Injection을 활용할 수 있다.

Lombok을 활용한 Constructor Injection

참고로 개발 편이성은 좋아질 수 있으나, 의존관계의 복잡성을 명확하게 보여주진 못하게 된다.

RequiredArgsConstructor는 초기화 되지 않은 final 필드를 매개 변수로 취하는 생성자를 생성하고 NonNull이 필드는 null 체크가 실행되고 파라미터가 null인 경우는 NullPointerException을 발생시킨다.

1. Spring 4.3 이상

@RequiredArgsConstructor
@Component
public class ConstructorInjection {
    @NonNull
    private final LoginService loginService;
    @NonNull
    private final SignupService signupService;
}

2. Spring 4.3 이전

@RequiredArgsConstructor(onConstructor = @__(@Autowired))
@Component
public class ConstructorInjection {
    @NonNull
    private final LoginService loginService;
    @NonNull
    private final SignupService signupService;
}

참고 사이트


Mimul

Written byMimul
Mimul is a programmer, technologist, exercise enthusiast and more.
Connect

Related ArticlesView All