SpringBoot

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

쩡선영 2024. 2. 20. 20:09

 

🤷‍♀️ 배경

개발자라면, 특히 백엔드 쪽이라면 "객체지향~ 객체지향에 맞게 설계해주세요~~" 라는 소리를 한 번쯤은 들어봤을 것입니다. 저도 처음 개발을 처음 접했던 고1때부터 객체지향이라는 단어를 참 많이 들어왔는데요. 이번 스프링부트를 공부하기 시작하면서 이 객체지향 설계의 5가지 원칙인 SOLID 원칙을 제대로 알고 넘어가고 싶어서 이 글을 포스팅하게 되었습니다

 

💁‍♀️ Solid 원칙이란?

로버트 마틴이라는 유명한 분이 만드신 원칙인데요. 

Solid 원칙이란 객체지향 설계에서 지켜줘야 할 5가지 개발 원칙(SRP, OCP, LSP, ISP, DIP)입니다.

 

  • SPR (Single Responsibility Principle) : 단일 책임 원칙
  • OCP (Open Closed Priciple) : 개방/폐쇄 원칙
  • LSP (Listov Substitution Priciple) : 리스코프 치환 원칙
  • ISP (Interface Segregatin Principle) : 인터페이스 분리 원칙
  • DIP (Dependency Inversin Principle) : 의존관계 역전 원칙

 

이 다섯가지의 원칙을 SOLID원칙 이라고 합니다.

 

 

💁‍♀️ SRP - 단일 책임 원칙

한 클래스는 하나의 책임만 가져야한다.

 

단일 책임 원칙의 뜻은, 한 클래스는 하나의 책임만 가져아한다는 것인데요. 이 책임이 클 수도 있고, 작을수도 있어서 참 애매합니다. 여기서 중요한 기준은 변경인데요. 변경이 있을 때 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것입니다.

 

class Person {
    void teach();		// 가르치기 - 선생님
    void study();		// 공부하기 - 학생
    void learn();		//배우기 - 학생
}

 

위와 같이 Person 클래스에서는 선생님 역할과 학생 역할을 모두 담고있습니다.

그래하여 Person 클래스는 선생님의 역할과 관련한 기능이 추가될 때마다 코드의 변경이 있어야합니다.

 

class Teacher{
	void teach();
}

class Student{
	void study();
    void learn();
}

 

이렇게 선생님과 학생의 역할을 분리하여 단일 책임 원칙을 지켰습니다. 

단일 책임 원칙에 맞게 클래스를 수정한다면, 선생님의 역할과 관련한 기능이 추가될 때 Student 클래스가 변경되지 않아도 됩니다.

 

💁‍♀️ OCP - 개방/폐쇄 원칙

소프트웨어 요소는 확장에 열려있어야하나, 변경에는 닫혀있어야 한다.

 

즉, 자신의 확장에는 열려있고, 주변의 변화에 대해서는 닫혀있어야 합니다. 

 

interface Car {
	void accel();
    	void break();
}

