SOLID 법칙 완전정복편

SOLID 법칙 완전정복편

삽화와 함께 SOLID 법칙에 대한 간단한 예시와 함께 알아보자.

개요

로버트 C 마틴의 Clean Architecture 를 보고 Software Architecture란 무엇인지, 왜 필요한지, 어떤 원칙들이 존재하는지 배우다가 번역체로 읽는게 쉽지 않고, 지식으로 정립이 잘 되지 않아서 정리하려고 시작했습니다. 로버트 마틴의 큰 틀에 개인적으로 이해한 견해와 예시코드를 만들어 붙였습니다. 

SoftwareArchitecture가 존재할 수 있는 이유는, 반세기 동안 하드웨어는 더 작아지고 빨라졌지만, 소프트웨어를 ‘구성’ 하는 것들은 조금도 바뀌지 않았기 때문입니다.(컴포넌트, 클래스, 함수, 모듈, 계층, 서비스 등) (반박 시 당신이 앨런 튜링)

흔히 소프트웨어의 설계를 건축물과 비교를 많이 합니다. 건축물은 물리적 한계 때문에 선택지가 제한되어 더 명확한 설계를 하게 만듭니다. 바닥이 콘크리트냐, 암반이냐 혹은 높이 쌓느냐 넓게 자리 잡냐와 같이 건축물이 한번 지어지면 이후 재료를 변경하거나 구조의 수정이 어렵기 때문입니다.

반면, 소프트웨어의 구조는 더 세부적인 소프트웨어들로 구성되어 있고 물리 법칙이 없어서 직관적으로 파악하기 힘듭니다. 그래서 명확한 설계가 어렵습니다. 한 번 제작된 Software는 사용자의 요구 때문에 변화에 큰 비용이 들거나 수용하기 어려우면 안 됩니다. 건축물에 비해 Software는 더 수정하기 쉬워야 합니다. 그래서 오히려 명확한 설계가 더 강조됩니다.

당연해 보이는 말이지만 Software가 더 깔끔한 설계일수록 직관적으로 원하는 기능 추가, 수정과 유지 보수가 용이한 프로그램이 됩니다.

Software Architecture란?

Software Architecture는 요구되는 사항(어떤 건축물이고 화장실은 몇 개 등) + 제약 조건(건축 자재는 어떤 것들을 사용해야 하는데 바닥이 물렁하다 등)들을 고려하여 소프트웨어 시스템의 구조를 설계하는 것을 의미한다. 대표적인 구조 설계는 다음과 같다.

  • 컴포넌트
  • 인터페이스
  • 커뮤니케이션 관계
  • 제어 흐름
  • 컴포넌트 간의 의존관계

이 구조 설계에서 고려해야 할 사항들은 아래와 같다.

  • 시스템의 기능, 성능
  • 가용성
  • 유지보수성
  • 신뢰성

오랜 기간동안 Software Architecture는 위의 요소들을 고려하여 소프트웨어 시스템을 설계하였다. 앞서 의외로 Software의 큰 틀은 불변하였다고 얘기하였다. 큰 틀은 결국 무수히 많은 작은 틀로 구성되어있다. 그래서 작은단위 부터 큰 단위 순서로 정리할 예정이다.

💡용어 정리

  • Class : 사람이 눈으로 확인할 수 없는 작은 단위다. ex) 세포, 영양소 등 객체 그 자체로 작동이 되지만 다른 객체와 관계, 상호작용을 하여 결과를 생성한다.
  • Component : 사람이 눈으로 직접 확인할 수 있는 물체 같은 개념이다. 다양한 객체들을 활용하여 단위별 조립/교환이 가능한 하나의 독립적인 소프트웨어 산출물이다. ex) 모니터, 키보드, 마우스 등
  • Architecture : 제일 높은 시야에서 소프트웨어를 봤을 때 전체적인 컴포넌트들의 관계와 흐름이다.
  • SoftwareArchitecture : 소프트웨어를 개발하는데 있어서 컴포넌트들의 관계와 흐름, 변경에 대한 원리와 가이드라인을 정의한 것이다.

💡설명 순서

  1. Class 단위 (SOLID) ->
  2. Component,모듈 단위(SOLID를 적용한 클래스를 활용하여 만든 컴포넌트들) ->
  3. 더 고수준의 Architecture 원칙

