본문 바로가기

Dependency Injection

DependencyInjection : (간단한 예제와 DI 이점)

728x90

수많은 프로그래밍 교과서의 전통에 따라 화면에 "Hello DI!"를 출력하는 간단한 콘솔 애플리케이션을 구성해보겠다.  또 DI가 주는 이점에 대해 설명하려 한다.
한 줄의 코드로 작성된 Hello World 예제를 본 적이 있을것이다. 그 쉬운 예제를 여기서는 좀 더 복잡하게 만들어 보겠다.

 

Collaborators

프로그램의 구조는 아래와 같다. 플랫폼/언어와 상관없이 이해를 돕도록 Main메서드를 예시로 든다.

func Main() {
    let wirter: IMessageWriter = ConsoleMessageWriter()
    let salutation = Salutation(writer)
    salutation.exclaim()
}

콘솔에 메시지를 출력하는 기능을 캡슐화한 IMessageWriter 인스턴스를 만들어준다.

그리고 Salutation 클래스의 생성자로 전달. 마지막으로 Salutation의 exclaim메서드를 호출한다. 콘솔에 메시지가 뜬다 끝-- 

 

Main메서드 내부의 객체 구성은 PureDI의 기본예시다. Salutation과 그 ConsoleMesasgeWriter 종속성을 구성하는데는 DIContainer가 사용되지 않았다.  결과적으로 아래와 같이 관계를 이해할 수 있다.


내부구현이 없어 이해가 어려웠다면 이제부터 차근차근 보면 된다.

우선 Salutation

public class Salutation {
    private let writer: IMessageWriter
	
    public init(_ writer: ImessageWriter)? {
        if writer == nil {
            throw ArgNullException("writer")
        }
        self.writer = writer
    }

    public func exclaim() {
        writer.write("Hello DI!")
    }
}

 

Salutation 클래스는  IMessageWriter 인터페이스에 의존한다. 위 코드는 생성자를 통해 인스턴스를 할당하고 있다.

이를 생성자 주입이라고 한다. exclaim 메서드의 구현부에서 주입된  IMessageWriter 인스턴스의 wirte 메서드를 호출한다. 그러면 "Hello DI!" 메시지가 IMessageWriter 종속성으로 전송된다.

생성자 주입은 필요한 종속성 목록을 클래스의 생성자에게 매개변수로 지정하여 정적으로 정의하는 작업이다.

 

생성자를 통해 Salutation 클래스에 IMessageWriter 종속성을 주입했다고 볼 수 있겠다.

Salutation은 ConsoleMessageWriter를 인식하지 못한다는 점을 유의하자! 오직 IMessageWriter 인터페이스를 통해서만 상호작용 한다. IMessageWriter는 이 경우를 위해 정의된 간단한 인터페이스이다.

public protocol IMessageWriter {
    func write(_ message: String)
}

 

다른 기능을 포함할 수도 있지만, 이 예제에선 간단히 wirte메서드만 넣어봤다. 이 메서드는 Salutation 클래스에 전달하는 ConsoleMessageWirter클래스가, IMesageWriter를 채택했기 때문에 필수로 구현해야 하는 메서드이다.

public class ConsoleMessageWriter: IMessageWriter {
    public func write(_ message: String) {
    	print(message)
    }
}

 

DI의 이점

func main() {
    print("Hello DI!")
}

위 코드처럼 아주아주아주 간단히 한 줄이면 끝날 것을. 왜 두개의 클래스와 프로토콜까지 사용해야 했을까? 

 

DI는 과잉 사용처럼 보일 수 있지만, DI를 사용하면 얻을 수 있는 몇 가지 이점이 있다. 

DI 예제가 과도하게 설계되었다고 생각되더라도 고려해볼만한 부분이 있다.Hello World 예제는제한된 요구 사항을 가졌다. 하지만 현실의 소프트웨어 개발은 결코 그렇지 않다. 요구 사항은 변화하고 모호하다. 기능도 복잡하고 말이다. DI는 느슨한 결합을 통해 이러한 문제를 해결하는 데 도움을 준다. 구체적으로 아래와 같은 이점을 얻을 수 있다.

 

