본문 바로가기

[스터디] OOP - 의존성 관리하기

의존성

어떤 객체가 협력하기 위해 다른 객체를 필요로 할 때 두 객체 사이에 의존성이 존재한다고 말할 수 있다.

 

class A implements I {

  methodOfA(b: B) {
    return b.methodOfB().filter((item) => item.chceked)
  }
}

class B {
  methodOfB(c: C) {
    return c.methodOfC()
  }
}

class C {
  methodOfC() {}
}

 

위 예제의 클래스 A는 methodOfA 메소드에서 클래스 B를 인자로 받아 B에게 methodOfB 메시지를 전송한다.

이 경우, A는 자신의 작업을 수행하기 위해 B 객체를 필요로 하는데 이 경우 A는 B에 의존한다고 한다.

 

의존성은 변경과 관련이 있다.

의존되는 요소(B)가 변경될 때 의존하는 요소(A) 또한 함께 변경될 수 있다.

 

클래스 B 뿐만 아니라 인터페이스 I 또한 A가 의존한다고 말할 수 있다.

만약 I에 선언된 오퍼레이션의 시그니처가 변경된다면 A 또한 수정이 불가피하기 때문이다.

* 의존성의 종류는 I와 B가 다르다.

 

의존성 전이

클래스 B를 보면 B또한 클래스 C에 의존하고 있음을 알 수 있다.

A가 B에 의존하는 경우, A 또한 B가 의존하는 C에 간접적으로 의존하게 되는데 이를 의존성 전이라고 한다.

다시 말하면, B의 의존성이 A로 전파된다.

 

의존성은 함께 변경될 수 있는 가능성을 의미하기 때문에 C가 변경된다고 해서 A까지 전파되는지는 변경의 방향과 캡슐화의 정도에 따라 다르다.

 

B가 내부 구현을 적절하게 캡슐화하고 있다면, A까지 C의 변경이 전파되지 않을 것이다.

의존성 전이는 변경에 의해 영향이 널리 전파될 수도 있다는 경고이다.

 

다시 말해, 의존성이란 의존하고 있는 대상의 변경에 영향을 받을 수 있는 가능성이다.

 

위처럼 의존성은 전이될 수 있기 때문에 직접 의존성간접 의존성으로 나눌 수 있다.

직접 의존성은 A가 B에 의존하는 것처럼 코드에 명시적으로 드러나는 의존성이다. 간접 의존성은 직접적으로 관계는 나타나있지 않지만 의존성 전이에 의해 변경이 전파되는 의존성이다. 이는 코드에 명시적으로 드러나지 않는다.

 

런타임 의존성과 컴파일타임 의존성

런타임 의존성이란, 실행 시점에 생기는 의존성으로, 객체 사이의 의존성을 말한다.

반면, 컴파일타임 의존성은 (자바스크립트같이 동적 타입언어 관점에서는) 코드를 작성하는 시점 시 생기는 의존성을 말하고, 이는 즉 클래스 사이의 의존성이다.

 

런타임 의존성과 컴파일타임 의존성은 다를 수 있다.

그리고, 유연하고 재사용 가능한 코드를 설계하기 위해서는 두 의존성을 서로 다르게 만들어야 한다.

 

다음은 실무에서 적용해보았던 예시이다.

 

설명 : 금융사별로 중간한도(MidLoan) 심사 결과를 조회하여 화면에 그린다.

 

컴파일타임에서는 MidLoan이라는 추상 클래스에 의존하는 것이고, 런타임에는 각각 Bank1MidLoan과 Bank2MidLoan 객체에 의존한다.

이처럼 런타임에서의 의존성이란 객체를 중심으로 말하는 것이고, 컴파일타임 의존성클래스를 중심으로 다룬다.

일반적으로 두 의존성의 거리가 멀먼 멀수록 유연한 설계라고 하며, 동일한 소스코드 구조를 가지고 다양한 실행 구조를 만들 수 있다.

 

의존성 해결하기

컴파일타임 의존성을 런타임의존성으로 대채하는 것을 의존성 해결이라고 한다.

의존성을 해결하기 위한 방법으로는 크게 세 가지가 있다.

 

  • 객체를 생성하는 시점에 생성자에서 해결
  • 객체를 생성한 후 setter를 통해서 해결
  • 메서드를 실행할 때 인자를 사용하여 해결

 

객체를 생성하는 시점에 생성자에서 해결

const a = new A(new B())

 

객체를 생성한 후 setter를 통해서 해결

class A {
  constructor(private b: B) {}
  
  changeB(b: B) {
    this.b = b
  }
}

const a = new A()

a.changeB(new B())

다만 객체 생성 시, 불완전한 객체를 생성할 수 있다.

따라서 생성자에서 Default 객체를 생성자에 넘겨주어 완전한 객체를 만든 후, setter로 의존 대상을 변경한다.

이 방법으로 의존하고 있는 대상을 동적으로 변경할 수 있다.

 

메서드를 실행할 때 인자를 사용하여 해결

const a = new A()

a.methodOfA(new B())

 