설계 원칙 (SOLID)

좋은 프로그램은 Clean Code 에서부터 시작된다. 설계 원칙인 SOLID 는 (함수or 데이터 구조)를 클래스로 배치하는 방법과 목적에 따라 배치된 클래스들을 분해, 결합하는 방법에 대한 설계다. 간단히 깔끔한 코드와 구조 설계로 프로그램을 만드는 방법이다. SOLID 원칙을 지키면 다음과 같은 장점이 있다.

  • 변경하기 쉬워진다.
  • 이해하기 쉬워진다.
  • 다른 소프트웨어 시스템에 활용될 수 있는 컴포넌트 기반이 된다.

📝 편의상 클래스 에 적용한다고 했지만, 메서드 단위, 큰 모듈 단위에도 적용된다.

SOLID 원칙은 각 단어의 앞 글자를 딴 것이다. 아래 5가지 원칙이다.

  1. Single Responsibility Principle (SRP): 각 클래스는 단 한 가지 책임을 가져야 한다.
  2. Open-Closed Principle (OCP): 클래스는 확장에는 열려 있어야 하지만, 수정에는 닫혀 있어야 한다.
  3. Liskov Substitution Principle (LSP): 상위 타입의 객체가 있다면 하위 타입의 객체로 대체 가능해야 한다.
  4. Interface Segregation Principle (ISP): 구현을 요구하는 인터페이스는 소규모의 인터페이스가 있는 것이 좋다.
  5. Dependency Inversion Principle (DIP): 상위 수준의 구성요소는 저수준의 구성요소에 의존해서는 안 된다.

원칙 하나씩 Java 예시 코드와 함께 알아보자.


SRP (Single Responsibility Principle)

SRP

단일 책임 원칙은 메서드와 클래스 수준의 원칙이다.

소프트웨어는 사용되기 위해 만들어지기 때문에 사용을 하며 변경을 원하는 사용자 집단이 있다.

그 경우, 한 모듈 (다양한 객체, 메서드가 모인 클래스같이)은 한 사용자 집단을 위해 존재해야 한다는 의미이다.

✨SRP를 지켜야 하는 이유는 한 사용자 집단(one)을 위해 기능을 변경 시에 다른 사용자 집단들(the others)이 써야하는 클래스에 영향이 없게 분리,격리하는 것이다.

🕯️목표 : 특정 모듈의 부분적,전체적으로 기능을 수정할 때, 나머지 상관 없는 부분의 기능에 영향이 없어야 한다.

  • BadCase
public class Worker {
    public void work() {
        // ....working
        finishWork();
    }
    public void chefWork() {
        //chef work
        finishWork();
    }
    public void gardenerWork() {
        //gardener work
        finishWork();
    }

    public void painterWork() {
        //painter work
        finishWork();
    }
    public void driverWork() {
        //driver work
        finishWork();
    }
    
    //마무리 작업
    public void finishWork() {
        //주변정리
        // 퇴근카드 찍기
    } 
}

이 클래스는 SRP를 위반하는데, Worker 클래스가 Chef,Gardener,Painter,Driver의 사용자 집단을 모두 책임지기 때문이다.

⚒️Chef의 사용자 집단에서 finishWork에 접시닦기 체크를 추가해달라고 요청이 들어왔다.

Chef,Gardener,Painter,Driver 모두 같은 마무리 작업을 공유하고 있기 때문에 Chef의 finishWork에 접시닦기 체크를 추가했다.

    //마무리 작업
    public void finishWork() {
        //주변정리
		//퇴근카드 찍기
		//접시닦기 체크
    } 

Gardener, Painter, Driver는 이 상황을 알 수 없기 때문에 여느날과 같이 일을 마무리하고 finishWork를 실행시켰다면, 영문도 모른채 접시닦기 체크를 해야하는 상황이 온다.

  • GoodCase
public interface Worker {
    void work();

    void finishWork();
    
    class Chef implements Worker {
        @Override
        public void work() {
            System.out.println("Cooking");
        }
    
        @Override
        public void finishWork() {
            System.out.println("Finished working");
            //쉐프 집단에 추가된 요구사항 작업 (닦인접시 체크)
            System.out.println("Checking cleaned dishes");
        }
    }
    class Gardener implements Worker {
        @Override
        public void work() {
            System.out.println("Gardening");
        }