Late Binding
코드를 다시 컴파일하지 않고도 서비스를 다른 서비스로 교체할 수 있음

Extensibility
명시적으로 계획되지 않은 방식으로 코드를 확장하고 재사용할 수 있음

Parallel Development
코드를 병렬로 개발할 수 있음.크고 복잡한 애플리케이션에서는 유용

Maintainability
기능이 명확하게 정의된 클래스는 유지 관리가 더 쉬움

Testability

항상 가치있는 부분. 

 

 

Late Binding

누군가는 프로그램의 요구 사항을 너무 잘 알고 있어서 코드에 변화가 없을거라 단언하기도 한다. 즉 ... SQL Server 데이터베이스를 다른 것으로 대체할 필요가 없다고 말하는것이다. 그걸 누가 장담할수 있을까? 요구사항은 변한다. 

위 코드에선  ConsoleMessageWriter 인스턴스 생성을 하드 코딩하여 명시적으로 IMessageWriter의 새 인스턴스를 만들었기 때문에 LateBinding을 사용하지 않았다. 여기에 한 줄의 코드를 변경하여 LateBinding을 도입할 수 있다.
IMessageWriter의 인스턴스를 생성하는 부분을 수정해보자.

let configuration = ConfigurationBuilder().setBasePath(localDirectory).addJsonFile("appsetting.json").build()
let typeName = configuration["messageWriter"]
let type = Type.getType(typeName)
let writer: IMessageWriter = activator.createInstance(type)

 

클래스나 메서드의 명칭만 보고, 흐름을 이해해보자. 

애플리케이션 구성파일에서 타입 이름을 가져와서 타입 인스턴스를 생성하면 컴파일 시점에 구체적인 타입을 알지 못해도 IMessageWriter인스턴스를 생성할 수 있다. 

 

Salutation 클래스는 IMessageWriter 인터페이스에 대해서만 알기 때문에, 인스턴스가 바뀌는 등 그 차이를 알아차리지 못한다.

LateBinding을 사용하면 콘솔이 아닌 다른 대상(예: 데이터베이스 또는 파일) 에 메시지를 쓸 수 있따. 이러한 기능은 명시적으로  미리 계획하지 않았더라도 추가할수 있게된다.

 

Extensibility

공적인 소프트웨어는 변화할 수 있어야 한다. 느슨한 결합을 사용하면 전기 플러그와 소켓으로 작업할 때 유연성을 확보하는 방식과 유사하게 애플리케이션을 효율적으로 재구성할 수 있게 된다.
인증된 사용자만 메시지를 작성할 수 있도록 허용하여 Hello DI! 예제를 더욱 안전하게 만들고 싶다고 가정해 보자. 

아래 코드는 기존 기능을 변경하지 않고 이 기능을 추가하는 방법을 보여준다. 

protocol MessageWriter {
    func write(_ message: String)
}

class SecureMessageWriter: MessageWriter {
    private let writer: MessageWriter
    private let identity: Identity

    init(writer: MessageWriter, identity: Identity) {
        self.writer = writer
        self.identity = identity
    }

    func write(_ message: String) {
        if identity.isAuthenticated {
            writer.write(message)
        }
    }
}

// Identity 프로토콜 및 예시 구현
protocol Identity {
    var isAuthenticated: Bool { get }
}

class UserIdentity: Identity {
    var isAuthenticated: Bool {
        // 사용자 인증 상태 반환 로직
        return true // 예시로 true 반환
    }
}

 

SecureMessageWriter 생성자에는 IMessageWriter의 인스턴스 외에도 Identity 인스턴스를 받는다. write 메서드는 먼저 주입된 Identity 를 사용하여 현재 사용자가 인증되었는지 여부를 확인. 인증된 사용자라면 장식된 작성자 필드에 메시지를 쓸 수 있다. 사용 가능한 클래스를 이전과 다르게 구성해야 하므로 기존 코드를 변경해야 하는 유일한 곳은 Main 메서드뿐이다.

let wirter: IMessageWriter = SecureMessageWriter( ConsoleMessageWriter(), UserIdentity())

 

기존 ConsoleMessageWriter 인스턴스를 새로운 SecureMessageWriter 클래스로 감싸거나 Decorate하는 것을 볼 수 있다. 다시 한 번 말하지만, Salutation 클래스는 IMessageWriter 인터페이스만 사용하기 때문에 수정이 필요없다. 마찬가지로 ConsoleWriter 클래스의 기능도 수정하거나 복제할 필요가 없다.

앞서 언급했듯이 느슨한 결합을 사용하면 확장성을 위해 열려 있지만 수정을 위해 닫혀 있는 코드를 작성할 수 있다. 코드를 수정해야 하는 유일한 곳은 애플리케이션 진입점. 따라서 각 클래스에는 고유한 단일 책임이 있다고 볼 수 있다.

 

Parallel Developemnt

관심사를 분리하면 코드를 병렬로 개발할 수 있다. 개발 프로젝트가 규모 이상으로 커지면 여러 개발자가 동일한 코드 베이스에서 동시에 작업할 필요성이 생긴다. 더 큰 규모에서는 개발팀을 관리 가능한 규모의 여러 팀으로 분리해야할 필요성도 생긴다.

책임을 구분하기 위해 각 팀은 완성된 애플리케이션에 통합되어야 하는 하나 이상의 모듈을 개발한다. 각 팀의 영역이 완전히 독립적이지 않는 한, 일부 팀은 다른 팀에서 개발한 기능에 의존할 가능성이 높다.

 

객체 지향 소프트웨어 설계에서 모듈은 논리적으로 관련된 클래스( 또는 컴포넌트)의 그룹으로, 모듈은 다른 모듈과 독립적이어야 한다. 일반적으로 레이어 하나 이상의 모듈로 구성된다.

 

앞의 예제에서 SecureMessageWriter와 ConsoleMessageWriter 클래스는 서로 직접적으로 의존하지 않으므로 병렬적으로 개발할 수 있다. 서로 다른 팀에서 개발할 수 있는 환경이었다는 뜻이다. 그러므로 개발시 논의 해야할 공통적인 부분은 IMessageWriter였을 것이다.

 

Maintainability

각 클래스의 책임이 명확하게 정의되고 제한됨에 따라 전체 애플리케이션의 유지관리가 더욱 쉬워진다. 단일 책임의 원칙이 불러오는 이점인 것이다. 단일 책임원칙에 대해선 나중에 차차 다시 알아보자.

 

애플리케이션에 새로운 기능을 추가하려 한다면 대부분의 경우 기존 코드를 변경할 필요 없이, 새로 클래스를 추가하면 된다. 이것이 개방/폐쇄 원칙이 다시 작동하는 방식이다.  또 문제 해결의 범위가 좁아지기 때문에 문제 해결이 덜 힘들어지는 경향이 있다. 책임이 명확하게 정의되어 있으니 문제의 근본 원인이 어디서 찾아야할지 유추를 빠르게 할 수 있다.

 

Testablility

어떤 사람들에게는 걱정거리가 아닐테지만, 어떤 사람들은 절대적인 요건이 되기도 한다.

이 용어는 주로 단위테스트를 수행하는 사람들이 사용한다. 일반적으로 애플리케이션은 사용해봄으로써 테스트할 수 있다. 그러나 수동 테스트는 시간과 비용이 많이 들기 때문에 자동화된 테스트가 선호된다. 
자동화된 테스트에는 UnitTest, integration Test, Performence Test, Stress Test 등 다양한 유형이 있다. 단위 테스트는 런타임 환경에 대한 요구 사항이 적기 때문에 효율적이고 강력한 테스트 유형으로 간주되는 경향이 있다. 

단위 테스트는 실제로 얼마나 세분화되어 있는지에 대해서는 다소 모호한 부분이 있지만, 여러 모듈에 걸쳐 있는것이 아니라는 점은 확실하다. 단위 테스트에서 모듈을 분리하여 테스트하는 능력은 매우 중요하다. 
테스트 용이성을 보장하는 안전한 방법은 TDD를 사용하여 개발하는 것이다. 

