본문 바로가기

Design pattern

[JAVA 디자인패턴] SOLID 원칙 - 객체지향프로그래밍(OOP) 설계

각 원칙을 설명하기 위해 문제 상황을 제시하고 어떻게 해결해야하는 지에 대해서 중점적으로 이야기해보도록 합시다. SOLID에 대한 기원등은 위키백과를 참고하시기 바랍니다.

 

SRP(단일 책임 원칙, Single Responsibility Principle)

 

객체가 단 하나의 책임만을 가지도록 설계해야 한다.

 

책임이라는 것은, 이 객체가 해야할 일을 의미한다. 즉 어느 객체보다도 가장 잘 할 수 있는 일이 1개여야만 한다.

일이 1개여야하는 것은 메소드가 1개라는 의미가 아니다. 논리적으로 하는 일을 의미한다.

우리가 설계 원칙을 배우는 이유는, 예상치 못한 변경사항이 발생하더라도, 다른 객체들이 크게 변화하지 않고 확장하기 위함이다. 그래서 SRP의 단일 책임 원칙이 중요하다.

이 객체가 SRP의 원칙에 맞게 설계되었는지 확인하려면, 변경될 수 있는 이유가 1개면 된다

 

1
2
3
4
5
6
public void calculatePay(){
    int amount = 0;
    amount = 10000*this.getWorkHours()+15000*this.getOverTimeHours();
    
    System.out.println(amount);
}
http://colorscripter.com/info#e" target="_blank" style="color:#e5e5e5text-decoration:none">Colored by Color Scripter

 

위의 메소드에 변경될 만한 요소가 있는가? 있다. 그런데 2가지가 있어서 SRP의 원칙에 맞지 않는다.

1. amount의 계산 방법이 다를 수 있다. 초과근무 시급이 20000원일 수 있다.

2. Console에 amount를 보여주는 게 아니라, XML이나 다른 형태로 출력 할 수 있다.

 

변경될 요소가 1개라면 1개의 책임 즉, 1개의 역할을 담당하는 것이라서 문제가 없다. 하지만, 여러 개의 변경될 요소가 있다면(즉, 책임이 여러 개) SRP를 만족하지 않는다.

해당 코드에는 2개의 역할이 있다. 계산을 수행하는 로직과 출력 형태가 같이 있다. 즉, 비즈니스 로직과 View가 구분되어 있지 않다. 임금계산 로직의 변화와 interface의 변화를 한 객체가 처리하면 안된다. 분리시켜야 한다.

그렇다면 어떻게 분리시킬 수 있을까? 콘솔에 출력하는 부분을 특정 인터페이스로 만들고, 멤버변수로 추가 하면 된다.

 

1
2
3
4
5
6
public void calculatePay(){
    int amount = 0;
    amount = 10000*this.getWorkHours()+15000*this.getOverTimeHours();
    
    this.printByConsole.print(amount);
}
http://colorscripter.com/info#e" target="_blank" style="color:#e5e5e5text-decoration:none">Colored by Color Scripter

 

다음과 같이 구현하면, SRP를 만족한다.

직접적으로 calculatePay에서 print를 하는 게 아니라, 해당 역할을 담당하는 객체에서 print를 수행한다.

하지만, 위의 설계는 다른 설계원칙의 관점으로 보면 잘못된 설계이다. OCP 설계원칙을 만족하지 않는다.

 

 

OCP(개방 폐쇄 원칙, Open Closed Principle)

 

새로운 기능을 추가할 때, 기존의 코드가 변하지 않도록 설계해야 한다.

 

위의 클래스 다이어 그램을 다시 보자.

 

 

이와같은 설계의 문제는 무엇인가?

크게 2가지가 있다. 첫 번째는 임금계산 방식이 추가되어 Employee가 해당 임금 방식을 도입하려면 코드를 수정해야한다. 두 번째는 출력의 방향이 추가되어 해당 방향으로 바꿀려 할 때, 코드를 수정해야 한다.

 

1
2
3
4
5
6
7
public void calculatePay(){
    int amount = 0;
    amount = 10000*this.getWorkHours()+15000*this.getOverTimeHours();
    //계산의 방식이 바꿀려면 코드 수정해야함.
    
    this.printByConsole.print(amount); //XML으로 바꿀려면 코드 수정해야함
}
http://colorscripter.com/info#e" target="_blank" style="color:#e5e5e5text-decoration:none">Colored by Color Scripter

 

OCP를 만족시키려면, 기능이 추가될 때마다, 기존의 코드가 바뀌면 안된다. 그렇다면, 어떻게 설계하면 될까? 스트레티지 패턴을 이용하면 OCP를 만족시킬 수 있다.

 

