상세 컨텐츠

본문 제목

객체 지향 설계의 5원칙(SOLID)

Java

by AyaanDev 2023. 5. 30. 16:23

본문

반응형

서론

자바를 공부하는 개발자라면 객체지향의 5원칙 SOLID에 대해서 들어본 적이 있을 것이다. 하지만 해당 부분을 공부하다 보면 너무 추상적이라고 느껴져 포기하거나 공부하더라도 몇일 후면 까먹는 일이 많다.

오늘은 객체지향의 5원칙을 하나씩 살펴보고 비유와 함께 확인하면서 왜 이것을 지켜야 하는지 생각해보려 한다.

 

객체지향의 5원칙에는 아래 5가지 종류가 있다.

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번부터 5번까지를 하나씩 살펴볼 것이다.
본론을 시작하기에 앞서 해당 포스팅은  "스프링 입문을 위한 자바 객체 지향의 원리와 이해", "UML 실전에서는 이것만 쓴다" , "넥스트리소프트 기술블로그", "zdnet 기사" 까지 4가지 서적과 포스팅을 참고하여 작성한 글임을 밝힙니다.

 

좋은 설계란 무엇인가?

객체지향의 설계원칙은 간단하다. 좋은 소프트웨어를 설계하기위해 지켜야 하는 원칙을 정리한 것을 말한다. 결국 우리는 좋은 소프트웨어 설계란 무엇일까에 대해 대답해야한다.

로버트 C 마틴은 나쁜 소프트웨어가 무엇인지에 대해 [UML 실전에서는 이것만 쓰인다 책]에서 아래와 같이 정의내렸다.

 

1. 경직성(Rigidity): 무언가 하나를 바꿀 때 마다 반드시 다른 것도 바꿔야 하며, 연쇄적으로 또 다른 것들도 바꿔야하는 것.

2. 부서지기 쉬움(Fragility): 시스템에서 한 부분을 변경하면 그것과 전혀 상관없는 다른 부분이 작동을 멈춘다.

3. 부동성(Immobility) : 시스템을 여러 컴포넌트로 분해해서 다른 시스템에 재사용하기 힘듬

4. 끈끈함(Viscosity): 편집 - 컴파일 - 테스트 순환을 한 번 도는 시간이 엄청나게 오래 걸린다.

5. 쓸데없이 복잡함(Needless Complexity): 괜히 머리를 굴려서 짠 코드 구조가 많다. 대게 당장 필요없지만 언젠가는 유용할거라는 기대로 만들었다.

6. 불필요한 반복(Needless Repetition): 코드에 반복되는 부분이 굉장히 많다.

7. 불투명함(Opacity): 코드의 의도를 설명하기 매우 어렵다.

 

위 7가지 특징은 Design Smell이라고 불린다. 좋은 코드란 저런 Design Smell이 나지 않는 코드를 좋은 설계라고 볼 수 있을 것이다. 앞으로 5가지 객체지향 설계원칙을 공부하며 해당 코드가 Design Smell의 어떤 부분과 연관 있는지, 어떻게 Design Smell문제를 해결했는지를 살펴보고자 한다.

 

* 사실 4번은 어떤 것을 말하고 싶은지 잘 모르겠다..

** 한글은 너무 길기 때문에 아래에선 번호와 영어로 나타내겠다.

1. 단일 책임의 원칙(SRP)

어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이여야 한다. - 로버트 c 마틴 -

SRP는 클래스는 하나의 책임만을 가져야 한다는 의미이다. 아래는 SRP원칙을 지키지 못한 클래스 다이어그램의 예시이다.

 

스프링 입문을 위한 자바 객체 지향의 원리와 이해 예시

남자 클래스는 혼자서 여러가지 책임을 지고있다. 남자친구 로서의 책임, 아들로서의 책임, 사원으로서의 책임, 소대원으로서의 책임. 너무 많은 책임을 혼자서 지고있다.

 

SRP 원칙을 지킨 클래스 다이어그램

