My Dream Being Visualized

SOLID 원칙에 대하여 본문

Programming/Computer Science

SOLID 원칙에 대하여

마틴킴 2022. 2. 15. 22:43
728x90

 개인 공부를 위한 공간입니다. 틀린 부분 지적해주시면 감사하겠습니다 (_ _)

 

SOLID 원칙에 대해 공부하게 되면서, 또 좋은 글을 찾게 되었다.

그래서 번역을 또 해보려고 한다.

원본은 아래에 참고 바랍니다!

https://blog.bitsrc.io/solid-principles-every-developer-should-know-b3bfa96bb688

 


모든 개발자들이 알아야 하는 SOLID 원칙

객체지향(Object-Oriented) 프로그래밍은 소프트웨어 개발에 새로운 디자인(설계)을 가져왔다.

이는 전체 애플리케이션과 관계없이, 단 하나의 목적을 위해 하나의 클래스 내 같은 목적/기능과 데이터를 결합할 수 있게 해 준다.

하지만, 객체지향 프로그래밍은 복잡함(confusing)과 유지 보수하기 힘든 프로그램에서 벗어나게 하지는 못 한다.

이와 같이, Robert C. Martin이 5가지 가이드라인을 제시했다. 이 5가지 가이드라인/원칙은 개발자에게 읽기 쉽고 유지보수하기 쉬운 프로그램을 만들 수 있게 해 주었다.

이 5가지는 S.O.L.I.D 원칙이라고 불린다. (Michael Feathers에 의해 축약되었다.)

  • S: 단일 책임 원칙, Single Responsibility Principle
  • O: 개방 폐쇄 원칙, Open-Closed Principle
  • L: 리스코프 치환 원칙, Liskov Substitution Principle
  • I: 인터페이스 분리 원칙, Interface Segregation Principle
  • D: 의존관계 역전 원칙, Dependency Inversion Principle

아래에 자세하게 다뤄보겠다.

참고: 대부분의 예제는 실제 개발에 적합하지 않을 수 있다. 실 설계와 사용함에 따라 다를 수 있다. 어떻게 원칙들을 적용하고 따를지를 이해하고 아는 것이 가장 중요하다.


1. 단일 책임 원칙 (SRP)

...You had one job
Loki to Skurge in Thor:Ragnarok

클래스는 오직 하나의 기능(job)을 가져야 한다.

 

클래스는 오직 한 가지에 대한 책임을 져야 한다. 만약 하나의 클래스가 여러 개의 책임을 지고 있다면, 그것은 결합(의존)된 것이다. 하나의 책임에 대한 수정은 다른 책임에 대한 수정을 필요로 한다.

  • 참고: 이 원칙은 클래스뿐만 아니라 소프트웨어 컴포넌트와 마이크로 서비스에도 적용된다.

예를 들어,

class Animal {
    constructor(name: string) {}
    getAnimalName() {}
    saveAnimal(a: Animal) {}
}

Animal 클래스는 SRP를 어긴 것이다.

 

어떻게 SRP를 어기게 된 것일까?

 

SRP는 클래스는 하나의 책임을 가져야 한다고 이야기했는데, 2개의 책임을 가진다.

  1. 동물 데이터베이스 관리
  2. 동물 개체 관리

saveAnimal이 Animal 저장소를 관리하는 반면에, 생성자(construtor)와 getAnimalName은 동물 개체를 관리한다.

어떻게 위 설계가 미래에 영향을 끼치게 될까?

만약 애플리케이션이 데이터베이스 관리 함수에 영향을 주는 방식으로 수정된다면, Animal 개체를 이용하는 클래스는 새로운 수정에 대해 영향을 받고 리컴파일 되어야 할 것이다.

마치 도미노 효과처럼, 하나를 건드리면 다른 것들도 영향을 받을 것 같은 느낌이 난다.

SRP 원칙에 맞추기 위해서는, 데이터베이스에 동물을 저장하는 단 하나의 책임을 갖는 또 다른 클래스를 만들어야 한다.

class Animal {
    constructor(name: string){ }
    getAnimalName() { }
}
class AnimalDB {
    getAnimal(a: Animal) { }
    saveAnimal(a: Animal) { }
}
클래스를 설계할 때, 연관 있는 기능들을 묶으려고 해야 한다. 그래야 하나가 수정될 때, 같은 이유로 수정이된다. 그리고 다른 이유로 수정이 각각 되어야 한다면 기능들을 분리하려고 해야한다.
Steve Fenton

