본문 바로가기
Study

[스터디] 객체 지향 설계 5원칙 - SOLID

by DuncanKim 2022. 7. 28.
728x90

[스터디] 객체 지향 설계 5원칙 - SOLID

 

2주간 객체 지향의 개념과 4대 특성을 완전하게 살펴보았다.

그렇다면 객체 지향 언어를 이용해서 객체 지향을 올바르게 설계해 나가는 방법, 원칙을 배워볼 차례이다.

 

객체 지향의 설계 중 정수는 SOLID다. SOLID는 위의 그림에서 보이는 것과 같이 두문자를 따서 만들어놓은 개념어이다.

좋은 소프트웨어 설계를 위해서 결합도를 낮추고, 응집도를 높이기 위한 방법들이 SOLID에 응축되어 있다고 보면 된다.

 

SOLID는 개념이다. 다양한 곳(아키텍처, 프레임워크 모듈, 클래스, 속성 등등)에 다양하게 적용되기 때문에 보는 사람의 관점에 따라 다르게 해석될 수도 있다. 그렇기 때문에 개념을 이해만 하고 실제로 어떻게 활용되는 지를 알아보고, 소프트웨어에 자연스럽게 적용할 수 있도록 훈련하는 것이 중요하다.

 

 

1. SOLID의 위치

 

SOLID는 객체 지향 4대 특성을 발판으로 해서 생성되었고, 디자인 패턴과 스프링 프레임워크의 근간이 되는 개념이다. 

 

 

2. SRP - 단일 책임 원칙

"어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다." (로버트 C. 마틴)

단일 책임의 원칙은 분할 업무 처리가 되지 않는 어느 한 작은 기업을 생각해보면 개념 이해가 쉽다.

 

김 대리는 회사에서 아주 중요한 키맨이다. 회사의 분기별 세무 처리를 주로 맡고 있고, 월 별 급여 신고도 맡아서 하고 있다. 여기까지는 여느 세무, 회계팀에서 하는 일과는 다를 바 없지만, 김 대리는 회사의 주요 생산품인 믹서기를 영업하는 일도 가끔 하고 있다. 성사율이 꽤 높아서 부장님이 아주 총애하는 편이다. 능력이 출중한 김 대리는 가끔 실무진으로 신입 선발 면접장에 들어가서 면접자를 맞이하기도 한다. 예리한 질문을 던지며 지원자의 진땀을 빼는 편이다. 연말에는 내년도 영업 기획을 하기도 하는데, 아이디어 뱅크인 김 대리는 기발한 생각으로 제품의 판로를 개척할 수 있는 방법에 대해 이야기를 하곤 한다.

 

자, 김 대리는 현실에 가끔 가다 볼만한 슈퍼맨이다. 뭐든 잘한다고 하니 회사 입장에서는 굉장히 촉망받는 인재이며 연봉을 500만 원씩 올려줘도 아깝지 않을 것이다. 그렇지만, 회사 전체로 볼 때, 김 대리와 같은 인물이 있는 것이 바람직할까 아니면 사람이 많더라도 각각 파트를 분할하여 어떤 파트의 전문가를 양성하는 것이 좋을까?

 

아무리 슈퍼맨이라 하더라도 저런 제너럴리스트는 특정한 분야의 깊은 지식이 필요할 때, 무너질 수 있다. 게다가 저 사람이 없다면, 회사의 업무 전체가 쇼크 상태에 빠질 수도 있다. 물론 작은 회사이기 때문에 이런 이야기들이 필요 없을 수도 있지만, 회사가 급성장하는 중에 있다면...? 김 대리는 아마 과로로 인해 번아웃이 오고, 결론적으로는 회사 전체의 시스템이 좋지 않게 돌아갈 가능성이 높다.

과로사 할 수도 있다.

이 사례를 소프트웨어의 세계로 치환해보면, 회사가 어떤 애플리케이션이고, 김 대리는 어떤 클래스라고 할 수 있겠다. 김 대리는 세무팀이다. 세무팀의 일을 충분히 처리하고 구멍없이 세금 신고를 하고, 절세를 할 수 있는 부분들을 찾아서 검토하고, 보완하면 그의 1인분 역할은 충분히 하는 것이다.

 

마찬가지로 어떤 클래스가 여러 클래스가 있음에도 불구하고, 하나의 클래스로 처리 과정이 모여서 진행이 된다면? 클래스가 문제가 있을 때 어떤 부분을 변경하는 이유가 수 십 가지가 될 수도 있다. 여기가 이렇게 연계되고,,, 저기가 이렇게 연계되고 하니까 클래스 안의 부품들을 전반적으로 모두 손을 봐야 할 수도 있다.

 

