원래 내 블로그에는 다른 데에서도 쉽게 찾을 수 있는 내용은 포스팅하지 않으려고 했다. 앞선 두 개의 포스팅은 사람마다 모두 다르게 정의하는 "객체지향"에 대해서 내가 한번 제대로 정의해보겠다는 취지로 작성했다. 두 번의 포스팅의 내용은 결국 OOP는 좋은 프로그램을 만들기 위한 하나의 방법론이며, 캡슐화, 상속, 다형성이라는 특징을 이용하여 그 목표를 이뤄내고자 한다는 것이다. 그리고 첫 번째 포스팅에서도 언급했듯이, 이 세 가지 특징을 지향점을 향해 잘 쓰기 위한 원리와 패턴을 정리한 것이 SOLID 원칙과 디자인 패턴이라는 게 내 주장이다. 이렇게 정리를 하고 나니, 나 역시 SOLID를 잘 모르기 때문에 공부할 필요성이 느껴졌다. 공부할 때마다 다음 공부의 방향성이 정해진다는 것이 이렇게 블로그 등으로 정리하는 것의 장점인 것 같다. 따라서 이번 포스팅에서는, SOLID에 대해 공부한 내용을 정리해보고자 한다.
"그래서 SOLID가 뭔데?"
SOLID는 5가지 principles의 앞글자를 모아 만든 축약어다. 이 5가지 principles를 객체 지향 개발 5대 원리라고 하는데, 말 그대로 이 원칙들을 잘 지킬수록 내가 만든 OOP 프로그램이 좋은 프로그램이 될 가능성이 높다고 볼 수 있다. 그럼 각 principle의 내용을 하나하나 보자.
1. S = SRP (Single Responsibility Principle, 단일책임의 원칙)
There should never be more than one reason for a CLASS to change.
원문 정의를 보면 어떤 클래스를 수정하는데 있어서 하나 이상의 이유가 있어서는 안 된다는 원칙이다. 이 말은 바꿔 말하면 하나의 클래스가 담당하고 있는 기능, 책임은 단 하나라는 것이다. 우리가 프로그래밍을 할 때, 처음에 예상한 시나리오와는 약간 결이 다른 새로운 시나리오를 Accept 할 수 있도록 프로그램을 변경해야 될 때가 있다. 그럴 때 어떤 하나의 기능에 대해서 하나의 클래스만 수정하면 되도록 하는 것이 이 원칙이 주장하는 바라고 볼 수 있다. 예를 들면 보고서를 편집하는 모듈을 개발했다고 생각해보자. 근데 단순히 하나의 편집 모듈로 만들면, 이 모듈의 변경을 요구하는 원인이 당장 생각할 수 있는 것만 해도 두 개다. 하나는 내용을 변경해야 하는 것이고, 다른 하나는 형식을 변경해야 하는 것이다. 따라서 SRP를 염두에 두고 개발을 했다면 사실 이 모듈은 두 개의 Submodule로 개발이 되었어야 했다.
조금 더 구체적인 예시를 들면, 처음에 Guitar라는 클래스를 만들었다고 하자. 이 Guitar 클래스는 기타의 정보에 대한 필드들과, 기타의 동작에 관련된 필드를 만들었다. 이런 경우에는 필요한 기타의 정보의 변경이 발생한 경우에도, 또한 기타의 동작 방식의 변경이 발생한 경우에도 Class의 수정이 발생한다. 이는 명백히 Guitar라는 클래스가 두 개 이상의 책임을 가지고 있는 셈이다. 따라서 GuitarSpec과 Guitar 이런 식으로 클래스를 나눠야 할 것이다.
Why SRP?
SRP의 경우는 존재 목적은 명확하다. SRP는 단순히 여러 책임을 가지고 있는 클래스를 분리하라고만 하지는 않는다. 책임이 여러 클래스에 분산되어 있다면, 가장 대표되는 클래스에 그 책임을 응집시키라고 한다. 이는 내가 포스팅에서 여러번 언급했듯이, 클래스의 응집도를 높이고 결합도를 낮추는 원칙인 셈이다. 하지만 더 정확하게는 캡슐화의 원칙이라고 생각한다. 캡슐화를 하는 방법이라던가, 하는 이유에 대한 것은 지난 포스팅에서 설명했다. 하지만 잘하는 방법에 대해서는 얘기하지 않았고, SOLID 원칙 자체가 캡슐화, 상속, 다형성을 잘하기 위한 원칙이라고 소개했다. 이 SRP는 캡슐화를 하는 Boundary를 지정해준다고 생각한다. 우리가 Class를 만듦에 있어서 어느 범위까지를 그 Class의 캡슐에 담을 것인가. 이는 SRP에서 얘기하는 것처럼, 단 하나만의 기능 책임을 가지도록 캡슐화를 해야 하는 것이다.
2. O = OCP (Open-Closed Principle, 개방 폐쇄의 원칙)
You should be able to EXTEND a classes behavior, without MODIFYING it.
원어를 해석해보면 클래스의 behavior를 수정하지 않고 확장할 수 있어야 한다는 원칙이다. 여기에 Open Close 라는 이름이 붙은 것은, 소프트웨어 개체(클래스, 모듈, 함수)는 확장(Extend)에는 열려(Open) 있고, 수정(Modify)에는 닫혀(Close) 있어야 한다는 데에서 나왔다.
이 원칙이 "절대로 클래스를 변경해서는 안되며, 변경할 내용이 있다면 클래스를 확장하여 변경점을 프로그램에 적용해야 된다"와 같이 이해하면 안된다. 내가 처음에 그렇게 생각했는데, 아 그러면 상속을 하라는 건가? 이렇게 생각이 전개된다. 이미 많은 사람이 알고 있듯, 무분별한 상속은 장점보다 단점이 많으며 어떻게 보면 결합도가 엄청나게 큰 괴물 프로그램을 만들어낼지도 모른다. 이 원칙은 이미 만든 클래스를 수정할 필요가 없고, 새로운 요구사항이나 변경사항에 대해서 확장만으로도 충분히 수용가능한 상태의 프로그램으로 만들어야 한다는 게 골자다. 즉, 무분별하게 코딩을 할 때 확장만 하면서 코딩하라는 게 아니고, 절대로 변경하지 않아도 되는 명세를 만들고 그 명세를 기반으로 프로그램을 만들어 세부 내용은 그 명세를 구현한 클래스를 이용하여 코딩하라는 것이다. 그러면 클래스를 수정할 필요 없이 확장성이 높게 만들 수 있다. 눈치챘겠지만 Interface와 다형성에 관련된 원칙이다.
이미 인터페이스 기반으로, 다형성을 이용하여 프로그램을 만드는 것은 지난 포스팅에서도 많이 언급했다. 그래도 예를 들어보면, 사람이 악기를 연주하는 상황을 프로그램으로 모델링한다고 할 때, Instrument라는 인터페이스를 만들고 play라는 method를 정의해놓는다. 그리고 사람은 Instrument 객체를 인자로 받아, 이 play()를 호출해준다. 이렇게 구현하고, 실제 악기(기타, 드럼, 첼로 등등)에 대한 구현은 Instrument를 implements 한 구현 클래스들을 작성해준다. 그러면 아무리 많은 악기가 새로 추가된다고 해도, Instrument를 구현한 클래스만 새로 작성하면 얼마든지 추가할 수 있다. Modify에는 닫혀있고, Extend에는 열려있는, OCP를 정확히 지킨 예시로 볼 수 있다.
3. L = LSP (The Liskov Substitution Principle, 리스코프 치환의 원칙)
Functions that use pointers or references to base classes must be able to use objects of drived classes without knowing it
이 원칙은 Barbara Liscov가 1987년의 컨퍼런스에서 제시한 개념이다. 여기서 말하는 걸 아주 아주 쉽게 풀어서 설명한다면, SuperType의 객체를 이용하여 동작하는 코드에 SubType의 객체가 참조될 때에도 프로그램에 오류가 없이 동작해야 한다는 것이다. 이는 OCP와도 아주 밀접한 관련성이 있다. OCP에서 Interface와 다형성에 관한 예시를 들어 설명했었다. 그런데 Interface도 사실 클래스고, C++ 같은 경우에는 Interface를 따로 선언하지 않고, Pure Abstract Class를 만들어 사용한다. 이때 만약, Interface를 상속받은 Subtype이자 구현 클래스가 Interface에 명시된 method를 구현하지 않을 수 있어서, 구현하지 않았다고 해보자. 그러면 우리는 OCP 원칙대로 작성한 코드가 제대로 동작함을 보장할 수 있을까? 애초에 인터페이스와, 인터페이스를 이용한 다형성, 이를 기반으로 하는 OOP의 코드가 동작하는 것은 Interface가 명시된 method들의 오버라이드와 구현을 강제하기 때문이다. LSP에서 규정하는 것처럼, Interface의 SubType 객체들이 Interface 참조 변수들에 할당되었을 때 프로그램에 오류 없이 동작하도록 강제하고 있는 것이다.
이런 점에서 Interface의 경우는 Language 자체에서 시스템적으로 LSP를 강제한다고 볼 수 있다. 하지만 단순한 Inheritance에 대해서는 사용자의 자유도가 더 높고, 때에 따라서는 LSP를 지키지 않고 구현할 수도 있을 것이다. 하지만 그런 경우에도 LSP를 잘 지켰을 때, 더욱 완성도 있는 OOP를 만들 수 있게 된다는 것이다. OCP와 상당히 유사한 원칙이지만, 나는 LSP의 경우에는 상속에 더욱 초점을 맞춘 원칙이라고 본다. 상속이란 절대로 무분별하게 이뤄져서는 안되며, 흔히들 IS-A 관계의 경우에만 상속을 해야 한다는 또 다른 일종의 원칙이 있을 정도다. 이러한 상속을 잘하기 위해 지켜야 되는 부분을 규정하고 있다.
4. I = ISP (Interface Segregation Principle, 인터페이스 분리의 원칙)
Clients should not be forced to depend upon interfaces that they do not use
이번에도 원어부터 보면, 클라이언트는 자신이 사용하지 않는 인터페이스에 종속되길 강요받아서는 안된다고 나와있다. 여기서 클라이언트는 인터페이스의 클라이언트이기 때문에 클래스로 보면 되는데, 자신이 사용하지 않는 인터페이스란 무슨 말일까? 인터페이스는 자신을 상속받은(구현한) 클래스들이 인터페이스에 정의된 method를 반드시 구현하도록 되어 있다. 즉, 여기서 말하고자 하는 것은 인터페이스를 상속받아 클래스를 만들 때, 구현할 필요가 없는 method가 있어서는 안 된다는 말이다. 더 풀어보면, 어떤 인터페이스에는 거기에 정의된 method를 반드시 모두 구현한 클래스만이 제대로 프로그램에 사용될 수 있어야 한다는 말이다. 내가 참고한 블로그에도 나와있듯이, 이 원칙은 인터페이스의 단일 책임을 강조하는 원칙이다.
SRP는 사실상 클래스의 단일 책임을 강조한다. 클래스 단위의 캡슐화의 범위를 지정해준다. 기능들을 클래스에 부여할 때, 많은 기능을 가지고 있는 클래스는 모두 다른 클래스로 기능을 분리하기를 권장한다. 하지만 우리가 OCP나 LSP를 살펴 보면서 OOP는 기본적으로 인터페이스 단위의 프로그래밍이 이루어져야 한다. 따라서 우리는 인터페이스를 정의함에 있어서도 어떠한 원칙이 필요한 것이다. JAVA에서 클래스 다중 상속은 금지했어도, 인터페이스 다중 상속은 허용함에는 반드시 ISP가 관여했을 것이다. 인터페이스가 반드시 단일 책임을 갖도록 최소 범주의 기능을 가지도록 작성되어야 하기 때문에, 때로는 클래스가 여러 인터페이스의 기능을 동시에 필요로 할 수 있다. OCP 원칙에 따라 인터페이스는 특히나 닫혀 있는 부분이기 때문에 정말 아주 아주 특별한 상황을 제외하고는 절대로 수정되어서는 안된다. 그렇기 때문에 더더욱 결합도를 낮추어 작은 단위로 작성을 해야 할 것이다.
5. D = DIP (Dependency Inversion Principle, 의존성 역전의 원칙)
A. High Level modules should not depend upon low level modules. Both should depend upon abstractions.
B. Abstractions should not depend upon details. Details should depend upon abstractions.
DIP는 다른 것보다 훨씬 이해하기 어려웠다. 먼저 원어 정의만 보면 그렇게 어려운 것은 아니다.
A. 고수준 모듈은 저수준 모듈에 의존해서는 안된다. 고수준, 저수준 모듈 모두 Abstraction에 의존해야 한다.
B. Abstranction은 구체적인 내용에 의존해서는 안된다. 구체적인 내용이 Abstraction에 의존해야 한다.
정리하자면, 앞서 말한 1-4 원칙의 내용과 유사하다. 결국은 Concrete Class가 아닌 Abstract Class, 즉 Interface를 잘 활용해야 된다는 것이다. 하지만 나는 특히 여기서 말하는 의존이 무엇인지를 모르겠어서 이해하기가 힘들었다. 이 원칙은 예시를 보면서 이해하는 것이 더 편하다. 예로 들어볼 것은 바이트 데이터를 읽어들여 특정 형식의 데이터로 반환하는 모듈이다. 이는 고수준의 모듈이 될 것이고, 여기서 특정 형식이 구체적으로 어떤 형식인지를 명시한 모듈이 저수준 모듈이 될 것이다. 아래의 코드는 고수준 모듈 Response와 저수준 모듈 JsonConverter로 이루어진 코드이다.
public class Response {
public String response(byte[] input) {
return new JsonConverter().convert(input);
}
}
public class JsonConverter {
public String convert(byte[] bytes) {
String result;
// Do Something
return result;
}
}
DIP를 완벽히 어기고 있는 코드이다. (물론 DIP 뿐만 아니라 OCP도 어기고 있지만) 고수준 모듈 안에 직접적으로 저수준 모듈의 코드가 작성되어 있다. 위의 예시는 많은 부분이 생략되어 있고, 단순화된 모듈이지만 일반적으로 이런 경우에는, 저수준 모듈의 구현이 변경됨에 따라서 고수준 모듈이 수정을 강요받을 수 있다. 특히, Json이 아닌 Xml로의 convert를 원하는 경우에는 Response의 구현이 반드시 변경되어야만 한다. 이 처럼 A의 변화에 B의 변화가 강요되는 상황, 이런 상황이 B가 A에 의존하고 있다고 볼 수 있다.
사실 이러한 구현은 OOP가 등장하기 이전의 패러다임 내에서는 일반적인 방식이다. 현대에 프로그래밍을 수학하는 우리들조차도 OOP를 제대로 배우기 전에는 모두들 당연하게, Naive 하게 위와 같은 방식의 코딩을 많이 한다. 하지만 DIP에서는 고수준 모듈에 저수준 모듈이 의존하는 것도 아니고, 상급자여야 할 고수준 모듈이 저수준 모듈에 의존하는 것을 이상하게 여겼다. 따라서 이를 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존하도록 설계해야 한다고 주장한 것이다. 이 추상 타입이 곧 Interface가 될 수 있고, Interface는 정의한 method를 Subtype들이 반드시 구현하도록 강제하므로 Subtype들이 interface를 의존하고 있다고 할 수 있다.
interface Converter {
String convert(byte[] bytes);
}
public class Response {
private final Converter converter;
public Response(Converter converter) {
this.converter = converter;
}
public String response(byte[] input) {
return this.converter().convert(input);
}
}
public class JsonConverter {
public String convert(byte[] bytes) {
String result;
// Do Something about json
return result;
}
}
public class XmlConverter {
public String convert(byte[] bytes) {
String result;
// Do Something about xml
return result;
}
}
이렇게 바꿔본 구성은 이미 OCP나 LSP의 예시에서도 많이 본 방식이다. 여기서 고수준 모듈인 Response와 저수준 모듈인 JsonConverter, XmlConverter는 모두 Abstraction인 Converter interface에 의존하고 있다. 이는 DIP를 지키는 예시가 된다. 그리고 동시에 이러한 방식을 DI(Dependency Injection)라고 부른다. DI는 DIP를 지켜 구현하는 하나의 기법이라고 한다. 이렇게 DIP를 지키며 프로그램을 구성하면 결과적으로 고수준 모듈이 저수준 모듈에 의존하는 것과는 반대의 양상을 띄게 된다. 이러한 점에서 의존성 역전(Dependency Inversion)이라고 명명하는 것이다. DIP는 캡슐화, 상속, 다형성의 어떤 특별한 활용에 대한 내용이라기보다는 OOP 내에서 요소들의 관계를 맺는 방법에 대한 기술이라고 볼 수 있다.
이로써 SOLID 원칙 5가지를 모두 살펴보았다. 다시 한번 요약하여 SOLID 원칙을 정리해보면
1. SRP - 캡슐화의 원칙. 캡슐화의 Boundary를 지정하는 법.
2. OCP - 인터페이스와 다형성을 활용하는 방법.
3. LSP - 상속 관계를 맺음에 있어서 지켜야되는 원칙.
4. ISP - 인터페이스를 만드는 방법.
5. DIP - 전반적으로 OOP의 요소들 간의 관계를 맺는 방법.
References
- http://www.nextree.co.kr/p6960/
- http://wonwoo.ml/index.php/post/1717
- https://vandbt.tistory.com/42
- https://justhackem.wordpress.com/2016/05/13/dependency-inversion-terms/
- wikipedia
'OOP' 카테고리의 다른 글
[OOP #2] 캡슐화, 상속, 다형성 (0) | 2019.11.25 |
---|---|
[OOP #1] 객체지향이란 무엇인가? (0) | 2019.11.14 |