계산하는 방식이 추가될 때마다, CalculateStrategy를 재구현하는 클래스를 생성하면 된다. 그리고 constructor/setter를 이용해서 의존성 주입을 해주면 된다. PrintStrategy도 마찬가지이다.

SRP에서 역할을 한 개로 나눴다. 그런데 요구사항은 무조건 변하게 되어있고, 기능이 변화하게 되어있다. 그러면 기능이 변화/추가될 때마다 OCP를 지키기 위해서는 strategy 패턴으로 오게끔 되어있다. 스트레티지 패턴은 너무 당연하게 쓰일 수 밖에 없는 설계패턴이다.

다시 말하지만, 클래스는 무조건 변화한다. 그러면 무엇이 변할지 잘 지켜본 다음, 변화에 대응하도록 클래스를 수정하면 된다.

SRP가 변경될만한 요소(책임)이 1개인지 확인해주는 원칙이라고 하면, OCP는 그 책임이 변할때마다 다른 코드와 독립적으로 변화할 수 있도록 도와주는 원칙이라고 생각하면 된다.

 

 

일반화, 캡슐화

 

나머지 설계원칙에 대해 알아보기 전에, 일반화와 캡슐화에 대해서 자세히 알아보고 가자.

 

캡슐화란, 객체 외부가 접근을 못하고, 외부로 부터 영향을 줄이기 위한 방법이다.

 

캡슐화는 외부의 접근을 방지하는 객체지향의 특성이다.

 

만약, 스택을 구현한다고 하자. 스택은 LinkedList 기반이나, Array기반으로 만들어 질 수 있다. 그런데, 스택을 사용할 때 사용자는 어떤 자료구조를 썼는 지 알 수 없다. 처음에 LinkedList 기반으로 스택을 만들었다가, Array기반으로 바뀌었다고 해도, 사용자는 그걸 알 수 없을 뿐만 아니라, 알 필요도 없다. 내부 자료형의 변화를 몰라도 된다. 사용자는 단순히 push, pop연산을 하면 된다. 배열에서 연결리스트로 바뀌어도 push/pop은 동일하다. 멤버 변수의 입장에서 본 캡슐화는 아니지만, 무언가 은닉하고 외부의 영향을 줄인다는 의미에서 이것도 캡슐화라고 볼 수 있다.

 

이제 일반화에 대해 알아보자.

 

일반화는 is a kind of관계를 가지고 있다. Fruit은 pineapple와 일반화 관계가 있는 지 알아보려면 이 관계를 이용하면 된다. pineapple is a kind of Fruit은 말이 된다. 이 문장이 부자연스럽다면, 일반화 관계가 아닌 것이다. 문장이 자연스러워야 일반화 관계를 가지는 것이다.

 

일반화의 개념으로 프로그래밍을 주로 한다. 다음 코드와 클래스 다이어그램을 통해서 일반화가 중요한 이유를 더 알아보자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Main{
    public static void main(String[] args){
        LinkedList<Fruit> fruits = new LinkedList<Fruit>();
        ///.... fruits의 자식클래스들을 add한다.
        
        Iterator<Fruit> iter = fruits.iterator();
    
           int total = 0;
        //만약 일반화 개념을 안쓴다면, switch-case를 이용해야 한다.
        while(iter.hasNext()){
            Fruit f = iter.next();
            total += f.getPrice(); //!!!
        }
    }
}
http://colorscripter.com/info#e" target="_blank" style="color:#e5e5e5text-decoration:none">Colored by Color Scripter

 

만약 일반화라는 개념을 사용하지 않는다면, 다시말해, 과일이라는 개념을 이용하지 않고 프로그래밍 한다고 하자. 그러면, pineapple, banana, melon에 대한 모든 경우의 수에 대해서 if문 또는 switch-case 문 처리를 해줘야한다. 하나하나 하드코딩 해줘야한다. 그래서 일반화의 개념은 필수이다.

위의 코드를 보면, 과일이 수많은 자식 클래스를 가진다고 해도, 외부에서는 과일(슈퍼클래스)이라는 개념으로 코딩을 한다. 단지 과일이 종류라는것만 안다. 바나나와 멜론이 자식클래스라는 것을 저 코드만으로 알 수 없다. 이것도 어찌보면 캡슐화라고 할 수 있다. 스택의 경우에서도, 내부에서 어떻게 구현하는지와 관계없이 push/pop을 이용하는 배경과 같다. 결론적으로, 캡슐화와 일반화는 큰 맥락에서 같다. (private/public 접근지정자 개념은 아니지만)

 

 

상속과 일반화의 차이

 

상속과 일반화는 뭐가 다를까?

is a kind of인지 아닌지도 있지만, 코드적으로 매우매우 큰 차이를 가진다.

