일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 31 |
- junit5
- kafka
- mokito
- effective java
- TDD
- JWT
- Spring Security
- consumer
- Stream
- 싱글톤
- Factory Method Pattern
- Java8
- 카프카
- signWith
- thread
- SpringBoot
- git cli
- orElseGet
- producer
- Java
- Clean Code
- topic
- Functional Programming
- optional
- 디자인패턴
- 인텔리제이 단축키
- 함수형 프로그래밍
- orelse
- Authentication
- Today
- Total
goodbye
좋은 객체 지향 설계의 5가지 원칙 - SOLID 본문
SOLID 란?
컴퓨터 프로그래밍에서 SOLID란 로버트 마틴이 2000년대 초반에 명명한 객체 지향 프로그래밍 및 설계의 다섯가지 기본 원칙을 마이클 페더스가 두문자어 기억술로 소개한것이다. 프로그래머가 시간이 지나도 유지보수와 확장이 쉬운 시스템을 만들고자 할 때 이원칙들을 함께 적용할 수 있다. 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)
1. 단일 책임 원칙(Single responsibility principle) : SRP
There should never be more then one reason for a class to change
1-1. 원칙
● 한 클래스는 하나의 책임만 가져야 한다는 원칙이다.
● 클래스가 제공하는 서비스는 그 하나의 책임을 수행하는데 집중되어 있어야 한다는 원칙이다
● 즉 어떤 변화에 의해 클래스를 변경하는 이유는 오직 하나뿐이여야 함을 의미한다.
● SRP 원리를 적용하면 무엇보다도 책임영역이 확실해진다
● 한 책임의 변경에서 다른 책임의 변경으로의 연쇄 작용에서 자유롭다
● 따라서 여러 책임을 가지고 있는 클래스가 있다면 각각 개별 클래스로 분할한다.
● 또는 중복되는 책임을 가지고 있다면 이를 부모 클래스(Super Class)로 정의하여 위임한다.
● 중요한 기준은 변경이며, 변경이 있을때 파급효과가 적으면 단일 책임 원칙을 잘 따른것이다.
1-2. 적용방법
● 리팩토링(Refactoring:Improving the Design of Exising Code - Martin Fowler)에서 소개하는 대부분의 위험상황에 대한 해결방법은 직/간접적으로 SRP원리와 관련이 있으며, 이는 항상 코드를 최상으로 유지한다는 리팩토링의 근본정신도 항상 객체들의 책임을 최상의 상태로 분배한다는 것에서 비롯되기 때문이다
● 여러 원인에 의한 변경(Divergent change) : Extract Class를 통해 혼재된 각 책임을 각각의 개별 클래스로 분할하여 클래스당 하나의 책임만을 맡도록 하는것이다. 여기서 관건은 책임만 분리하는 것이 아니라 분리된 두 클래스간의 관계의 복잡도를 줄이도록 설계하는것이다. 만약 Extract Class된 각각의 클래스들이 유사하고 비슷한 책임을 중복해서 갖고 있다면 Extract SuperClass를 사용 할 수 있다. 이것은 Extract된 각각의 클래스들의 공유되는 요소를 부모 클래스로 정의하여 부모 클래스에 위임하는 기법이다. 따라서 각각의 Extract Class들의 유사한 책임들은 부모에게 명백히 위임하고 다른 책임들은 각자에게 정의 할 수 있다.
● 산타총 수술(Shotgun surgery): Move Field와 Move Method를 통해 책임을 기존의 어떤 클래스로 모으거나, 이럴만한 클래스가 없다면 새로운 클래스를 만들어 해결 할 수 있다. 즉 산발적으로 여러 곳에 분포된 책임들을 한곳에 모으면서 설계를 깨끗하게 한다. 즉 응집성을 높이는 작업이다.
1-3. 적용코드
// SRP 적용전
class Piano() {
public Piano(String serialNum, int price, Maker maker, Type type, String model, long num) {
this.serialNum = serialNum;
this.price = price;
this.maker = maker;
this.type = type;
this.model = model;
this.num = num;
}
private String String serialnum;
private int price;
private Maker maker;
private Type type;
private String model;
private Long num;
위에서 serialNum은 변화요소라고 할 수 없고 단지 고유정보라고 할 수 있다. 동종의 다른 클래스와 구분되는 정보에 해당한다. 그리고 price, maker, type, model, num은 모두 특성 정보로 변경이 발생 할 수 있는 부분으로 변화 요소로 예상된다. 따라서 특정 정보에 변화가 발생하면 항상 해당 클래스를 수정해야 하는 부담이 발생하기 때문에 이부분이 SRP 적용 대상이 된다. 이를 코드로 표현하면 아래와 같다
// SRP 적용후
class Piano() {
public Piano(String serialNum, PianoSpec spec) {
this.serialNum = serialNum;
this.spec = spec
}
private String serialnum;
private PianoSpec spec;
class PianoSpec() {
int price;
Maker maker;
Type type;
String model;
Long num;
}
특정 정보에 변경이 일어나면 PianoSpec 클래스만 변경하면 된다. 변환에 의해 변경되는 부분을 한곳에서 관리 할 수 있다. 클래스는 자신의 이름이 나타내는 일을 해야 한다. 올바른 클래스 이름은 해당 클래스의 책임을 나타낼 수 있는 가장 좋은 방법이다. 즉 각 클래스는 하나의 개념을 나타내어야 한다. 사용되지 않는 속성이 결정적 증거이다. 무조건 책임을 분리한다고 SRP가 적용되는것은 아니다. 각 개체간의 응집력이 있다면 병합이 순 작용의 수단이 되고 결합력이 있다면 분리가 순 작용의 수단이 된다.
2. 개방-폐쇄 원칙 (Open/closed principle) : OCP
2-1. 원칙
● 소프트웨어 요소(컴포넌트, 클래스, 모듈, 함수)는 확장에는 열려있으나 변경에는 닫혀 있어야 한다는 원칙이다
● 변경을 위한 비용은 가능한 줄이고 확장을 위한 비용은 가능한 극대화 해야 한다는 의미에 해당한다.
● 요구사항의 변경이나 추가사항이 발생하더라도 기존 구성요소는 수정이 일어나지 않아야 한다
● 요구사항의 변경이나 추가사항이 발생하더라도 기존 구성요소를 쉽게 확장해서 재사용 할수 있어야 한다
● OCP는 관리가능하고 재사용 가능한 코드를 만드는 기반이다.
● OCP를 가능케 하는 중요 매커니즘은 추상화와 다형성이다
● 즉 OCP 는 객체지향의 장점을 극대화 하는 아주 중요한 원리
2-2. 적용방법
● 변경(확장) 될 것과 변하지 않을 것을 엄격히 구분한다.
● 이 두 모듈이 만다는 지점에 인터페이스를 정의한다
● 구현에 의존하기보다 정의한 인터페이스에 의존하도록 코드를 작성한다
2-3. 적용코드
// OCP 적용전
class Piano() {
public Piano(String serialNum, PianoSpec spec) {
this.serialNum = serialNum;
this.spec = spec
}
private String serialnum;
private PianoSpec spec;
class PianoSpec() {
int price;
Maker maker;
Type type;
String model;
Long num;
}
class Drum() {
public Drum(String serialNum, DromSpec spec) {
this.serialNum = serialNum;
this.spec = spec
}
private String serialnum;
private DrumSpec spec;
class Drum() {
int price;
Maker maker;
Type type;
String model;
Long num;
}
위 코드는 SRP원리를 적용하여 Piano 클래스에서 변경이 예쌍되는 부분을 추출해서 PianoSpec이라는 새로운 클래스를 만들어 변화요소들을 하나로 모아 변화를 국소화 시켰다. 하지만 여기에서도 변경이 발생 할 수 있다. 예를 들면 Piano외에 다른 악기들도 추가된다면 이들을 추상화하는 작업이 필요하다. 이를 코드로 표현하면 아래와 같다
// OCP 적용후
class Piano extends Instrument() {
public Piano(String serialNum, PianoSpec spec) {
this.serialNum = serialNum;
this.spec = spec
}
private PianoSpec spec;
}
class PianoSpec extends InstrumentSpec() {
}
class Drum extends Instrument() {
public Drum(String serialNum, DromSpec spec) {
this.serialNum = serialNum;
this.spec = spec
}
private DromSpec spec;
}
class DrumSpec extends InstrumentSpec() {
}
추가될 악기들의 공통 속성을 모두 담을 수 있는 Instrument라는 인터페이스를 생성하여 새로운 악기가 추가되면서 발생하는 부분을 추상하여 분리하였다. 즉 코드의 수정을 최소화하여 결합도는 줄이고 응집도는 높이는 효과를 볼수 있다.
인터페이스는 가능하면 변경되어서는 안된다. 따라서 인터페이스를 정의할 때 여러 경우의 수에 대한 고려와 예측이 필요하다. 물론 과도한 예측은 불필요한 작업을 만들고 보통 이 불필요한 작업의 양은 상당히 크기 마련이다. 따라서 설계자는 적절한 수준의 예측 능력이 필요한데 설계자에게 필요한 또 하나의 자질은 예질력이다. 인터페이스 설계에서 적당한 추상화 레벨을 선택해야 한다. 우리는 추상화라는 개념에 구체적이지 않은 정도의 의미로 액간 느슨한 개념을 갖고 있다. 그래서 부치(Grady Booch)에 의하만 '추상화란 다른 모든 종류의 객체로부터 식별될 수 있는 객체의 본질적인 특징'이라고 정의하고 있다. 즉 이 '행위'에 대한 본질적인 정의를 통해 인터페이스를 식별해야 한다.
3. 리스코브 치환의 원칙 (The Liskov Substitution Principle) : LSP
3-1. 원칙
● 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위타입의 인스턴스로 바꿀수 있어야 한다.
● 즉 자식 클래스는 부모클래스에서 확장된 형태로 부모클래스를 대체 할 수 있어야 한다
● 부모 클래스의 객체 대신 자식 클래스의 객체를 삽입해도 프로그램에 영향을 주면 안 된다
● 다형성에서 하위(서브)클래스는 확장에 대한 인터페이스를 준수해야 한다
● 다형성을 지원하기 위한 원칙, 인퍼테이스를 구현한 구현체를 믿고 사용하려면 이 원칙이 필요하다
LSP를 한마디로 한다면 "서브 타입은 언제나 기반 타입으로 교체 할 수 있어야 한다" 라고 할수 있다. 즉 서브타입은 언제나 기반타입과 호환 될 수 있어야 한다. 달리 말하면 서브 타입은 기반 타입이 약속한 규약(public 인터페이스, 메서드가 던지는 예외까지 포함)을 지켜야 한다. 상속은 구현상속(extends관계)이든 인터페이스 상속(implements관계) 이든 궁긍적으로 다형성을 통한 확장석 획득을 목표로 한다. LSP원리도 역시 서브 클래스가 확장에 대한 인터페이스를 준수해야 함을 의미한다.
다형성과 확장성을 극대화 하려면 하위 클래스를 상요하는 것보다는 상위의 클래스(인터페이스)를 사용하는것이 더 좋다. 일반적으로 선언은 기반 클래스로 생성은 구현체 클래스로 대입하는 방법을 사용한다. 생성 시점에서 구체 클래스를 노출시키기 꺼려질 경우 생성 부분을 Abstract Factory등의 패턴을 사용하여 유연성을 높일 수 있다. 상속을 통한 재사용은 기반 클래스와 서브 클래스 사이에 IS-A관계가 있을 경우로만 제한 되어야 한다. 그외의 경우에는 합성(composition)을 이용한 재사용을 해야 한다.
상속은 다형성과 따로 생각할수 없다. 그리고 다형성으로 인한 확장 효과를 얻기 위해서는 서브 클래스가 기반 클래스와 클라이언트 간의 규약(인터페이스)를 어겨서는 안된다. 결국 이구조는 다형성을 통한 확장의 원리인 OCP를 제공하게 된다. 따라서 LSP는 OCP를 구성하는 구조가 된다. 객체지향 설계 원리는 이렇게 서로가 서로를 이용하기도 하고 포함하기도 하는 특징이 있다. LSP는 규약을 준수하는 상속구조를 제공한다. LSP를 바탕으로 OCP는 확장하는 부분에 다형성을 제공해 변화에 열려있는 프로그램을 만들 수 있다록 한다
3-2. 적용방법
● 만약 두 개체가 똑같은 일을 한다면 둘을 하나의 클래스로 표현하고 이들을 구분할수 있는 필드를 둔다
● 똑같은 연산을 제공하지만 약간씩 다르다면 공통의 인터페이스를 만들고 이를 상속하여 이를 구현한다
● 공통된 연산이 없다면 완전 별개인 2개의 클래스를 만든다
● 만약 두 개체가 하는 일에 추가적으로 무언가를 더 한다면 구현 상속을 사용한다
3-3 적용코드 : 컬럭션 프레임워크
// LSP 적용전
void f() {
LinkedList list = new LinkedList();
//...
modify(list);
}
void modify(LinkedList list) {
list.add(...);
doSomethingWith(list);
}
List만 사용할 것이라면 위 코드도 문제가 없다. 하지만 만약 속도 개선을 위해 HashSet을 사용해야 하는 경우가 발생한다면 LinkedList를 다시 HashSet으로 어떻게 바꿀수 있을까? LinkedList와 HashSet은 모두 Collection인터페이스를 상속하고 있으므로 아래와 같이 작성 할 수 있다
//LSP 적용후
void f() {
Collection collection = new HashSet();
//...
modify(list);
}
void modify(Collection collection) {
collection.add(...);
doSomeThingWith(collection);
}
컬렉션 생성 부분만 고치면 마음대로 어떤 컬렉션 구현 클래스라도 사용할 수 있다. 이 프로그램에서 LSP와 OCP 모두를 찾아볼 수 있는데 우선 컬렉션 프레임워크 LSP를 준수하지 않았다면 Collection 인터페이스를 통해 수행하는 범용 작업이 제대로 수행 될 수 없다. 하지만 모두 LSP를 준수하기 때문에 이들을 제외한 모든 Collection 연산에서는 앞의 modify() 메서드가 잘 동작하게 된다. 그리고 이를 통해 modify()는 변화에 닫혀 있으면서 컬렉션의 변경과 확장에는 열려 있는 구조(OCP)가 된다. 물론 Collection이 지원하지 않는 연산을 사용한다면 한계가 계층 구조를 내려가야 한다. 그렇다 하더라도 ArrayList, LinkedList, Vector 대신 이들이 구현하고 있는 List를 사용하는것이 현명하다
Design by Contract("서브 클래스에서는 기반 클래스의 사전 조건과 같거나 더 약한 수준에서 사전 조건을 대체 할 수 있고, 기반 클래스의 사후 조건과 같거나 더 강한 수준에서 사후 조건을 대체 할 수 있다") 적용 : 기반 클래스를 서브 클래스로 치환 가능하게 하려면 받아들이는 선 조건에서 서브 클래스의 제약사항이 기반 클래스의 제약 사항보다 느슨하거나 같아야 한다. 만약 제약조건이 더 강하다면 기반클래스에서 실행되던 것이 서브 클래스의 강조건으로 인해 실행되지 않을 수도 있기 때문이다. 반면 서브 클래스의 후 조건은 같거나 더 강해야 하는데, 약하다면 기반 클래스의 후 조건이 통과시킺 않는 상태를 통과시킬수도 있기 때문이다.
4. 인터페이스 분리의 원칙(Interface Segregation Principle) : ISP
4-1. 원칙
● 한 클래스는 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다
● 즉 어떤 클래스가 다른 클래스에 종속될 때에는 가능한 최소한의 인터페이스만을 사용해야 한다
● 하나의 일반적인 인터페이스보다는 여러개의 구체적인 인터페이스가 낫다
● SRP가 클래스의 단일책임을 강조한다면 ISP는 인터페이스의 단일책임을 강조한다
● 다만 ISP는 어떤 클래스 혹은 인터페이스가 여러 책임 혹은 역할을 갖는 것을 인정한다
● 이런 경우 ISP가 사용되는데 SRP가 클래스 분리를 통해 변화에의 적응성을 획득하는 반면
● ISP에서는 인터페이스 분리를 통해 같은 목표에 도달한다.
4-2. 적용방법
● 클래스의 상속을 이용하여 인터페이스를 나눌 수 있다 (클래스 인터페이스를 통한 분리)
● 위임을 이용하여 인터페이스를 나눌 수 있다 (객체 인터페이스를 통한 분리)
● 기 구현된 클라이언트에 변경을 주지 말아야 한다
● 두 개 이상의 인터페이스가 공유하는 부분의 재사용을 극대화 한다
● 서로 다른 성격의 인터페이스를 명백히 분리한다
클래스의 상속을 이용하여 인터페이스를 나누는 구조는 클라이언트에게 변화를 주지 않을 뿐 아니라 인터페이스를 분리하는 효과를 갖는다. 하지만 거의 모든 객체지향 언어에서는 상속을 이용한 확장은 상속받는 클래스의 성격을 디자인 시점에 규정한다. 따라서 인터페이스를 상속받는 순간 인터페이스에 예속되어 제공하는 서비스의 성격이 제한된다.
위임을 통해 인터페이스를 나누는 구조는 특정 일의 책임을 다른 클래스나 메소드에 맡기는 것을 의미한다. 만약 다른 클래스의 기능을 사용해야 하지만 기느 기능을 변경하고 싶지 않다면, 상속 대신 위임을 사용한다.
4-3. 적용코드 : Java Swing의 JTable
import javax.swing.event.*;
import javax.swing.table.TableModel;
public class SimpleTableDemo implements TableModelListener {
public SimpleTableDemo() {
table.getMode().addTableModelListener(this);
...
}
// 인터페이스를 통해 노출할 기능을 구현한다
public void tableChange(TableModeEvent e) {
int row = e.getFirstRow();
int column = e.getColumn();
TableModel model = (TableModel)e.getSource();
String columnName = model.getColumnName(column);
Object data = model.getValueAt(row, column);
}
}
5. 의존성 역전의 원칙(Dependency Inversion Principle) : DIP
High level modules should not depend upon low lovel modeles.
Both should depend upon abstractions.
Abstractions shoud not depend upon detauls.
details should depend upon abstractions.
5-1. 원칙
● 하위레벨모듈의 변경이 상위레벨모듈의 변경을 요구하는 위갸관계를 끊는 의미의 역전을 의미한다
● 실제 사용 관계는 바뀌지 않으며 추상을 매개로 메세지를 주고 받음으로써 관계를 최대한 느슨하게 만든다
5-2. 적용방법
● 구체화에 의존하지 말고 추상화에 의존해야 한다
● 즉 구현 클래스에 의존하지 말고 인터페이스(역할)에 의존하라는 의미이다
● 객체도 클라이언트가 인터페이스에 의존해야 유연하게 구현체를 변경 할 수 있다
● 구현체에 의존하게 되면 변경이 아주 어려워진다
● 서비스 클라이언트가 구현클래스를 직접 선택(의존:안다는것)한다는것은 DIP 위반이다
5-3. 적용코드
// DIP에 어긋나는 코드
Move basicMove = new TakeBus();
Move QuickMove = new TakeSubway();
// DIP 적용한 코드
// interface에서 어떤 운송수단을 선택할지 결정하면 된다
Move basicMove = new Move();
Move QuickMove = new Move();
DI란 클라이언트가 어떤 구현체를 쓸지 정하는 게 아니라 우리가 클라이언트에게 어떤 구현체를 쓸지 말해주는 것이다. Spring에서는 객체들을 스프링 컨테이너에 스프링 빈으로 등록하고 적재적소에 의존 관계를 만들어 줌으로써 OCP, DIP를 만족하게 한다. (@Autowired, @Component 등)
'Java' 카테고리의 다른 글
Optional -1 (1) | 2022.12.26 |
---|---|
Java Stream & Function API (0) | 2022.12.21 |
Concurrency solution -1 (0) | 2022.10.22 |
추상클래스와 인터페이스 (0) | 2021.12.12 |
[Java] enum(enumeration) (0) | 2021.07.27 |