이런 상황을 방지하는 것이 '단일 책임 원칙'이다. 세무팀(인터페이스)의 김 대리(클래스)는 세무팀에서 필요로 하는 속성(세금, 급여, 지출)만 가지고 있어야 한다. 마케팅과 영업과 관련된 속성이 있으면 안 되는 것이다. 메서드도 마찬가지이다. 영업하기(), 면접보기()와 같은 다른 팀(클래스)의 메서드들이 들어가 있으면 안 된다. 이와 더불어 영업하기() 메서드 안에 세금신고하기() 메서드와 관련된 것이 if문(영업이 한가하면)을 통과하면서 분기되어 세금신고하기() 메서드가 처리되는 것도 단일 책임 원칙을 지지 않는 것이다.

 

class 김대리 extends 세무팀{
    final static Boolean 한가함 = true;
    final static Boolean 바쁨 = false;
    Boolean 현재상태;
    
    void 세금신고하다(){
    	if(현재상태 == 한가함){
        	영업하다();
        } else {
        	홈택스를켠다();
        }
    }
}

이러한 경우가 메서드가 단일 책임 원칙을 지키지 않은 경우다. 분기 처리를 위한 if문이 나타나면 코드를 리팩터링 할 수 있는지를 살펴보아야 한다.

 

// 리팩터링

abstract class 회사업무{
    abstract void 자기팀업무하다();
}

class 김대리 extends 회사업무{
    void 자기팀업무하다(){
    	홈택스를켠다();
    }
}

class 박대리 extends 회사업무{
    void 자기팀업무하다(){
    	영업하다();
    }
}

 

SOLID의 기반이 되는 객체 지향 4대 특성을 다시 상기해보면, 모델링 과정을 담당하는 추상화와 가장 관련이 깊은 것을 느낄 수 있다. 애플리케이션의 경계를 정하고 추상화를 통해 클래스를 선별하고 속성과 메서드를 설계할 때 반드시 단일 책임 원칙을 고려해야 한다는 것을 위의 사례를 통해 알 수 있을 것이다. 우리에게 슈퍼맨은 필요 없다.

 

 

3. OCP - 개방 폐쇄 원칙

"소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하지만 변경에 있어서는 닫혀 있어야 한다."
(로버트 C. 마틴)

 

최근 엄마가 핸드폰을 바꾸었다. 우리 엄마는 LG 핸드폰을 쓰고 있었는데, 갤럭시 S22 Ultra로 바꾸었다. 엄마들은 보통 기계의 변화에 무서움을 느낀다. 나의 카카오톡 대화내용이 어떻게 되는 것인지, 전화번호부는 어떻게 되는 것인지, 자판은 어떻게 변경이 되는 것인지, 애플리케이션 목록은 어디에 뜨는 것인지... 엄마들은 모른다.

 

어떤 핸드폰을 쓰던지 간에 사용방법은 똑같을 수는 없을까? 그대로 무엇인가가 옮겨지게 하는, 방법은 똑같은 그 만능의 기계는 없는 것인가?

 

현실 세계에서는 불행히도 존재하지 않지만, 이번 만큼은 상상을 해보자. LG 핸드폰과 삼성 핸드폰에서 카카오톡 내용을 그대로 받아볼 수 있게 하는, 전화번호부도 같이 공유할 수 있는, 자판도 천지인으로 통일된, 애플리케이션 목록도 똑같이 보이게 할 수 있는 모든 핸드폰의 기능을 공통적으로 사용할 수 있는 삼성 인터페이스 만능 스마트폰을 만들어보는 것이다. 그렇다면, 우리 엄마는 삼성 인터페이스 만능 스마트폰의 기능만 알고 있으면 모든 핸드폰을 호환시켜서 나의 핸드폰처럼 쓸 수 있게 되는 것이다.

 

그렇다면 엄마는 핸드폰 사용법의 교체라는 '변경'에 있어서는 닫혀있게 된다. 삼성 인터페이스 만능 스마트폰은 다양한 핸드폰이 생긴다는 '확장'에 대해서는 열려있게 된다.

 

비현실적인 비유였지만, 자바에서는 이런 것이 응용되고 있다. JVM, JDBC가 그 예시이다.

 

각 운영체제별 JVM과 목적 파일(.class)이 있기 때문에, 어느 운영체제에서 돌아갈지 생각하지 않고 본인이 지금 쓰고 있는 운영체제에서 돌아가게끔만 하면 자바 파일은 무조건 돌아가게 되어 있다. 이것은 개발자가 작성한 소스코드는 운영체제의 변화에 닫혀있고, 각 운영체제별 JVM은 확장에 열려있는 구조로 설명이 된다.

 