위 다이어그램은 SRP 원칙을 준수하도록 수정한 다이어그램이다. 해당 다이어그램에서는 각각의 클래스가 하나의 책임을 가지고 있다.

 

SRP 위반의 악취

마틴 파울러는 『리팩토링』에서 SRP를 위반했을 때 나나타는 악취를 구체화 했다.

 

- 여러 원인에 의한 변경(divergent change):

해당 악취는 앞에서 SRP를 설명할 때, 사용한 예시와 같은 경우이다. 하나의 클래스가 여러 책임을 지니고 있다면 하나의 책임의 변화가 다른 책임에게 영향을 줄 수 있다.

 

앞서 살펴봤던 SRP원칙을 적용하기 전 남자 클래스를 다시 떠올려 보자. 남자 클래스는 혼자서 남자친구, 아들, 회사원, 소대원으로서의 역할을 수행한다. 남자친구 역할에 변화나 문제가 있으면 남자 클래스를 수정해야 하고 아들 역할에 변화나 문제가 있어도 남자 클래스를 수정해야한다. 즉, 남자친구 역할에 문제가 생겨 수정했는데 아들 역할에 문제가 생길 수 있다는 것이다. 로버트 C 마틴이 제안한 Design Smell중 Flagility에 해당한다.

 

이를 해결하기 위해서는 클래스를 책임별로 분할하여 여러개의 클래스로 나눠줘야 한다. 이것을 Extract Class라고 부른다. 만약  Extract Class끼리 유사하고 비슷한 책임을 중복해서 갖고 있다면 Extract SuperClass를 사용할 수 있다.

위 예시에서 남자친구, 아들, 회사원, 소대원이 Extract Class에 속하고 남자는 Extract SuperClass에 속한다. 각각의 Extract Class는 잠자기, 먹기라는 공통된 책임을 갖고있어서 Extract SuperClass로 남자 클래스를 만들었다.

 

- 산탄총 수술(shotgun surgery):

산탄총 수술은 어떤 변경이 있을 때, 여러 클래스를 수정해야 하는 경우를 말한다. 앞에서 살펴보았던 Ragility와 연관이 있다. 한 클래스가 너무 많은 책임을 맡고 있는 것만 SRP를 위배한 것이 아니다. 책임을 식별하지 못하고 이를 담당할 클래스를 만들지 못한 경우. 하나의 책임이 여러 클래스에 흩뿌려져 있는 경우도 SRP를 위배한 것이라고 볼 수 있다.

 

전화하기 메서드 추가

위 클래스의 남자친구, 아들, 회사원, 소대원에게는 모두 전화하기 메서드가 존재한다. 따라서 전화하기에 문제가 생기면 남자친구, 아들, 회사원, 소대원 모두를 손봐야한다. 이런 경우를 산탄총 수술이라 하며 이를 해결하는 법은 간단하다.

 

1. 남자 클래스에 전화하기 메서드를 구현하여 사용한다.
2. 흩어져있는 책임(전화하기)를 모아 하나의 클래스로 만들어 관리한다.

SRP가 적용된 사례

 

2. 개방 폐쇄 원칙(OCP)

소프트웨어 엔티티는 확장에 대해서는 개방되어야 하지만, 변경에 대해서는 폐쇄되어야 한다.

마찬가지로 로버트 c 마틴이 한 말이다. 개방 폐쇄 원칙에 대해 검색하면 가장 많이 나오는 구절이지만 나에겐 와닿지 않아 UML실전에서는 이것만 쓰인다에서 나온 구절을 덧 붙이겠다.

 

모듈 자체를 변경하지 않고도 그 모듈을 둘러싼 환경을 바꿀수 있어야 한다

해당 내용을 이해하기 위해 한가지 예시를 들겠다. 

User가 있고 해당 User는 NaverCloudFileUploader클래스의 upload메서드와 delete메서드를 호출해서 파일을 업로드,삭제한다고 생각해보자. 시스템 설계자는 처음에 네이버 클라우드에만 파일을 올리고 삭제할 수 있으면 될 줄 알았으나 나중에 요구사항에 변경이 생겼다.

