본문 바로가기
Study

좋은 객체지향의 다섯 가지 원칙, SOLID

by DawIT 2022. 1. 6.
320x100

 

개발자라면 흔히 SOLID 원칙에 대해 들어보았을 것이다. SOLID 원칙은 클린 코드의 저자로 유명한 로버트 마틴이 객체지향의 다섯 가지 원칙의 앞 글자를 따서 제시한 것이다. 이 원칙의 목적은, 결국 유지보수가 쉽고 확장하기에 용이한 코드를 작성하기 위함이다.

 

이 5가지 원칙을 모두 완벽하게 지키면서 코드를 작성할 수는 없지만 이러한 원칙들을 기준으로 코드를 작성하려는 노력이 필요하다.

 

각각 원칙의 정의와 위반 사례를 통해 이를 이해한다.

 

SRP(Single Responsiblity Principle) 단일 책임 원칙

하나의 클래스는 하나의 책임만을 가져야 한다.

 

가장 먼저 나오는 SRP는 개인적으로 객체지향에서 가장 중요한 원칙이라고 생각한다. 만약 하나의 클래스가 여러가지의 책임을 가지고 있다면, 그 책임들 중 하나라도 변경이 발생했을 때 해당 클래스를 수정해야 한다. 단일 책임 원칙은 따라서 "하나의 클래스를 변경하는 이유는 단 하나여야 한다" 라는 말로 표현할 수도 있다.

 

단일 책임 원칙에서는 응집도는 높이면서, 결합도는 낮춰야 한다. 여기서 응집도란, 한 프로그램(클래스)의 요소들이 얼마나 잘 뭉쳐있는지를 이야기하며, 결합도는 클래스들끼리의 의존도를 이야기한다.

 

가령 다음과 같은 코드가 있다.

 

class Post {
  // 글 제목
  getTitle() { ... }
    
  // 글 내용
  getDesc() { ... }
    
  // DB에서 글 가져오기
  getPost() { ... }
    
  // DB에서 글 삭제하기
  deletePost() { ... }
}

 

상단의 코드는 SRP를 위반하고 있다. Post 라는 클래스에서 Post 엔티티에 대한 역할과 DB접근에 대한 역할을 모두 가지고 있기 때문이다.

 

Post의 작성자를 가져오는 함수를 추가해야 한다면 Post 클래스를 수정해야 하고, 수정된 Post를 DB에 저장하는 함수를 추가한다고 해도 Post 클래스를 수정해야 한다. 하나의 클래스를 수정하는 이유가 광범위해질수록 유지보수하기 어려운 코드가 된다.

 

여기서 이 두가지 책임에 각각 한 가지의 클래스를 만들어서 코드를 개선한다.

 

class Post {
  getTitle() { ... }
  getDesc() { ... }
}

class PostRepo {
  getPost() { ... }
  deletePost() { ... }
}

 

Post 클래스는 Post에 대한 책임만을 가지고 있다. 그리고 PostRepo 클래스에서는 Post에 관련된 DB 접근에 대한 책임만을 가지게 되었다. 이를 통해 코드의 응집력을 높일 수 있게 되었다.

 

OCP(Open-Closed Principle) 개방-폐쇄 원칙

소프트웨어는 확장을 위해 열려있어야 하지만, 수정에는 닫혀있어야 한다.

 

좀 다르게 말한다면, 기존의 코드를 수정하지 않고 기능의 확장이 가능해야한다는 이야기이다.

 

상단의 Post 예시에서 Category 라는 프로퍼티가 있고, Notice 와 Humor 가 이에 속한다고 한다.

 

만약 카테고리가 각각 Notice 혹은 Humor 라면 제목 앞에 자동으로 [공지] 혹은 [유머] 라는 말머리를 달고 저장해야 한다면 이렇게 작성할 수 있을 것이다.

 

class Post {
  constructor(category, title) {
    if (category === 'Notice') {
        this.title = '[공지]' + title;
    } else if (category === 'Humor') {
        this.title = '[유머]' + title;
    }
  }
  
  getTitle() { ... }
  getCategory() { ... }
}

 

