객체지향 5원칙 SOLID

곽태욱·2020년 4월 6일
0

1. 단일 책임 원칙

Single Responsibility Principle (SRP)

모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화해야한다.

관련있는 기능끼리만 모아서 클래스로 만들어야 프로젝트 유지보수에 좋기 때문에 생겨난 원칙이다. 여기서 책임은 변화가 필요한 부분을 말한다. 다시말해 (거의) 동시에 변하는 변수끼리만 클래스로 모으고 그 외 항목은 다른 클래스에 떨어트려 놓아야 한다는 원칙이다.

클래스 내부에 서로 관련없는 여러 로직을 모아 놓는 것은 좋지 않다. 왜냐하면 어떤 변수에 변화가 생겼을 때 관련 로직만 모아서 클래스로 만들어야 가독성이 좋고, 해당 클래스 안엔 특정 변수와 관련있는 함수만 있어서 유지보수도 수월하기 때문이다.

그래서 예를 들면 모든 것을 할 수 있는 스마트폰 클래스보단, 이를 계산기 클래스, 시계 클래스, 전화기 클래스, 인터넷 클래스, 게임 클래스로 분리하는 것이 좋다는 뜻이다. 즉, 클래스엔 되도록이면 (거의) 동시에 변하는 변수끼리만 모아 놓고, 그런 변수에 관련된 함수만 모아 놓자는 원칙이다.

import React, { useState } from 'react';

function App() {
  const [signupData, setSignupData] = useState({
    id: '',
    password: '',
    email: '',
    name: '',
  });

  ...
}

export default App;

이 원리는 굳이 클래스가 아니더라도 상태와 함수를 같이 다루는 모든 분야에 적용할 수 있다. 예를 들어 React에선 위와 같은 코드는 좋지 않다고 본다. 왜냐하면 사용자의 입력을 저장하는 변수가 한 클래스에 모여있기 때문에 사용자가 id만 수정해도 나머지 3개 항목도 불필요하게 (기존값 그대로) 수정되기 때문이다.

import React, { useState } from 'react';

function App() {
  const [id, setId] = useState('');
  const [password, setPassword] = useState('');
  const [email, setEmail] = useState('');
  const [name, setName] = useState('');

  ...
}

export default App;

사용자는 모든 입력을 동시에 수정하지 않는다. 따라서 위와 같이 useState를 여러 번 사용해서 여러 개의 상태로 나눠야 사용자가 입력했을 때 해당 항목만 변하도록 코드를 작성할 수 있다. 그래서 불필요한 연산을 하지 않으면서 사용자의 입력값을 관리할 수 있다.

또한 코드를 여러 파일에 분할해 저장할 때도 적용할 수 있다. 이 원칙을 적용하면 기능을 추가한다든지 버그를 수정한다든지의 이유로 어떤 파일을 수정해야 할 때 수정해야할 파일 수와 파일 크기가 적어진다. 왜냐하면 관련 코드는 한 파일에 모아 놓고 관련 없는 기능은 다른 파일로 분리했기 때문이다. 그렇지만 파일 크기와 수정해야 할 파일 수는 서로 반비례 관계에 있으니 이 사이에서 적절한 균형을 찾는 감각이 필요하다.

이 원칙은 우리가 모듈화를 추구하는 이유와 맥락이 비슷하다. 하지만 이 원칙이 위배됐는지 판단할 수 있는 객관적인 기준은 없기 때문에 앞서 말했듯이 노련한 디자인 감각이 필요하다.

2. 개방 폐쇄 원칙

Open-Closed Principle (OCP)

소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다'

기능을 추가하려면 기존 코드를 확장해서 사용하고, 기존 코드를 최대한 수정하지 않는 것을 권장하는 원칙이다. 결국 이 원칙도 코드를 잘 추상화하라고 권장하고 있다.