이러한 원칙을 잘 지킨다면, 애플리케이션은 응집력 있게 된다.


2. 개방 폐쇄 원칙 (OCP)

소프트웨어 엔티티(클래스, 모듈, 함수)는 확장엔 열려 있어야 하지만, 수정엔 닫혀 있어야 한다.

Animal 클래스를 계속 활용해보자.

class Animal {
    constructor(name: string){ }
    getAnimalName() { }
}

animals 리스트를 순회하며 동물 소리를 만들어보자.

//...
const animals: Array<Animal> = [
    new Animal('lion'),
    new Animal('mouse')
];
function AnimalSound(a: Array<Animal>) {
    for(int i = 0; i <= a.length; i++) {
        if(a[i].name == 'lion')
            log('roar');
        if(a[i].name == 'mouse')
            log('squeak');
    }
}
AnimalSound(animals);

AnimalSound 함수는 OCP 원칙을 따르지 않는데, 이는 새로운 동물에 대해 열려있기(cannot be closed) 때문이다.

 

만약 뱀을 추가한다면

//...
const animals: Array<Animal> = [
    new Animal('lion'),
    new Animal('mouse'),
    new Animal('snake')
]
//...

AnimalSound 함수를 수정해야 한다.

//...
function AnimalSound(a: Array<Animal>) {
    for(int i = 0; i <= a.length; i++) {
        if(a[i].name == 'lion')
            log('roar');
        if(a[i].name == 'mouse')
            log('squeak');
        if(a[i].name == 'snake')
            log('hiss');
    }
}
AnimalSound(animals);

보이는 것처럼, 새로운 동물이 추가될 때마다 AnimalSound 함수에 새로운 로직이 추가되어야 한다. 현재는 매우 간단해 보이지만 애플리케이션이 커지고 복잡해진다면, 추가되는 동물마다 AnimalSound 함수에 수많은 if 문이 반복될 것이다.

 

그렇다면 어떻게 OCP 원칙에 맞게 AnimalSound를 만들어야 할까?

class Animal {
        makeSound();
        //...
}
class Lion extends Animal {
    makeSound() {
        return 'roar';
    }
}
class Squirrel extends Animal {
    makeSound() {
        return 'squeak';
    }
}
class Snake extends Animal {
    makeSound() {
        return 'hiss';
    }
}
//...
function AnimalSound(a: Array<Animal>) {
    for(int i = 0; i <= a.length; i++) {
        log(a[i].makeSound());
    }
}
AnimalSound(animals);

Animal은 makeSound라는 가상 함수를 가지고 있다. 각각의 동물들은 Animal 클래스를 상속받고 가상 makeSound 메서드를 구현한다.

모든 동물들은 makeSound 함수에서 어떤 소리를 내는지 각각 구현부(own implementation)를 추가한다. AnimalSound 함수는 동물로 이루어진 배열을 순회하고 각각의 makeSound 메서드를 호출한다.

새로운 동물을 추가한다면, AnimalSound는 바뀔 필요가 없다. 단지 동물 배열에 새로운 동물을 추가하면 될 뿐이다.

AnimalSound 함수는 OCP 원칙을 지키고 있다.

 

또 다른 예제를 보자.

한 가게를 운영하고 있는데, 아래 클래스를 활용하여 단골 고객들에게 20% 세일을 제공한다고 가정해보자.

class Discount {
    giveDiscount() {
        return this.price * 0.2
    }
}

VIP 고객들에게는 40% 세일을 제공하기로 결정했을 때, 해당 클래스를 아래와 같이 변경해야 할 것이다.

class Discount {
    giveDiscount() {
        if(this.customer == 'fav') {
            return this.price * 0.2;
        }
        if(this.customer == 'vip') {
            return this.price * 0.4;
        }
    }
}

당연히 OCP 원칙을 어겼다. 다른 고객들에게 새로운 할인을 제공한다고 했을 때, 새로운 로직이 추가되어야 할 것이다.

OCP 원칙을 지키기 위해서는, Discount 클래스를 상속받는 새로운 클래스를 추가해야 한다.

class VIPDiscount: Discount {
    getDiscount() {
        return super.getDiscount() * 2;
    }
}