만약 이상태에서 Question 카테고리가 추가된다면 어떻게 될까? consturctor 에다 else if 구문을 추가하고 [질문] 을 넣는 코드를 추가해야 할 것이다. 즉 카테고리가 추가될 때마다 기존의 Post 클래스를 수정하는것이며, 이는 OCP 원칙을 지키지 않는 행위이다.

 

이는 상속을 통해 해결할 수 있다.

 

class Post {
  getTitle() { ... }
  getCategory() { ... }
}

class Notice extends Post {
  constructor(title) {
    super()
    this.title = '[공지]' + title;
    this.category = 'Notice'
  }
}

class Humor extends Post {
  constructor(title) {
    super()
    this.title = '[유머]' + title;
    this.category = 'Humor'
  }
}

 

이렇게 작성하게 되면, 이후에 Question 등의 새로운 카테고리가 추가되더라도 기존의 Post를 수정하지 않고 Post 를 상속하는 클래스를 새로 작성하면 된다. 이로써 OCP 를 지킬 수 있다.

 

LSP(Liscov Subsitution Principle) 리스코프 치환 원칙

속성의 변경 없이 상위 클래스를 하위 클래스로 대체 가능해야 한다.

 

가장 전형적인 LSP 위반의 예시로, 직사각형과 정사각형 클래스를 들 수 있다.

 

class Rectangle {
  setWidth(width) {
    this.width = width;
  }

  setHeight(height) {
    this.height = height;
  }

  getWidth() {
    return this.width;
  }

  getHeight() {
    return this.height;
  }

  getArea() {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
}

 

흔히 정사각형은 직사각형이기 때문에 직사각형 클래스에서 정사각형 클래스를 자식 클래스로 둘 수 있을 것이라고 생각한다.

 

그러나 여기에는 문제가 있다. 첫 번째로 setWidth 혹은 setHeight를 단독으로 실행할 경우 두 변의 길이가 달라질 수 있기 때문에 정사각형이 아니게 된다.

 

이 문제를 해결하기 위해 setWidth와 setHeight를 오버라이드 하여 동시에 바꾸도록 구현했다고 해보자.

 

class Square extends Rectangle {
  setWidth(width){
    super.setWidth(width);
    super.setHeight(width)
  }

  setHeight(height){
    super.setWidth(height);
    super.setHeight(height);
  }
}

사실 직사각형 클래스에서 독립적으로 setWidth와 setHeight를 구현했다는 것은 직사각형 객체는 독립적으로 너비와 높이를 변경할 수 있다는 사후 조건이 존재한다는 의미이다. 그러나 이를 상속한 정사각형 클래스에서 이를 위반하므로 이 코드도 문제가 존재한다.

 

이렇게 구현했다고 해도 문제가 해결되지는 않는다. 만약 직사각형 클래스에 width를 증가시키는 함수를 작성했다고 해보자.

 

class Rectangle {
  ...

  increaseWidth(amount){
    this.setWidth(this.width + amount);
  }
}

 

이는 직사각형 객체에서는 잘 작동하겠지만, 정사각형 객체는 width와 height모두 amount 만큼 증가시키게 될 것이고, 이는 분명히 예상치 못한 결과이다.

 

어떻게 해결해야 할까? 두 가지 해결책이 있다.

 

  1. 상속으로 구현하지 않는다.
  2. setter 를 없애 immutable 한 객체로 만든다.

 

직사각형과 정사각형을 상속으로 구현하려는 것 자체가 문제가 될 수 있다. 수학적 집합으로 볼 때 정사각형은 직사각형에 포함되지만, 이게 객체지향의 상속과 1:1 매칭이 되지는 않는다. 따라서 그냥 따로 클래스를 두는 것이 하나의 방법이 될 수 있다.

 

또는 두 객체에서 setter(그리고 속성을 변경할 수 있는 모든 메서드)를 제거하여 불변 객체로 만드는 것이다.

 

LSP(Interface Segregation Priniciple) 인터페이스 분리 원칙

구현체는 사용하지 않는 메서드에 의존해서는 안된다.

 

말 그대로 인터페이스를 최대한 분리해야 한다는 원칙이다. 하나의 인터페이스가 다양한 기능들을 담당한다면, 해당 인터페이스의 구현체들이 필요하지 않은 메서드까지 모두 구현해야 한다는 문제가 생긴다.

 

 

위 그림처럼 하나의 구현체에 변화가 생겨 인터페이스에 영향을 준다면, (관련없는)해당 구현체가 모두 영향을 받는 상황을 LSP원칙에서는 지양한다.

 

interface Animal{
    name: string;
    bark: () => string;
    quark: () => string;
}

class Dog implements Animal {
    constructor(public name: string) {}