class Bus implement Car{
	void accel() { //속도 10 증가 };
    	void brake() { //속도 5 감소 };
 }
 
 class Truck implements Car{
 	void accel() { //속도 5 증가 };
    	void brake() { //속도 3 감소};
}

 

위 코드는 자동차가 변경되더라도 액셀이나 브레이크를 밟든 아무런 영향을 받지 않습니다. 이는 변경에 닫혀있는 것을 의미합니다.

 

반대로, 자동차는 Bus나 Truck 이외에도 다른 자동차 종류로 인터페이스를 통해 클래스 확정을 할 수 있고, 클라이언트의 역할에 영항을 끼치지 않으니 확장에 열려있다는 것을 의미합니다.

 

💁‍♀️ LSP - 리스코프 치환 원칙

프로그램의 객체는 프로그램의 정확성을 깨드리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.

 

즉, 하위 타입이 상위 타입이 지정한 제약조건들을 지키고, 상위 타입에서 하위 타입으로 변동이 일어나도 상위타입의 역할을 문제없이 제공하는 것을 의미합니다.

 

class Car {
	int speed;
	void drive() { 
		this.speed += 10;
	}
}

class Bus extends Car {
	int speed;
	int km;
	
	@Override 
	void drive() {
		// 부모 기능 그대로 수행
		this.speed += 10;
		
		// 하위 타입 기능 추가
		this.km += 1;
	}
}

 

이렇게 오버라이딩 하게 되면 Bus는 Car(부모) 기능인 speed += 10 기능을 구현할 수 있을뿐더러 km += 1인 하위 타입 기능도 수행할 수 있게 됩니다. 

 

리스코프 치환 원칙을 지키기 위해서는 하위 타입에서 상위 타입은 상속을 하지만 무분별한 메소드 오버라이딩을 줄이는 것입니다. 메소드 오버라이딩을 하더라도 상위 타입이 구현한 기능을 변경없이 구현 후 추가적인 기능을 구현하는 것이 바람직합니다.

 

💁‍♀️ ISP - 인터페이스 분리 원칙

클라이언트는 자신이 사용하지 않는 메소드에 의존 관계를 맺으면 안된다.

 

단일 책임 원칙 (SRP)에서는 하나의 역할만 하도록 다수의 클래스로 분할하였습니다.

하지만 터페이스 분리 원칙은 각 역할에 맞게 인터페이스로 분리하는 것입니다. 

 

단일 책임 원칙과 인터페이스 분할 원칙은 같은 문제에 대한 두가지의 해결책으로도 볼 수 있습니다. 하지만 특별한 경우가 아니라면 단일 책임 원칙을 쓰는게 더 바람직하다고 하네요.

 

왜냐하면 상위 클래스는 풍성하면 풍성할수록 더 좋고, 인터페이스 내에 메소드는 최소한 일수록 좋기 때문입니다.

 

 

interface Person {
    void teach();		// 가르치기 - 선생님
    void study();		// 공부하기 - 학생
    void learn();		//배우기 - 학생
}

 

이렇게 Person 인터페이스에 선생님과 학생 메소드를 모두 구현하면 안되고

 

interface Teacher{
	void teach();
}

interface Student{
	void study();
    void learn();
}

 

기능에 맞게 인터페이스를 분리해야 합니다.

 

 

💁‍♀️ DIP - 의존관계 역전 원칙

프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안된다.

 

쉽게 이야기하면 구현 클래스에 의존하지 말고, 인터페이스에만 의존하라는 뜻입니다.

역할에 의존하게 해야한다는 것과 같습니다. 객체 세상도 클라이언트가 인터페이스에 의존해야 유연하게 구현체를 변경할 수 있습니다. 만약 구현체에 의존하게 되면 변경이 아주 어려워집니다.

 

class ComicBook {
	void read();
	void save();
}

 

이렇게 클라이언트가 ComicBook 클래스에 의존하게 된다면, Magazine이나 클래스를 이용하고 싶을 때 클라이언트의 코드를 전격 수정해주어야 합니다.

 

 

interface Book {  
	void read();
	void save();
}

class ComicBook implements Book{
	void read() {...}
	void save() {...}
}

class Magazine implements Book{
	void read() {...}
	void save() {...}
}

class Poem implements Book{
	void read() {...}
	void save() {...}
}

 

따라서, 클라이언트는 구현체(클래스)에 의존하기 보다는 ComicBook, Magazine, Poem을 추상화한 Book 인터페이스에 의존을 해야합니다. 그리하면 구현체가 변경되어도 코드는 별다른 변경 없이 사용 가능합니다.

 

 

 

📄참고 문헌

https://velog.io/@zayson/%EA%B0%9D%EC%B2%B4-%EC%A7%80%ED%96%A5-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D%EC%9D%98-5%EA%B0%80%EC%A7%80-%EC%9B%90%EC%B9%99-SOLID-%EC%9B%90%EC%B9%99