좋은 객체지향 설계를 위해서 다음의 5가지 원칙을 따르는 것이 좋고, 이 원칙들을 기반으로 디자인 패턴이 되기 때문에 알아두면 확실하게 도움이 된다.
객체지향의 5대 원칙의 앞글자를 따서 SOLID라고 부르기도 한다.

1. SRP

  • SRP(Single Responsibility Principle)는 단일 책임 원칙이라고 한다.
  • 모든 클래스는 단 하나의 책임을 갖는다. 다시 말해서 클래스를 변경할 이유는 오직 하나여야 한다는 뜻이다.
  • 책임 영역이 확실해지기 때문에 한 책임의 변경에서 다른 책임의 변경으로의 연쇄 작용에서 자유로울 수 있다.
  • 책임을 적절히 분배함으로써 코드의 가독성 향상, 유지보수 용이의 이점이 생긴다.
  • 간단한 예를 들면, 계산기 클래스가 있을 때, 계산을 하는 책임과 GUI를 나타내는 책임은 서로 분리되어야 한다. 계산기 클래스에 GUI를 나타내는 부분까지 있을 경우, 이는 SRP를 위반한다.

Before Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 현재 UserSettingService 클래스에는 두 개의 책임이 있다.
// 1. 변경
// 2. 접근 권한에 대한 부분
public class UserSettingService{

public void changeEmail(User user){
if(checkAccess(user)){
// do someting
}
}

public boolean checkAccess(User user){
// user check
}
}

After Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 현재 UserSettingService 클래스의 두 개의 책임을 둘로 나눈다.
// 1. 변경(UserSettingService.class)
// 2. 접근 권한에 대한 부분(SecurityService.class)
public class UserSettingService{

public void changeEmail(User user){
if(SecurityService.checkAccess(user)){
// do something.
}
}
}

public class SecurityService{

public static boolean checkAccess(User user){
// Check user access.
}
}

2. OCP

  • OCP(Open-Closed Principle)는 개방 폐쇄 원칙이라고 한다.
  • 기능을 확장하거나 변경하는 것에 대해서는 개방되어야 하지만, 수정에 대해서는 폐쇄되어야 한다.
  • 변경을 위한 비용은 가능한 줄이고 확장을 위한 비용은 가능한 극대화 해야 한다는 의미이다.
  • 요구사항의 변경이나 추가사항이 발생하더라도 기존 구성요소는 수정이 일어나지 말아야 하며, 기존 구성요소를 쉽게 확장해서 재사용할 수 있어야 한다는 뜻이다.
  • 중요 매커니즘은 추상화와 다형성이다.
  • 예를 들자면, 캐릭터를 하나 생성한다고 가정하자. 각각의 캐릭터가 움직임이 다를 경우 움직임의 패턴 구현을 하위 클래스에 맡긴다면 캐릭터 클래스의 수정은 필요없고(수정에 대해 폐쇄) 움직임의 패턴만 재정의하면 된다.(확장에 대한 개방)

OCP 원칙이 깨질 때의 주요 현상

  1. 다운 캐스팅을 한다.
1
2
3
4
5
6
7
8
public void drawCharacter(Character character){
if(character instanceof Missile){ // 타입 확인.
Missile missile = (Missile) character; // 다운 캐스팅.
missile.drawSpecific(); // 미사일일 경우 drawSpecific() 호출.
}else{
character.draw(); // 미사일 외의 경우는 draw() 호출.
}
}
  1. 비슷한 if-else 블록이 존재한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Enemy extends Character{
private int pathPattern;

public Enemy(int pathPattern){
this.pathPattern = pathPattern;
}

public void draw(){
if(pathPattern == 1){
x +=5;
y +=5;
}else if(pathPattern == 2){
x +=10;
y +=10;
}else if(pathPattern == 3){
x +=15;
y +=15;
}else{
x +=20;
y +=20;
}
}
}
  • OCP는 자주 사용되는 문법이 인터페이스라고 보면 된다.

Before Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 음악을 재생하는 클래스.
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();
}
}

SoundPlayer 클래스는 기본적으로 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
interface playFile(){
public void play();
}

class Wav implements playFile(){
@Override
public void play(){
System.out.print("Play wav");
}
}

class Mp3 implements playFile(){
@Override
public void play(){
System.out.print("Play Mp3");
}
}

재생하고자 하는 파일 클래스(Wav, Mp3)를 만들어 playFile 인터페이스와 play() 메소드를 재정의하도록 설계한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class SoundPlayer{
private playFile file;

public void setFile(playFile file){
this.file = file;
}

public void play(){
this.play();
}
}