이 원칙은 공통되는 부분은 상위 클래스에서 구현해주고, 차이가 있는 부분은 추상 메소드로 제공해 하위 클래스에서 재정의하라는 뜻이다. 이렇게 공통되는 부분은 상위 클래스에서 메소드로 구현해주고 차이가 있는 부분은 추상 메소드로 제공하는 추상 클래스를 만들면, 새로운 기능을 추가할 때 기존 추상 클래스를 확장해서 사용할 수 있다.

그래서 소프트웨어 유지보수가 쉬워지고 새로운 기능을 추가할 때 기존 코드를 최대한 안 건들 수 있다. 그리고 이 원칙을 지키기 위해서 객체 지향 프로그래밍 대신, 순수 함수 사용을 권장하는 함수형 프로그래밍이 등장하기도 했다.

사실 이 원칙은 프로그래밍을 갓 시작한 사람도 본능적으로 사용하고 있는 원칙이다. 우리가 함수를 어떻게 정의하고 사용하는지 배우고 나면 코드 상에서 자주 쓰이는 부분을 함수로 만들어서 그 함수를 재사용한 적이 있을 것이다. 이렇게 코드 상에서 비슷한 부분을 함수로 만들면 여러 곳에서 재사용 가능한 함수를 만들 수 있는데, 기존 코드를 확장해서 사용하라는 이 원칙과 맥락이 비슷하다.

class Chrome(metaclass=ABCMeta):
    def __init__(self, wait_sec=10):
        ...

    # Scrape the new post
    def scrape_posts(self, sns_bot_token, sns_chat_id, period=10):
        ...

    # Go to the post list page
    # and return a list of [post_link, post_title]
    @abstractmethod
    def get_posts(self):
        pass

    # Go to the post details page
    # and return a text message with the post details
    @abstractmethod
    def get_message_from(self, post_link, post_title):
        pass

만약 어떤 사이트의 게시글을 스크랩하면서 새로운 게시글이 올라올 때마다 SNS로 알림을 주는 프로그램을 만들고 싶을 때 위와 비슷하게 클래스를 디자인하면 좋다. 제일 위에 있는 글이 새로운 글인지 확인하는 부분과 SNS로 메시지를 보내는 부분은 모든 사이트에 등일하게 적용할 수 있는 로직이기 때문에 상위 클래스에서 구현하고, 모든 게시글 제목을 가져오는 부분과 게시글 세부 내용을 가져오는 부분은 사이트마다 레이아웃이 다르기 때문에 하위 클래스에서 구현할 수 있도록 추상 메소드로 분리했다. 이렇게 디자인하면 기존 코드를 그대로 활용해서 각 사이트마다 공통되는 로직을 구현할 수 있어 생산성이 높아진다.

사실 이 원칙도 잘 지켜졌는지 판단할 수 있는 객관적인 기준은 없기 때문에 디자인 감각이 필요하다. 판단할 수 있는 주관적인 근거로는 '새로운 기능을 추가하기 위해 날밤 새며 개고생했나?', '새로운 기능을 추가할 때 기존 코드를 많이 수정했나?' 정도가 있다. 소프트웨어의 기존 코드를 적게 수정하면서 새로운 기능을 쉽게 추가시킬 수 있다면 이 원칙이 잘 지켜지고 있는 것이다.

3. 리스코프 치환 원칙

Liskov Substitution Principle (LSP)

상위 타입(부모) 객체를 하위 타입(자식) 객체로 치환해도 해당 코드의 결과는 동일해야 한다.

예시

class Parent {
  void method();
  void method2();
}

class Child extends Parent {
  void method3();
}

// 1. 기존 코드
Parent p = new Parent();
p.method();
p.method2();
...

// 2. 부모 클래스의 객체를 파생(자식)클래스의 객체로 치환한 코드
Parent p = new Child();
p.method();
p.method2();
...

이는 파생클래스에서 상속을 하든 오버라이딩을 하든 뭘 하든 간에, 기존 1번 코드와 부모 클래스 객체를 자식 클래스 객체로 치환한 2번 코드의 실행 결과는 서로 동일해야 한다는 원칙이다.

