클린 소프트웨어 - 인터페이스 분리 원칙

인터페이스 분리 원칙

‘비대한’ 인터페이스의 단점을 해결하는 원칙이다. 비대한 인터페이스를 가지는 클래스는 응집력이 없는 인터페이스를 가지는 클래스다. 즉 이들은 메소드의 그룹으로 분리될 수 있고, 각 그룹은 각기 다른 클라이언트 집합을 지원할 수 있다.

응집력이 없는 인터페이스를 필요로 하는 객체가 있다는 것을 인정하지만, 클라이언트는 그것을 하나의 단일 클래스로 생각해서는 안 된다는 것을 시사한다. 클라이언트는 응집력이 있는 인터페이스를 가지는 추상 기반 클래스에 대해 알고 있어야 한다.

인터페이스 오염

protocol Door {
func lock()
func unlock()
var isDoorOpen: Bool { get }
}

protocol TimerClient {
func timeout()
}

class Timer {
func register(timeout: Int, client: TimerClient) { ... }
}

extension Door: TimerClient {}

class TimedDoor: Door {
func lock() { ... }
func unlock() { ... }
var isDoorOpen: Bool { ... }
func timeout() { ... }
}

위의 예제에서 TimedDoor는 문이며, 문이 열린 채로 너무 오랜 시간이 지나면 알람을 울려야 한다는 요구 사항이 있다. 따라서 DoorTimerClient를 상속받으며, TimedDoorDoor를 상속받는다.

이에 따라 DoorTimerClient에 의존하게 되었다. 하지만 Door를 구현한 구체적 사항이 모두 타이머 기능을 필요로 하는 것은 아닐 것이다. 타이머 기능을 사용하지 않는 구체 클래스는 timeout 메소드의 구현을 퇴화시켜야 할 것이다. 이것은 잠재적인 LSP 위반이다.

또한 구체 클래스를 사용하는 애플리케이션은 TimerClient 클래스를 사용하지 않는다 하더라도 이것을 임포트해야 할 것이다.

이렇게 불필요한 복잡성불필요한 중복성의 악취를 풍기게 된다.

Door의 인터페이스는 불필요한 메소드로 오염되었다. 이러한 방식을 계속 유지한다면, 파생 클래스가 새로운 메소드를 필요로 할 때마다 그 메소드가 기반 클래스에도 추가되어야 할 것이다. 이것은 기반 클래스의 인터페이스를 더욱 오염시키고 ‘비대하게’ 만든다.

게다가 새로운 메소드를 기반 클래스에 추가할 때마다 모든 파생 클래스에서 이를 구현해야 한다. 구현을 퇴화시키면 LSP을 위반할 가능성이 있게 되고, 유지보수와 재사용성 측면에서 문제를 일으킬 수 있다.

클라이언트 분리는 인터페이스 분리를 의미한다

DoorTimerClient는 완전히 다른 클라이언트가 사용하는 인터페이스를 의미한다. 문을 나타내는 클래스는 Door를, 타이머는 TimerClient를 사용한다.

클라이언트가 분리되어 있기 때문에, 인터페이스도 분리되어 있어야 한다. 클라이언트가 자신이 사용하는 인터페이스에 영향을 끼칠 수 있기 때문이다.

클라이언트가 인터페이스에 미치는 반대 작용

일반적으로 인터페이스의 변경이 클라이언트에 미치는 영향을 생각하게 된다. 하지만 때로는 클라이언트가 인터페이스 변경을 불러일으킨다.

예를 들어 각 타이머의 등록에 timeoutID를 추가하고, TimerClient에 대하여 이 코드를 반복하여 사용한다. TimerClient의 파생 클래스가 어떤 타이머의 사용 요청에 대한 응답을 받고 있는지 알 수 있게 된다. 이 변경이 TimerClient의 모든 사용자에게 영향을 미치는 것은 분명하다. 하지만, 타임아웃과 관계 없는 DoorDoor의 모든 클라이언트에게도 영향을 준다는 것이 문제이다. DoortimeoutID를 받아야 하기 때문이다. 결과적으로 경직성점착성의 악취를 풍기게 된다.

