SOLID 원칙은 당연하기도 하고 중요하기도 한 객체 지향 설계에서의 기본 원칙이다.
SOLID 원칙을 몰랐던 사람은 없겠지만 이걸 헷갈리고 까먹는 사람들은 제법 많다.
나도 그 중 한 명이고.

어떻게 까먹지 않을 수 있을까 고민하다가 예제와 같이 정리해본다.

SOLID 원칙

우선 SOLID 원칙은 다섯 가지의 객체지향 원칙들의 앞 문자를 따서 만든 원칙이다.

  1. SRP, Single Responsibility Principle, 단일 책임 원칙
  2. OCP, Open Closed Principle, 개방 폐쇄 원칙
  3. LSP, Liskov Substitution Principle, 리스코프 치환 원칙
  4. ISP, Interface Segregation Principle, 인터페이스 분리 원칙
  5. DIP, Dependency Inversion Principle, 의존 역전 원칙

사실 SOLID 원칙이란 클래스를 어떻게 나누고, 인터페이스로 명세하고 사용하는지에 대한 내용이다.
객체 지향적 개발을 잘 해온 개발자라면 당연한 내용들이어서 ‘이걸 외워야하나?’ 싶었지만 다른 사람들과 얘기할 때 원칙들을 딱 얘기하면 의사소통 비용이 줄어든다.
잘 알고 있는 개념들을 정리하고 자바의 List를 예시로 설명해본다.

SRP, Single Responsibility Principle

단일 책임 원칙은 가장 이해하기 쉬운 원칙이다.
하나의 책임만을 가져야한다는 이름과 아주 걸맞는 원칙이기 때문이다.

Class has one job to do. Each change in requirements can be done by changing just one class. - wiki

클래스는 단 하나의 책임만 가져야 한다.
각 요구사항의 변경은 하나의 클래스의 변경으로 만족되어야 한다.

이 말은 결국 클래스가 단 하나의 책임만 가져야 한다는 말이면서 동시에 하나의 책임이 여러 클래스로 나뉘어선 안된다는 말이다.

결국 여러 책임을 갖지 않도록 하여 결합도를 낮추고, 책임이 온전히 하나의 클래스게 있게 함으로써 응집도를 높히는 원칙이다.
이를 통해 유지보수와 확장이 용이한 구조를 갖는다.

단일 책임 원칙을 지키지 않을 경우 하나의 변경으로 인해 여러 기능에 영향을 미칠 수 있다.

클래스가 단 하나의 책임만 가져야 한다를 어기는 경우
:x: 클래스가 여러 책임을 가짐으로써 하나의 기능 변경으로 인해 여러 기능이 변경될 가능성(위험)이 있다.
:x: 클래스를 사용할 때 필요하지 않은 책임까지 갖게된다.

하나의 책임이 여러 클래스로 나뉘어선 안된다 를 어기는 경우
:x: 하나의 기능 변경을 위해 여러 클래스가 바뀌어야 한다.
:x: 변경해야 하는 클래스를 놓칠 가능성(위험)이 있다.

객체 지향은 객체를 나누는 것이고, 객체를 나누는 이유는 책임을 명확히하고 재사용하기 위함이다.
SRP를 지키지 않는 것은 객체 지향의 이점을 포기하는 것과 같다.

예시

Java의 ArrayList를 보자.
ArrayList는 list 자료구조를 내부적으로 array를 가지고 구현하는 하나의 책임만을 가진다.
list에서 제공하는 추가, 삭제, iteration을 구현한다.

ArrayList가 array가 아닌 list의 기능을 구현하거나 set 등의 다른 자료구조를 구현했다면 SRP를 위배하는 클래스가 됐을 것이다.

OCP, Open Closed Principle

개방 폐쇄 원칙은 내가 맨날 헷갈렸던 원칙이다.
“뭘 개방하고 뭘 폐쇄하는데?” 라는 생각을 아주 자주하게 만들었더 원칙.

Class is happy (open) to be used by others. Class is not happy (closed) to be changed by others. - wiki

클래스는 다른 클래스에 의해 사용(확장)되는데 열려 있어야 한다.
클래스는 다른 것들에 의해 변경(수정)되는데 닫혀 있어야 한다.

즉 개방한다는 것은 기능을 추가하는 것에 개방해야한다는 말이고, 폐쇄한다는 것은 기능이 추가될 때 기존의 코드 변경에 폐쇄되어야 한다는 말이다.

이 원칙을 주로 확장으로 표현하는데 이 단어가 나를 더 헷갈리게 만든 것 같다.
원칙적인 OCP는 인터페이스 구현을 통한 확장 뿐 아니라 기능이 추가되는 모든 상황들을 말하고 있다.

OCP를 어기는 경우
:x: 클래스간 결합도가 높아지고 코드 변경이 다른 클래스에 영향을 미친다.
:x: 재사용성이 감소하고 확장이 어려워진다.

예시

다시 ArrayList를 보자.

ArrayList는 List 인터페이스를 상속하고 있는데, 이 List 인터페이스에 LinkedList라는 새로운 클래스를 구현하려고 한다.
LinkedList 구현으로 인한 기능 추가는 Open이 되며, 구현으로 인해 ArrayList나 List가 수정될 필요는 없으므로 Close한 구조이다.

LSP, Liskov Substitution Principle

“리스코프라는 사람 참 대단하다.” 라는 생각이 들었던 원칙.
굳이 왜 이것만 사람 이름일까 싶긴한데 원칙 자체는 쉽다.

Class can be replaced by any of its children. Children classes inherit parent’s behaviours. - wiki

클래스는 하위 클래스로 대체 될 수 있어야 한다.

즉 하위 클래스는 상위 클래스가 할 수 있는 기능들을 모두 수행할 수 있어야 한다는 말이다.