무분별한 상속을 방지하기 위해 자식 클래스를 설계할 때 유의해야할 원칙이다. 가끔 부모 클래스 메소드를 오버라이드하면서 객체의 상태를 변화시키는 경우 이 원칙이 깨질 수 있으니 주의해야 한다. (메소드가 객체의 상태를 변화시키지 않는 순수함수라면 신경쓰지 않아도 된다.)

이 원칙은 객관적인 기준이 있는 원칙으로서 자식 클래스를 설계할 땐 기존 코드에서 부모 클래스의 객체를 자식 클래스의 객체로 바꾼(치환한) 후 실행 결과가 동일한지 테스트해봐야 한다. 만약 실행 결과가 서로 다르다면 이 원칙이 지켜지지 않았다는 뜻이니까 자식 클래스를 다시 설계해야 할 것이다.

4. 인터페이스 분리 원칙

Interface Segregation Principle (ISP)

인터페이스 분리 원칙은 어떤 클래스가 자신이 이용하지 않는 메서드엔 의존하지 않아야 한다는 원칙이다.

큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리시킴으로써 클라이언트들이 꼭 필요한 메서드들만 이용할 수 있게 한다. 이와 같은 작은 단위들을 역할 인터페이스라고도 부른다. 인터페이스 분리 원칙을 통해 시스템의 내부 의존성을 약화시켜 리팩토링, 수정, 재배포를 쉽게 할 수 있다.

공통되는 부분은 인터페이스로 묶어주고 다른 부분은 떨어트려야 한다는 뜻이기에, 다른 원칙에서도 말했듯이 이 원칙은 코드 추상화를 잘 해야 한다는 말과 비슷하다. 동시에 변화하는 변수와 그에 해당하는 함수만 모아서 클래스를 설계하는 것이 좋다는 단일 책임 원칙과 비슷한 개념이다.

5. 의존관계 역전 원칙

Dependency Inversion Principle (DIP)

상위 계층(정책 결정)이 하위 계층(세부 사항)에 의존하는 전통적인 의존관계를 역전시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립할 수 있다

아래와 같이 2가지 개념으로 나뉘는데 둘이 사실상 동일한 말이다.

첫째, 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다. 이 말은 문제 해결 절차를 추상화하자는 말과 비슷하다. 즉, 인터페이스 분리 원칙에서 말했듯이 공통되는 부분은 인터페이스로 묶고, 개방 폐쇠 원칙에서 말했듯이 차이가 있는 부분은 추상 메소드로 분리하자는 개념이다.

둘째, 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다. 이 말은 하향식(Top-down) 방식으로 문제를 해결하자는 말과 동일하고, 이는 문제를 크게 추상화한 후 세부 사항을 구현하라는 뜻이다.

이 원칙은 '상위와 하위 객체 모두가 동일한 추상화에 의존해야 한다'는 객체 지향적 설계의 대원칙을 제공한다. 다시 말해 숲을 본 후 나무를 보라는 뜻이고, 큰 그림을 그린 후 세부 사항을 구현하라는 뜻이다.

결론

결국 SOLID는 주어진 문제와 해결 방식을 잘 추상화하라는 뜻이다. 추상화는 공통되는 부분을 찾는 행위로서, 코드를 추상화한다는 것은 공통되는 부분은 같은 인터페이스로 묶어서 관리하고 상황마다 다른 부분은 다른 인터페이스로 분리해야 한다는 뜻이다. 이때 공통되는 부분이란 단일 책임만을 가지는 부분으로 상태가 (거의) 동시에 변하는 변수와 이와 관련된 함수만 모아 놓은 부분을 의미한다. 그리고 하향식으로 접근하면서 문제를 해결하는 것을 권장하고 있다.

참고 : https://ko.wikipedia.org/wiki/SOLID_(객체_지향_설계)

profile
이유와 방법을 알려주는 메모장 겸 블로그. 블로그 내용에 대한 토의나 질문은 언제나 환영합니다.

0개의 댓글