본문 바로가기

ComputerScience/DesignPattern

[SOLID] ISP: The Interface-Segregation Principle


* 이 글은 
Agile Software Development, Principles, Patterns, and Practices - Robert Martin 책 내용을 번역 및 요약하여 작성하였습니다.

The Interface-Segregation Principle

  • 이 원칙은 "두꺼운(fat)" 인터페이스의 단점에 대처하기 위한 원칙입니다.
  • "두꺼운" 인터페이스를 가진 클래스란, 인터페이스가 그 인터페이스를 구현한 클래스와 연관성이 없는 클래스를 말합니다.
  • ISP는 연관성이 없는 인터페이스가 필요한 객체가 있음을 인정하지만, 이러한 객체들에 대한 클라이언트가 하나의 클래스로 이해하면 안된다고 말합니다. 대신 클라이언트는 연관성 있는 인터페이스를 갖는 추상 기본 클래스를 알아야 합니다.

Interface Pollution

class Door
{
  public:
    virtual void Lock()   = 0;
    virtual void Unlock() = 0;
    virtual bool IsDoorOpen() = 0; 
};

위에 C++로 작성된 Door 클래스는 '문'의 역할을 하는 가상 메서드가 포함된 추상 클래스입니다.

class Timer
{
  public:
    void Register(int timeout, TimerClient* client); };
class TimerClient
{
  public:
    virtual void TimeOut() = 0; };

 

우리는 TimedDoor라는, 문을 너무 오래 열어두면 경고음을 내야 하는 구현이 필요하다고 가정해봅시다.

이를 위해 TimedDoor 객체는 Timer라는 다른 객체와 통신해야 합니다.

이러한 구현은 문제점이 있습니다.

 

  • 그 중 가장 중요한 문제는 Door 클래스가 이제 TimerClient에 의존한다는 것입니다. 모든 종류의 문이 타이밍이 필요하지 않습니다.
  • 실제로 원래의 Door 추상화는 Timer 클래스와는 아무 상관이 없었습니다. Door의 Timer가 필요하지 않은 파생 클래스가 생성되면 이러한 파생 클래스는 TimeOut 메서드에 대한 변형된 구현을 제공해야 하며, 이는 LSP 위반의 위험을 안게됩니다.
  • 게다가 이러한 파생 클래스를 사용하는 응용 프로그램은 TimerClient 클래스의 정의를 가져와야 하며, 이것은 사용하지 않는데도 불구하고 해당 정의를 가져와야 합니다. 이것은 불필요한 복잡성과 불필요한 중복의 증상입니다.
  • 이것은 인터페이스 오염(interface pollution)의 예이며, C++ 및 Java와 같은 정적으로 타입화된 언어에서 흔한 증상입니다. Door의 인터페이스가 필요하지 않은 메서드를 포함해야 한다는 문제입니다. 이 메서드는 단순히 하나의 하위 클래스의 이익을 위해 Door 클래스에 포함되어야 합니다. 이러한 실천이 계속되면 파생 메서드가 새 메서드가 필요한 경우마다 기본 클래스에 추가될 것입니다. 이로 인해 기본 클래스의 인터페이스가 오염되어 "두꺼워"질 것입니다.
  • 또한 기본 클래스에 새 메서드가 추가될 때마다 이 메서드는 파생 클래스에서 구현(또는 기본값으로 허용)되어야 합니다. 실제로 관련된 실천 중 하나는 이러한 인터페이스를 기본 클래스에 추가하여 파생 클래스가 이를 구현할 필요가 없게하는 것입니다. 이러한 실천은 LSP를 위반할 수 있어 유지 및 재사용성 문제를 야기할 수 있습니다.

Separate Clients Mean Separate Interfaces

우리가 소프트웨어의 변경이 필요해지는 경우가 있다면, 인터페이스의 변경이 해당 인터페이스를 사용하는 파생 클래스들에게 어떤 영향을 미칠지에 대해 생각합니다. 예를 들어, TimerClient 인터페이스가 변경된다면 TimerClient의 모든 사용자에 대한 변경 사항에 대해 우리는 걱정할 것입니다.

그러나 반대 방향으로 작용하는 힘도 있습니다. 때로는 사용자가 인터페이스를 변경하도록 강제하는 경우가 있습니다.