이처럼 프로그램 한 부분의 변경이 전혀 관계 없는 부분에도 영향을 줄 때, 이 변경에 드는 비용과 그 영향은 예상할 수 없을 정도가 되며, 이 변경이 남기는 부작용의 위험성은 급격히 증가한다.

인터페이스 분리 원칙

Interface Segregation Principle

  • 클라이언트가 자신이 사용하지 않는 메소드에 의존하도록 강제되어서는 안 된다.

클라이언트가 자신이 사용하지 않는 메소드에 의존하도록 강제될 때, 이 클라이언트는 이런 메소드의 변경에 취약하다. 이는 모든 클라이언트 간의 의도하지 않은 결합을 불러일으킨다.

어떤 클라이언트가 자신은 사용하지 않지만 다른 클라이언트가 사용하는 메소드를 포함하는 클래스에 의존할 때, 그 클라이언트는 다른 클라이언트가 그 클래스에 가하는 변경에 영향을 받게 된다.

이러한 결합을 막기 위해 인터페이스를 분리하기를 원한다.

클래스 인터페이스와 객체 인터페이스

객체의 클라이언트는 그 객체의 인터페이스를 통해 객체에 접근할 필요가 없다. 이들은 위임이나 그 객체의 기반 클래스를 통해 접근할 수 있다.

위임을 통한 분리

TimerClient에서 파생된 객체를 생성하고, 그것의 일을 TimedDoor에 위임할 수 있다.

class TimedDoor: Door {
...
func doorTimeout(timeoutID: Int) { ... }
}

class DoorTimerAdapter: TimerClient {
private let timedDoor: TimedDoor
func timeout(timeoutID: Int) {
timedDoor.doorTimeout(timeoutID: timeoutID)
}
}

ISP를 방지하며, Door 클라이언트의 Timer에 대한 결합을 방지한다. Timer의 변경이 Door의 클라이언트에 영향을 주지 않는다. TimedDoorTimerClient와 같은 인터페이스를 갖지 않는다. DoorTimerAdapterTimerClient 인터페이스를 TimedDoor 인터페이스로 변환시켜준다.

이와 같은 해결책은 매우 범용적이다. 하지만 세련되지 못하다. 타이머 사용자 등록을 할 때마다 새로운 어댑터 객체를 생성해야 하는 추가 작업이 발생하며, 이 객체를 생성할 때 소요되는 시간과 공간을 생각할 필요가 있다.

다중 상속을 통한 분리

class TimedDoor: Door, TimerClient {
...
func timeout(timeoutID: Int)
}

TimedDoorDoorTimerClient 모두로부터 상속을 받는다. 분리된 인터페이스를 통해 같은 객체를 사용할 수 있게 된다.

Swift에서는 프로토콜 지향 프로그래밍으로 다중 상속을 통한 분리 방법을 사용할 수 있다.

ATM 사용자 인터페이스 예

ATM은 다양한 방식(다양한 언어, 점자판, 음성)으로 UI를 나타내어야 한다. 다음과 같이 설계할 수 있을 것이다.

protocol ATMUI { ... }
class ScreenUI: ATMUI { ... }
class BrailleUI: ATMUI { ... }
class SpeechUI: ATMUI { ... }

ATM이 수행하는 트랜잭션은 다음과 같이 추상화된 후 구체화될 수 있을 것이다.

protocol ATMTransaction { 
func execute()
}
class DepositTransaction: ATMTransaction { ... }
class WithdrawalTransaction: ATMTransaction { ... }
class TransferTransaction: ATMTransaction { ... }

트랜잭션의 각 클래스는 UI를 사용한다.

protocol UI {
func requestDepositAmount()
func requestWithdrawalAmount()
func requestTransferAmount()
func informInsufficientFunds()
}

