본문 바로가기

4편 객체지향 설계 5원칙 SOLID『인터페이스 분리원칙』

by Recstasy 2020. 4. 1.

객체지향 설계 4원칙, 인터페이스 분리 원칙을 위키백과에서 찾아보면 아래와 같다.


인터페이스 분리 원칙 인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다. 인터페이스 분리 원칙은 큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리시킴으로써 클라이언트들이 꼭 필요한 메서드들만 이용할 수 있게 한다.


인터페이스는 말 그대로 우리가 사용하고 있는 많은 기기들의 조작장치 정도로 생각하면 된다. 스마트폰의 터치스크린, 컴퓨터의 키보드, 마우스, 전자레인지의 버튼, 자동차의 핸들, 브레이크 등... 소비자가 기기를 작동하기 위해서 사용하고 있는 모든 조작장치들이 인터페이스다.


그런데 객체지향 설계를 위해서 인터페이스들을 분리하라는 것일까?


자동차를 생각해보자. 자동차 인터페이스를 크게 '조향장치(방향조절)', '제동장치', '가속장치', '전자제어장치', '변속장치' 5개로 나눴을 때 인터페이스 역시 5개가 된다. 아니, 5개가 되어야 한다.

 

 기능

 

 인터페이스

 


=>

 


 


=>

 


 


=>

 


 


=>

 


 


=>

 



위의 사진들을 보더라도 인터페이스들을 왜 분리해야하는지 대략 알 수 있다. 만일 전자제어장치, 센터패시아(중앙패널) 인터페이스에 제동장치(브레이크) 인터페이스를 결합했다고 생각해보자. 끔찍한 일이 발생할 것이다. 가령, 고속도로에서 자동차 온도를 조절하려고 버튼을 클릭하려다 실수로 제동장치 버튼을 클릭한다면 급정지 이후 대형사고가 발생한다. 


인터페이스는 서로 간섭을 일으키지 않아야 하고, 내부의 동작과 직접적으로 연결되어서도 안 된다. 이 부분은 객체지향 설계의 유연성에 있어 상당히 중요하다. 객체지향 설계가 유연하다는 의미는 인터페이스와 내부기능이 직접적으로 연결되어 있지 않다는 뜻이다. 


가령, 우리는 브레이크 패달이 부서졌다고 해서 브레이크 패드나 라이닝을 교체할 필요가 없고, 기어봉이 부식되었다고 미션을 손보지 않는다. 그냥 브레이크 패달과 기어봉만 바꿔주면 된다. 이는 인터페이스와 내부설계가 모두 분리되어 있기 때문에 가능한 일이다. 핸들, 가속패달, 센터패시아 버튼도 마찬가지다. 세월이 흘러 버튼이 부식되거나 이음새가 떨어지면 버튼만 바꿔주면 될뿐, 엔진이나 내부장치까지 변경할 필요는 없다. 


이 때문에 내부 설계와 상관없이 인터페이스는 변함없이 재사용 가능하며, 그래서 50년 전에 출시된 자동차와 현재의 가솔린 엔진 그리고 전기차의 인터페이스 기능은 과거와 달라지지 않았다.(디자인만 변경되었을 뿐) 


객체지향 설계에서는 인터페이스와 내부장치를 절대 직접적으로 연결하지 않는다. 



인터페이스 분리원칙

기능 구현

인터페이스를 설계할 때 다음 규칙을 염두에 두자.


1) 기능을 구현한다

2) 사용자의 요구에 맞춰 기능을 분리한다(인터페이스 생성)

3) 인터페이스 간섭이 발견되면 분리한다


가령, 복합기 시스템을 인터페이스로 구현한다면 아래와 같다.


class Machine{

      constructor(){

              if(this.constructor.name === 'Machine'){

                    throw new Error('Machine is abstract');  //추상클래스 생성

              } 

      }


      print(doc){ }

      fax(doc){ }

      scan(doc){ }

}


Machine클래스를 추상클래스로 생성했으며, Machine클래스는 print(), fax(), scane()이란 3가지 기능을 갖고 있다. 추상 클래스는 붕어빵의 '틀'과 같기때문에 Machine클래스를 기반으로 복합기 프린터를 생성할 수 있다.


class MultiPrinter extends Machine{