예를 들어, Timer의 일부 사용자는 여러 개의 타임아웃 요청을 등록할 수 있습니다. TimedDoor를 생각해보겠습니다. 이 문은 문이 열리면 타임아웃을 요청하기 위해 Timer에게 Register 메시지를 보냅니다. 그러나 이 타임아웃이 만료되기 전에 문이 닫히고 어느 정도의 시간 동안 닫혔다가 다시 열립니다. 이로 인해 이전 타임아웃이 만료되기 전에 새로운 타임아웃 요청을 등록하게 됩니다. 마침내 첫 번째 타임아웃 요청이 만료되고 TimedDoor의 TimeOut 함수가 호출됩니다. 문은 잘못 경보가 울립니다.

 

class Timer
{
  public:
    void Register(int timeout, 
                  int timeOutId, 
                  TimerClient* client); 
};
class TimerClient
{
  public:
    virtual void TimeOut(int timeOutId) = 0; 
};

위 코드에서 각 타임아웃 등록에 고유한 timeOutId 코드를 포함하고, 이 코드를 TimerClient에게 TimeOut 호출에서 반복해서 제공합니다. 이려면 TimerClient의 파생 클래스마다 어떤 타임아웃 요청에 응답하는지 알 수 있도록 합니다.

이 변경 사항은 TimerClient의 모든 사용자에게 영향을 미칠 것으로 명확합니다. 우리는 이것을 받아 들이는데, 왜냐하면 timeOutId의 부재는 수정이 필요한 실수이기 때문입니다.

그러나 위 코드의 설계는 Door 및 Door 파생 클래스의 모든 클라이언트가 이 수정에 영향을 받게 만듭니다. 이것은 강직성(Rigidity)과 점도성(Viscosity)의 흔적이 있습니다. 타이밍이 필요하지 않은 Door 파생 클래스의 클라이언트에게 TimerClient의 버그가 어떤 영향을 미쳐야 하는 이유가 있을까요?

프로그램의 한 부분에서의 변경 사항이 프로그램의 완전히 관련 없는 다른 부분에 영향을 미칠 때 변경의 비용과 파급 효과는 예측할 수 없게 되며, 변경으로 인한 부작용의 위험이 급격하게 증가합니다.

ISP: The Interface-Segregation Principle

Clients should not be forced to depend on methods that they do not use.

Separation through Delegation

이것의 해답은 객체의 클라이언트가 객체의 인터페이스를 통해 액세스할 필요가 없다는 사실에 있습니다. 그 대신 그들은 위임(delegation)이나 객체의 기본 클래스(base class)를 통해 액세스할 수 있습니다.

이 해결책은 ISP를 준수하며 Door 클라이언트가 Timer와 결합되지 않도록 합니다. 앞서 설명한 코드에서 Timer의 변경이 이루어진다고 해도 Door 사용자 중 아무도 영향을 받지 않을 것입니다. 게다가 TimedDoor는 TimerClient의 인터페이스를 가져야 할 필요가 없습니다. DoorTimerAdapter는 TimerClient 인터페이스를 TimedDoor 인터페이스로 변환할 수 있습니다. 

그러나 이 해결책은 완벽하지 못한 면도 있습니다. Timeout()을 등록할 때마다 새 객체를 생성해야 합니다.

이는 매우 작지만 런타임 및 메모리를 일부 소모하는 작업입니다. 런타임 및 메모리가 부족한 내장형 실시간 제어 시스템과 같은 응용 분야에서는 이것이 문제가 될 수 있습니다.

Separation through Multiple Inheritance

아래 Figure 12-3은 ISP(인터페이스 분리 원칙)를 달성하기 위해 다중 상속을 사용하는 방법을 보여줍니다. 이 모델에서, TimedDoor는 Door와 TimerClient 두 가지를 동시에 상속받습니다. Door와 TimerClient 두 가지의 기본 클래스 모두의 클라이언트는 TimedDoor를 사용할 수 있지만, 실제로는 TimedDoor 클래스에 의존하지 않습니다. 따라서 이들은 별개의 인터페이스를 통해 동일한 객체를 사용합니다.

The ATM User Interface Example

더 중요한 예를 고려해 봅시다. 전통적인 자동화된 현금 인출기(ATM) 문제입니다. ATM 기계의 사용자 인터페이스는 매우 유연해야 합니다. 출력물은 여러 가지 다른 언어로 번역되어야 할 수 있습니다. 화면에 표시되어야 할 수도 있고, 점자 태블릿에 표시되어야 할 수도 있고, 음성 합성기를 통해 음성으로 출력되어야 할 수도 있습니다. 이를 달성하기 위해 인터페이스에서 제시해야 하는 모든 다양한 메시지를 위한 추상 기본 클래스를 생성하는 것으로 분명히 이루어질 수 있습니다.

