객체지향의 5대 원칙을 의미한다. 시간이 지나도 유지 보수와 확장이 쉬운 소프트웨어를 만들기 위해 이 원칙들을 적용할 수 있다.
1. SRP(Single Responsibility Principle) : 단일 책임 원칙
2. OCP(Open-Closed Principle) : 개방-폐쇄 원칙
3. LSP(Liskov Substitution Principle) : 리스코프 치환 원칙
4. DIP(Dependency Inversion Principle) : 의존 역전 원칙
5. ISP(Interface Segregation Principle) : 인터페이스 분리 원칙
1. SRP(Single Responsibility Principle) : 단일 책임 원칙
단일 클래스는 오직 하나의 일을 가져야 한다.
Ⅰ. 정의
작성된 클래스는 하나의 기능만 가지며 클래스가 제공하는 모든 서비스는 그 하나의 책임을 수행하는 데 집중되어 있어야 한다는 원칙이다. 어떤 변화에 의해 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 함을 의미한다. SRP 원칙을 적용하면 무엇보다도 책임 영역이 확실해지기 때문에 한 책임의 변경에서 다른 책임의 변경으로의 연쇄작용에서 자유로울 수 있다. 뿐만 아니라 책임을 적절히 분배함으로써 코드의 가독성 향상, 유지보수 용이라는 이점까지 누릴 수 있다.
Ⅱ. 적용 방법
1) Divergent Change(수정의 산발) : Extract Class를 통해 혼재된 각 책임을 각각의 개별 클래스로 분할하여 클래스 당 하나의 책임만을 맡도록 하는 것이다. 이 과정에서 단순히 책임만 분리하는 것이 아니라 분리된 두 클래스간의 관계의 복잡도를 줄이도록 설계해야 한다. 만약 Extract Class된 각각의 클래스들이 비슷한 책임을 중복해서 갖고 있다면 Extract Superclass를 사용할 수 있다. 각 클래스들의 유사한 요소를 부모 클래스로 정의하여 클래스에 위임하는 기법이다. 따라서, 각각의 Extract Class들의 유사한 책임들은 부모 클래스에게 위임하고 다른 책임들은 각자에게 정의할 수 있다.
2) Shotgun Surgery(기능의 산재) : 한 번에 여러 개의 클래스를 동시에 변경하는 경우를 말한다. Move Field와 Move Method를 통해 책임을 기존의 어떤 클래스로 모으거나, 이럴만한 클래스가 없다면 새로운 클래스를 만들어 해결할 수 있다. 즉, 산발적으로 여러 곳에 분포된 책임들을 한 곳에 모으면서 설계를 깨끗하게 한다. 이는 응집성(Cohesion)을 높이는 작업이다. 쉽게 말해서 하나의 수정을 위해서 여러 클래스를 건드려야 할 경우가 발생된다면, 차후 하나의 클래스만 수정하는 것이 가능하도록 만드는 것을 의미한다.
Ⅲ. 적용 사례
먼저 SRP를 지키지 않은 코드를 살펴보자.
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
32
33
34
35
36
37
38
39
40
41
42
43
44
|
//데이터를 읽는 부분과 데이터를 랜더링 해주는 부분이 혼재되어있다.
public class NoSRPClass {
public void doSomething() {
String data = readData();
renderData(data);
}
public String readData() {
return DataSource.readData();
}
private void renderData(String data) {
RenderedData renderedData = parseData(data);
renderedData.render();
}
private RenderedData parseData(String data) {
//파싱 로직
}
}
//책임이 혼재되어 있을 때 readData()에서 String이 아니라 다른 객체가 나온다 하자
public class NoSRPClass {
public void doSomething() {
String[] data = readData();
renderData(data);
}
public String[] readData() {
return DifferentDataSource.readData();
}
private void renderData(String[] data) {
RenderedData renderedData = parseData(data);
renderedData.render();
}
private RenderedData parseData(String[] data) {
//파싱 로직
}
}
//책임이 혼재되어 있기 때문에 데이터를 읽는 부분이 String에서 String[] 바뀌자 데이터를 Render하는 부분도 바뀌었다.
//즉, " 클래스를 변경하는 이유는 단 하나여야한다"를 위반한다. 데이터 읽기 로직이 바뀌니 데이터 렌더링 로직이 바뀌었다, SRP를 어겼다
|
cs |
이를 SRP를 지킨 코드로 변경하면
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
public class SRPDataReader {
public Data readData() {
return new Data(DataSource.readData());
}
}
public class SRPDataRenderer {
public void render(Data data) {
//랜더링 로직
data.render();
}
}
public class SRPClient {
public void doSomething() {
Data data = srpDataReader.readData();
srpDataRenderer.render(data);
}
}
//코드를 이렇게 짠다면 SRPDataReader코드가 바뀌어도 SRPDataRenderer코드는 바뀌는 않는다
//즉, SRP를 지킨것이다 (가능했던 이유 return 값 추상화, 객체나누기!)
|
cs |
SRP를 지키지 않은 코드에서는 데이터를 읽는 로직과 데이터를 보여주는 로직이 함께 있다 보니 변경이유가 여러개가 되고 어느 하나에 변경이 일어났을 때 다른 코드에도 영향을 주게 된다.
2. OCP(Open-Closed Principle) : 개방-폐쇄 원칙
기존의 코드를 변경하지 않고(Closed) 기능을 수정하거나 추가할 수 있도록(Open) 설계해야 한다.
Ⅰ. 정의
소프트웨어의 구성요소(컴포넌트, 클래스, 모듈, 함수)는 확장에는 열려있고, 변경에는 닫혀있어야 한다는 원리이다. 이것은 변경을 위한 비용은 가능한 줄이고 확장을 위한 비용은 가능한 극대화 해야 한다는 의미로, 요구사항의 변경이나 추가사항이 발생하더라도, 기존 구성요소는 수정이 일어나지 말아야 하며, 기존 구성요소를 쉽게 확장해서 재사용할 수 있어야 한다는 뜻이다.
OCP는 관리가능하고 재사용 가능한 코드를 만드는 기반이며, OCP를 가능케 하는 중요 메커니즘은 추상화와 다형성이다. OCP는 객체지향의 장점을 극대화하는 아주 중요한 원리이다.
Ⅱ. 적용 방법
1) 변경(확장)될 것과 변하지 않을 것을 엄격히 구분한다.
2) 이 두 모듈이 만나는 지점에 인터페이스를 정의한다.
3) 구현에 의존하기보다 정의한 인터페이스에 의존하도록 코드를 작성한다.
Ⅲ. 적용 사례
1
2
3
4
5
6
7
8
9
10
11
12
|
class SoundPlayer{
void play(){
System.out.print("play wav"); // wav 재생
}
}
public class Client{
public static void main(String[] args){
SoundPlayer sp = new SoundPlayer();
sp.play();
}
}
|
cs |
SoundPlayer 클래스는 음악을 재생해주는 클래스이다. 이 클래스는 기본적으로 wav 파일을 재생할 수 있다. 그러나 wav가 아닌 mp3 파일을 재생하도록 요구사항이 변경되었다. 이를 만족시키기 위해서는 SoundPlayer의 play() 메소드를 수정하여야 한다. 하지만, 이러한 소스코드 변경은 OCP 원칙에 위배(기존 코드의 변경)된다.
OCP 원칙을 만족시키기 위해서 인터페이스를 이용해보자. 먼저 변해야 하는 것이 무엇인지 정의한다. 위 클래스에서는 play() 메소드가 변경 되어야 한다. 따라서 play() 메소드를 인터페이스로 분리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
interface playAlgorithm {
public void play();
}
class Wav implements playAlgorithm {
@Override
public void play(){
System.out.println("Play Wav");
}
}
class Mp3 implements playAlgorithm {
@Override
public void play(){
System.out.println("Play Mp3");
}
}
|
cs |
일단 재생하고자 하는 파일 클래스(Wav, Mp3)를 만들어 playAlgorithm 인터페이스의 play() 메소드를 정의하도록 설계한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
class SoundPlayer{
private playAlgorithm file;
public void setFile(playAlgorithm file){
this.file = file;
}
public void play(){
file.play();
}
}
public class Client{
public static void main(String[] args){
SoundPlayer sp = new SoundPlayer();
sp.setFile(new Wav()); // 원하는 재생 파일 선택
sp.setFile(new Mp3());
sp.play();
}
}
|
cs |
SoundPlayer 클래스에서는 playAlgorithm 인터페이스를 멤버 변수로 만든다. 그 후 SoundPlayer의 play() 함수는 인터페이스르 상속받아 구현된 클래스의 play() 함수를 실행시키게 한다. 마지막으로 메인함수에서 setter를 이용하여 우리가 플레이하고자 하는 파일의 객체를 지정해주면 된다.
이와 같은 설계를 디자인 패턴에서는 Strategy pattern(전략 패턴)이라고 한다.
3. LSP(Liskov Substitution Principle) : 리스코프 치환 원칙
자식클래스는 부모클래스에서 가능한 행위를 수행할 수 있어야 한다.
Ⅰ. 정의
부모 클래스와 자식 클래스 사이의 행위에는 일관성이 있어야 한다는 원칙이며, 이는 객체 지향 프로그래밍에서 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용해도 문제가 없어야 한다는 것(동일한 동작을 보장해야 한다)을 의미한다.
상속 관계에서는 일반화 관계(IS - A)가 성립해야 한다. 일반화 관계에 있다는 것은 일관성이 있다는 것을 의미한다. 따라서 리스코프 치환 원칙은 일반화 관계에 대해 묻는 것이라 할 수 있다.
Ⅱ 예제
정사각형/직사각형 예제를 통해 알아보자.
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
32
33
34
35
36
37
38
|
class Rectangle {
private int width = -1;
private int height = -1;
public int getWidth(){
return this.width;
}
public void setWidth(int width){
this.width = width;
}
public int getHeight(){
return this.height;
}
public void setHeight(int height){
this.height = height;
}
public int getArea(){
return this.width * this.height;
}
}
class Square extedns Rectangle {
public void setWidth(int width) {
this.width = width;
this.height = width;
}
public void setHeight(int height){
this.width = height;
this.height = height;
}
}
|
cs |
Rectangle이라는 클래스와 Rectangle을 상속하는 Square라는 클래스가 있다. Rectangle 클래스는 너비와 높이를 가지며, getter와 setter를 이용해 둘을 조정하고, 넓이를 구하는 getter 하나가 있다. Square 클래스는 너비와 높이의 getter, setter를 재정의한다.
이 코드를 실제로 사용하면 어떻게 될까?
1
2
3
4
5
6
7
8
9
10
11
12
|
Rectangle rec1 = new Rectangle();
rec1.width = 3;
rec1.height = 4;
System.out.println(rec1.getArea() == 12); // true
Rectangle rec2 = new Square();
rec2.width = 3;
rec2.height = 4;
System.out.println(rec2.getArea() == 12); // false
|
cs |
Rectangle을 사용하면 true, Square를 사용하면 false가 나오게 된다. 이는 리스코프치환 원칙을 위배한 사례가 된다.
이를 어떻게 해결할까?
논리상으로 "정사각형은 직사각형이지만, 직사각형은 정사각형이 아니다." 라고 생각해서 코드에 상속 관계를 반영할 수 있지만, SOLID 법칙의 기준으로 보면 둘은 코드에서 상속 관계로 존재할 수 없다는 것을 위 코드를 통해 알아보았다.
정사각형-직사각형 관계보다 더 포괄적인, 도형을 상속하도록 변경하고 코드를 리팩토링 해보자.
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
|
public interface Shape {
abstract int getArea();
}
public class Rectangle implements Shape {
// 생략
@Override
public int getArea() {
return this.height * this.width;
}
}
public class Square implements Shape {
// 생략
@Override
public int getArea() {
return this.width * this.width;
}
}
|
cs |
Rectangle과 Square의 상속 관계가 없어졌기 때문에 리스코프 치환 원칙을 위반하지 않는 코드가 되었다.
1
2
3
4
5
|
Shape rec = new Rectangle(3, 4);
System.out.println(rec.getArea()); // 12
Shape sq = new Square(4);
System.out.println(sq.getArea()); // 16
|
cs |
※ 참고 : https://medium.com/humanscape-tech/solid-%EB%B2%95%EC%B9%99-%E4%B8%AD-lid-fb9b89e383ef
4. DIP(Dependency Inversion Principle) : 의존 역전 원칙
의존 관계를 맺을 때, 변화하기 쉬운것 보단 변화하기 어려운 것에 의존해야 한다는 원칙이다.
고수준 모듈은 저수준 모듈의 구현에 의존해서는 안된다. 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다.
Ⅰ. 정의
여기서 말하는 변화하기 쉬운것이란 구체적인 것을 말하고, 변화하기 어려운 것이란 추상적인 것을 말한다. 객체지향적인 관점에서 보자면 변화하기 쉬운것이란 구체화 된 클래스를 의미하고, 변화하기 어려운 것은 추상클래스나 인터페이스를 의미한다. 따라서, DIP를 만족한다는 것은 의존관계를 맺을 때, 구체적인 클래스보다 인터페이스나 추상 클래스와 관계를 맺는다는 것을 의미한다.
조금 더 풀어서 설명하자면
* 변하기 어려운 것: 정책, 전략과 같은 어떤 큰 흐름이나 개념 같은 추상적인 것
* 변하기 쉬운 것: 구체적인 방식, 사물 등과 같은 것
Ⅱ. 예제
아이가 장난감을 가지고 놀 때 어떤 경우에는 로봇을, 어떤 경우에는 인형을 가지고 놀 것이다.
이 때, 구체적인 장난감은 변하기 쉬운 것이고, 아이가 장난감을 가지고 노는 사실은 변하기 어려운 것이다.
DIP를 만족하려면 어떤 클래스가 도움을 받을 때 구체적인 클래스보다는 인터페이스나 추상 클래스와 의존 관계를 맺도록 설계해야 한다. DIP를 만족하는 설계는 변화에 유연한 시스템이 된다.
[인터페이스나 추상 클래스와 의존 관계를 맺도록 설계]
* 인터페이스 = 변하지 않는 것
* 구체 클래스 = 변하기 쉬운 것
1
2
3
4
5
6
7
8
9
10
11
|
public class Kid {
private Toy toy;
public void setToy(Toy toy){
this.toy = toy;
}
public void play(){
System.out.println(toy.toString());
}
}
|
cs |
Kid 클래스에서 setToy 메소드로 아이가 가지고 노는 장난감을 바꿀 수 있다.
만약 로봇 장난감을 가지고 놀고 싶다면 다음 코드가 그 일을 해줄 것이다.
1
2
3
4
5
|
public class Robot extends Toy{
public String toString(){
return "Robot";
}
}
|
cs |
1
2
3
4
5
6
7
|
public class Main{
public static void main(String[] args){
Kid k = new Kid();
k.setToy(new Robot());
k.play();
}
}
|
cs |
레고를 가지고 놀고 싶어진다면 다음 코드면 된다.
Kid, Toy, Robot 등 기존의 코드에 전혀 영향을 받지 않고도 장난감을 바꿀 수 있다.
1
2
3
4
5
|
public class Lego extends Toy{
public String toString(){
return "Lego";
}
}
|
cs |
1
2
3
4
5
6
7
8
|
public class Main{
public static void main(String[] args){
Kid k = new Kid();
k.setToy(new Lego());
k.play();
}
}
|
cs |
만약 Kid 클래스가 다음과 같이 Robot 클래스와 연관 관계를 맺는다면 어떤 일이 발생할까?
1
2
3
4
5
6
7
8
9
10
11
|
public class Kid{
private Robot toy;
public void setToy(Robot toy){
this.toy = toy;
}
public void play(){
System.out.println(toy.toString());
}
}
|
cs |
1
2
3
4
5
6
7
|
public class Main{
public static void main(String[] args){
Kid k = new Kid();
k.setToy(new Robot());
k.play();
}
}
|
cs |
이 경우, 레고로 장난감의 종류를 변경하려면 기존의 Kid 클래스를 다음처럼 바꿔야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
|
public class Kid{
private Lego toy;
// 아이가 가지고 노는 장난감의 종류만큼 메서드가 존재해야 한다.
public void setToy(Lego toy){
this.toy = toy;
}
public void play(){
System.out.println(toy.toString());
}
}
|
cs |
장난감을 바꿀 때마다 코드를 계속 바꿔야 한다. 즉, DIP의 위반이 OCP의 위반을 초래한다.
※ 참고 : https://defacto-standard.tistory.com/113
5. ISP(Interface Segregation Principle) : 인터페이스 분리 원칙
한 클래스는 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다. 하나의 일반적인 인터페이스 보다는, 여러 개의 구체적인 인터페이스가 낫다.
인터페이스 분리 원칙이란 클라이언트가 사용하지 않는 메소드에 의존하지 않아야 하다는 것을 의미한다.
Ⅰ. 정의
이는 다시 말해서, 자신이 사용하지 않는 기능(인터페이스)에는 영향을 받지 말아야 한다는 의미이다. 자신과 관련없는 메서드들은 구현하지 말아야 한다ㅏ.
Ⅱ. 예제
노트북(맥북프로)를 사용한 예제로 알아보자.
1
2
3
4
5
6
7
|
public interface MacPro{
void display();
void keyboard();
void touch();
}
|
cs |
맥북프로 2016년형에는 디스플레이, 키보드, 터치바등이 있다. 이를 통해 MacbookPro 2016년형을 구현해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public class MacPro2016 implements MacPro{
@Override
public void display(){
System.out.println("mac pro2016 display");
}
@Override
public void keyboard(){
System.out.println("mac pro2016 keyboard");
}
@Override
public void touch(){
System.out.println("mac pro2016 touch");
}
}
|
cs |
이번에는 맥북프로 2015년형을 구현해 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public class MacPro2015 implements MacPro{
@Override
public void display(){
System.out.println("mac pro2016 display");
}
@Override
public void keyboard(){
System.out.println("mac pro2016 keyboard");
}
@Override
public void touch(){
// ??
}
}
|
cs |
2015년형 맥북프로에는 터치바가 존재하지 않는다. 그래서 touch 함수는 구현되지 않았다.
이는 클라이언트가 사용하지 않는 메소드에 의존하지 않아야 한다는 것을 의미한다. 이를 인터페이스 분리 원칙에 따라서 수정해 보자.
1
2
3
4
5
6
|
public interface MacPro{
void display();
void keyboard();
}
|
cs |
기본적으로 모든 맥북프로에는 display와 keyboard가 존재한다. 그렇기 때문에 MacPro 인터페이스에는 display와 keyboard만 정의해주자.
1
2
3
4
|
public interface MacProTouch{
void touch();
}
|
cs |
그 후, MacProTouch라는 인터페이스를 만들어서 touch라는 메소드를 정의하자. 이제 맥북프로 클래스를 구현해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
public class MacPro2016 implements MacPro, MacProTouch{
@Override
public void display(){
System.out.println("mac pro2016 display");
}
@Override
public void keyboard(){
System.out.println("mac pro2016 keyboard");
}
@Override
public void touch(){
System.out.println("mac pro2016 touch");
}
}
|
cs |
2016년형 맥북프로는 기본이 되는 MacPro와 추가된 MacProTouch 인터페이스를 구현하면 된다. 이번에는 2015년형 맥북프로를 구현해보자.
1
2
3
4
5
6
7
8
9
10
11
12
|
public class MacPro2015 implements MacPro{
@Override
public void display(){
System.out.println("mac pro2016 display");
}
@Override
public void keyboard(){
System.out.println("mac pro2016 keyboard");
}
}
|
cs |
2015년형 맥북프로에는 터치바가 없으므로 MacPro만 구현해주면 된다.
※ 참고 : http://wonwoo.ml/index.php/post/1675
※ 전체 참고 : https://dev-momo.tistory.com/entry/SOLID-%EC%9B%90%EC%B9%99
댓글