본문 바로가기

개발

SOLID Principle 알아보기 - 첫번째

SOLID Principle

SOLID Principle은 객체 지향 프로그래밍에서 유지보수성, 재사용성, 확장성에 중점을 두고 설계하는 원칙이다. SOLID 알아보기 첫번째 게시글은 SRP와 OCP에 대한 내용이다. 

 

SRP (단일 책임 원칙 - 하나의 클래스, 하나의 책임)

하나의 Class를 바꿀 이유는 오직 하나여야만 한다.
하나의 클래스는 하나의 책임만 가져야 한다.

여기서 책임은 그 Class가 무엇을 하는지가 아니다. 바뀌여야 하는 이유이다.

 

즉, 하나의 클래스에 여러 책임이 부과되면 안된다.

또한, 하나의 책임이 여러 클래스에 흩어져 있거나,

다른 책임들과 섞이면 안된다. → 가독성의 문제도 있다.

그렇지 않으면, 추후에 변경사항들이 생길 수록, 고쳐야하는 클래스들이 늘어난다.

 

다음과 같은 경우를 생각해보자.

나는 회사에서 일하고 있는 말단 직원이고, 내가 하는 일은 3가지이다.

  1. 급료를 계산하는 일
  2. 직원에 대한 리포트 작성
  3. 직원정보를 DB에 저장하는 일

 

public class Employee {
    public double CalculatePay(Money money) {
        //logic for payments
    }

    public String reportHours(Employee employee) {
        //logic for get report for employee
    }

    public Employee save(Employee employee) {
        //store employee to the database
    }
}

 

내가 작업하는 일이 변경된다면, 이유는 어떤게 될까?

  1. 급료 계산방식이 변경되어 코드 수정을 요청 받거나
  2. 근무시간 산정 방식이 변경되어 리포트 작성 코드 수정을 요청 받거나
  3. 직원정보 테이블에 새로운 칼럼이 추가되어 관련 코드 수정을 요청받거나

즉, 나는 3가지 다른 이유로 코드를 수정하게 될 수 있다. (각기 다른 3가지의 업무 책임을 가지고 있다.) 이는 SRP 원칙에 위배된다.

 

그렇다면 어떻게 이 문제를 해결할 수 있을까?

우선 Employee가 가지고 있던 3가지 책임에 대해 Extract Class 활용하여 각기 다른 3개의 클래스로 흩뿌려 줄 수 있다.

하지만, 이 방식은 인스턴스화 해야하는 클래스를 3개로 만드는 문제를 지니고 있다.

그래서, 흔히 보이는 해결책은 Facade pattern이다.

 

 

 

 

SRP를 적용하면 클래스(여기선 3개의 extract Class)의 숫자가 늘어날 수 있는데, 클래스의 숫자가 늘어나면 사용이 불편하게 되므로 퍼사드 패턴을 통해 사용의 복잡함을 단순화 시켜 줄 수 있다.

 

이외에도 SRP 원칙을 따르게 코드를 수정하는 방식은 정말 많다고 한다.

Interface와 역할 구현체로 분리

Employee를 Interface로 만들고 3가지 업무를 실질적으로 수행하는 Class를 만들어 나누어줘보자.

상속을 통한 클래스 분할등이 있다.

 

조금 다른 예시로는, 한 클래스의 여러 책임이 혼재되어 있는걸 해결 하는 방안으로

Spring의 AOP가 있다. 여러 개의 클래스에 나뉘어져있는 로깅이나 보안, 트랜잭션과 같은 부분을 모듈화를 통해 각각 필요한 곳에 위빙해주는 방식을 위해 도입된 AOP 또한 하나의 모듈에 단일책임을 부여함으로써 SRP를 지키는 방법이다.

 

생각

하나의 책임을 보다 명시적으로 표현하기 위해 클래스 이름 짓기에 신중해져야 함

응집도는 높히고 결합도는 낮추자.

 

OCP (개방-폐쇄 원칙 - 코드 수정없이 기능추가 가능해야 함)

소프트웨어 요소(컴포넌트, 클래스, 모듈, 함수)는 확장에는 열려 있으나 수정에는 닫혀 있어야 합니다.

💡 다시 말하면, 기존의 코드를 변경하지 않고 새로운 기능(Behavior)을 추가할 수 있어야 한다.

기존 코드의 변경이 없다는 것은 기존 코드를 망가트릴 위험성을 현저히 줄여준다.

원과 정사각형을 그려주는 c언어 코드를 짠다고 생각해보자. (자바 예시를 들다가 갑자기 c 예시를 드는건 이 코드 예시에서 배울점이 많았다.)

원과 정사각형에 대한 자료구조와, 도형 타입을 enum으로 표현해줄 Shape 헤더파일이 있다.

//Shape.h
enum ShapeType {circle, sqare};
struct Shape {enum ShapeType itsType;};

//Circle.h
struct Circle
{
	enum ShateType itsType;
	double itsRadius;
	Point itsCenter;
}
void DrawCircle(struct Circle*)