      print(doc){ //내용 }

      fax(doc){ //내용 }

      scan(doc){ //내용 }


복합기, MultiPrinter클래스는 Machine클래스를 상속받았고, print(), fax(), scan()기능을 모두 사용할 수 있다. 하지만 변수가 발생했다. 고객이 팩스, 스캔 기능이 없는 순수 프린터 기능만 있는 프린터를 원했기 때문이다. 이에 개발자는 Machine클래스를 이용해서 다음과 같은 저가형 프린트를 만들었다.


class lowlevelPrinter extends Machine{

     print(doc){}

}


let lowPrinter = new lowlevelPrinter();

   lowPrinter.scan();  // 아무일도 발생하지 않음


위와같이 Machine클래스를 상속받은 LowlevelPrinter클래스를 만들더라도 코드는 돌아간다. 하지만 LowlevelPrinter클래스는 자신에게 필요없는 기능까지 Machine클래스로부터 모두 상속받았다. 그래서 무응답 에러방지를 위해서 다음과 같은 쓸데없는 코드를 집어넣어야한다.


class OperationError extends Error{

     constructor(name){

           let msg = `${name} is not operated`;

           super(msg);


       if(Error.captureStackTrace){

             Error.captureStackTrace(this, OperationError);

         }   

     }     

}


class LowlevelPrinter extends Machine{

     print(doc){ }

     fax(doc){

        throw new OperationError('해당 프린터는 팩스기능이 지원되지 않습니다.')

     }

     scan(doc){

        throw new OperationError('해당 프린터는 스캔기능이 지원되지 않습니다.') 

     }

}


인터페이스가 없는 상태에서 통째로 상속받은 기능을 사용하다보니 위와같이 필요없는 기능에 대한 불필요한 코드를 입력해야하는 사태가 발생했다. 이를 해결할 수 있는 간단한 방법이 바로 인터페이스 분리다.


인터페이스 분리원칙

인터페이스 생성 및 분리

유저와 접점이 이뤄지는 기능과 이에대한 인터페이스를 찾았다면 이제 분리를 해야 한다. 현재 Machine클래스에 있는 프린트, 스캔, 팩스기능은 뭉쳐져 있기 때문에 고객의 요청에 따라 유동적으로 반응할 수 없다. 이를 해결하기 위해서는 이 3가지 기능들이 서로 간섭이 발생하지 않게 만들어야 하며, 인터페이스 자체는 아무 기능이 없는 상태(버튼)로 만들어야 한다. 


class Printer{

     constructor(){

         if(this.constructor.name === 'Printer'){

              throw new Error('Printer is abstract');

         }

     }

     print(doc){ }

}


class Scanner{

     constructor(){

         if(this.constructor.name === 'Scanner'){

               throw new Error('Scanner is abstract');

         }

     }

      scan(doc){ }

}


위와같이 Printer, Scanner인터페이스(빈껍데기)를 생성했다면, 이제 저가형 프린터는 다음과 같이 Machine클래스 대신 Printer클래스만 상속받으면 된다. 


class LowlevelPrinter extends Printer{

     print(doc){ //내용 }

}


그런데 또 문제가 발생했다. 고객은 항상 변덕쟁이라는 점을 잊어서는 안 된다.

만일 LowlevelPrinter클래스에 스캔 기능을 첨가하고 싶다면 어떻게 해야할까? 



인터페이스 분리원칙

인터페이스 병합::참고사항

class LowlevelPrinter extends Scanner{}라고 해도 되겠지만 한번에 여러 인터페이스를 상속받는 방법이 있다면 편리할 것이다. 물론 굳이 아래 방식을 사용하지 않고, 인터페이스별로 하나씩 상속받아도 되지만 aggregation방식을 알아두면 편리하다. 


 var aggregation = (baseClass, ...mixins) => {

    class base extends baseClass {

      constructor (...args) {

        super(...args);

        mixins.forEach((mixin) => {

          copyProps(this,(new mixin));

        });

      }

    }

    let copyProps = (target, source) => {  

      Object.getOwnPropertyNames(source)

        .concat(Object.getOwnPropertySymbols(source))

        .forEach((prop) => {

          if (!prop.match(/^(?:constructor|prototype|arguments|caller|name|bind|call|apply|toString|length)$/))

            Object.defineProperty(target, prop, Object.getOwnPropertyDescriptor(source, prop));

        })

    };

    mixins.forEach((mixin) => {

      copyProps(base.prototype, mixin.prototype);

      copyProps(base, mixin);

    });

    return base;

  };


aggregation함수는 클래스를 받아서 배열로 만들어, 클래스를 모아주는 역할을 한다. 분리된 여러 인터페이스를 동시에 상속받을 때 aggregation함수를 사용하면 편리하다.


class Photocopier extends aggregation(Printer, Scanner){

     print(doc){ }


     scan(){ }

}


aggregation함수를 사용하면, 위와같이 필요한 인터페이스를 한번에 상속받을 수 있다. 참고사항으로 알아두자.



결론

인터페이스란 빈껍데기 버튼을 만든다고 생각해야 한다. 

버튼 그 자체가 무슨 기능이 있어서 동작하는 게 아니다. 버튼으로 인하여 내부기능이 구현되는 것임을 염두에 둔다면 강한 연결로 인한 에러률을 현저하게 줄일 수 있을 것이다. 



댓글

최신글 전체

이미지
제목
글쓴이
등록일