VIP 중 최고의 VIP 고객들에게 80% 할인을 제공한다고 하면, 아래와 같이 작성되어야 한다.

class SuperVIPDiscount: VIPDiscount {
    getDiscount() {
        return super.getDiscount() * 2;
    }
}

수정(modification) 없는 확장(extension)이 가능하다.


3. 리스코프 치환 원칙 (LSP)

하위(sub) 클래스는 상위(super) 클래스를 대체(substitutable)할 수 있어야 합니다.

LSP의 목적은 하위 클래스가 오류 없이 상위 클래스를 대신할 수 있는지 확인하는 것이다. 만약에 코드단에서 클래스의 타입을 확인한다면, 해당 원칙을 어기는 것이다.

Animal 예제를 다시 활용해보자.
//...
function AnimalLegCount(a: Array<Animal>) {
    for(int i = 0; i <= a.length; i++) {
        if(typeof a[i] == Lion)
            log(LionLegCount(a[i]));
        if(typeof a[i] == Mouse)
            log(MouseLegCount(a[i]));
        if(typeof a[i] == Snake)
            log(SnakeLegCount(a[i]));
    }
}
AnimalLegCount(animals);

이는 LSP 원칙을 어긴 것이다 (OCP 원칙도 어겼다.) 각 Animal 종류를 알고 관련된 leg-counting 함수를 호출해야 한다.

새로운 동물마다, 해당 함수는 새로운 동물들을 위해 수정되어야 한다.

//...
class Pigeon extends Animal {
        
}
const animals[]: Array<Animal> = [
    //...,
    new Pigeon();
]
function AnimalLegCount(a: Array<Animal>) {
    for(int i = 0; i <= a.length; i++) {
        if(typeof a[i] == Lion)
            log(LionLegCount(a[i]));
        if(typeof a[i] == Mouse)
            log(MouseLegCount(a[i]));
         if(typeof a[i] == Snake)
            log(SnakeLegCount(a[i]));
        if(typeof a[i] == Pigeon)
            log(PigeonLegCount(a[i]));
    }
}
AnimalLegCount(animals);

해당 함수가 LSP 원칙을 따르기 위해서는, Steve Fenton이 주장한 LSP 요구사항들을 지켜야 한다.

  • 만약 상위 클래스(Animal)가 상위 클래스 타입(Animal)을 매개 변수로 받는 메서드를 가지고 있다면, 하위 클래스(Pigeon)는 상위 클래스 타입 (Animal type) 혹은 하위 클래스 타입(Pigeon type)을 인자로 받아야 한다. -> If the super-class (Animal) has a method that accepts a super-class type (Animal) parameter. Its sub-class(Pigeon) should accept as argument a super-class type (Animal type) or sub-class type(Pigeon type).
  • 만약 하위 클래스가 하위 클래스 타입(Animal)을 반환한다면, 그 하위 클래스는 상위 클래스 타입 (Animal type) 혹은 하위 클래스 타입 (Pigeon)을 반환해야 한다. -> If the super-class returns a super-class type (Animal). Its sub-class should return a super-class type (Animal type) or sub-class type(Pigeon).

AnimalLegCount 함수를 재구현(re-implement) 해보자.

function AnimalLegCount(a: Array<Animal>) {
    for(let i = 0; i <= a.length; i++) {
        a[i].LegCount();
    }
}
AnimalLegCount(animals);

AnimalLegCount 함수는 전달받는 Animal의 타입에 대해서는 크게 신경 쓰지 않고, LegCount 메서드를 호출할 뿐이다. 명심해야 할 것은, 매개변수는 Animal type, Animal 클래스 혹은 그 하위 클래스이어야 한다는 것이다.

Animal 클래스는 LegCount 메서드를 구현/정의해야 한다.

class Animal {
    //...
    LegCount();
}

그리고 그 하위 클래스는 LegCount 메서드를 구현해야 한다.

//...
class Lion extends Animal{
    //...
    LegCount() {
        //...
    }
}
//...

AnimalLegCount 함수로 전달될 때, 사자(Lion)가 가지고 있는 다리의 개수를 반환한다.

AnimalLegCount는 다리 개수를 반환하기 위해서 Animal의 타입을 알 필요가 없고 Animal 타입의 LegCount 메서드를 호출할 뿐이다. 왜냐하면 원칙에 따라(by contract), Animal 클래스의 하위 클래스는 LegCount 함수를 구현해야 하기 때문이다.


