객체 지향 5대 원칙 - SOLID

    1. 시작하며

    SOLID 원칙에 대해 공부할 기회가 생겨 읽은 내용을 정리하고자 한다. 이 글을 통해 주로 공부하였으므로 원문을 확인하는 것을 추천한다.

     

    SOLID 원칙은 2000년대 초반 로버트 마틴이 제안한 프로그래밍 및 설계의 다섯 가지 원칙이다. 이를 마이클 페더스가 앞글자를 따서 SOLID라 명명하고 기본 개념을 구축하였다.

     

    이 디자인 원칙은 코드를 더 유지보수하기 쉽고, 설계를 이해하기 쉬우며, 확장성이 뛰어난 소프트웨어를 만들 수 있도록 도와준다. 결과적으로 복잡성, 종속성을 감소시켜 개발자가 다른 영역에 영향을 주지 않고 한 영역을 변경할 수 있도록 한다.

    2. SOLID 원칙이란

    이미지: https://hackernoon.com/how-to-learn-solid-design-principles-in-5-minutes-952x33rm

    1. Single Responsibility
    2. Open/Closed
    3. Liskov Substitution
    4. Interface Segregation
    5. Dependency Inversion

    Single-responsibility

    • There should never be more than one reason for a class to change
    • In other words, every class should have only one responsibility
    • 클래스는 단 하나의 책임만을 가져야 한다
    • Testing, 낮은 결합도, Organization 의 측면에서 도움이 된다.

    아래와 같이 Book 이라는 클래스가 있다
    Book 클래스에 name, author, text를 저장할 수 있고 text 를 조회하는 메서드가 있다.

    public class Book {
    
        private String name;
        private String author;
        private String text;
    
        //constructor, getters and setters
    
        // methods that directly relate to the book properties
        public String replaceWordInText(String word){
            return text.replaceAll(word, text);
        }
    
        public boolean isWordInText(String word){
            return text.contains(word);
        }
    }

    Book class 는 이제 제대로 동작한다. 해당 클래스를 통해 많은 book 을 저장할 수 있다.
    여기에 콘솔에 print를 출력하는 메소드를 추가해보자.

    public class Book {
        //...
    
        void printTextToConsole(){
            // our code for formatting and printing the text
        }
    }

    그러나 위의 코드는 single responsibility 을 위반한다.
    이를 수정하기 위해 text의 print만 처리하는 별도의 class 로 구현해보자.

    public class BookPrinter {
    
        // methods for outputting text
        void printTextToConsole(String text){
            //our code for formatting and printing the text
        }
    
        void printTextToAnotherMedium(String text){
            // code for writing to any other location..
        }
    }

    이제 Book 에서 Print와 관련된 책임들은 분리되어 개발 완료하였다. BookPrinter class를 통해 Book class의 print 책임을 줄여줄 뿐만 아니라 text의 내용을 다른 미디어로도 전송할 수 있다.

    Open–closed

    • Software entities ... should be open for extension, but closed for modification
    • 클래스는 확장에 대해 열려 있어야 하고, 변경에 대해 닫혀있어야 한다.
    • 기존 코드를 수정하고 잠재적인 버그를 일으키는 것을 방지한다.

    Guitar Class 가 있다.

    public class Guitar {
    
        private String make;
        private String model;
        private int volume;
    
        //Constructors, getters & setters
    }

    기타에 불꽃 패턴을 적용하여 더 롹앤롤처럼 보이게 하고자 한다.
    이때 Guitar Class 에 Flame 을 추가할 수 있지만 어떤 side effect 가 발생할지 알 수 없다. 따라서 Open–closed 원칙을 지키면서 간단하게 Guitar class 를 확장해보자.

    public class SuperCoolGuitarWithFlames extends Guitar {
    
        private String flameColor;
    
        //constructor, getters + setters
    }

    Guitar 클래스 를 확장하여 기존 애플리케이션이 영향을 받지 않도록 할 수 있다.

    Liskov substitution

    • Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it
    • 기반 클래스의 메소드는 파생 클래스 객체의 상세를 알지 않고서도 사용될 수 있어야 한다.
    • Class A 가 Class B 의 하위 유형이면 프로그램의 동작을 방해하지 않고 B 를 A로 바꿀 수 있어야 한다.

    Car interface 가 있다.

    public interface Car {
    
        void turnOnEngine();
        void accelerate();
    }

    모든 자동차가 수행할 수 있는 작업, 즉 엔진을 켜고 가속하는 interface를 정의한다.
    interface를 구현하고 메서드에 몇가지 기능을 제공한다.

    public class MotorCar implements Car {
    
        private Engine engine;
    
        //Constructors, getters + setters
    
        public void turnOnEngine() {
            //turn on the engine!
            engine.on();
        }
    
        public void accelerate() {
            //move forward!
            engine.powerOn(1000);
        }
    }

    위의 코드를 통해 이제 엔진을 켜고 가속할 수 있다.
    그런데 우리는 전기자동차 시대에 살고 있으므로 엔진이 없는 전기자동차도 추가해야 한다.

    public class ElectricCar implements Car {
    
        public void turnOnEngine() {
            throw new AssertionError("I don't have an engine!");
        }
    
        public void accelerate() {
            //this acceleration is crazy!
        }
    }

    엔진이 없는 자동차를 추가함으로써 프로그램의 동작을 변경하였다. 이는 Liskov 치환에 대한 위반이며 이 전의 두 가지 원칙보다 수정하기 힘들다.

    한 가지 가능한 솔루션은 Car 의 엔진이 없는 상태를 고려하는 interface로 재작업 하는 것이다.

    Interface segregation

    • Many client-specific interfaces are better than one general-purpose interface
    • 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
    • 클라이언트가 사용하지 않는 메서드에 의존하지 않아야 한다.
    • 더 큰 인터페이스를 더 작은 인터페이스로 분할함을 의미한다.
    • 이를 통해 구현 클래스가 관심 있는 메소드에 대해서만 관심을 가질 수 있다.

    곰 사육사 interface가 있다.

    public interface BearKeeper {
        void washTheBear();
        void feedTheBear();
        void petTheBear();
    }

    열렬한 사육사로서 곰을 씻기고, 먹이는 것을 기쁘게 생각한다. 그러나 우리는 곰을 쓰다듬는 것이 위험하다는 것을 알고 있다. 불행히도 infterface가 다소 커서 곰을 쓰다듬는 코드를 구현하는 것 외에는 선택의 여지가 없다.

     

    이제 BearKeeper interface를 개별 interface로 분할해보자.

    public interface BearCleaner {
        void washTheBear();
    }
    
    public interface BearFeeder {
        void feedTheBear();
    }
    
    public interface BearPetter {
        void petTheBear();
    }

    interface 덕분에 우리에게 관심사가 있는 중요한 메서드만 자유롭게 구현 가능하다.

    public class BearCarer implements BearCleaner, BearFeeder {
    
        public void washTheBear() {
            //I think we missed a spot...
        }
    
        public void feedTheBear() {
            //Tuna Tuesdays...
        }
    }

    마지막으로 위험한 일은 맛간 사람들에게 맡길 수 있다.

    public class CrazyPerson implements BearPetter {
    
        public void petTheBear() {
            //Good luck with that!
        }
    }

    더 나아가 BookPrinter class도 같은 방식으로 interface 분리를 사용할 수 있다. 하나의 print method를 가진 Printer interface를 구현하여 별도의 ConsoleBookPrinter, OtherMediaBookPrinter 클래스를 인스턴스화할 수 있다.

    Dependency inversion

    • Depend upon abstractions, [not] concretions
    • 추상화에 의존해야지, 구체화에 의존하면 안된다.
    • 자주 변경되는 구체적인 것에 의존하지 말고 추상화된 것을 참조

    구식으로 돌아가서 코드가 있는 Windows 98 컴퓨터에 생명을 불어 넣어 보자.

    public class Windows98Machine {}

    그러나 모니터와 키보드가 없는 컴퓨터가 무슨 소용이 있겠는가?
    인스턴스화 해놓은 Windows98Computer 가 Monitor와 StandardKeyboard로 포장 될 수 있도록 생성자에 각각 추가해보자

    public class Windows98Machine {
    
        private final StandardKeyboard keyboard;
        private final Monitor monitor;
    
        public Windows98Machine() {
            monitor = new Monitor();
            keyboard = new StandardKeyboard();
        }
    
    }

    Windows98Computer 클래스 내에서 StandardKeyboard 와 Monitor 를 사용할 수 있게 되었다.

    문제는 해결되었으나 StandardKeyboard와 Monitor를 선언함으로써 이 3개의 클래스는 긴밀하게 결합되었다.

    이는 Windows98Computer 를 테스트하기 어렵게 만들 뿐만 아니라 StandardKeyboard를 다른 키보드로 전환하기 어렵게 만들었다. Monitor 역시 마찬가지이다.

     

    따라서 좀 더 일반적인 Keyboard interface를 추가하고 이를 사용하여 StandardKeyboard 를 Windows98Machine를 분리해보자.

    public interface Keyboard { }
    public class Windows98Machine{
    
        private final Keyboard keyboard;
        private final Monitor monitor;
    
        public Windows98Machine(Keyboard keyboard, Monitor monitor) {
            this.keyboard = keyboard;
            this.monitor = monitor;
        }
    }

    Windows98Machine class에 Keyboard 종속성을 쉽게 추가하기 위해 종속성 주입 패턴을 사용하였다.
    StandardKeyboard clss를 수정하여 Keyboard interface를 구현하여 Windows98Machine class에 추가하기 쉽게 수정하였다.

    public class StandardKeyboard implements Keyboard { }

    이제 모든 class 들은 결합성이 분리되었고 Keyboard 추상화를 통해 communication 한다. Machine의 Keyboard 타입 역시 다른 interface 구현받아서 쉽게 교체할 수 있다. 이는 Monitor class 에도 적용 가능하다.

    이제 종속성은 분리되었고 선택한 테스트 프레임워크를 사용하여 Windows98Machine을 자유롭게 테스트할 수 있다.

    3. 마치며

    SOLID 원칙을 적용하여 코드를 리팩터링 하여 코드 스멜을 제거할 수 있다. 이론 및 예제는 확인해보았지만 현업에서 막상 적용하려고 하니 막막함을 느낄 수 있었다. 앞으로 가야 할 길이 멀었다.

    참고

    반응형

    댓글

    Designed by JB FACTORY