public class Client{

public static void main(String[] args){
SoundPlayer sp = new SoundPlayer();
sp.setFile(new Wav()); // 원하는 재생 파일 선택.
sp.serFile(new Mp3());
sp.play();
}
}

SoundPlayer 클래스에서는 playFile 인터페이스를 멤버 변수로 만든다. 그 후 SoundPlayer의 play() 함수는 인터페이스를 상속받아 구현된 클래스의 play() 함수를 실행시키게 한다. main() 함수에서 setFile() 함수를 이용해 우리가 재생하고자 하는 파일의 객체를 지정해준다.

이와 같은 설계를 디자인 패턴에서는 Strategy Pattern(전략 패턴)이라고 한다. 디자인 패턴은 추후에 공부할 필요가 있을 것 같다.

아무튼 결과적으로 SoundPlayer 클래스의 변경 없이 재생되는 파일을 바꿀 수 있으므로 위 코드는 OCP 원칙을 만족하게 된다. OCP 원칙을 적용한 설계는 변경에 유연하므로 유지보수 비용을 줄여주고 코드의 가독성 또한 높아지는 효과를 얻을 수 있다.

3. LSP

  • LSP(Liskov Substitution Priciple)는 리스코프 치환 원칙이라고 한다.
  • 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.
  • 쉽게 말하면, 자식 클래스는 언제나 자신의 부모 클래스를 교체할 수 있다는 원칙이다. 즉, 부모 클래스와 자식 클래스 사이의 행위에는 일관성이 있어야 한다는 원칙이다.
  • 상속 관계에서는 일반화 관계(IS-A)가 성립해야 한다. 일반화 관계에 있다는 것은 일관성이 있다는 것이다. 따라서 리스코프 치환 원칙은 일반환 관계에 대해 묻는 것이라 할 수 있다.

이해를 돕기 위해 도형을 예로 하는 설명이 있다.
도형 클래스와 사각형 클래스가 있고, 사각형 클래스는 도형 클래스를 상속한다.

(1). 도형은 둘레를 가지고 있다.
(2). 도형은 넓이를 가지고 있다.
(3). 도형은 각을 가지고 있다.

일반화 관계(일관성인지 확인하는 방법은 단어를 교체해보면 알 수 있다. 도형 대신 사각형을 넣어보자.)

(1). 사각형은 둘레를 가지고 있다.
(2). 사각형은 넓이를 가지고 있다.
(3). 사각형은 각을 가지고 있다.

이상한 부분이 보이지 않는다. 따라서 도형과 사각형 사이에는 일관성이 있다고 할 수 있다.

그럼 원이라는 도형에 대해서 생각해보자. 원 클래스 역시 도형 클래스의 상속을 받는다고 가정하고 (1) ~ (3)의 도형 단어 대신 원을 대입해보자.

(1). 원은 둘레를 가지고 있다.
(2). 원은 넓이를 가지고 있다.
(3). 원은 각을 가지고 있다.

원의 경우에는 (3)번 문장이 어색하다는 것을 알 수 있다. 따라서 도형 클래스는 LSP 원칙을 만족하지 않는 설계라고 할 수 있다. (3) 문장에 대해서 일반화 관계가 성립하도록 수정되어야 한다.

4. ISP

  • ISP(Interface Segregation Priciple)는 인터페이스 분리 원칙이라고 한다.
  • 인터페이스는 그 인터페이스를 사용하는 클라이언트를 기준으로 분리해야 한다.
  • 즉, 자신이 사용하지 않는 기능(인터페이스)에는 영향을 받지 말아야 한다는 의미이다.
  • 클라이언트는 자신이 사용하지 않는 메소드에 의존하지 않아야 한다. 한 클래스는 자신이 사용하지 않는 인터페이스는 구현하지 않는 편이 낫다. 하나의 일반적인 인터페이스보다는 차라리 여러 개의 구체적인 인터페이스가 낫다는 개념을 갖는다.
  • 한 가지 예를 들어보자. 우리는 스마트폰으로 전화, 웹서핑, 사진 촬영 등 다양한 기능을 사용할 수 있다. 그런데 전화를 할 때에는 웹 서핑, 사진 촬영 등 다른 기능은 잘 사용하지 않는다. 따라서 전화 기능과 웹 서핑 기능, 사진 촬영 기능은 각각 독립된 인터페이스로 구현하여, 서로에게 영향을 받지 않도록 설계해야 한다. 이렇게 설계된 소프트웨어는 ISP를 통해 시스템 내부의 의존성을 약화시켜 리팩토링, 수정, 재배포를 쉽게 할 수 있다.