4. 인터페이스 분리 원칙 (ISP)

Client별로 잘 나누어진 인터페이스를 만들어라.
(Make fine grained interfaces that are client specific)
Client는 사용하지 않는 인터페이스에게 의존해서는 안된다.
(Clients should not be forced to depend upon interfaces that they do not use.)

해당 원칙은 큰 인터페이스를 구현함에 있어 난점(disadvantage)을 다룬다.

IShape 인터페이스를 보자.

interface IShape {
    drawCircle();
    drawSquare();
    drawRectangle();
}

해당 인터페이스는 원, 정사각형, 직사각형을 그린다. IShape 인터페이스를 구현하는 Circle 클래스, Square 클래스, 혹은 Rectangle 클래스는 drawCircle(), drawSquare(), drawRectagle()이라는 메서드를 정의해야 한다.

class Circle implements IShape {
    drawCircle(){
        //...
    }
    drawSquare(){
        //...
    }
    drawRectangle(){
        //...
    }    
}
class Square implements IShape {
    drawCircle(){
        //...
    }
    drawSquare(){
        //...
    }
    drawRectangle(){
        //...
    }    
}
class Rectangle implements IShape {
    drawCircle(){
        //...
    }
    drawSquare(){
        //...
    }
    drawRectangle(){
        //...
    }    
}

코드가 꽤 우습게 생겼다. Rectagle 클래스가 drawCircle(), drawSquare()을, Sqaure 클래스가 drawCircle(), drawRectagle()을, Circle 클래스가 drawSquare(), drawRectagle()을 사용하지 않는데도 메서드들을 구현해야 한다.

drawTriangle()이라는 메서드를 IShape 인터페이스에 추가하게 된다면, 각각의 클래스는 새로운 메서드를 구현해야 하며 그렇지 않으면 에러가 난다.

interface IShape {
    drawCircle();
    drawSquare();
    drawRectangle();
    drawTriangle();
}

원을 그릴 수 있지만 직사각형, 사각형, 삼각형을 그릴 수 있는 shape를 구현한다는 건 불가능하다는 것을 안다. 에러를 띄우기 위해서 해당 메서드들을 구현하면, 해당 작업을 수행할 수 없음을 알게 된다.

ISP는 IShape 인터페이스와 같은 설계를 원하지 않는다(frown against). Clients(여기서는 Rectangle, Circle, Square 클래스들)는 필요하지 않거나 사용하지 않는 메서드들에 의존해서는 안된다. 또한 ISP는 하나의 job(SRP 원칙처럼)만 수행해야 하며, 추가적인 job들에 대해서는 다른 인터페이스로 분리되어야 한다.

IShape 인터페이스는 다른 인터페이스들에 의해 독립적으로 다루어져야 하는 액션들을 수행하고 있다.

IShape 인터페이스가 ISP 원칙에 준수하게 하기 위해서, 다른 인터페이스로 액션들을 분리해야 한다.

interface IShape {
    draw();
}
interface ICircle {
    drawCircle();
}
interface ISquare {
    drawSquare();
}
interface IRectangle {
    drawRectangle();
}
interface ITriangle {
    drawTriangle();
}
class Circle implements ICircle {
    drawCircle() {
        //...
    }
}
class Square implements ISquare {
    drawSquare() {
        //...
    }
}
class Rectangle implements IRectangle {
    drawRectangle() {
        //...
    }    
}
class Triangle implements ITriangle {
    drawTriangle() {
        //...
    }
}
class CustomShape implements IShape {
   draw(){
      //...
   }
}

 ICircle 인터페이스는 원을 그리는 것만 다루고, IShape는 모든 shape를 그리는 것을 다룬다. ISquare는 정사각형 그리는 것에만, IRectangle은 직사각형을 그리기만 한다.

혹은!

Circle, Rectangle, Square, Triangle 클래스들은 IShape 인터페이를 상속받아 각 클래스에 맞는 draw() 메서드를 구현한다.

class Circle implements IShape {
    draw(){
        //...
    }
}

class Triangle implements IShape {
    draw(){
        //...
    }
}

class Square implements IShape {
    draw(){
        //...
    }
}

class Rectangle implements IShape {
    draw(){
        //...
    }
}