JDBC 인터페이스는 자바 애플리케이션이 DB가 오라클에서 MySQL로 바뀌더라도 동일하게 작동하도록 도와준다. 커넥션을 바꿔주는 것 이외에 다른 설정들은 건드릴 필요가 없이 JDBC 드라이버를 JDBC 인터페이스가 바꿔주기 때문에 DB를 바꿔도 무리 없이 돌아가는 것이다. 자바 애플리케이션은 DB 변화에 있어서 닫혀있고, JDBC 인터페이스는 드라이버의 확장에 열려있게 되는 것이다.

 

개방 폐쇄 원칙에 대한 좋은 예로 스프링 프레임워크도 들 수 있다.

 

개방 폐쇄 원칙을 무시하고 프로그램을 할 수는 있다. 그렇지만, 개방 폐쇄 원칙을 무시하고 프로그램을 작성하면 OOP의 가장 큰 장점인 유연성, 재사용성, 유지보수성을 얻을 수 없다.

 

 

4.  LSP - 리스코프 치환 원칙

"서브 타입은 언제나 자신의 기반 타입(base type)으로 교체할 수 있어야 한다." (로버트 C. 마틴)

 

상속과 관련이 있는 원칙이다. 상속은 조직도, 계층도가 아닌 분류도가 되어야 한다.

 

하위 클래스 is a kind of 상위 클래스 : 하위분류는 상위 분류의 한 종류이다.

구현 클래스 is able to 인터페이스 : 구현 분류는 인터페이스 할 수 있어야 한다.

 

이전 회차에서 예시를 들었던 것 처럼 아버지를 상위 클래스(기반 타입)로 하는 딸이라는 하위 클래스(서브 타입)가 있다고 하면, 전형적 계층도 형식이기 때문에, 객체 지향의 상속을 잘못 적용하고 있으며, LSP 원칙을 위반한 것이라고 할 수 있다.

 

아버지 춘향이 = new 딸();

 

아버지의 역할을 춘향이라는 객체 참조 변수가 하고 있다. 춘향이는 아버지 객체가 가진 행위(메서드)를 할 수 있어야 하는데, 아버지의 어떤 역할을 할 수 있을까...? 

 

이번에는 동물 - 뽀로로 예시를 보자.

 

동물 뽀로로 = new 펭귄();

 

펭귄 객체인 뽀로로는 동물의 행위(메서드)를 하게 되는데 이상한 것이 없다. 이는 리스코프 치환 원칙을 만족한다고 할 수 있다.

 

"서브 타입은 언제나 자신의 기반 타입(base type)으로 교체할 수 있어야 한다."는 말을 다시 바꿔보자.

 

"하위 클래스의 인스턴스는 상위형 객체 참조 변수에 대입해 상위 클래스의 인스턴스 역할을 하는 데 문제가 없어야 한다."

 

결국 리스코프 치환 원칙은 객체 지향의 상속이라는 특성을 올바르게 활용하면 자연스럽게 얻게 되는 것이라고 할 수 있다.

 

 

5. ISP - 인터페이스 분리 원칙

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

단일 책임 원칙에서 슈퍼맨 김 대리를 다시 소환해보자.

어쨌든 김 대리는 계속 일을 할 수 있고, 여전히 회사는 그를 너무나 원해서 연봉 2천만 원을 올려주고 차도 선물해줬다고 하자.

그러면 그 일들을 모두 계속해서 해보아야 한다. 김 대리는 업무를 나누어 하루하루 처리할 수 있지만(클래스 분할), 인터페이스를 분할하여 업무를 처리하는 것도 가능하다. 

 

abstract class 김대리의업무{
    세금신고하기();
    급여신고하기();
    영업하기();
    마케팅하기();
    꽃에물주기();
    개키우기();
    술상무하기();
    신입면접들어가기();
    차년도기획하기();
    이사님딸하원시키기();
}

interface 월요일의김대리{
    세금신고하기();
    급여신고하기();
}

interface 화요일의김대리{
    영업하기();
    마케팅하기();
}

interface 수요일의김대리{
    꽃에물주기();
    개키우기();
    술상무하기();
}

interface 목요일의김대리{
    신입면접들어가기();
}

interface 금요일의김대리{
    차년도기획하기();
    이사님딸하원시키기();
}

 

이런 식으로 일별 김대리를 분리시키고, 각 요일마다 김대리가 하는 일을 인터페이스를 통해서 받는 방법이 있다.

 

결론적으로 단일 책임 원칙(SRP)과 인터페이스 분할 원칙(ISP)은 같은 문제에 대한 두 가지 다른 해결책이라고 볼 수 있다. 프로젝트 설계자의 취향에 따라 단일 책임 원칙이나 인터페이스 분할 원칙을 선택할 수 있는 것이다. 하지만, 특별한 경우가 아니라면 SRP를 적용한 프로젝트가 더 좋은 해결책이라 볼 수 있다.

 