Before Code

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
45
46
47
48
49
50
51
// 하나의 인터페이스를 모든 클라이언트가 구현하고 있다.
public interface ArticleService{
void list();
void write();
void delete();
}

public class UiList implements ArticleService{
@Override
public void list(){

}
@Override
public void write(){

}
@Override
public void delete(){

}
}

public class UiWist implements ArticleService{
@Override
public void list(){

}
@Override
public void write(){

}
@Override
public void delete(){

}
}

public class UiDist implements ArticleService{
@Override
public void list(){

}
@Override
public void write(){

}
@Override
public void delete(){

}
}

After Code

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
// 각각의 클라이언트별로 interface를 구분한다.
public interface ArticleListService{
void list();
}

public interface ArticleWriteService{
void write();
}

public interface ArticleDeleteService{
void delete();
}

public class UiList implements ArticleListService{
@Override
public void list(){

}
}

public class UiWist implements ArticleWriteService{
@Override
public void write(){

}
}

public class UiDist implements ArticleDeleteService{
@Override
public void delete(){

}
}

5.DIP

  • DIP(Dependency Inversion Principle)는 의존성 역전 원칙이라고 한다.
  • 고수준 모듈은 저수준 모듈의 구현에 의존해서는 안된다. 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다.
  • 고수준 모듈 : 어떤 의미있는 단일 기능을 제공하는 모듈
    • 바이트 데이터를 읽어와 암호화하고 결과 바이트 데이터를 쓴다.
  • 저수준 모듈 : 고수준 모듈의 기능을 구현하기 위해 필요한 개별 기능 = 좀 더 작은 모듈
    • 파일에서 바이트 데이터를 읽어온다.
    • AES 알고리즘으로 암호화한다.
    • 파일에 바이트 데이터를 쓴다.
  • 중간에 interface와 같은 추상화를 통해서 고수준 모듈과 저수준 모듈이 모두 추상 타입에 의존하게 만든다. 추상을 매개로 메시지를 주고 받음으로써 관계를 최대한 느슨하게 만드는 원칙이다.

설명하는 말들이 어렵다. 모듈도 나오고 이런 추상 타입도 나온다… 쉽게 설명하면 DIP를 만족한다는 것은 의존 관계를 맺을 때, 구체적인 클래스보다는 인터페이스나 추상 클래스와 관계를 맺는다는 것을 의미한다.

이와 같은 말은 변화하기 어려운 것, 변화가 거의 없는 것에 의존하라고 한다. 의미는 변화하기 어려운 부분들을 추상화하여 인터페이스나 추상 클래스로 참조함으로써 DIP를 지킬 수 있다는 뜻이다.

예를 들어 핸드폰의 경우 전화를 하거나 문자를 보내거나 앱을 실행하는 것 자체는 변하기 어렵지만 브랜드의 가격, 모델 명등은 변하기 쉽다. 따라서 변하기 어려운 것은 추상화하여 인터페이스나 추상 클래스로 만들어 참조하면 DIP를 만족하게 될 수 있다.

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
public class Person{
private String name;
private int age;
private Phone phone;

// getter/setter 생략.
}

// Phone은 추상 클래스.
public abstract class Phone{
private String phoneNumber;

public String getPhoneNumber(){
return phoneNumber;
}

public void setPhoneNumber(String phoneNumber){
this.phoneNumber = phoneNumber;
}

public abstract void call(String phoneNumber);
public abstract void turnOn();
public abstract void turnOff();
}

// 아래와 같이 상속받아 구현한다.
public class GalaxyS7 extends Phone{
@Override
public void call(String phoneNumber){
System.out.println("Call to "+phoneNumber);
}

@Override
public void turnOn(){
System.out.println("Turn on GalaxyS7");
}

@Override
public void turnOff(){
System.out.println("Turn off GalaxyS7");
}
}

만약 갤럭시 핸드폰이 아니라 LG의 G 시리즈나 아이폰 등도 Phone 클래스를 상속받아서 구현하여 Person의 인스턴스 객체의 속성으로 설정함으로서 의존성을 역전시켜 DIP 원칙을 만족할 수 있다.

객체지향 설계 5대 원칙을 공부해봤는데 DIP는 아직 이해가 잘 가지 않는 것 같다. 의존성은 결합도를 낮추는게 목표라고 생각한다. 그래서 느슨한 결합을 만들고 그 과정에서 인터페이스나 추상 클래스를 사용하는 것으로 이해가 된다.

이 부분은 아직 이해가 더 필요한 부분이니 차근 차근 공부해서 보충해나가자.

참고