아래 코드는 스택을 구현할 때, ArrayList를 상속받아 구현한 것이다. is a kind of관계가 성립되지 않기 때문에, 일반화가 아니라 상속 받은 것이다.

 

1
2
3
4
5
6
7
8
public class MyStack<String> extends ArrayList<String>{
    public void push(String e){
        add(e);
    }
    public String pop(){
        return remove(size()-1);
    }
}
http://colorscripter.com/info#e" target="_blank" style="color:#e5e5e5text-decoration:none">Colored by Color Scripter
1
2
3
4
5
6
7
8
9
public class Main{
    public static void main(String[] args){
        MyStack<String> st = new MyStack<String>();
        st.push("hello"); //ok
        st.pop(); //ok
        
        st.set(0"what is this?"); //???????
    }
}
http://colorscripter.com/info#e" target="_blank" style="color:#e5e5e5text-decoration:none">Colored by Color Scripter

 

일반화가 아닌, 상속의 문제는 무엇인가? ArrayList의 모든 메소드를 상속받기 때문에, MyStack 객체를 생성하면 push/pop만을 수행하는 게 아니라, 정의하지 않은 메소드를 사용할 수 있다.내가 원하는 add()와 remove()만 상속받는게 아니라, ArrayList의 모든 메소드를 상속받았다. 스택에서 제공하면 안되는 메소드를 제공하고 있다.

그렇다면, 상속을 이용하지 않고 ArrayList의 일부기능만 가져오고 싶다면 어떻게 해야할까? ArrayList를 MyStack의 멤버변수로 두면 된다. 이러한 방식을 위임(Delegation)이라고 한다

 

1
2
3
4
5
6
7
8
9
10
11
public class MyStack<String>{
    //상속이 아닌, 위임을 이용.
    private ArrayList<String> arr = new ArrayList<String>();
    
    public void push(String e){
        arr.add(e);
    }
    public String pop(){
        return arr.remove(size()-1);
    }
}
http://colorscripter.com/info#e" target="_blank" style="color:#e5e5e5text-decoration:none">Colored by Color Scripter

 

그렇다면, main함수에서 map() 같은 메소드를 사용할 수 없게 된다.

전체를 상속받는게 아니라, 연관관계를 이용해서 필요한 연산만 가져오면 된다.

 

자바에서 extends의 의미는 상속이 아니라 일반화이다. 상속의 의미로 extends를 쓰게 되면, 필요로 하지 않은 메소드도 상속받게 된다. 결론적으로, 일반화의 의미로 extends를 사용하지 않을 때 위임(Delegation)을 이용해서 필요한 메소드만 가져오면 된다.

 

 

피터코드의 상속규칙

 

위의 예시에서, 우리는 상속과 일반화의 차이를 보았다. 또한, 상속의 사용은 크리티컬한 문제를 가져올 수 있다. 그래서 피터 코드는 상속의 오용을 막기위해 엄격한 제한조건을 두었다. 5가지의 조건이 있지만, 가장 중요한 1가지를 보자

 

자식 클래스가 부모 클래스의 책임을 무시하거나 재정의하지 않고 확장만을 수행해야한다.

 

이는 SOLID의 원칙중 하나인 LSP와 연결된다

 

LSP(리스코프 치환 원칙, Liskov Substitution Principle)

 

LSP는 자식 클래스와 부모 클래스 간의 행위적 일관성이 있어야 함을 강조하는 원칙이다.

 

LSP 원칙을 만족하려면 부모 클래스의 인스턴스를 자식 클래스의 인스턴스로 대신할 수 있어야한다. 즉, 일반화의 기준은 is a kind of 관계라고 할 수 있다.

 

부모클래스가 과일이고, 자식클래스가 포도, 딸기라면 is a kind of가 성립한다. 그리고 대체할 수 있다는 걸 우리는 실생활의 체감으로 이해할 수 있다. 하지만, 실제 개발에서 우리의 체감으로 모르는 것들이 상당히 많다. 하나의 예시를 더 들어보고 일반화해보자.

 

Phone A가 있다고 하자. A는 문자만 수신할 수 있다. 문자가 오면, 문자로만 답을 할 수 있다. 메시지 기능만 있다.

 

Phone B를 A의 자식 클래스라고 하자. 이 상태에서 B가 리스코프의 원칙에 따라 작성하려면 몇 가지 조건이 필요하다.

 

Phone A의 기능인, 문자로 답하기를 수행하기 위해서는 Phone A가 문자를 수신할 수 있어야 한다. A의 자식인 B도 문자를 수신할 수 있는 조건은 최소한으로 있어야 한다. 즉, 부모의 행위의 조건이 자식의 조건에도 있어야 한다. Phone B는 문자와 전화를 수신할 수 있다고 한다면, B에도 부모의 행위 조건이 있다고 볼 수 있다. 행위의 조건도 그대로 받는다.

 