로컬에 파일을 업로드하는 기능, USB에 파일을 업로드하는 기능이 새로 생긴것이다. 개발자는 User클래스의 코드를 확인하고 수정해 Local에 업르드 하는 기능, USB에 업로드 하는 기능을 추가하여야 한다. 결국 User에 변경이 생기는 것이며, 이는 Design Smell의 1번 경직성과도 연관있다. Local에 업로드하는 기능을 위해 User클래스라는 전혀 상관없어보이는 클래스에 변경이 필요한것이다.

OCP원칙을 지킨 다이어그램을 살펴보자. FileUploader라는 인터페이스를 선언하였고 각각의 구현채는 해당 메서드를 구현하고 있다.

유저는 네이버 클라우드에 업로드하는건지, 로컬에 업로드하는건지, USB에 업로드하는건지에 상관없이 FileUploader 참조변수의 upload메서드를 호출하면 된다. 또 네이버에 업로드하는 기능에 변경이 발생하다라도 NaverCloudFileUploader클래스의 upload 메서드를 수정하면 될 뿐, 상관없는 User클래스를 건드리지 않아도 된다. 

만약, GoogleCloudFileUploader, GitHubUploader, TapeUplodaer등 다른 요구사항들이 추가로 생기더라도 User클래스가 수정되는 일은 더 이상 없을 것이다. 

 

설계자의 과제

시스템 설계자에겐 아직 과제가 남아있다. FileUploader클래스는 파일 업로드라는 책임을 가진다. 하지만 CloudUploader와 LocalFileUploder를 하나의 인터페이스로 관리하는 것이 적절할까?

 

현실 세계에서 클라우드에 파일을 업로드하는 것은, 파일 뿐만 아니라 유저의 계정 정보 혹은 토큰과 같은 파라미터가 추가로 필요할 수도 있다. 또한 용량이 부족하거나 네트워크상의 문제가 생겼거나와 같은 로컬 컴퓨터에 업로드하는 것과는 다른 예외가 발생할 수도 있다.

극단적으로는 doSomething 인터페이스를 만들고 모든 클래스는 해당 인터페이스를 구현한뒤 유저는 doSomething메서드만 호출하더라도 문제가 없지않은가? 이때 설계자는 어디까지를 하나의 인터페이스로 묶을지를 SRP원칙 관점에서 생각하고 설계해야한다.

 

인터페이스도 하나의 책임을 가지도록 설계해야하며 사람들이 혼동을 느끼지 않을만한 이름을 작성해야한다. 구현체들이 해당 인터페이스를 구현함에 있어서 문제가 생기지 않도록 확장성을 잘 고려해야하며 불필요한 Adapter같은 것이 필요하지 않도록 설계해야한다.

 

3. 리스코프 치환 원칙(LSP)

서브타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 한다. - 로버트 c 마틴티

LSP 원칙은 자식클래스로 둘 것인지, 다른 별도의 클래스로 둘 것인지를 판단하는 기준이 될 수 있다.

우선 LSP원칙이 왜 좋은지부터 살펴보자.

 

Collection linkedList = new LinkedList();
Collection hashMap = new HashMap();

Element e1=customGet(linkedList);
Element e2=customGet(hashMap);

public Element customGet(Collection c){
	if(c instance of LinkedList){
    	Element e = c.get(0);
        return e;
    }else if(c instance of HashMap){
    	Element e = c.get(0);
        return e;
    }else if(c instance of HashSet){
    	Element e = c.get(0);
        return e;
    }
    .... 
}

 

위 코드의 customGet메서드를 보면 무언가 이상함을 느낄것이다. customGet메서드를 보면 if instance of 문을 통해 구현체가 무엇인지를 확인하고 호출해야만 한다. 하지만 우리는 누구도 저렇게 코드를 짜지 않는다.

 

Collection linkedList = new LinkedList();
Collection hashMap = new HashMap();