        @Override
        public void finishWork() {
            //가드너의 finishWork에는 공유되지 않는다.
            System.out.println("Finished working");
        }
    }
}

가장 쉬운 해결책은 각각 구현하면 된다. 이렇게 분리되면 각 클래스가 책임져야하는 사용자 집단이 분리되고 Chef의 기능 추가에 나머지 직업군들에 영향이 가지 않는다.

각 클래스는 자신에게 필요한 기능과 코드만 가지고 있어야 한다. 그래야 우연한 중복 문제를 피할 수 있다.

💡용어 정리

  • Responsibility(책임) : 특정 모듈이 그 모듈을 사용하는 사용자 집단에게 제공하는 일관된 기능에 대한 책임
  • Actor(사용자 집단) : 모듈이 생성되면 타깃되는 사용자들이 있는데 그 집단 (개인일 수도)
  • 우연한 중복 : 다른 클래스에서 같은 메서드를 중복하여 사용하는 문제. 한 메서드가 변경되면 메서드를 사용한 다른 기능들 또한 수정되기 때문에 문제가 된다.

📝 이보다 상위의 수준에서는 다른 형태로 이 원칙을 준수한다.

ex) 컴포넌트 - 공통 폐쇄 원칙 Common CLosure Principle
ex) 아키텍처 - 경계의 생성을 책임지는 변경의 축.


OCP (Open-Closed Principle)

OCP

소프트웨어는 클래스의 ‘행위’ 는 쉽게 확장할 수 있어야 하지만, 이를 위해 개체자체를 변경해서는 안된다.

기존 동작하는 기능에 대한 수정의 양은 최소화 해야 한다. 내가 추가하고 싶은 동작을 위해 기존의 코드를 수정한 코드 량이 0 일 수록 이상적이다.

🕯️목표 : 클래스에 기능을 확장하기 쉽게 만드는 대신, 기존 기능에 영향이 없어야 한다.

  • BadCase
public class BadWorker {
    public void work() {
        System.out.println("I can cut");
       //+ I can paint
      //System.out.println("I can paint");  
    }

    public static void main(String[] args) {
        BadWorker worker = new BadWorker();
        worker.work(); //cut/paint? or both
    }
}

BadWorker에게 paint라는 작업을 추가시키기 위해 기존 work라는 메서드를 수정했다. BadWorker가 일하는 모든 곳에 영향을 미친다. (cut만 해야하는 곳이 특정되지 않는다.)

  • GoodCase
public class GoodWorker {
    public void cut() {
        System.out.println("I can cut");
    }
    public void paint() {
        System.out.println("I can paint");
    }

    public static void main(String[] args) {
        GoodWorker worker = new GoodWorker();
        worker.cut(); //cut
        worker.paint(); //paint
    }
}

GoodWorker는 paint라는 기능을 확장하기 위해 기존 소스코드에 영향 없이 paint()라는 메서드를 추가했다.

이로써 GoodWorker에게 paint라는 기능이 생겼다! GoodWorker가 일하는 모든 곳에서 paint()를 동작하지 않으면 painting은 실행되지 않는다. 기존 기능에 영향이 가지 않는다.

📝아키텍쳐 컴포넌트 단위에서 OCP

다른 목적의 요소를 적절하게 분리하여 응집도를 올리고(SRP), 이들 사이의 요소의 의존성을 체계적으로 설계함으로써 결합도를 낮춰(DIP) OCP에서 요구하는 변경량 최소화를 할 수 있다.

ex ) 중심적인 역할을 할 수록 의존성은 핵심 로직으로 향하게 의존 관계를 설정. 필요하다면 Interface로 구현체와 분리하여 필요에 의해 사용.


LSP (Liskov Substitution Principle)

LSP

🕯️목표 : 부모 클래스(상위타입)와 자식 클래스(하위타입)가 프로그램에 오류를 발생시키지 않고 동일한 방식으로 구현되도록 일관성을 유지하는 것

여기서 중요한 단어는 하위 타입이다. 하위 타입은 쉽게 말해 상속한 자식(extends) or 구현체(implements)로 말할 수 있다. 내가 계승을 하겠다고 선언한 것인 만큼, 부모의 역할은 모두 기본으로 수행해야 한다.