결과적으로 각각의 구체 트랜잭션 객체는 다른 클래스에서 사용하지 않는 UI의 메소드를 사용하게 된다. 이는 Transaction의 파생 클래스 중 하나를 변경하는 일이 UI에서 이에 대응하는 변경을 불러일으킬 가능성을 발생시키고, Transaction의 모든 파생 클래스와 UI에 의존하는 모든 클래승 영향을 끼치게 된다. 경직성취약성의 악취를 풍기게 된다.

예를 들어 새로운 Transaction의 파생 클래스가 생기면, 해당 클래스에 대응하는 작업을 수행하기 위해 UI에 새로운 메소드를 추가하고, 이에 의존하는 모든 것들에 새로운 메소드를 추가해야 한다. 전부 다시 컴파일되어야 하고, 다시 배포되어야 할 가능성이 있다. 점착성의 악취를 풍기게 된다.

UI 인터페이스를 개별 인터페이스로 분리하여 위의 문제를 해결할 수 있다.

protocol DepositUI {
func requestDepositAmount()
}
protocol WithdrawalUI {
func requestWithdrawalAmount()
func informInsufficientFunds()
}
protocol TransferUI {
func requestTransferAmount()
}
protocol UI: DepositUI, WithdrawalUI, TransferUI {}

각 트랜잭션은 각각에 대응하는 UI를 사용하게 될 것이다.

복합체와 단일체

함수에 DepositUITransferUI 둘 모두를 넘겨주려 할 때 함수를 어떻게 작성해야 할까?

// 1 : 복합
func foo(depositUI: DepositUI, transferUI: TransferUI) { ... }
// 2 : 단일
func foo(ui: UI)

1의 경우 두 인자는 같은 객체를 참조하게 될 때, 호출 시점에서 foo(depositUI: ui, transferUI: ui)와 같은 코드를 작성하여 이상해 보일 수 있다.

하지만 일반적으로 복합 형태가 단일 형태보다는 바람직하다. 2의 경우에는 함수가 UI와 관련된 모든 인터페이스에 의존하도록 하므로, 다른 UI, 예를 들어 WithdrawalUI가 변경될 때 함수와 함수를 사용하는 클라이언트에 영향을 끼칠 수 있다.

또한 함수의 두 인자가 항상 같은 객체를 가리키리라고 단정할 수 없다. 분리된 인터페이스 객체가 각각 넘겨질 수 있기 때문이다.

클라이언트 그룹 만들기

클라이언트는 이들이 호출하는 서비스 메소드에 의해 그룹화될 수 있다. 이 경우 클라이언트가 아닌 그룹에 대해 분리된 인터페이스를 만들 수 있어, 각 서비스가 구현해야 하는 인터페이스의 수가 줄어들 뿐만 아니라, 그 서비스가 각 클라이언트의 타입에 의존하게 되는 일을 방지할 수 있다.

서로 다른 클라이언트 그룹이 호출하는 메소드가 겹치는 경우, 겹치는 부분이 작으면 인터페이스는 분리된 상태로 남아있어야 한다. 공통 함수는 겹친 인터페이스에서 한 번만 선언되어야 한다.

인터페이스 변경

기존 인터페이스를 변경해야 할 때, 변경 대신 새로운 인터페이스를 만들어 기존 객체에 추가할 수 있을 것이다.

func foo(_ service: Service) {
if let newService = service as? NewService {
// NewService 인터페이스 사용
}
}

하지만 이 전략을 과도하게 사용하는 것은 좋지 않다. 수백 개의 인터페이스를 갖는 클래스는 좋아보이지 않을 것이다.

결론

비대한 클래스는 클라이언트 간에 기이하고 해가 되는 결합도를 유발한다. 한 클라이언트가 이 비대한 클래스에 변경을 가하면, 모든 나머지 클래스가 영향을 받게 된다.

이를 해결하기 위해 클라이언트는 자신이 실제로 호출하는 메소드에만 의존해야 하며, 클라이언트 고유의 인터페이스로 기존 인터페이스를 분리해야 한다.

이렇게 하여 호출하지 않는 메소드에 대한 클라이언트의 의존성을 끊을 수 있고, 클라이언트가 서로에 대해 독립적으로 행동할 수 있게 된다.