Element e1=customGet(linkedList);
Element e2=customGet(hashMap);

public Element customGet(Collection c){
	return c.get(0); 
}

 

코드를 보면 매우 단순해진 것을 볼 수 있다. 이것이 가능한 이유는 자바가 리스코프 치환 원칙을 지켜 설계되었기 때문이다!

 Collection 클래스엔 get이 선언되어 있으며 서브 타입은 모두 get메서드를 구현해 놓았다. 따라서 Collection의 구현체로 무엇이 들어가든 서브타입이라면 해당 타입의 자리에 들어갈 수 있다. 따라서 복잡하게 분기문 처리를 하고 구현체에 따라 다르게 작성할 필요가 없는것이다. 반면 다음 UML 실전에서는 이것만 쓰인다 책에서 나온 예시를 함께 살펴보자.

 

상속을 잘 못 설계했을 때..

Employee 추상 클래스가 존재하고 Salaried Employee와 Hourly Employee는 해당 클래스를 상속받아 구현하고 있다.

연봉제로 받는 사람과 시간제로 받는사람은 봉급 계산 방식이 다르기 때문에 구현하는 방식도 다를 것이다. 하지만 자바는 리스코프 치환원칙을 따르고 있으므로 다형성이라는 성질에 의해 Employee클래스가 들어가는 자리에 어떤 구현체가 들어가든 코드가 작동하는데는 아무런 문제가 안된다.

 

하지만 만약 Volunteer 클래스가 존재한다고 가정해보자. 해당 클래스가 Employee클래스를 상속한다면, calcPay메서드는 어떻게 해야할까?

 

세가지 정도 방법이 있을것이다.

1. 메서드를 구현하되 내용물을 비운다.

2. 메서드를 구현하되 0을 리턴한다.

3. 예외를 발생시킨다.

 

저중 어떤 방식을 사용하든 이치에 맞지 않다. 내용물을 비우는 것은 납득하기 어려운 방식이며, 0을 리턴하는것도 애초에 봉급이 0원이라는게 맞나? 이치에 맞지 않다는 생각이든다.

예외를 발생시키는 경우 더 심각하다. 모든 직원의 연봉합을 계산할 때, 예외처리를 해야하는 코드가 새로 작성되어야 하는등 코드를 더럽힐 수도 있다.

 

이는 애초에 Volunteer가 직원이 아니기 때문에 발생하는 문제이다. 이치에 맞게 상속을 구현했다면 리스코프 치환 원칙은 맞아 떨어지기 나름이다. 하지만 Volunteer는 Employee가 아님에도 불구하고 억지로 상속을 시켜서 나타나는 문제이다.

 

상속을 구현할 때에는 리스코프 치환원칙에 위배되지 않을지를 고려하며 설계하는 습관이 필요하다.

4. 인터페이스 분리 원칙(ISP)

클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안된다. - 로버트 c 마틴

남자 클래스 문제를 SRP에서는 어떻게 해결했는지 보고 오라. ISP는 SRP에서 맞이한 문제를 해결하는 또 다른 방법이다.

SRP에서와는 달리 Class가 아니라 Interface로 접근한다.

package designPattern.ISP;

public class Man implements BoyFriend, Army, Employee,Son{

    // 남자친구 역할.
    public void celebrateAnniversary() { // 기념일 챙기기
        System.out.println("celebrate Anniversary");
    }

    public void kiss(){
        System.out.println("kiss");
    }

    // 아들 역할
    public void filialPiety(){// 효도하기
        System.out.println("fillial piety");
    }

    public void massage(){
        System.out.println("give massage");
    }

    // 회사원 역할
    public void goToWork(){
        System.out.println("go to work");
    }

    public void work(){
        System.out.println("work");
    }

    // 소대원 역할
    public void shoot(){
        System.out.println("shoot");
    }

    public void running(){
        System.out.println("run");
    }
}

public interface BoyFriend {
    void kiss();
    void celebrateAnniversary();
}

public interface Army {
    public void shoot();
    public void running();
}