보통은 자동차로 표현을 많이 하는데, Car라는 부모가 있으면 Accelerator, Break 등 자동차의 기본 기능을 모두 정확히 구현된 티볼리란 차를 만들어야 한다. (티볼리가 자동차의 하위 타입)

티볼리만 Accelerator이 Break로 구현되고, Break가 Accelerator로 구현되면 안된다.

하위타입의 정의

S 타입의 객체 o1 각각에 대응하는 T 타입 객체 o2가 있고, T 타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면, S는 T의 하위 타입이다.

객체는 어떤 역할을 맡아 무슨 행동을 하느냐로 구분된다. 상위 타입은 일반화된 추상화 객체라고 보면 되고,
하위 타입은 구체화된 객체로 일반화된 범주에 속하는 객체이다. 당연히 다이어그램상 부분집합이기 때문에 일반화된 특징을 모두 가지고 있어야 한다.

같은 역할과 책임을 지고 있기 때문에 하위 타입도 동일한 행동의 결과를 내야하는 책임이 동일하게 있다.
그 이후 추가적인 특징을 가지게 되는 것이다.

그래서 하위 타입은 상위 타입의 행위를 모두 수행할 수 있어야 한다.

  • BadCase
public interface Worker {
    void makeCoffee();
    class Sam implements Worker {
        @Override
        public void makeCoffee() {
            System.out.println("Sam : I can make coffee");
        }
    }

    class Eden implements Worker {
        @Override
        public void makeCoffee() {
            System.out.println("Eden : I can`t make coffee, but here's water ");
        }
    }

    public static void main(String[] args) {
        Worker sam = new Sam();
        sam.makeCoffee();
        //eden은 Sam의 makeCoffee자리에 가면 물을 준다. (LSP 위반)
        Worker eden = new Eden();
        eden.makeCoffee();
    }
}

LSP-eden 무능하지만 최선을 다해주는 Eden.. 화이팅

makeCoffee의 기본 원칙은 Coffee를 만들어 주는 것이다. Eden은 makeCoffee에서 물을 가져다 주고 있기 때문에

makeCoffee() 작업을 수행하지 못하고 Sam(Worker)의 자리에 가서 makeCoffee()를 할 수 없다.

  • GoodCase
public interface Worker {
    void makeCoffee();
    class Sam implements Worker {
        @Override
        public void makeCoffee() {
            System.out.println("Sam : I can make coffee");
        }
    }

    class Eden implements Worker {
        @Override
        public void makeCoffee() {
            System.out.println("Eden : here's cappuccino");
        }
    }

    public static void main(String[] args) {
        Worker sam = new Sam();
        sam.makeCoffee();
        //eden은 Sam의 makeCoffee 자리를 완벽히 대체할 수 있다.
        Worker eden = new Eden();
        eden.makeCoffee();
        eden = sam;
        eden.makeCoffee();
    }
}

LSP-right 지시자는 그냥 Worker에게 시키면 Sam이던 그의 자식 Eden이던 알아서 makeCoffee의 기능에 충실하기만 하면 된다.

💡용어 정리

  • Liscov : Barbara Liskov, 1939년 11월 7일 ~ , 미국 컴퓨터 과학자의 이름
  • subType(하위타입) : 인터페이스 - 구현체에서 구현체. 인터페이스의 명세를 잘 따르고 조건에 부합하는 구현을 하여 모든 구현체가 치환되더라도 Program 의 로직에 문제가 없으면 구현체들을 하위타입이라 한다.

ISP (Interface Segregation Principle)

ISP

기능, 역할을 잘 구분하여 기능 정의 Interface를 세세하게 구별해놓을 수록 좋다.

그래서 그걸 구현한 클래스들이 역할을 수행하는 데 있어서 필요한 작업만 수행해야 한다. 즉, 필요한 코드만 구현하여 갖고 있어야 한다.

🕯️목표 : 클래스가 필요한 작업들만 가지고 수행할 수 있도록 Interface를 작은 단위로 쪼개놓는 것. 세세하게 덩어리를 나눌 수록, Interface가 명확해지고 구현체 교체시에 영향이 적어진다.

  • BadCase
public interface RobotOperation {
    void spinAround();
    void rotateArms();
    void wiggleAntennas();