그리고 자식클래스인 Phone B는 카톡만 답(행위)을 할 수 있다고 하자. 그러면 원칙을 만족하는 걸까? Phone A가 없어지고 B로 대체된다면 문자의 기능이 없기 때문에 대체할 수 없다. 기능이 확장되지 않았다.

 

자식클래스의 조건은 그대로 상속받으면서, 행위는 부모클래스와 같거나 확장되어야 한다. 그러면 Phone B의 행위를 문자, 전화, 카톡이라고 하자. Phone B로 대체된다면 문자발신 기능을 수행할 수 있다. 이렇게 하면 리스코프의 원칙을 만족한다.

 

 

이제 일반화 해보자.

1. 부모 클래스의 기능이 있다고 하자. 그러면 부모 클래스는 기능을 수행하기 위한 조건이 자식 클래스에게도 있어야 한다. 즉, 조건은 그대로 상속되어야 한다

 

2. 자식 클래스의 기능이 있다고 하자. 그러면 자식 클래스의 기능이 부모 클래스의 기능을 포함해야 한다. 즉, 기능이 확장되어야 한다.(extends)

 

부모 클래스의 기능이 실현될 제한 조건은 그대로 상속받아야 한다. 해당 조건만 상속받는다면, 다른 조건을 추가해도 상관 없다. 그리고 기능은 같거나 확장되어야 한다. 어찌보면 재정의의 제약조건이라고 할 수도 있다.

 

 

 

ISP(인터페이스 분리 원칙, Interface Segregation Principle)

 

인터페이스를 클라이언트가 하는 기능별로 interface를 분리하자는 원칙이다.

 

클라이언트 관점에서, 자신이 사용하지 않는 기능은 접근하지 않도록 하는 원칙이다.

출퇴근 기록기의 예시를 들어보자.

 

회사에 출/퇴근 및 외출을 할때마다 기록부에 자신의 회사 카드를 바코드에 입력 해야한다. 출근 클라이언트는 출퇴근 기록부의 goToWork()기능만 사용하면 된다. 동시에 사용할 수 없다. 클라이언트가 사용하는 기능을 다 사용하지 않는다. 그런데, 출퇴근 기록부의 getOffWork()가 수정되어 테스트한다고 하자. 이와같은 구조일 경우, 출근,퇴근,외출 클라이언트 모두 테스트를 해야한다. 왜냐하면 모든 클라이언트가 접근할 수 있기 때문이다. 출퇴근 기록부의 수정으로 인해 다른 클라이언트가 영향을 받지 않도록 해야한다.

 

그러면, 클라이언트에 특화된 인터페이스를 사용하면 된다. 각 클라이언트는 자신의 행위에 대한 인터페이스로 접근을 하기 때문에, 다른 기능을 접근할 수 없다. 또한, getOffWork()코드를 테스트할 때도, 퇴근 클라이언트로 테스트하기만 하면 된다.

 

 

 

DIP(의존 역전 원칙, Dependency Inversion Principle)

 

의존 관계가 자주 변하는 것보다, 변화가 없는것에 의존하자는 걸 강조하는 원칙

 

의존 역전 원칙에 대해 설명하기 위해, 한 가지 예시를 들어보자.

운동선수는 매일 다양한 운동복을 입는다. 이 상황을 객체지향 관점으로 바꿔보자.

 

 

현재 운동선수는 아디다스 상의1과 필라하의2를 입고있다. 그런데 내일도 이 옷을 계속 입지 않는다. 다른 옷들을 입게된다. 그러면 의존관계를 바꾸기 위해서 코드를 수정해야한다. 확실하게 변하는 것은 아디다스 상의2나 필라하의1을 입는 다는 것이다. 즉, 다른 옷으로 입는다는 것이다.

그렇다면, 확실히 변하지 않는 것은 무엇인가? 바지와 상의를 입는다는 것이다. 즉, 일반화된 개념은 바뀌지 않는다. 그러면 일반화된 개념을 의존하고 자주 변하는 것은 setter로 바꿔주면 된다.

 

결국 OCP랑 연결되는 원칙이다.

 

 

5개의 설계원칙과, 일반화 캡슐화등에 대해서도 알아보았다. 이 글을 읽은 독자분들도 느끼겠지만, 사실 4개의 원칙은 OCP를 위함이라고 봐도 무방할 정도로 OCP는 아주 중요한 설계원칙이다. 이를 잘 이해하는 것이 중요하다.

 

참고 : JAVA 객체 디자인 패턴(정인상, 한빛미디어)