public interface Employee {
    void goToWork();

    void work();
}

public interface Son {
    void filialPiety();
    void massage();
}
package designPattern.ISP;

public class ManTestDriver {
    public static void main(String[] args) {
        Army army = new Man();
        army.shoot();
        army.running();

        BoyFriend boyFriend = new Man();
        boyFriend.kiss();
        boyFriend.celebrateAnniversary();

        Employee employee = new Man();
        employee.goToWork();
        employee.work();

        Son son=new Man();
        son.filialPiety();
        son.massage();

		boyFriend.shoot();
        boyFriend.running();
    }
}

해당 코드의 주석을 지우면 아래와 같은 에러가 발생한다.

Cannot resolve method 'shoot' in 'BoyFriend'
Cannot resolve method 'running' in 'BoyFriend'

자바에서는 참조변수가 가지고 있는 멤버에만 접근가능하다. 여자 친구 클래스는 군인으로서의 모습을 볼 수 없는 것이다.

 

5. 의존 역전 원칙(DIP)

A. 고차원 모듈은 저차원 모듈에 의존하면 안된다. 이 두 모듈 모두 다른 추상화된 것에 의존해야 한다.
B. 추상화된 것은 구체적인 것에 의존하면 안된다. 구체적인 것이 추상화된 것에 의존해야한다.
                                                                                                                               - 로버트 c. 마틴 -

해당 문구만 봐서는 무슨 뜻인지 이해하기 힘들다. 아래 지켜야 하는 수칙과 함께 확인해보자.

1. 자주 변경되는 Concrete Class에 의존하지 마라.
2. 만약 어떤 클래스를 상속받아야 한다면, 참조 대상이 되는 클래스를 추상 클래스로 만들어라.
3. 만약 어떤 함수를 호출해야 한다면, 호출 되는 함수를 추상 함수로 만들어라.

* Concrete Class는 디자인 패턴에서 자주 등장하는 어휘로 추상클래스의 반대 말이다. 구현된 클래스로 이해하면 된다.

즉 결국 Concrete Class에 의존하지 말고 추상 클래스 혹은 인터페이스를 만들어 호출하라는 것을 의미한다.

아래 예시 코드를 살펴보고 DIP를 통해 해당 문제를 해결해 보자.

 

package designPattern.dip;

public class English{
    String name;

    English(){
        name="English";
    }

    @Override
    public String toString() {
        return "I'm Study English";
    }
}

public class Korean{
    String name;

    Korean(){
        name="Korean";
    }

    @Override
    public String toString() {
        return "I'm Study Koeran";
    }
}

package designPattern.dip;

public class Math{
    String name;

    Math(){
        name="Math";
    }

    @Override
    public String toString() {
        return "I'm Study Math";
    }
}

public class Student {
    String name;
    int grade;

    Math studyingSubject;

    public void setSubject(Math subject){
        studyingSubject=subject;
    }

    public void study(){
        System.out.println(studyingSubject);
    }
}


public class Studying {
    public static void main(String[] args) {
        Student ayaan= new Student();
        Math math=new Math();

        ayaan.setSubject(math);
        ayaan.study();
    }

}

 

여기 공부를 열심히 하는 학생 클래스가 있다. 해당 클래스는 study라는 메서드를 호출하는데 Math인스턴스를 파라미터로 받아 I'm studying math를 출력한다.

하지만 학생은 수학을 아무리 좋아한다 해서 수학만 공부해서는 안된다. 영어와 국어도 공부해야 하는데 Student객체를 보면 Math 인스턴스 변수를 가지고 있다.

Student객체를 보면 studyingSubject 변수의 타입이 Math이다. 이렇게 되면 수학 말고 국어나 영어를 공부하려면 Student객체를 다시 짜야한다. 즉 학생이 Concret Class에 의존하고 있는 상황이다.

 

Subject라는 인터페이스를 만들고 아래와 같이 코드를 고쳐보자.

 

public class Student {
    String name;
    int grade;

    Subject studyingSubject;