    class NoAntennasRobot implements RobotOperation {
        @Override
        public void spinAround() {
            System.out.println("I can spin around");
        }

        @Override
        public void rotateArms() {
            System.out.println("I can rotate my arms");
        }
				
				//ISP를 위반하는 부분. 안테나가 없는 로봇에게는 필요 없는 구현이다.
        @Override
        public void wiggleAntennas() {
            throw new UnsupportedOperationException("I don't have antennas");
        }
    }
}

class AntennasRobot implements RobotOperation {
    @Override
    public void spinAround() {
        System.out.println("I can spin around");
    }

    @Override
    public void rotateArms() {
        System.out.println("I can rotate my arms");
    }

    @Override
    public void wiggleAntennas() {
        System.out.println("I can wiggle my antennas");
    }
}

로봇의 기능 Interface가 세세하게 기능별로 쪼개져있지 않다. 안테나가 없는 로봇은 불필요하게 wiggleAntennas() 를 구현해야 한다. (안테나가 없다는 오류를 발생하면서까지)

그리고 Robot끼리 구현체를 변경할 시에도 동일한 기능을 하지 못한다.

  • GoodCase
public interface RobotOperation {
    public interface SpinAround {
        void spinAround();
    }
    public interface rotateArms {
        void rotateArms();
    }
    public interface WiggleAntennas {
        void wiggleAntennas();
    }
    class NoAntennasRobot implements SpinAround, rotateArms {
        @Override
        public void spinAround() {
            System.out.println("I can spin around");
        }
        @Override
        public void rotateArms() {
            System.out.println("I can rotate my arms");
        }
    }

    class AntennasRobot implements SpinAround, rotateArms, WiggleAntennas {
        @Override
        public void spinAround() {
            System.out.println("I can spin around");
        }
        @Override
        public void rotateArms() {
            System.out.println("I can rotate my arms");
        }
        @Override
        public void wiggleAntennas() {
            System.out.println("I can wiggle my antennas");
        }
    }
}

안테나가 없는 NoAntennasRobot 의 구현체는, 세분화된 로봇의 동작 Interface에서 원하는 기능을 골라 구현하면 된다. 더이상 불필요한 소스코드를 구현할 필요가 없어졌다.

📝ISP 고려사항

ISP는 아키텍처가 아니라 개발 언어에 관련된 문제라고 결론 내려질 경우가 많다.
정적 타입 언어는 import, include와 같이 선언을 사용하도록 강제하여 클래스별로 의존성을 만든다.

이로 인해 뭉쳐진 Interface에서 변경이 되면 이에 의존하고 있는 모든 클래스가 재컴파일 or 재배포를 해야 한다.
파이썬과 같은 동적 타입 언어는 코드 런타임시에 추론으로 수행한다. 따라서 의존성이 없고 ISP의 문제가 덜하다.

📝JAVA 에서의 ISP

자바는 final,private이 아닌 인스턴스 변수에 대해서는 호출할 정확한 메서드를 런타임에 결정하는 ‘늦은 바인딩’을 수행한다.

따라서 정적 타입 언어임에도 불구하고, 사용자들이 사용하고 있는 Interface의 메서드 시그니쳐가 변하여도 모든 Implements의 클래스를 재컴파일 하는 것이 아니라 변한 메서드를 사용한 class만 재컴파일을 진행한다.

시그니쳐는 그대로이고 구현 코드만 바뀌면 다시 컴파일을 하지도 않는다. 그래서 언어별로 ISP를 고려할 상황이 다르다는 말이 나오는 것.

💡용어 정리

  • late binding(늦은 바인딩) : 사전에 모든 동작에 대한 결합이 되어있는 것이 아닌, 런타임시에 어떤 메서드를 연결하여 호출할지 결정하는 바인딩.

DIP (Dependency Inversion Principle)

DIP

프로그래머는 추상화에 의존해야지 구체화에 의존하면 안된다. 라는 표현을 들어본 적이 있는가?

이 문장이 바로 DIP의 핵심이다. Java로 따지면 Interface를 호출하고 구현체들은 필요에 의해 바꿔끼며 쓰란 얘기다. 이렇게 하면 하위의 소스코드 변경으로부터 상위의 개체들이 보호받을 수 있다.