I-인터페이스들을 사용하여 Semi-Circle, Right-Angled Triangle, Equilateral Triangle, Bluent-Edged Rectangle과 같은 shape들을 만들 수 있다.


5. 의존관계 역전 원칙 (DIP)

의존성은 구체화가 아닌 추상적 개념이 되어야 한다.
A. 상위 레벨 모듈은 하위 레벨 모듈에 의존해서는 안된다. 둘 다 추상적 개념에 의존해야 한다.
B. 추상적 개념은 디테일에 의존해서는 안된다. 디테일은 추상적 개념에 의존해야 한다.

소프트웨어 개발 단계에서 우리 애플리케이션이 대체적으로 모듈로 이루어질 때가 온다. 그렇게 되면, 의존성 주입(dependency injection)을 통해 정리를 좀 해야 한다. High-level components depending on low-level components to function.

class XMLHttpService extends XMLHttpRequestService {}
class Http {
    constructor(private xmlhttpService: XMLHttpService) { }
    get(url: string , options: any) {
        this.xmlhttpService.request(url,'GET');
    }
    post() {
        this.xmlhttpService.request(url,'POST');
    }
    //...
}

여기서, HttpService가 하위 레벨 컴포넌트, Http는 상위레벨 컴포넌트이다. 해당 설계는 DIP의 A: 상위레벨 모듈은 하위레벨 모듈에 의존해서는 안되며 추상적 개념에 의존해야 한다. 를 어긴다.

Http 클래스는 XMLHttpService 클래스에 의존하게 되어있다. If we were to change to change the Http connection service, maybe we want to connect to the internet through Nodejs or even Mock the http service. 코드를 수정하려면 힘들게 Http의 모든 인스턴스를 수정해야 하고 OCP 원칙도 어기게 된다.

Http 클래스는 사용하고 있는 Http 서비스의 타입을 걱정할 필요가 없어야 한다. 그래서 Connection 인터페이스를 만들어보자.

interface Connection {
    request(url: string, opts:any);
}
Connection 인터페이스는 request 메서드를 가지고 있다. 이를 활용하여, Http 클래스로 Connection 타입 인자로 넘겨보자.
class Http {
    constructor(private httpConnection: Connection) { }
    get(url: string , options: any) {
        this.httpConnection.request(url,'GET');
    }
    post() {
        this.httpConnection.request(url,'POST');
    }
    //...
}

자, 이로써 Http로 어떤 Http 연결을 위한 서비스가 전달되어도 네트워크 연결이 어떤 타입인지 알 필요 없이 네트워크에 연결할 수 있다.

Connection 인터페이스를 구현하기 위해서 XMLHttpService 클래스를 재구현 할 수 있다.

class XMLHttpService implements Connection {
    const xhr = new XMLHttpRequest();
    //...
    request(url: string, opts:any) {
        xhr.open();
        xhr.send();
    }
}

이제 많은 Http Connection 타입을 만들 수 있고 에러에 대한 혼란 없이 Http 클래스에 넘길 수 있다.

class NodeHttpService implements Connection {
    request(url: string, opts:any) {
        //...
    }
}
class MockHttpService implements Connection {
    request(url: string, opts:any) {
        //...
    }    
}

상위 레벨 모듈과 하위 레벨 모듈 둘 다 추상적 개념에 의존하고 있음을 알 수 있다. Http 클래스(상위 레벨 모듈)는 Connection 인터페이스(추상적 개념)에 의존하고 있고, Http 서비스 타입(하위 레벨 모듈) 또한 Connection 인터페이스(추상적 개념)에 의존하고 있다.

또한 DIP는 LSP 원칙을 어기지 않게 해 준다: Connection 타입인 NodeHttpService, XMLHttpService, MockHttpService은 부모 타입인 Connection과 대체 가능하다.


결론

지금까지 모든 소프트웨어 개발자가 준수해야 하는 다섯가지 원칙을 다루었다. 처음엔 모든 원칙들을 지키는 데 있어 겁먹을지도 모르지만 꾸준한 연습과 지키고자 하는 의지를 가지면 우리 코드의 일부가 되고 향후 어플리케이션 유지 보수에 좋은 영향을 줄 수 있다.
 

 

이렇게 필자는 자세하게 써주셨는데, 번역하는 나는 제대로 못 한 거 같다.
개념만 익힌 느낌..
이젠 실전으로!!