일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- 엔테크서비스
- dto
- compiler
- disjoint set
- Java
- 음수가 포함된 최단경로
- 플로이드 와샬
- 거쳐가는 정점
- 유니온 파인드
- 직무면접
- bottom-up
- Python
- top-down
- clean code
- kmeans
- spring boot
- 벨만 포드 알고리즘
- Android Studio
- 우선순위큐
- 기술면접
- union-find
- onclick
- scikit-learn
- 최단경로
- BufferedReader
- 코딩테스트
- Django
- Controller
- 동적계획법
- 다익스트라
- Today
- Total
춤추는 개발자
객체지향 프로그래밍(OOP)의 설계 원칙 'SOLID' 본문
SOLID 원칙이란?
소프트웨어를 설계함에 있어 이해하기 쉽고, 유연하고, 유지보수가 편하도록 도와주는 5가지 원칙을 의미합니다. 시스템에 새로운 요구사항이나 변경사항이 있을 때, 영향을 받는 범위가 적은 구조를 만들면서 장기적으로 운영하기 위한 원칙이라고 볼 수 있습니다. 한마디로 좋은 설계를 위한 원칙
1️⃣ 단일 책임 원칙(SRP)
2️⃣ 개방-폐쇄의 원칙(OCP)
3️⃣ 리스코프 치환 원칙(LSP)
4️⃣ 인터페이스 분리 원칙(ISP)
5️⃣ 의존 역전 원칙(DIP)
1. SRP (Single Responsibility Principle)
- 모든 클래스(Class)는 한 가지의 책임을 부여받아서 추후, 수정할 이유가 단 한가지여야함을 의미합니다. 즉, 클래스에 속해있는 멤버들과 메소드는 모두 공통적으로 단 하나의 서비스를 위해 필요한 것입니다. 즉, 하나의 클래스가 여러가지 기능과 책임을 부여받아 복잡도 높아지는 것을 피해야합니다. SRP는 다른 4가지 원칙들을 적용하는 기초이기 때문에 꼭 적용해야하는 원칙입니다.
적용사례
위 코드를 보면 Guitar Class의 SerialNumber는 고유 정보, price, maker 등 나머지 컬럼들은 모두 특성 정보로 분류할 수 있습니다. 특성 정보들은 변화 요소로 예상되어, 항상 해당 Class를 수정해야 하는 부담이 발생할 수 있으며, 이러한 부분이 SRP적용의 대상이 됩니다.
SRP를 적용하게 되면 Guitar와 GuitarSpec Class로 분리한 것을 확인할 수 있습니다. 따라서 특성 정보에 변경이 발생하면 GuitarSpec 클래스만 변경하면 됩니다. 전보다 Class를 파악하기 편하고 수정 사항을 한 곳에서 관리할 수 있게 되었습니다.
사례를 보면 Class의 이름을 통해 무슨 역할을 하는지 한눈에 파악할 수 있습니다. 이런 방식으로 각 Class들은 하나의 개념을 나타내어야 합니다. 또한, 무조건 책임을 분리한다고 SRP가 적용되는 것은 아닙니다. 각 개체간의 응집력이 있다면 병합을, 결합력이 높다면 분리하는 것이 순 작용의 수단이 됩니다.
2. OCP (Open-Closed Principle)
- 소프트웨어의 구성 요소(컴포넌트, 클래스, 모듈, 함수)가 확장에 대해서는 유연해야하지만 수정에 대해서는 폐쇄적이어야 함을 의미합니다. 즉, 새 기능이 필요할 때 기존에 작성하고 테스트했던 코드를 수정하지않고 추가할 수 있어야하는 것입니다. OCP는 OOP의 가장 큰 장점인 유연성, 재사용성, 유지보수성 등을 얻을 수 있게하여 관리와 재사용이 가능한 코드를 만드는 기반이라고 할 수 있습니다.
- OCP의 주요 메커니즘은 추상화와 다형성입니다. 변경(확장)될 것과 변하지 않을 것을 엄격하게 구분하여 인터페이스를 정의하고 구체적인 타입 대신에 인터페이스에 의존하도록 코드를 작성합니다. 상속보다는 포함 관계를 활용하는 것이 이 원칙을 실현하기 쉽습니다.
적용사례
예를 들어 위에서 SRP에서 언급했던 Guitar Class는 Guitar만을 의미합니다. 만약, 다른 악기들을 다루어야할 때는 Guitar Class가 아닌 아래와 같이 새로운 악기들과 요소들을 만들어가야 합니다.
위와 같은 상황들에 대응하기 위해 추가될 다른 악기들을 추상화하는 작업이 필요합니다. 여기서는 추가될 악기들이 공통 속성들을 모두 담을 수 있는 StringInstrument라는 인터페이스를 생성합니다. 이 인터페이스가 앞으로 추가될 악기들을 대표하게 될것입니다. 이를 통해 기존 코드의 수정은 최대한 줄여서 결합도는 줄이고, 응집도는 높일 수 있을 것입니다.
확장되는 것과 변경되지 않는 모듈을 분리하는 과정에서 크기 조절에 실패하면 오히려 OCP를 적용하기 전보다 Class들의 관계가 복잡해질 수 있습니다. (결국, 이런 크기 조절과 Class간의 엄격한 분리 과정이 설계자의 능력이며 훌륭한 개발자라고 생각합니다.)
가능한 인터페이스는 변경해선 안됩니다. 인터페이스를 정의할 때, 여러 경우의 수에 대한 고려와 예측이 필요하지만, 과도한 예측은 불필요한 작업을 만들게 되니 항상 적당한 범위를 고려하며 설계해야 합니다.
인터페이스 설계에서 적당한 추상화 레벨을 선택해야 합니다. 'Grady Booch'에 의하면 '추상화란 다른 모든 종류의 객체로부터 식별될 수 있는 객체의 본질적인 특징'이라고 정의하고 있습니다. 필자의 경우, 인터페이스를 '할 수 있는' 또는 '행위나 기능'이라는 본질적인 정의를 통해 식별하고 있습니다.
3. LSP (The Liskov Substitution Principle)
- LSP는 한마디로 '하위 Class는 언제나 상위 Class와 호환될 수 있어야 한다.'라고 할 수 있습니다. 달리 말하면 하위 Class에 접근할 때, 그 상위 Class로 접근하더라도 아무런 문제 없이 일관성있는 행동을 해야합니다.
- 상속은 구현 상속(extends), 인터페이스 상속(implements)이 있는데 이들은 궁극적으로 다형성을 통한 확장이라는 목적이 있습니다. LSP원리도 역시 하위 Class가 확장에 대한 인터페이스를 준수해야 함을 의미합니다. 다형성과 확장성을 극대화하려면 하위 Class보다 상위 Class를 사용하는 것이 더 좋습니다.
- 일반적으로 선언은 상위 Class, 생성은 하위 Class로 대입하는 방법을 사용합니다. 이 때, Abstract Factory 등의 패턴으로 유연성을 높일 수 있으며, 다형성을 통한 확장은 결국, OCP를 제공하게 됩니다. 따라서 LSP는 OCP를 구성하는 구조가 될 수 있습니다.
- 객체 간의 IS-A 관계일 때만 상속 관계로 모델링합니다. 하위 Class의 공통된 연산을 인터페이스로 제공하고, 이들을 구분할 수 있는 멤버를 둡니다. 하위 Class는 확장만 수행해야 하며 상속 받은 기능 외에 필요한 게 있다면 구체화(implements)를 이용합니다.
만약, 상속 관계로 모델링하였는데 LSP에 위배되면 아래와 같은 검토가 필요합니다.
1. 상위 Class의 설계를 바꾼다.
2. 상위-하위 관계 대신 형제 관계로 모델링한다. - 합성(Composition)
3. 상속 관계 대신 포함 관계로 모델링한다. - (의존성 주입)
적용사례
void function() {
LinkedList list = new ListLisd<>();
// ...
modify(list);
}
void modify(LinkedList list) {
list.add(...);
doSomething(list);
}
위처럼 List만 사용할 것이라면 문제없다. 하지만, HashSet을 사용해야 하는 경우가 발생하면 LinkedList를 다시 HashSet으로 어떻게 바꿀 수 있을까? LinkedList와 HashSet은 모두 Collcetion 인터페이스를 상속하므로 아래와 같이 코드를 개선하는 것이 바람직하다.
void function() {
Collection collection = new HashSet<>();
// ...
modify(collection);
}
void modify(Collection collection) {
collection.add(...);
doSomething(collection);
}
이제 Collcetion생성부분만 고치면 어떤 구현 Collection Class라도 사용할 수 있습니다. 이는 Collection Class가 LSP를 모두 준수하기 때문에 가능합니다. 그리고 modify()는 변화에 닫혀있지만, collection의 변경과 확장에는 열려있는 OCP구조가 됩니다.
주의할 점으로 상속 구조가 필요하다면 Extract Subclass, Push Down Field, Push Down Method 등의 리팩토링 기법을 이용하여 LSP를 준수하는 상속 계층 구조를 구성합니다. 또한, Deisgn by Contract을 따라야 합니다. (하위 Class에서는 상위 Class의 사전 조건과 같거나 더 약한 수준에서 사전 조건을 대체할 수 있고, 상위 Class의 사후 조건과 같거나 더 강한 수준에서 사후 조건을 대체할 수 있다.)
4. ISP (Interface Segregation Principle)
- ISP란 한 Class가 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다는 원리입니다. 즉, 특정 Class가 다른 Class에 종속될 때에는 최소한의 인터페이스만을 사용해야 합니다. ISP는 '하나의 인터페이스보다 여러 개의 인터페이스가 낫다.'라고 정의할 수 있습니다.
- 만약, 어떤 Class를 이용하는 클라이언트가 여러 개고 이들이 해당 Class의 특정 부분 집합만을 이용한다면, 이들을 따로 인터페이스로 분리해서 클라이언트가 기대하는 메시지만을 전달할 수 있도록 합니다.
- SRP가 Class의 단일 책임을 강조한다면 ISP는 인터페이스의 단일 책임을 강조합니다. 하지만, ISP는 어떤 클래스 혹은 인터페이스가 여러 책임 혹은 역할을 갖는 것을 인정합니다.
ISP를 적용하는 방법은 2가지로 상속과 위임이 있습니다. 상속의 경우, 상속 받는 Class의 성격을 설계 시점에서부터 규정하기 때문에 제공되는 서비스의 성격이 제한되며, 위임은 다른 Class의 기능을 사용해야 합니다.
적용사례
예를 들어, 핸드폰을 모델링할 때, 옛날 2G폰과 최신 스마트폰은 모두 call, sms 등 공통 기능이 있으므로 아래와 같이 Class 다이어그램을 작성할 수 있습니다.
그러나, ISP를 만족하려면 다음과 같이 각 인터페이스로 기능들을 나누고, OldPhone Class와 SmartPhone Class에서 4개의 인터페이스를 구현하도록 설계해야 합니다.
5. DIP (Dependency Inversion Principle)
- DIP란 상위 모듈이 하위 모듈에 종속성을 가져서는 안되며, 양쪽 모두 추상화에 의존해야 함을 의미합니다. 쉽게 말해, 하위 레벨 모듈의 변경이 상위 레벨 모듈의 변경을 요구하는 구조적 문제에서 발생하는 위계관계를 끊는 것이다. 추상을 매개로 메시지를 주고 받음으로써 관계를 최대한 느슨하게 만드는 원칙이다.
- 이 원칙은 복잡한 컴포넌트들의 관계를 단순화합니다. 이를 통해, 구성에 대한 설정이 편리해지고, 모듈을 테스트하는 것이 용이해집니다.
- 즉, 의존 관계를 맺을 때, 변화하기 쉬운 것에 의존하기 보다는, 변화하지 않는 것에 의존하라는 원칙입니다.
- 이러한 원칙을 잘 지키는 방법이 바로 의존성 주입(DI)입니다. 이는 인터페이스 인스턴스의 타입을 바꿀 때, 단 한 곳만 수정하면 모든 곳에 적용됩니다.
적용사례
상위 레벨의 레이어가 하위 레벨의 레이어를 바로 의존하게 하는 것이 아니라, 이 둘 사이에 존재하는 추상 레벨을 통해 의존해야할 것을 의미합니다. 이러한 서비스들의 집합을 제공하는 레이어들로 구성된 것을 바로 '구조화된 객체지향 아키텍쳐'라고 할 수 있습니다.
결제Service를 예로 코드를 살펴보겠습니다.
class SamsungPay {
String paymeny() {
return "samsung";
}
}
public class PayService {
private SamsungPay pay;
public void setPay(final SamsungPay pay) {
this.pay = pay;
}
public String payment() {
return pay.payment();
}
}
위 코드의 문제라면, 요구사항이 바뀌어서 다른 PayService(Kakao, Naver)가 필요할 때마다 새로운 메서드를 하나씩 만들어야 합니다. 이를 해결하기 위해 공통 부분을 추상화하는 리팩토링을 한다면 아래와 같이 코딩할 수 있습니다.
public interface Pay {
String payment();
}
class SamsungPay implements Pay {
@Override
public String payment() {
return "samsung";
}
}
class KakaoPay implements Pay {
@Override
public String payment() {
return "kakao";
}
}
public class PayService {
private Pay pay;
public void setPay(final Pay pay) {
this.pay = pay;
}
public String payment() {
return pay.payment();
}
}
포스팅을 마치며.
- 객체지향 프로그래밍(OOP)을 떠올리면 Class와 Object, 상속, 추상화, 캡슐화, 다형성만 생각났다. 이번에 SOLID 원칙을 공부하며 앞서 언급한 OOP의 원리들을 어떻게, 어떤 상황에서, 어떤 목적으로 활용해야 하는지 알 수 있었다. 특히, 비즈니스 로직 구현을 주로 고민했던 나에게 구조적으로 Class를 잘 설계해야한다는 책임감이 생겼다. 체계적인 설계, 모델링이 코드의 장기적인 관리 유지보수가 가능하다는 것을 다시 한번 느낄 수 있었다.
'Developer's_til > 그외 개발 공부' 카테고리의 다른 글
[Java] 람다식과 함수형 인터페이스 (0) | 2021.07.29 |
---|---|
[Java] JDK8부터 등장한 Stream API (0) | 2021.07.29 |
[Crawling] Python을 활용한 동적 웹 크롤링 구현하기 (0) | 2020.11.03 |
[Clean Code] 네이밍 기법, 카멜과 파스칼, 스네이크? (0) | 2020.10.28 |
[객체지향] Java를 Java스럽게 (0) | 2020.10.28 |