🕯️목표 :  DIP는 Interface를 도입하여 하위 구현체들을 교체해가며 사용할 상위 클래스에서 종속성 을 줄이는 것을 목표로 한다.

  • GoodCase
public interface PizzaCuttingTool {
    void cut();
}

class CutterArm implements PizzaCuttingTool {
    @Override
    public void cut() {
        System.out.println("Cutting pizza with cutter arm");
    }
}

class KnifeArm implements PizzaCuttingTool {
    @Override
    public void cut() {
        System.out.println("Cutting pizza with knife arm");
    }
}

class ScissorsArm implements PizzaCuttingTool {
    @Override
    public void cut() {
        System.out.println("Cutting pizza with scissors arm");
    }
}

PizzaCuttingTool로 피자커팅을 위해 Robot팔을 사용하는 로봇(사용자 집단)만을 위해(SRP) 자르는 동작을 하는 Interface(ISP)를 만들었다.

새로운 PizzaCutter 도구 추가는 기존 소스코드를 수정하지 않아도 되게 구현했고(OCP), 각 도구들은 pizza를 cut 하는 기능을 충실하게 구현했다. (LSP)

public class Robot {
    public void cutPizza(PizzaCuttingTool pizzaCuttingTool) {
        pizzaCuttingTool.cut();
    }

    public static void main(String[] args) {
        Robot robot = new Robot();
        //로봇은 cutPizza() 메서드로 다양한 도구를 사용할 수 있게 된다.
        robot.cutPizza(new CutterArm());
        robot.cutPizza(new KnifeArm());
        robot.cutPizza(new ScissorsArm());
    }
}

로봇들이 (사용자 집단) pizzaCuttingTool을 사용하여 피자를 자르는 기능을 하는 cutPizza() 에서특정 Cutter에 의존한 것이 아니라 PizzaCuttingTool의 추상체를 가져다가 사용했다.

이로 인해 클래스는 특정 도구와 융합되지 않았고 Interface와 융합하였다. 얻는 이점으로는 클래스는 어떤 도구를 사용하던 피자를 자를 수 있게 되었고, 로봇은 각 도구의 구체적인 사용법을 몰라도 피자를 자를 수 있게 되었다.

클래스의 유연성이 극대화 되었다.

📝 모든 상황에서 DIP를 피할 순 없다. 우리가 경계해야 하는 것은 개발중이라 자주 변경되는 구체화된 모듈들의 의존성을 말하는 것이다.

자바의 String같은 클래스는 자주 변경되지 않고 엄격히 통제되어 있기 때문에 그냥 사용된다. 운영체제나 플랫폼같이 안정성이 보장된 환경에서는 DIP는 무시된다. (안변하는 구현체들이기 때문)

TIP : DIP의 다른 말로는 변동성이 큰 구현체일 수록 절대로 그 이름을 소스코드에 넣지 말라 이다.

💡용어 정리

  • 의존성 역전 : 소스코드의 제어 흐름과는 반대 방향으로 역전된다는 의미이다.

DIP-image

결론

지금까지 클래스 단위에서 SOLID 법칙을 적용하는 것을 설명했습니다. 이 5가지의 법칙은 지켜질 때 각각의 법칙이 단일적으로 지켜지는 것이 아닌, 아름답게 서로 조화를 이루며 지켜지는 것을 DIP 예제 코드에서 확인할 수 있습니다.

Java를 주로 사용하는 입장으로써 Spring이 OOP 객체지향 설계 원칙을 아주 잘 도와주고 있어서 굳이 심하게 의식하지 않아도 이미 SOLID 법칙을 잘 지키며 개발을 하셨을 텐데요. 다른 언어 사용자들도 최대한 이해하기 편하시라고 가급적 예제에서 Spring 동작은 뺐습니다.

다음으로는 Class 단위를 넘어 확장해 Component 수준의 설계 원칙은 어떤 것들이 있는지 알아보고자 합니다.

긴 글 읽어주셔서 감사합니다.

삽화 사용을 허락해주신 Ugonna Thelma  께 감사드립니다.  Thanks for Ugonna Thelma!

참고

• Clean Architecture (로버트 C. 마틴 지음, 송준이 옮김)

삽화 출처 - The S.O.L.I.D Principles in Pictures