//Square.h
struct Square
{
	enum ShateType itsType;
	double itsSide;
	Point itsTopLeft;
};
void DrawSquare(struct Square*)
//DrawAllShape.c
#include ...
void DrawAllShapes(ShapePtr list[], int n){
	int i;
	for(i = 0; i < n, i++ ){
		ShapePtr s = list[i];
		switch ( s-> itsType ){
			case square:
				DrawSqaure((struct Square*)s);
				break;
			case circle:
				DrawCircle((struct Circle*)s);
				break;
		}
	}
}

 

이 코드의 문제는 타원이라는 새로운 도형 타입이 추가될때 드러된다. 우선, 타원이라는 새로운 도형타입이 생기고, 이렇게 변경된 ShapeType을 반영해주기 위해서는 이를 사용하고 있는 Circle.h, Square.h도 재컴파일 되어야 한다.

 

타원을 추가 했지만 원과 정사각형 헤더파일도 재컴파일 되어야 하는 것이다. 이 코드를 강의에서 경직(rigid)되어 있다고 한다.

 

또한, 스위치문에도 타원이라는 새로운 case가 생긴다. 단 하나의 case가 만들어지는건 문제가 아니라고 생각할 수도 있다. 하지만, 스위치문은 보통 한곳에만 존재하지 않는다. 여러 비슷한 동작(dragAllShape, scaleAllShape, rotateAllShape….)을 하는 곳에서 복제되어 쓰이고 있다.

 

그리고 우리는 모든 switch문을 찾아서 타원이라는 새로운 case를 추가해줘야한다. (if-else문도 마찬가지이다.)

 

따라서 이 코드는 모든 switch문을 찾고 또 적절하게 바꿔주어야하기 때문에 fragile, 실수할 가능성이 크다.

 

여기서 한가지 가정을 더 해보자, 회사 대표가 나에게 와서, 도형을 그리는 기능은 사용자들에게 무료로 제공하고 새로운 도형 타입을 그리려면 5000원씩 내게 하자고 한다. (게임 DLC 같은 느낌이다.)

 

하지만 이 코드에서는 불가능하다. 왜냐하면, 새로운 타입이 추가된다는 것은 DrawAllShape.c의 코드가 변경되어야 한다는 것을 의미한다. 결국 나는 “우리 아키텍쳐 상으로는 불가능합니다.”라고 말할 수 밖에 없다.

→ 변경되어야할 부분과 변경이 안되어야 할 부분을 잘 정의하자.

 

그렇다면 어떻게 이 문제를 해결할 수 있을까?

아래는 이 문제를 해결한 코드이다. 자바로 치면 Shape 라는 최상위 인터페이스를 만들고 그 아래 Circle, Square 인터페이스를 둔다. 그리고 DrawAllShape에서는 Shape Type으로 그려줄 도형을 받아 도형을 그려준다.

//Shape.h
Class Shape
{
 public: 
	virtual void Draw() const = 0;
};

//Square.h
Class Square: public Shpae
{
	public:
		virtual void Draw() const;
}

//Circle.h
Class Circle: public Shpae
{
	public:
		virtual void Draw() const;
}

//DrawAllShapes.cpp
#include <Shape.h>

void
DrawAllShapes(Shape* list[], int n)
{
	for(int i = 0; i < n; i++)
		list[i] -> draw();
}

 

위 코드에서 새로운 타입 타원이 추가 된다고 할 때, recompile 되어야할 코드는 “없다”.

 

DrawAllShapes에서는 어떤 도형타입들이 존재하는지 몰라도 된다. 우리는 단지 타원 인터페이스를 만들고 Draw 메서드를 구현하는 것만 하면 된다.

 

여기서 주의할 점은, 섣부른 추상화는 조심해야한다. 고객의 추가적인 요청(정사각형이 항상 원보다 위에 그려지게 해달라와 같은)에 따라, 끔찍한 상황을 맞이할 수도 있다.

 

정형화된 OCP를 지키는 방법

  1. 변하는(확장되는) 것과 변하지 않는 것을 엄격히 구분해야 한다. 변하는 것은 가능한 변하기 쉽게, 변하지 않는 것은(폐쇄돼야 하는 것은) 변하는 것에 영향을 받지 않게 설계하는 것
  2. 이 두 모듈이 만나는 지점인터페이스를 정의 인터페이스는 변하는 것과 변하지 않는 모듈의 교차점으로 서로를 보호

주의할점

  1. 변하는 것과 변하지 않는 것을 모듈로 분리하는 과정에서 크기 조절에 실패하면 오히려 관계가 더 복잡해져서 설계를 망칠 수 있다.
  2. 한 번 정해진 인터페이스는 시간이 갈수록 사용하는 모듈이 많아지기 때문에 바꾸는 데 엄청난 출혈을 각오해야 한다. 자바의 deprecated API가 대표적인 경우다. 따라서 인터페이스를 정의할 때 예지력이 필요하다.
  3. 인터페이스 설계에서 적당한 추상화 레벨을 선택할 것

생각

새로운 기능의 추가가 기존 코드의 변경 없이 이루어진다. → 기존 코드의 recompile, rebuild, redeploy가 없어도 된다.

 

 

마무리

정리하다보니 분량이 너무 길어져서 두번째시간에는 미처 다루지못한 나머지 원칙들에 대해서 알아보자. SOLID