LSP를 어기는 경우
:x: 다형성(Polymorphism)을 보장할 수 없다.
:x: 이로 인해 기능을 예측할 수 없고 안정성이 떨어진다.

객체지향에서 가장 중요한 것은 인터페이스를 분리하는 것이고, 이 원칙을 어긴다는 것은 인터페이스를 통해 얻을 수 있는 다형성의 이점을 포기한다는 것이다. LSP를 지키지 않는다는 것은 인터페이스를 사용하지 않는 편이 옳다.

예시

List와 ArrayList를 보자.

일반적으로 아래와 같이 사용한다.

class TestObject {
    List propertyList;
    
    void test(List paramList) {
        // do something   
    }
}

그치만 이 List들은 ArrayList로 바뀌어도 내부 동작에는 아무런 영향이 없다.
물론 List로 어떤 동작을 하느냐에 따라 어떤 List를 사용하는 것이 성능에 영향을 줄 수는 있지만 동일한 기능들을 수행할 수 있다.

class TestObject {
    ArrayList propertyList;
    
    void test(ArrayList paramList) {
        // do something   
    }
}

ISP, Interface Segregation Principle

“인터페이스를 분리한다는 것은 결국 인터페이스의 기능(책임)을 나눈다는 점에서 SRP에 속한 원칙이 아닌가?” 라는 생각을 했던 원칙.
조금 다른 점은 SRP는 클래스 내부의 책임에 대한 내용이라면 ISP는 인터페이스의 책임에 대한 내용이라고 볼 수 있다.

When classes promise each other something, they should separate these promises (interfaces) into many small promises, so it’s easier to understand. - wiki

클래스가 몇 가지 기능들을 제공하기로 약속되어 있다면 이런 약속들은 이해하기 쉽게 작은 인터페이스들로 나뉘어야 한다는 말이다.
대부분의 객체 지향 언어들은 클래스가 여러 인터페이스를 상속받을 수 있다.
인터페이스를 명확하게 나눔으로써 각 객체가 구현하는 내용들을 명확하게 하고 이해하기 쉽게 한다.

작은 인터페이스들로 나뉘어야 한다를 어기는 경우
:x: 클라이언트가 필요하지 않고 사용하지 않는 함수에도 의존관계를 갖게된다.
:x: 인터페이스 변경 시 영향도가 증가한다.
:x: 따라서 유연성이 감소하고 테스트가 어려워진다.

결국 다시 인터페이스. 인터페이스를 어떻게 나누어야 올바른지를 제시하는 원칙이다.
ISP를 적용하지 않고 인터페이스를 그냥 만든다면 그냥 함수 명세만 적어놓은 것에 불과하다.

예시

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    // ...    
}

abstract class는 잠시 접어두고 ArrayList는 4가지의 인터페이스를 상속하고 있다.
ArrayList는 일단 List라는 약속을 지켜야하고, RandomAccess가 가능해야하고 Clone, Serialize가 가능해야 한다.
이 인터페이스들의 일부가 나뉘어있지 않거나 혹은 전체가 List 인터페이스 속에 있었다고 가정하면 인터페이스 변경에 따라 바뀌어야 할 클래스들이 많아질 것이다.

따라서 명확한 기능에 맞게 인터페이스를 나누는 것이 중요하다.

DIP, Dependency Inversion Principle

나는 왜 이게 의존성 역전 원칙인거지? 하는 생각이 많이 들었다.
일반적으로 고수준 클래스가 저수준 클래스에 의존하는데, 이 방향을 interface(추상화)를 통해 의존성 방향을 반대로 한다는 것이다.
Layered 아키텍처를 기준으로 생각해보면 고수준이 저수준을 의존하는게 당연하니, 이 방향을 interface로 역전하는 것에 대한 이야기.
나는 MSA가 익숙해서 이게 역전이라고 생각하지 않았던게 이해를 방해했다.

When classes talk to each other in a very specific way, they both depend on each other to never change. Instead classes should use promises (interfaces, parents), so classes can change as long as they keep the promise. - wiki

클래스가 구체적으로 소통한다면 서로에게 의존성이 생기고 바뀔 수 없다.
클래스는 약속을 정의한 인터페이스를 사용하여 약속 안에서 수정 가능하도록 해야한다.

인터페이스를 명확하게 정의하고 인터페이스 안에서 수정이 가능하도록 하자는 방향이다.
여기서 인터페이스란 java interface 뿐 아니라 클라이언트와 약속한 함수 명세들을 의미한다고 보는 편이 맞다.
결국 인터페이스란 클라이언트와의 약속이고, 변경은 최대한 약속을 깨지 않는 선에서 이뤄져야 한다.
인터페이스가 없다면 약속없이 맘대로 쓴다는 것이다.

약속을 정의한 인터페이스를 사용하여 약속 안에서 수정 가능하도록 해야한다를 어기는 경우
:x: 클래스간의 결합도가 높아지고 의존성이 불분명해진다.
:x: 변경이 발생할 때마다 클라이언트에도 영향을 줄 수 있다.
:x: 따라서 재사용성이 낮아지고 유연성이 떨어진다.

예시

public interface List<E> extends Collection<E> {
    Iterator<E> iterator();
    // ...
}

List 인터페이스는 Iterator 라는 인터페이스에 의존하고 있다.
List가 더 고수준이기 때문에 저수준인 Iterator 인터페이스를 의존하는건 당연하지만, CharIterator와 같은 구체적인 클래스에 의존하지 않고 약속된 인터페이스에 의존함으로써 결합도를 낮추고 변경에 유연할 수 있다.

reference

  • SOLID wiki, https://simple.wikipedia.org/wiki/SOLID_(object-oriented_design)
  • OCP, https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle