Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

루비에서 결합도 낮추기: 위임(Delegation) vs 의존성 주입(Dependency Injection)

객체지향 프로그래밍(OOP)에서는 한 객체가 제 기능을 하기 위해 다른 객체에 의존하는 경우가 매우 흔합니다.

예를 들어, 재무 보고서를 생성하는 간단한 클래스를 만든다고 가정해 봅시다.

class FinanceReport
  def net_income
    FinanceApi.gross_income - FinanceApi.total_costs
  end
end

이 코드에서 FinanceReport는 외부 결제 시스템으로부터 정보를 가져오는 FinanceApi의존하고 있다고 말할 수 있습니다.

그런데 나중에 다른 API를 사용해야 한다면 어떻게 될까요? 아니면 더 현실적으로, 이 클래스를 테스트할 때 외부 리소스에 접근하지 않고 검증하려면 어떻게 해야 할까요? 가장 일반적인 답은 바로 의존성 주입(Dependency Injection)입니다.

의존성 주입을 사용하면 FinanceReport 내부에서 FinanceApi를 직접 참조하지 않습니다. 대신 이를 인자로 전달받습니다. 즉, 객체를 클래스 안으로 주입(inject)하는 것입니다.

의존성 주입을 적용한 클래스는 다음과 같이 바뀝니다.

class FinanceReport
  def net_income(financials)
    financials.gross_income - financials.total_costs
  end
end

이제 우리 클래스는 FinanceApi라는 객체가 존재한다는 사실조차 모릅니다. gross_incometotal_costs 메서드만 구현하고 있다면 어떤 객체든 전달할 수 있습니다.

이 접근 방식에는 여러 가지 장점이 있습니다.

  • FinanceApi와의 결합도(coupling)가 낮아집니다.
  • FinanceApi를 공개 인터페이스(public interface)를 통해서만 사용하게 됩니다.
  • 테스트 시 목(mock)이나 스텁(stub) 객체를 전달할 수 있어 실제 API를 호출하지 않고도 테스트할 수 있습니다.

대부분의 개발자는 의존성 주입을 좋은 기법이라고 생각합니다 (저 역시 마찬가지입니다!). 하지만 모든 기술이 그렇듯이, 여기에도 트레이드오프가 존재합니다.

먼저 코드가 약간 더 불투명해집니다. FinanceApi를 명시적으로 사용했을 때는 값이 어디서 왔는지 분명히 드러났지만, 의존성 주입을 적용한 코드에서는 그 출처가 덜 명확해집니다.

또한, 원래 self로 향했을 호출이라면 코드가 더 장황해집니다. "객체에 메시지를 보내고 그 객체가 스스로 동작하게 하는" 객체지향 패러다임 대신, "입력 → 출력" 방식의 좀 더 함수형 패러다임으로 옮겨가게 되는 것입니다.

오늘은 바로 이 마지막 경우, 즉 원래 self로 향할 호출을 다른 곳으로 돌려야 하는 상황을 살펴보려 합니다. 그리고 이런 상황에서 의존성 주입의 대안이 될 수 있는 방법을 소개하겠습니다. 바로 베이스 클래스를 동적으로 변경하는 것(일종의 트릭)입니다.

해결해야 할 문제

잠시 처음으로 돌아가서, 제가 이 길에 들어서게 된 계기가 된 문제부터 이야기해 보겠습니다. 바로 PDF 보고서입니다.

클라이언트는 다양한 인쇄용 PDF 보고서 생성 기능을 요청했습니다. 하나는 계정의 모든 지출을 나열하는 보고서, 또 하나는 매출을 정리한 보고서, 그리고 향후 몇 년간의 예상 수익을 보여주는 보고서 등이었습니다.

우리는 오랫동안 사랑받아 온 prawn 젬(gem)을 사용해 PDF를 생성하고 있으며, 각 보고서는 Prawn::Document를 상속하는 별도의 Ruby 객체로 구현되어 있습니다.

대략 다음과 같은 형태입니다.

class CostReport < Prawn::Document
  def initialize(...)
    ...
  end

  def render
    text "Cost Report"
    move_down 20
    ...
  end

여기까지는 문제없습니다. 하지만 여기에 함정이 있습니다. 클라이언트가 원한 것은 다른 모든 보고서들의 일부를 포함하는 "Overview(개요)" 보고서였습니다.

해결책 1: 의존성 주입

앞서 언급했듯이, 이런 종류의 문제에 대한 일반적인 해결책은 코드를 의존성 주입 방식으로 리팩토링하는 것입니다. 즉, 각 보고서가 self의 메서드를 호출하는 대신 PDF 문서 객체를 인자로 전달받도록 변경하는 것입니다.

그러면 코드는 대략 다음과 같은 형태가 됩니다.

class CostReport < Prawn::Document
...
  def title(pdf = self)
    pdf.text "Cost Report"
    pdf.move_down 20
    ...
  end
end

동작은 하지만, 여기에는 어느 정도의 오버헤드가 따릅니다. 우선 모든 드로잉 메서드가 pdf 인자를 받아야 하고, prawn에 대한 모든 호출이 이 pdf 인자를 거쳐야 합니다.

의존성 주입에는 분명한 장점이 있습니다. 시스템의 컴포넌트들을 느슨하게 결합된 구조로 만들어 주고, 목이나 스텁을 전달해 단위 테스트를 쉽게 만들어 줍니다.

하지만 우리 시나리오에서는 이런 이점을 누릴 수 없습니다. 이미 prawn API와 강하게 결합되어 있어서, 다른 PDF 라이브러리로 교체하려면 사실상 코드 전체를 다시 작성해야 할 것입니다.

테스트 관점에서도 큰 고민거리가 아닙니다. 우리 경우에는 자동화된 테스트로 생성된 PDF 보고서를 검증하는 것이 너무 번거로워서 그만한 가치가 없었기 때문입니다.

결국 의존성 주입은 원하는 동작을 제공하지만, 이득은 미미한 반면 추가적인 오버헤드만 가져다줍니다. 이번에는 다른 선택지를 살펴보겠습니다.

해결책 2: 위임(Delegation)

Ruby 표준 라이브러리는 데코레이터 패턴을 손쉽게 구현할 수 있는 SimpleDelegator를 제공합니다. 생성자에 객체를 넘기면, 위임자(delegator)에 대한 메서드 호출이 해당 객체로 전달됩니다.

SimpleDelegator를 활용하면 prawn을 감싸는 베이스 보고서 클래스를 만들 수 있습니다.

class PrawnWrapper < SimpleDelegator
  def initialize(document: nil)
    document ||= Prawn::Document.new(...)
    super(document)
  end
end

이제 각 보고서들이 이 클래스를 상속하도록 수정하면, 초기화 단계에서 생성된 기본 문서를 사용하면서 기존과 동일하게 동작합니다. 진짜 마법은 이것을 overview 보고서에서 사용할 때 일어납니다.

class OverviewReport < PrawnWrapper
  ...
  def render
    sales = SaleReport.new(..., document: self)
    sales.sales_table
    costs = CostReport.new(..., document: self)
    costs.costs_pie_chart
    ...
  end
end

여기서 SaleReport#sales_tableCostReport#costs_pie_chart는 전혀 변경되지 않았습니다. 다만 이 안에서 prawn으로 향하는 호출들(text(...), move_down 20 등)이 이제 우리가 만든 SimpleDelegator를 통해 OverviewReport로 전달됩니다.

동작 관점에서 보면, 마치 SalesReportOverviewReport의 서브클래스인 것처럼 만든 셈입니다. 즉, prawn API에 대한 모든 호출이 SalesReport → OverviewReport → Prawn::Document 순서로 흘러갑니다.

SimpleDelegator의 동작 원리

SimpleDelegator가 내부적으로 동작하는 방식은 기본적으로 Ruby의 method_missing 기능을 활용해 메서드 호출을 다른 객체로 전달하는 것입니다.

SimpleDelegator(또는 그 서브클래스)가 메서드 호출을 받으면, 해당 메서드가 정의되어 있다면 다른 객체처럼 그대로 실행합니다. 하지만 그 메서드가 정의되어 있지 않다면 method_missing에 도달하고, method_missing은 생성자에 전달된 객체에 대해 해당 메서드를 호출하려고 시도합니다.

간단한 예제를 살펴보겠습니다.

require 'simple_delegator'
class Thing
  def one
    'one'
  end
  def two
    'two'
  end
end

class ThingDecorator < SimpleDelegator
  def two
    'three!'
  end
end

ThingDecorator.new(Thing.new).one #=> "one"
ThingDecorator.new(Thing.new).two #=> "three!"

여기서 SimpleDelegator를 상속한 ThingDecorator 클래스를 만들면, 일부 메서드는 오버라이드하고 나머지는 기본 Thing 객체로 그대로 흘려보낼 수 있습니다.

하지만 위의 단순한 예제만으로는 SimpleDelegator의 진가를 알기 어렵습니다. 이 코드를 보고 "그냥 Thing을 상속하면 똑같은 결과를 얻을 수 있지 않나?"라고 물으실 수도 있습니다.

네, 맞습니다. 실제로 그렇습니다. 하지만 결정적인 차이가 있습니다. SimpleDelegator는 위임할 객체를 생성자의 인자로 받습니다. 즉, 런타임에 서로 다른 객체를 전달할 수 있다는 것입니다.

바로 이 특성 덕분에 위의 해결책 2에서 prawn 객체로 향하는 호출을 다른 곳으로 돌릴 수 있었습니다. 개별 보고서를 단독으로 호출하면 prawn 호출은 생성자에서 새로 만든 문서로 향하지만, overview 보고서에서는 이를 바꿔 prawn 호출이 자신의 문서로 전달되도록 할 수 있습니다.

마치며

대부분의 결합도 분리(decoupling) 문제에는 의존성 주입이 최선의 해결책일 가능성이 높습니다.

하지만 모든 기술이 그렇듯 트레이드오프가 존재합니다. 제 경우에는 DI가 도입하는 오버헤드가 그 이점보다 크다고 판단해서 다른 해결책을 찾아보았습니다.

Ruby의 세계에서 언제나 그렇듯, 항상 또 다른 방법이 존재합니다. 이 해결책을 자주 사용하지는 않겠지만, 이런 상황에서 유용하게 꺼내 쓸 수 있는 Ruby 도구함의 멋진 무기 하나가 분명해졌습니다.