인터페이스 분할 원칙을 이야기 할 때 항상 함께 등장하는 원칙 중 하나로 인터페이스 최소주의 원칙이라는 것이 있다. 인터페이스를 통해 메서드를 외부에 제공할 때는 최소한의 메서드만 제공하라는 것이다. 위의 코드에서는 월요일의김대리 인터페이스에 신입면접들어가기 메서드는 제공해서는 안된다는 것이다. 상위 추상클래스는 풍성할수록 좋고, 하위 인터페이스는 작을수록 좋다. 

 

상위 클래스에 많은 것들이 제공되어 있어야 억지스러운 형변환을 최대한 줄일 수 있다. 다형성을 활용하여

김대리의업무목록 김대리1월 = new 김대리화요일();

이렇게 한다고 하자. 만약 김대리의업무목록에 김대리화요일 클래스에 새롭게 구현되는 것들이 많으면, 나중에 김대리1월이라는 객체 참조 변수를 활용할 때, 형변환이 많아지게 된다.

 

(김대리의화요일)김대리1월.xxxxx(); 이런 식의 코드가 많아지는 것이다. 그렇지만 업무 목록에 모든 것들을 적어놓으면 이러한 강제형변환이 줄어들게 된다. 상위 추상클래스는 풍성할수록 좋고, 하위 인터페이스는 작을수록 좋다. 또한 인터페이스는 역할에 충실한 최소한의 기능만 공개하는 것이 좋다.

 

 

6. DIP - 의존 역전 원칙

"고차원 모듈은 저차원 모듈에 의존하면 안 된다. 이 두 모듈 모두 다른 추상화된 것에 의존해야 한다."
"추상화된 것은 구체적인 것에 의존하면 안 된다. 구체적인 것이 추상화된 것에 의존해야 한다."
"자주 변경되는 구체(Concrete) 클래스에 의존하지 마라." (로버트 C. 마틴

 

먼저 위의 말을 의역하면 다음과 같다. "자신보다 변하기 쉬운 것에 의존하지 마라."

 

의존 역전 원칙은 개방 폐쇄 원칙과 비슷하다. 개방 폐쇄 원칙에서 든 삼성 인터페이스 만능 스마트폰은 변하기 쉽지 않다. 그렇지만, 엄마가 각각의 LG 단종된 핸드폰, 삼성 갤럭시, 애플 아이폰과 직접 연결이 되어 있다면, 어려움을 면치 못한다.

 

엄마가 변하기 보다는 핸드폰이 교체되기 쉽다. 그렇기 때문에 엄마는 핸드폰에 의존하면 안 된다. 그렇기 때문에 삼성 인터페이스 만능 스마트폰과 같이 불변하는 인터페이스나 상위 클래스를 두어서 변하기 쉬운 클래스에 영향을 덜 받도록 설계를 해야 한다는 원칙이라고 생각하면 된다.

 

상위 클래스일수록, 인터페이스일수록, 추상 클래스일수록 변하지 않을 가능성이 높다. 하위 클래스나 구체 클래스가 아닌 상위 클래스, 인터페이스, 추상 클래스를 통해 의존하라는 것이 의존 역전의 원칙인 것이다.

 

 

7. 총정리

 

객체 지향 4대 특성 위에서 나온 개념이듯 모든 원칙에 4대 특성이 드러나는 것을 알 수 있었다. 유연하게 모든 것을 이어서 생각하고, 어디에서 파생된 개념인지를 잘 생각해보면 전체적인 원리가 이해될 것이다.

 

SOLID를 이야기 할 때 빼놓을 수 없는 것이 SoC라고 한다. SoC(Separation Of Concerns)는 관심사의 분리이다. 하나의 속성, 하나의 메서드, 하나의 클래스, 하나의 모듈 또는 하나의 패키지에는 하나의 관심사만 들어 있어야 한다는 것이다. SoC를 적용하면 자연스럽게 SRP, ISP, OCP에 도달하게 된다. 스프링도 SoC를 통해 SOLID를 극한으로 적용하고 있다.

 

SOLID 원칙을 적용하면 소스 파일의 개수는 늘어난다. 위의 코드를 봐도 알 수 있을 것이다. 하지만 이렇게 많아진 파일이 논리를 분할하고 더 이해가 가기 쉽고 구동시키기 좋도록, 유지 보수하기 쉽도록 해준다. 개발에 필요한 공수가 증가하더라도 이에 대한 부담을 충분히 감수할 수 있을 정도로 전체적 효율은 크다고 할 수 있다.

728x90

댓글