위 도식화에서 ATM이 수행할 수 있는 각기 다른 거래(transaction)가 Transaction 클래스의 파생 클래스로 캡슐화되었다고 생각해보겠습니다. 따라서 DepositTransaction, WithdrawalTransaction 및 TransferTransaction과 같은 클래스가 있을 수 있습니다. 각 클래스는 UI의 메서드를 호출합니다. 예를 들어, 사용자에게 입금할 금액을 입력하라는 메시지를 보내기 위해 DepositTransaction 객체는 UI 클래스의 RequestDepositAmount 메서드를 호출합니다. 마찬가지로, 사용자에게 계좌 간에 얼마를 이체하고자 하는지 묻기 위해 TransferTransaction 객체는 UI의 RequestTransferAmount 메서드를 호출합니다.

이제 ISP에 의해 피해야 하는 상황임을 명심해봅시다. 각 트랜잭션이 다른 클래스가 사용하지 않는 UI의 메서드를 사용하고 있습니다. 이렇게 하면 Transaction의 파생 클래스 중 하나를 변경하면 UI에 해당 변경사항을 강제 적용하게 될 가능성이 생깁니다.

결과적으로 Transaction의 모든 파생 클래스와 UI 인터페이스에 의존하는 모든 다른 클래스에 영향을 미칠 수 있습니다. 

이러한 문제가 있는 결합은 DepositUI, WithdrawUI 및 TransferUI와 같은 개별 인터페이스로 UI 인터페이스를 분리함으로써 피할 수 있습니다. 이러한 별도의 인터페이스는 마지막 UI 인터페이스로 다중 상속될 수 있습니다.

Grouping Clients. 

클라이언트는 종종 호출하는 서비스 메서드에 따라 함께 그룹화될 수 있습니다. 이러한 그룹화를 통해 각 그룹을 위한 분리된 인터페이스를 생성할 수 있으며 각 클라이언트 유형에 서비스가 의존하지 않도록 방지합니다.

때로는 다른 그룹의 클라이언트에서 호출되는 메서드가 중첩될 수 있습니다. 중첩이 작은 경우, 그룹을 위한 인터페이스를 별도로 유지해야 합니다. 공통 함수는 중첩되는 모든 인터페이스에 선언되어야 합니다. 서버 클래스는 이러한 인터페이스 각각에서 공통 함수를 상속받지만, 이를 한 번만 구현해야 합니다.

Changing Interfaces. 

객체 지향 애플리케이션을 유지 보수할 때 기존 클래스와 컴포넌트의 인터페이스가 종종 변경됩니다. 이러한 변경 사항이 시스템의 매우 큰 부분의 다시 컴파일 및 재배포를 강제로 발생시키는 경우가 있습니다. 기존 인터페이스를 변경하는 대신 기존 객체에 새로운 인터페이스를 추가함으로써 이러한 영향을 완화할 수 있습니다. 이전 인터페이스의 클라이언트 중 새 인터페이스의 메서드에 액세스하려는 경우, 객체에서 해당 인터페이스를 쿼리할 수 있습니다. 

Conclusion

  • "두꺼운(fat)" 클래스는 그 클래스를 의존하는 클라이언트 클래스들간에 좋지 않은 결합을 유발합니다.
  • 하나의 클라이언트가 뚱뚱한 클래스에 변경을 강요하면 다른 모든 클라이언트가 영향을 받습니다.
  • 따라서 클라이언트들은 실제로 호출하는 메서드에만 의존해야 합니다.
  • 이를 위해 뚱뚱한 클래스의 인터페이스를 많은 클라이언트별 인터페이스로 분리할 수 있습니다.
  • 각 클라이언트별 인터페이스는 해당 클라이언트나 클라이언트 그룹이 호출하는 함수만 선언합니다. 그런 다음 뚱뚱한 클래스는 모든 클라이언트별 인터페이스를 상속하고 구현할 수 있습니다. 이렇게 함으로써 클라이언트들이 호출하지 않는 메서드에 대한 의존성을 끊고 클라이언트들이 서로 독립적일 수 있게 됩니다.