    public void setSubject(Subject subject){
        studyingSubject=subject;
    }

    public void study(){
        System.out.println(studyingSubject);
    }
}

이제 공부할 과목으로 수학, 국어, 영어등을 마음대로 바꿀 수 있다.

setSubject의 파라미터를 ConcretClass가 아닌 추상클래스(인터페이스)로 수정함으로써 의존관계를 없앤것이다.

 

이제 과목을 변경함에 따라 Student클래스까지 함께 변경할 필요가 없어졌다. 물론 역사, 물리, 화학, 생물등 여러 과목이 추가되더라도 각과목을 Subject인터페이스를 구현해주기만 하면된다.

 

이것이 DIP를 따랐을 때 얻을 수 있는 이점이다.

참고 문서


1. [스프링 입문을 위한 자바 객체 지향의 원리와 이해 - 김종민] :

http://www.yes24.com/Product/Goods/17350624 

 

스프링 입문을 위한 자바 객체 지향의 원리와 이해 - YES24

자바 엔터프라이즈 개발을 편하게 해주는 오픈소스 경량 애플리케이션 프레임워크인 스프링은 자바와 객체 지향이라는 기반 위에 굳건히 세워져 있다. 따라서 스프링을 제대로 이해하고 활용

www.yes24.com

2. [UML 실전에서는 이것만 쓴다. - 로버트 C. 마틴] :

http://www.yes24.com/Product/Goods/4492519

 

UML 실전에서는 이것만 쓴다 - YES24

프로젝트를 진행하려면 UML을 사용해야 하지만, UML은 너무 복잡하고 난해하다. 현업 개발자에게 맞춰 실무 실제 프로젝트에 사용되는, 알아야 하는 UML을 다루었다. UML과 객체지향 설계를 동시에

www.yes24.com

3. [zdnet IT 기사 객체지향 소프트웨어 설계의 원칙들 시리즈] - 최상훈(헨디소프트), 송치형(서울대):

https://zdnet.co.kr/view/?no=00000039134727

 

[객체지향 SW 설계의 원칙] ① 개방-폐쇄 원칙

소프트웨어 설계의 묘미는 개발자가 작업하는 시스템의 창조자 역할을 할 수 있다는 것이다. 실세계의 경우 좋은 세상을 만들기 위해 적절한 질서, 정책, 의식 등이 전제돼야 하...

zdnet.co.kr

4. NEXTREE 기술 블로그:

https://www.nextree.co.kr/p6960/

 

객체지향 개발 5대 원리: SOLID

현재를 살아가는 우리들은 모두 일정한 원리/원칙 아래에서 생활하고 있습니다. 여기서의 원칙 이라 함은 좁은 의미로는 개개인의 사고방식이나 신념, 가치관 정도가 될 수가 있겠고, 넓게는 한

www.nextree.co.kr

 

5. Inpa Dev 기술 블로그 포스팅

https://inpa.tistory.com/entry/OOP-%F0%9F%92%A0-%EC%95%84%EC%A3%BC-%EC%89%BD%EA%B2%8C-%EC%9D%B4%ED%95%B4%ED%95%98%EB%8A%94-DIP-%EC%9D%98%EC%A1%B4-%EC%97%AD%EC%A0%84-%EC%9B%90%EC%B9%99

 

💠 완벽하게 이해하는 DIP (의존 역전 원칙)

의존 역전 원칙 - DIP (Dependency Inversion Principle) DIP 원칙이란 객체에서 어떤 Class를 참조해서 사용해야하는 상황이 생긴다면, 그 Class를 직접 참조하는 것이 아니라 그 대상의 상위 요소(추상 클래스

inpa.tistory.com

 

반응형

'Java' 카테고리의 다른 글

[JAVA] Singleton 패턴에 대해 알아보자  (0) 2023.04.11
String vs StringBuffer vs StringBuilder  (0) 2023.04.03
동등성(Equality) VS 동일성(Identity)  (0) 2023.02.15

관련글 더보기

댓글 영역