    bark() {
        return 'woof!'
    }

    quark() {
        ??
    }
}

class Duck implements Animal {
    constructor(public name: string) {}

    bark () {
        ??
    }

    quark() {
        return 'quark!'
    }
}

Typescript

 

상단 코드에서는 광범위한 Animal 인터페이스에서 bark와 quark 를 모두 가지고 있어서, 관련없는 구현체들에서 이를 구현해야하는 문제가 발생한다. 만약 이런 식으로 작성한다면 동물의 종류가 추가될 때마다 Animal 인터페이스에 새로운 메서드를 추가하고, 해당 구현체들에 전부 필요도 없는 해당 메서드를 추가해야 한다.

 

이때 인터페이스를 분리하여 이런 문제를 해결할 수 있다.

 

interface Animal{
    name: string;
}

interface IDog {
    bark: () => string;
}

interface IDuck {
    quark: () => string;
}

class Dog implements Animal,IDog {
    constructor(public name: string) {}

    bark() {
        return 'woof!'
    }
}

class Duck implements Animal,IDuck {
    constructor(public name: string) {}

    quark() {
        return 'quark!'
    }
}

 

Dog 와 Duck의 인터페이스를 분리하고, Animal 인터페이스는 공통적인 동물의 특성만 가지고 있게 한다. 이렇게 하면 쓸데없는 메서드 구현이 필요하지 않고 ISP를 만족할 수 있게 된다. 또한 Animal 인터페이스를 수정한다면 이는 전체 동물 클래스에게 꼭 필요한 변화에 대한 것이다.

 

DIP(Dependency Inversion Principle) 의존성 역전 원칙

상위 모듈은 하위 모듈에 의존해서는 안되며, 추상화에 의존해야 한다. 또한 추상화는 세부 사항에 의존하면 안되며, 세부 사항이 추상화에 의존해야 한다.

 

간단히 말해 자기 자신보다 더 변하기 쉬운 것에 의존하지 말라는 의미이다.

 

간단하게 스마트폰과 케이스를 통해 예시를 들어 본다면 이러한 구조가 될 것이다.

 

 

스마트폰에 투명케이스를 쓰고 있다면 스마트폰이 투명 케이스를 의존하는 형태일 것이다. 그런데 기분에 따라 투명 케이스가 아니라 젤리 케이스를 사용하고 싶다면 어떻게 해야 할까?

 

실생활에서는 투명 케이스를 빼고 젤리 케이스를 장착하면 되겠지만 코드상에서는 그렇게 간단하지 않다. 투명 케이스와 관련된 모든 코드를 바꿈과 동시에 스마트폰에서 투명 케이스를 의존하고 있기 때문에 스마트폰에서도 수정이 일어나게 된다. 스마트폰이라는 상위 모듈이 투명케이스라는 하위 모듈에 의존하고 있기 때문에 DIP 위반이며, OCP 위반이기도 하다.

 

 

이를 방지하기 위해 스마트폰은 자신보다 더 변하기 어려운 케이스 인터페이스(케이스를 장착하지 않는 상황은 배제한다)에 의존하면 된다. 그리고 해당 케이스 인터페이스에 의존하고 있는 투명케이스, 젤리케이스, 가죽 케이스... 등등을 구현한다.

 

케이스 인터페이스에서는 전체 케이스에 대한 공통적인 속성을 정의한다. 그리고 의존성 주입을 통해 간편하게 실제 사용하는 케이스를 바꿀 수 있다.

 

여기서 주의할 점은 이는 코드상에서의 의존성 역전이다. 실제 런타임에서는 스마트폰은 투명케이스, 젤리케이스, 가죽케이스 등등 하위 케이스 중 하나를 의존하고 있다. 코드상에서의 유지보수와 리팩토링의 편의를 위해서 의존성을 역전시킬 뿐이라는 것을 기억해야 한다.

댓글