우아한 테크코스에서 @손너잘이 객체지향 5원칙에 대한 스터디를 열어주셔서, 뭣도 모르지만 일단 참여를 눌렀다. (난 항상 뭣도 몰라도 참여를 누른다...).
그래서 우선은 SOLID에 대해서 찾아본 내용들을 적어보려고 한다!
이론을 먼저 작성한 뒤, 내 경험(짧지만)에 빗대어 어떤 이슈가 있었는지?에 대해서도 아이디어를 적어볼까 생각한다 😋
시작에 앞서..
SOLID는 좋은 객체 지향 설계를 위한 5가지 원칙이라고 흔히들 말한다. 객체 지향은 무엇이고 또 좋은 객체 지향 설계는 무엇일까?
객체지향이란 핵심 의존성들을 역전시킴으로써 경직된 코드나 취약한 코드 및 재사용 불가능 코드가 되지 않게 하는 식으로 의존체들을 관리하는 일이며, 이를 위해 프로그램을 어떻게 설계해야하는지에 대한 개념이자 방법론이다. 이 방법론은 절차적 프로그래밍, 구조적 프로그래밍을 거쳐 객체지향 방식으로 발전해왔다.
절차적 프로그래밍은 Input에서 Output 까지의 흐름 관점에서 프로그래밍 하는 것으로, 프로그램의 기능이 중심이고, 프로그램이 취급하는 데이터에는 관심이 없다.
구조적 프로그래밍은 프로그램을 함수 단위로 나누고, 그 함수간 호출을 하면서 구동되도록 프로그래밍하는 방법이다. 프로그램을 큰 문제로 보고, 이를 해결하기 위해 몇 가지 작은 문제로 나누어 해결하기 때문에 Top-Down 방식이라고 한다.
객체지향 프로그래밍은 먼저 작은 문제들을 해결할 수 있는 객체들을 만든 뒤, 이 객체들을 조합해서 큰 문제를 해결하는 방식으로서 Bottom-Up 방식이라고도 한다. 일단 독립성/신뢰성이 높게 객체를 만들어 놓는다면, 이후 그 객체는 수정없이 재사용 가능하므로 개발 기간과 비용이 대폭 줄어들게 된다.
객체는 하나의 역할을 하는 메서드와 데이터의 묶음으로 볼 수 있다. OOP는 프로그램을 이러한 수많은 객체라는 기본 단위로 나누고, 객체 간 상호작용으로 프로그램을 서술한다. 우리는 이러한 객체를 이용하여 좋은 객체 지향 설계를 하기 위해, 다음과 같이 정의된 세 가지 문제를 해결하면서 그 목표를 달성할 수 있다.
1. rigidity (경직성): 프로그램의 한 부분을 변경하면 다른 부분까지 변경해야하는 경우
2. fragility (취약성): 관련이 없는 곳에서 오류가 발생하는 경우
3. immobility (부동성): 코드를 원래 맥라겡서 벗어나 재사용할 수 없는 경우
우리는 이 세가지 문제를 해결하고 목표를 달성하기 위해 SOLID를 활용할 수 있다 😀
아래에서부터는 공부한 뒤 정리한 내용이므로 잘못된 내용이 있을 수 있습니다! 댓글로 남겨주시면 정정하겠습니다 🙂
SOLID 원칙
'소프트웨어 설계를 더 이해하기 쉽고 더 유연하며 더 유지보수 가능하게 만들기' 위해 도입한 다섯가지 원칙을 SOLID로 정의한다.
객체지향 설계 5대 원칙인 SOLID는 SRP(단일 책임 원칙), OCP(개방-폐쇄 원칙), LSP(리스코프 치환 원칙), ISP(인터페이스 분리 원칙), DIP(의존 역전 원칙)을 말한다.
하나씩 자세히 알아보자.
1. 단일 책임 원칙 (Single Responsibility Principle)
There should never be more than one reason for a class to change.
모든 클래스 및 컴포넌트는 각각 하나의 책임만 가져야 한다는 원칙이다. (= 그 책임은 완전히 캡슐화되어야 한다. 그렇다면, 캡슐화란? 🙄) 즉, 클래스가 제공하는 모든 서비스는 하나의 책임을 수행하는 데에 집중되어있어야 한다는 것이다. 이는 어떤 변화에 의해 클래스를 변경해야하는 이유는 오직 하나뿐이어야함을 의미한다.
SRP를 적용한다면 응집도는 높이고, 결합도는 낮출 수 있다. 더불어 책임을 적절하게 분배함으로써 코드의 가독성 향상, 유지보수 용이라는 이점까지 누릴 수 있다.
그런데 여기서, 단일 책임이란 정확히 어떤 것을 의미할까?
- 클래스가 여러가지의 메소드를 가진다면, 그 클래스는 복수의 책임을 갖나?
- 클래스가 다중상속 또는 다중구현을 한다면, 그 클래스는 복수의 책임을 갖나?
- 해당 클래스를 의존하는 사용자가 여러명이라면 변경되는 이유는 여러가지가 되는가?
SOLID를 정의한 로버트C 마틴은 '클린 아키텍처'에서 SRP를 다음과 같이 다시 정의한다.
하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다.
여기에서 액터란, 시스템이 동일한 방식으로 변경되기를 원하는 사용자 집단을 말한다. 즉, 액터는 한 명의 사용자가 될 수 도 있고, 여러 사용자가 모여 하나의 액터가 될 수도 있다.
따라서 SRP를 적용하기 위해서는 설계하는 해당 클래스에만 초점을 맞추는 것이 아닌, 거시적 관점에서 해당 클래스에 어떤 액터들이 의존하게 되는지를 생각해보아야한다.
이 질문에 대해 고민하도록 해준 해당 블로그에서 좋은 예시를 가져와 인용한다.
'스마트폰' 이라는 객체를 철수와 영희가 모두 사용하고 있다고 가정해보겠습니다. 철수는 스마트폰을 영상 시청을 위해서 사용하고, 영희는 스마트폰을 전화 통화를 위해서 사용합니다.
class 스마트폰 implements 동영상플레이어, 전화 { ... }
위의 스마트폰은 철수와 영희가 다른 방식으로 변경되기를 원할 수 있기 때문에
철수와 영희는 별개의 액터입니다. 철수가 만약 영상 시청을 위해서 스마트폰의 액정크기를 15인치로 바꾼다면 영희의 전화통화 요구사항에는 맞지 않는 변경사항이 됩니다. 그러므로 해당 스마트폰이 SRP를 준수하기 위해서는 다른 액터에 맞게 분리되어야 합니다.
하지만 철수와 영희가 같은 요구사항으로써 스마트폰을 사용한다면 철수와 영희를 하나의 액터로 볼 수 있고 이러한 경우는 SRP를 준수한다고 볼 수 있습니다.
🤔 레이싱카 프로젝트에서 입력값에 대한 검증이 필요한 부분이 있었다. 이름은 빈 칸이어서는 안되고, 5자 이하이어야하며, 중복이 불가능하다. 시도 횟수는 1회 이상이어야하며, 숫자이어야한다 등. 이러한 검증을 하는 부분에 있어서 처음에는 하나의 함수 내부에서 모두 검증하는 방법을 취했었는데, 이렇게 진행하였더니 그 함수 내부에서 에러가 나면 어느 부분이 잘못된 것인지 한눈에 확인할 수 없었고 모든 코드를 다 확인하는 과정을 거쳐야했다. 이렇듯 한 메소드조차 한 가지 이상의 역할을 수행하면 응집도를 낮추고, 결합도를 높이는 극악의 상황으로 갈 수 있다.
2. 개방-폐쇄 원칙 (Open Closed Principle)
You should be able to extend a classes behavior, without modifying it.
확장에는 열려있고, 변경에는 닫혀있어야 한다는 원리이다. 변경을 위한 비용은 가능한 한 줄이고, 확장을 위한 비용은 가능한 극대화해야한다는 의미로서 요구사항의 변경이나 추가사항이 발생하더라도, 기존 구성요소는 수정이 일어나지 말아야하며, 기존 구성요소를 쉽게 확장해서 재사용할 수 있어야한다는 뜻이다.
미디엄의 홍찬기님께서 작성하신 SOLID 원칙에 대한 글에서 좋은 예시를 발견해 인용한다.
캐릭터를 하나 생성한다고 할때, 각각의 캐릭터가 움직임이 다를 경우 움직임의 패턴 구현을 하위 클래스에 맡긴다면 캐릭터 클래스의 수정은 필요가없고(Closed) 움직임의 패턴만 재정의 하면 된다.(Open)
3. 리스코프 치환 원칙 (Liskov Substitution Principle)
Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
서브 타입은 언제나 기반 타입으로 교체될 수 있어야한다는 원칙이다. 즉, 부모 클래스를 상속한 자식 클래스는 부모 클래스의 역할을 정확히 해내야한다. (서브 타입은 기반 타입이 약속한 규약을 지켜야한다.) 상속은 구현상속(extends)이든 인터페이스 상속(implements)이든 궁극적으로는 다형성을 통한 확장성 획득을 목표로 한다.
이 원칙은 잘 이해가 되지 않아서, 😶 여러 글을 찾아 보았는데 LSP는 결국 상속의 과정 중 메소드의 재정의가 필요하다면 현재 자식 클래스가 부모 클래스의 기존 메소드의 의미를 해치지는 않는지 신중히 고민하고 올바르게 상속하라는 의미라고 한다! 즉 부모 클래스가 들어갈 자리에 자식 클래스를 넣어도 잘 구동되도록 상속하라 라는 의미로 이해할 수 있는 것 같다. (일관성없는 자식 클래스는 따로 분리하자.)
리스코프 치환 원칙을 처음에는 상속에 대해서만 적용 가능한 방법이라고 이해했었다. 그래서 합성과 상속 사이에서 과연 상속을 통해서만 이용가능하다면 이 원칙을 꼭 지켜야하는가? 에 대한 고민이 되었는데, 오늘 스터디를 통해 interface를 통한 합성도 가능하다고 하였다. 이는 이후 적절한 예시를 찾아 추가해놓을 예정 !!!!
4. 인터페이스 분리의 원칙 (Interface Segregation Principle)
Clients should not be forced to depend upon interfaces that they do not use.
한 클래스는 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다는 원리이다. 어떤 클래스가 다른 클래스에 종속될 때에는 가능한 최소한의 인터페이스만을 사용해야한다. 앞선 두 말은 어떻게 보면 모순된다고 느껴질 수도 있는데, 하나의 일반적인 인터페이스보다 여러개의 구체적인 인터페이스가 낫다. 라는 의미라고 한다.
즉 이 이야기는 사람이라는 클래스를 만들 때 달리는 행위, 먹는 행위, 잠자는 행위를 포함한 단일 엔티티를 만드는 대신, 각각의 행위를 단일 인터페이스로 분리하는 것이다. 그리고 사람을 구성할 때, 합성 기반 설계를 통해 구성하는 것이다. 특정 사람 A는 살면서 달리는 행위를 전혀 하지 않는다면, 우리는 이 사람을 구성할 때 먹는 행위, 잠자는 행위를 합성하여 만들면 되는 것이다. (굳이 필요없는 달리는 행위가 포함된 단일 사람을 상속받을 필요가 없다.)
그렇다면, 우리는 그 사람이 어떤 사람일지 아직 모르는데, 미리 그 사람의 행위를 구분지을 필요가 있을까? 스터디를 통해 내가 동의하는 정답은 "필요가 있다" 이다. 그 이유는, 어쨌든 이렇게 구분짓는 행위 자체가 여러 클라이언트에 대해 대응해주는 것이 될 것이며 도메인별로 적절히 세분화하여 합성을 통해 체계적으로 관리하면다면 구분짓지 않은 프로그램보다 더욱 재사용성, 유지보수성이 높은 프로그램이 될 것이라 생각한다 👀
5. 의존성 역전 원칙 (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.
의존 관계를 맺을 때 변화하기 쉬운 것 또는 자주 변화하는 것보다는 변화하기 어려운 것, 거의 변화가 없는 것에 의존하라는 원리이다. 즉, 구체적인 클래스보다 인터페이스나 추상 클래스와 관계를 맺으라는 의미이다. 클래스 사이에는 의존 관계가 존재하는데, 그 의존 관계가 존재하되, 구체적인 클래스에 의존하지 말고 최대한 추상화된 클래스에 의존하라(== interface를 적극적으로 활용하라)라는 의미이기도 하다. 만약 구체적인 클래스에 의존하고 있다면 그 클래스는 변화에 취약할 것이고, 그 클래스를 변경해야한다면 코드에 많은 변화가 필요할 것이다.
이 원칙을 논의하기 앞서 이해해야할 몇 가지 핵심 용어는 다음과 같다.
- 의존성 역전: 의존체들을 역전시키는 원칙
- 의존성 주입: 의존체들을 역전시키는 행위
- 생성자 주입: 생성자를 통해 의존성 주입을 수행
- 파라미터 주입: 세터와 같은 메서드의 파라미터를 통해 의존성 주입을 수행
우선, 우리가 논의할 의존성 역전의 목표는 구상적인 것에 결합하기 보다는 추상적인 것에 결합하는 것이다. 더불어 컴파일 타임이 아니라, 런타임에 객체를 선택하는 것이다. 우리는 이전 클래스를 다시 컴파일하지 않고도 새 클래스를 작성해 주입할 수 있다.
객체지향 사고 프로세스라는 도서에서 발췌한 예제를 통해 알아보자.
Mammal 클래스는 추상적이며, makeNoise()라는 단일 메서드를 포함한다. Mammal의 자식 클래스인 Cat, Dog는 상속을 사용해 포유류의 행위인 MakeNoise()를 활용한다.
public class Test { public static void main(String args[]) { Mammal cat = new Cat(); Mammal dog = new Dog(); sout("Cat says " + cat.makeNoise()); sout("Dog says " + dog.makeNoise()); } } abstract class Mammal { public abstract String makeNoise(); } class Cat extends Mammal { public String makeNoise() { return "Meow"; } } class Dog extends Mammal { public String makeNoise() { return "Warr"; } }
이 코드는 포유류와 소리를 내는 행위)를 연결한다. 포유류의 행위를 포유동물 자체에서 분리하면 상당한 이점을 얻을 수 있다. 이렇게 분리한다면 우리는 포유류 뿐만 아니라 포유류가 아닌 것들도 모두 사용할 수 있는 '소리를 낼 수 있는 행위'라는 클래스를 만든다.
abstract class MakingNoise { public abstract String makeNoise(); } class CatNoise extends MakingNoise { public String makeNoise() { return "Meow"; } } class DogNoise extends MakingNoise { public String makeNoise() { return "Warr"; } } abstract class Mammal { public abstract String makeNoise(); } class Cat extends Mammal { CatNoise behavior = new CatNoise(); public String makeNoise() { return behavior.makeNoise(); } } class Dog extends Mammal { DogNoise behavior = new DogNoise(); public String makeNoise() { return behavior.makeNoise(); } }
위와 같은 방식을 통해 우리는 Cat, Dog클래스 내부에서 하드코딩하지 않고 각각의 울음소리에 해당하는 클래스를 활용할 수 있다. 하지만 위와 같이 작성했을 때에도 우리는 코드의 주요 부분을 분리했지만, Cat이 여전히 Cat 울음 소리내기 행위를 인스턴스화하기 때문에 우리는 의존성 역전이라는 목표에 도달하지 못했다. 으악!
이를 해결하기 위해 우리는 Cat을 저수준 모듈인 CatNoise에 결합하는 것이 아닌, 울음 생성을 위한 추상화에 연결하여야 한다. 즉, 울음 생성 동작을 인스턴스화하는 것이 아닌 주입을 통해 행위를 받아야 한다.
그래서 우리는 결론적으로 ••• 상속을 포함하지 않고 합성을 통한 의존성 주입을 활용하여보자.
사실 위 코드를 보았을 때도 눈치챘겠지만, Cat 과 Dog는 코드의 상당 부분이 중복된다. (나도 작성할 때, 복붙한 뒤 Cat -> Dog, 울음소리만 변경해주었다.) 만약 포유동물이 많아진다면 중복되는 부분은 매우 많아질 것이고, 인텔리제이는 수정하라고 밑줄을 그을 것이다..
그래서 우리는 포유류 자체가 울음소리를 내도록 코드를 취하고, 울음 소리 생성 행위를 인스턴스화 한뒤, 이를 포유류 클래스에 제공하여 우리가 원하는 특정 동물처럼 행위를 하는 포유류를 만드는 것이다.
class Mammal { MakingNoise grrrr; public Mammal(MakingNoise grrr) { this.grrr = grrr; } public String makeNoise() { return this.grrr.makeNoise(); } } Mammal cat = new Mammal(new CatNoise()); Mammal dog = new Mammal(new DogNoise());
의존성 역전의 목표는 특정 시점에서 구체적으로 무언가를 만들어야 하지만, 무언가 구상적인 게 아닌 추상적인 것에 결합하는 것이다. 따라서 간단한 목표 중 하나는 최대한 멀리까지 이어지게 구상객체를 만드는 것이다. (Mammal이 Mammal 그 자체에서부터 Cat, Dog까지 이어진 것 처럼) new 키워드를 볼 때마다 언제나 그 대상의 값을 evaluate하자.
오늘 드디어 스터디를 마쳤는데, 스터디 결과로 SOLID에 대해 결론내린 부분은! 너무 심취하지 말되 적절하게 이용하자! 이다. 너무 모호한가? 🤔 하지만 토론 중에도 나왔듯이 "적정 선을 잘 지켜" "과하지 않게" 사용하는 것이 좋은 객체 지향 설계를 위해 한 걸음 더 다가가는 것이 아닐까? 라는 생각을 하게 되었다!
reference
- medium.com/@hckcksrl/solid-%EC%9B%90%EC%B9%99-182f04d0d2b
- www.nextree.co.kr/p6960/
- jaeyeong951.medium.com/%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5-5%EC%9B%90%EC%B9%99-solid-ac7d4d660f4d
- blog.martinwork.co.kr/theory/2017/12/10/oop-solid-principle.html
- gmlwjd9405.github.io/2018/07/05/oop-solid.html
- www.yes24.com/Product/Goods/90688759