의존 관계를 지속적으로 유지할 필요 없을 때 유용하다.

 

유연한 설계

의존성을 관리하는 데 유용한 몇 가지 원칙이 있다.

 

의존성과 결합도의 관계

바람직한 의존성은 재사용성과 관련이 있다.

어떤 의존성이 해당 클래스를 다양한 문맥에서 사용할 수 없도록 제한한다면 그것은 바람직한 의존성이 아니다.

이것을 '단단한 결합도'라고 칭한다.

 

의존성은 설계를 재사용하기 쉽게 만들어야 하며, 다시 말하면 결합도가 느슨해야 한다.

 

더 많이 알수록 더 많이 결합된다.

어떤 요소가 다른 요소에 대해 너무 많은 정보를 알고 있을수록 두 요소는 강하게 결합된다.

가령, 어떤 클래스의 인스턴스를 생성할 때, 그 클래스가 필요로 하는 정보가 무엇인지 너무 많이 알고 있거나, 협력하는 객체가 작업을 어떻게 수행하는지를 메시지를 전송하는 요소가 알고 있다면 이것은 두 요소가 강하게 결합되었다고 할 수 있다.

 

따라서 우리는 추상화를 통해 이것을 감추어야 한다.

 

추상화

결합도는 구체 클래스 < 추상 클래스 < 인터페이스 순으로 느슨해진다. 즉 알고 있어햐 하는 정보의 양이 인터페이스로 갈수록 줄어든다.

 

인스턴스의 사용과 생성을 분리하라.

퍼블릭 인터페이스를 통해 명시적으로 의존성을 드러내라. 그래야 코드를 직접 수정하는 위험을 줄일 수 있다.

// 명시적인 의존성
const retryStorage = new SingleKeySessionStorage('RETRY_LOAN_KEY1')
const midLoanStorage = new SingleKeySessionStorage('MID_LOAN_KEY1')
const retryLoan = new RetryLoan(bankCode, retryStorage)
const midLoan = new Bank1MidLoan(midLoanStorage, retryLoan)

반면, 숨겨진 의존성은

class Bank1MidLoan extends MidLoan {
  constructor() {
    this.midloanStorage = new SingleKeySessionStorage('MID_LOAN_KEY1')
    const retryStorage = new SingleKeySessionStorage('RETRY_LOAN_KEY1')
    this.retryLoan = new RetryLoan(bankCode, retryStorage)
  }
}

 

클래스 내의 new 생성자(혹은 인스턴스를 생성하는 팩토리 함수)는 결합도를 높이고 변경에 취약하게 만든다.

 

따라서 이것을 피하기 위해 인스턴스를 생성하는 고직과 사용하는 로직을 분리한다.

 

클래스 내부에는 메시지를 전송하는 코드만 남는다.

abstract class MidLoan {
    protected midLoanData: MidLoanDataType

    constructor(
        private midLoanStorage: SingleKeyStorageManager<MidLoanDataType>,
        private retryLoan: RetryLoan,
    ) {
        this.midLoanData = {} as MidLoanDataType

        if (!this.midLoanStorage.isEmpty) {
            this.setMidLoanData(this.midLoanStorage.data)
        }
    }

    setMidLoanData(midLoanData: MidLoanDataType) {
        this.midLoanData = midLoanData
        this.midLoanStorage.set(midLoanData)
    }

    abstract get data(): MidLoanData

    abstract get status(): MidLoanStatus

    get isMaxAppLmt() {
        if (!this.midLoanData.appLmt) {
            return false
        }
        return this.midLoanData.appLmt >= this.maxAmount
    }

    clear() {
        this.midLoanData = {} as MidLoanDataType
        this.retryLoan.clear()
        this.midLoanStorage.clear()
    }

    async retry(nfMngNum: string) {
        const retryResult = await this.retryLoan.retry(nfMngNum)
        this.setMidLoanData(retryResult.loanInfoResponse)
        return retryResult.loanInfoResponse
    }
}

 

예외

어떤 클래스가 단 하나의 객체하고만 협력한다면 생성자 내부에서 생성해도 무방하다. 역시 설계의 트레이드 오프를 고려하여 외부에서 생성하여 주입하는 것이 불필요하다면 내부에서 생성또한 고려할 수 있다.

 

만약 외부에서 인스턴스를 생성할 때 어떻게 생성해야 하는지 너무 많은 정보를 알고 있을때에는 (9장에 나오는) 팩토리 패턴이나 JS에서는 함수로 묶어서 표현할 수 있다.

export const createMidLoanFactory = ({retryKey, midloanKey, bankCode, createMidLoan}) => {
    const retryStorage = createSingleKeySessionStorage<number>(retryKey)
    const midLoanStorage = createSingleKeySessionStorage<MidLoanDataType>(midloanKey)
    const retryLoan = createRetryLoan(bankCode, retryStorage)
    const midLoan = createMidLoan(midLoanStorage, retryLoan)

    return midLoan
} // Bank1MidLoan과 Bank2MidLoan 생성 함수