아마 테스트 용이성이란 이점에 대해선 논란이 많을 것이다. 일부 개발자와 아키텍트는 여전히 단위테스트에 힘을 쏟지 않으려 하기 때문에, 이 이점에 대해 신경쓰지 않는다.

 

어쨌거나. 느슨한 결합이 단위 테스트를 가능하게 하는 이유는 의존성이 구체적인 유형에 신경쓰지 않는 리스코프 원칙을 따르기 때문이다. 아래에서 설명하듯 System Under Test 를 삽입할 수 있다.

 

테스트 더블이란?
소프트웨어 테스트 중 실제 의존성(예: 데이터 베이스, 웹 서비스 등)을 대체하기 위해 사용되는 객체. 그리고 이런 객체로 의존성을 대체하는 기법. 이것을 테스트 더블이라 한다. 유념해야 할 것은 최종 애플리케이션에서 사용은 절대 NO. 
테스트 더블은 실제 사용될 종속성이 느리거나, 비용이 많이들거나, 파괴적인 경우 유용하게 사용할 수 있다. 

여기서 비용이 많이 든다는 뜻은, 리소스, 노력등을 포함한다. 예를 들어 실제 데이터베이스에 접근하거나 외부 API를 호출하는 것은 네트워크 지연, 서비스 비용 등 테스트 환경의 변수를 더 고려해야한다. 이러한 경우 테스트 더블을 사용하면 실제 의존성을 사용하는 것보다 훨씬 적은 비용으로 테스트할 수 있다.

또 파괴적 이라는 의미는, 실제 데이터나 환경에 영구적인 변화나 손상을 일으킬수 있는 작업을 의미한다.
테스트 과정에서 실제 데이터베이스의 데이터를 삭제, 수정하는것은 데이터 무결성을 해칠 수 있다. 따라서 테스트 더블로 실제 데이터베이스에 영향을 주지않고 안전하게 테스트를 진행할 수 있다.

테스트 더블은 여러 하위 유형이 있다.

스텁(Stubs): 특정 입력에 대해 미리 정해진 응답을 제공하는 간단한 구현. 예를 들어, 데이터베이스 쿼리에 대해 미리 정해진 결과를 반환하는 경우.

모크(Mock): 스텁과 비슷하지만, 특정 호출이 일어났는지를 검증하는 기능을 가진다. 예를 들어, 특정 메서드가 특정 인자와 함께 호출되었는지 확인하는 데 사용된다.

페이크(Fakes): 실제 의존성보다 간단하지만, 실제로 작동하는 구현을 제공한다. 예를 들어, 실제 데이터베이스 대신 인메모리 데이터베이스를 사용하는 경우가 여기에 해당한다.

 

Example: UniTesting Hello DI Logic

// 테스트 대상 클래스
class Salutation {
    private var messageWriter: MessageWriter

    init(messageWriter: MessageWriter) {
        self.messageWriter = messageWriter
    }

    func exclaim() {
        messageWriter.write(message: "Hello DI!")
    }
}

// 메시지 작성을 위한 프로토콜
protocol MessageWriter {
    func write(message: String)
}

// 스파이 객체
class SpyMessageWriter: MessageWriter {
    var writtenMessage = ""

    func write(message: String) {
        writtenMessage += message
    }
}

// 테스트 케이스
class SalutationTests: XCTestCase {
    func testExclaimWillWriteCorrectMessageToMessageWriter() {
        let writer = SpyMessageWriter()
        let sut = Salutation(messageWriter: writer)
        sut.exclaim()
        XCTAssertEqual("Hello DI!", writer.writtenMessage)
    }
}

 

테스트에 계속 떠들었으니, 관련된 코드를 간단히 작성해봤다. MessageWriter 프로토콜을 정의하고 SpyMessageWriter클래스가 이 프로토콜을 준수하도록 구현했다.

이 코드는  Salutatino객체가 SpyMessageWriter를 통해 올바른 메시지를 작성하는지 확인하는 코드다.

 

728x90