※ 2024.01 원티드 프리온보딩 백엔드 챌린지 강의를 기반으로 작성했습니다.
SOLID는 로버트 C. 마틴이 2000년 초반 작성한 에세이에 의해 개발되었다.
좋은 설계 원칙이 없으면 소프트웨어가 경직되고, 깨지기 쉽고, 움직이지 않고, 점성이 있게 된다고 경고한다.
SOLID 원칙은 이러한 문제가 있는 설계 패턴을 해결하기 위해 개발되었다.
SOLID 원칙의 광범위한 목표는 개발자가 다른 영역에 영향을 주지 않고 소프트웨어의 한 영역을 변경할 수 있도록 종속성을 줄이는 것이다.
또한 디자인을 더 쉽게 이해하고, 유지 관리하고, 확장할 수 있도록 하기 위한 것이다.
궁극적으로 이러한 설계 원칙을 사용하면 소프트웨어 엔지니어가 문제를 방지하고 적응력이 뛰어나고 효과적이며 민첩한 소프트웨어를 더 쉽게 구축할 수 있다.
원칙에는 많은 이점이 있지만 원칙을 따르면 일반적으로 더 길고 복잡한 코드를 작성하게 된다. 즉, 디자인 프로세스를 확장하고 개발을 조금 더 어렵게 만들 수 있다. 그러나 이러한 추가적인 노력으로 인해 소프트웨어를 유지 관리, 테스트 및 확장하기가 훨씬 쉬워지기 때문에 그만한 가치가 있다.
이러한 원칙을 따르는 것이 만병통치약은 아니며 설계 문제를 피할 수 없다. 즉, 이 원칙을 올바르게 따를 경우 가독성, 유지 관리 용이성, 디자인 패턴 및 테스트 용이성에 대한 더 나은 코드로 이어지기 때문에 널리 사용되고 있다. 현재 환경에서 모든 개발자는 이러한 원칙을 알고 활용해야 한다.
"클래스는 하나의 책임이나 기능만을 담당해야 한다.
즉, 기본적으로 작은 단위와 단일 기능을 가진 클래스를 설계해야 한다."
그렇다면 어떻게 단일 책임을 맡고 있는지 알 수 있을까?
위의 경우에 해당하는 경우 단일 책임 원칙을 잘 따르지 못한다고 말할 수 있다.
하지만 확실한 기준이 없으므로 각 상황에 맞게 잘 판단해야 한다.
코드 유지 관리성: 단일 책임 원칙을 준수하면 각 클래스에 특정 책임이 있으므로 특정 문제와 관련된 코드를 더 쉽게 찾고 수정할 수 있다. 이를 통해 의도하지 않은 부작용이 발생할 위험을 줄이고 유지 보수를 더 쉽게 할 수 있다.
코드 재사용성: 단일 책임이 있는 클래스는 다양한 컨텍스트에서 재사용할 수 있는 경향이 있다. 다른 클래스나 코드에 대한 의존도가 낮기 때문에 다양한 시나리오에서 더 쉽게 추출하고 재사용할 수 있다.
테스트 용이성: 클래스에 단일 책임이 있는 경우 집중적이고 세분화된 단위 테스트를 작성하는 것이 간단해진다. 책임이 적을수록 테스트 범위가 줄어들어, 보다 표적화되고 포괄적인 테스트가 가능하다.
책임 식별: 단일 책임 원칙을 효과적으로 적용하려면 먼저 코드베이스 내에서 책임을 식별해야 한다. 책임은 변화의 이유로 생각할 수 있다. 클래스의 어떤 측면이 독립적으로 바뀔 수 있는지 고려하고 그것들을 별도의 책임으로 파악한다.
리팩터링: 클래스 내에서 여러 책임을 식별한 후에는 각각 단일 책임이 있는 더 작고 집중적인 클래스로 리팩터링하는 것이 좋다. 이 프로세스에는 메서드, 속성을 추출하거나 완전히 새로운 클래스를 만드는 작업이 포함될 수 있다.
협업과 응집도: 단일 책임 원칙을 준수하는 것과 과도한 수의 작고 고립된 클래스로 나누는 것 사이의 균형을 맞추는 것이 중요하다. 또한, 높은 수준의 협업이 이루어지고 긴밀하게 상호 작용하는 클래스는 응집력 있는 컨텍스트를 유지하기 위해 함께 그룹화되어야 한다.
"수정에는 닫혀있지만, 확장에는 열려있어야 한다.
다시 말해, 새로운 기능을 추가할 때 기존 코드의 수정이 아닌 새로운 코드의 작성이 이루어져야 한다."
추상화와 다형성: 다른 클래스에서 사용할 공통 메서드가 있는 인터페이스와 추상 클래스를 정의할 수 있다. 이런 식으로 동일한 메서드 또는 클래스의 새 버전이 필요할 때마다 기본 클래스를 상속하고 새로운 구현으로 메서드를 재정의하기만하면 된다. 이러한 방식으로 이미 작동 중인 기능을 수정하지 않고도 코드를 쉽게 확장할 수 있다.
종속성 주입: 구체 클래스를 상속하는 것이 항상 좋은 생각은 아니며 때로는 추상 클래스도 마찬가지다. 많은 개발자가 상속 클래스가 기본 클래스의 정확한 유형이 아닌 경우 상속보다 컴포지션을 사용할 것을 제안한다.(Effective Java Item 18: 상속보다는 컴포지션을 사용하라) 메서드에서 특정 클래스의 인스턴스화를 하는 대신 해당 클래스를 주입받아 사용할 수 있다. 이러한 방식으로 기능에 의존하는 코드를 수정하지 않고도 기능을 쉽게 교체하거나 확장할 수 있다.
디자인 패턴: 개방 폐쇄 원칙 사용을 장려하는 다양한 디자인 패턴이 있다. 전략, 데코레이터 및 관찰자 패턴의 경우를 예로 들 수 있다. 이러한 디자인 패턴은 기존 코드를 수정하지 않고도 새로운 기능을 얻을 수 있게 설계되었다.
"상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다."
리스코프 치환 원칙은 상속의 동작에 중점을 둔다. 수퍼 클래스는 서브 클래스의 객체로 대체가 가능해야 한다. 서브 클래스의 경우 수퍼 클래스의 메서드 동작을 수정하여 성능을 높이거나 낮출 수 있지만 기본적인 동작에서 벗어나서는 안된다. 이 원칙은 수퍼 클래스를 서브 클래스로 대체하여 다른 방식으로 동작을 수행할 수 있는 권한을 제공하지만 입력, 출력, 예외 모두 수퍼 클래스를 따르며, 버그가 없는 코드를 보장한다.
파라미터 유효성 검사: 서브 클래스에 재정의된 메서드는 수퍼 클래스의 메서드와 동일한 입력 파라미터 값을 허용해야 한다. 즉, 서브 클래스에서 덜 엄격한 유효성 검사를 할 수는 있지만 더 엄격한 유효성 검사를 하면 안된다. 그렇지 않다면 수퍼 클래스의 메서드를 호출하는 코드가 서브 클래스와 함께 사용될 경우 예외가 발생할 수 있다.
Is-a 관계: 클래스를 상속할 때 서브 클래스가 수퍼 클래스와 "is-a" 관계를 가져야 한다. 서브 클래스가 수퍼 클래스의 모든 메서드와 필드를 가질 수 없거나 일부 필드 또는 메서드의 기본적인 동작을 변경해야 하는 경우 상속을 하면 안 된다.
인터페이스 사용: 서브 클래스에서 수퍼 클래스의 모든 메서드를 지원할 수 없는 경우가 생길 수 있다. 이럴 경우에는 상속을 받기보다 인터페이스를 이용해 구현하는 것이 좋은 방법이 될 수 있다. 이렇게 불필요한 상속을 피하고 상속에 대한 책임을 질 수 있다.
만약 Ellipse클래스와 이 클래스를 상속받는 Circle클래스가 아래와 같이 있다고 하자.
public class Ellipse {
protected double radiusX;
protected double radiusY;
public void setRadius(double radiusX, double radiusY) {
this.radiusX = radiusX;
this.radiusY = radiusY;
}
@Override
public String toString() {
return "Ellipse{" +
"radiusX=" + radiusX +
", radiusY=" + radiusY +
'}';
}
}
public class Circle extends Ellipse{
public void setRadius(double radius) {
this.radiusX = radius;
this.radiusY = radius;
}
}
public static void main(String[] args) {
Ellipse ellipse = new Circle();
ellipse.setRadius(5.0, 10,0); // 원의 정의를 깨뜨림
System.out.println(ellipse);
}
위의 코드를 보면 Ellipse타입에 구현체로 Circle클래스를 사용하고 있다.
그리고 setRadius메서드를 호출해 5.0과 10.0을 파라미터로 넣어주고 있는데, Circle은 Ellipse와 달리 radius파라미터를 하나만 받는데 Ellipse타입으로 사용하고 있기 때문에 Ellipse의 반지름을 받는 메서드를 호출하고 있는 것을 볼 수 있다.
그리고 해당 원을 출력하면 아래와 같은 결과를 볼 수 있다.
Ellipse{radiusX=5.0, radiusY=10.0}
명백히 리스코프 치환 원칙에 위배된 코드인 것이다.
클라이언트는 자신이 사용하는 메서드에만 의존해야 한다.
클라이언트는 자신이 사용하지 않는 메서드에 의존해서는 안된다는 것을 뜻한다.
예를 들어서 아래와 같은 코드가 있다.
public interface Worker {
void work();
void eat();
}
public class Developer implements Worker {
@Override
public void work() {
System.out.println("개발 작업을 수행합니다.");
}
@Override
public void eat() {
System.out.println("식사를 합니다.");
}
}
public class Robot implements Worker {
@Override
public void work() {
System.out.println("로봇 작업을 수행합니다.");
}
@Override
public void eat() { // 로봇은 식사를 하지 않다. 불필요한 구현
}
}
Developer클래스는 Worker인터페이스를 온전히 구현할 수 있지만, Robot클래스는 온전히 구현할 수 없다. 로봇은 식사를 하지 않기 때문이다. 즉, Robot 클래스는 자신이 사용하지 않는 메서드에 의존을 하고 있는 것이다. 이럴 경우 인터페이스 분리 원칙을 위반하고 있는 것이다.
이럴 경우 아래와 같이 인터페이스를 분리하여 해결할 수 있다.
public interface Worker {
void work();
}
public interface Eatable {
void eat();
}
public class Developer implements Worker, Eatable {
@Override
public void work() {
System.out.println("개발 작업을 수행합니다.");
}
@Override
public void eat() {
System.out.println("식사를 합니다.");
}
}
public class Robot implements Worker {
@Override
public void work() {
System.out.println("로봇 작업을 수행합니다.");
}
}
고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 된다. 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다.
먼저, 위에서 고수준 모듈과 저수준 모듈이 뜻하는 게 무엇인지 알 필요가 있다.
고수준 모듈: 의미있는 단일 기능을 제공하는 모듈
저수준 모듈: 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현
아래와 같은 코드가 있다.
public class Computer {
private Keyboard keyboard;
private Monitor monitor;
public Computer(final Keyboard keyboard, final Monitor monitor) {
this.keyboard = keyboard;
this.monitor = monitor;
}
public void start() {
keyboard.connect();
monitor.turnOn();
System.out.println("컴퓨터가 시작되었습니다.");
}
}
public interface Keyboard {
void connect();
}
public interface Monitor {
void turnOn();
}
public class StandardKeyboard implements Keyboard {
@Override
public void connect() {
System.out.println("키보드가 연결되었습니다.");
}
}
public class LEDMonitor implements Monitor {
@Override
public void turnOn() {
System.out.println("모니터가 켜졌습니다.");
}
}
public static void main(String[] args) {
Keyboard keyboard = new StandardKeyboard();
Monitor monitor = new LEDMonitor();
Computer computer = new Computer(keyboard, monitor);
computer.start();
}
여기서 고수준 모듈은 Computer로 저수준 모듈은 Keyboard, Monitor로 볼 수 있다.
그리고 Computer에서 직접 구현에 의존하는 것이 아닌 추상 타입인 Keyboard, Monitor에 의존하고 있다.
의존성 역전 원칙을 위반하는 코드는 아래와 같을 것이다.
public class Computer {
private StandardKeyboard keyboard;
private LEDMonitor monitor;
...
}
Computer가 Keyboard, Monitor의 구현인 StandardKeyboard와 LEDMonitor에 의존하고 있다.
고수준 모듈이 저수준 모듈의 구현에 의존하게 될경우 유연성, 확장성에 문제가 있다.
만약 새로운 유형의 키보드가 추가될 경우 Computer의 내부 구현이 변경되어야 한다.
즉, OCP를 위반하게 된다.
https://www.bmc.com/blogs/solid-design-principles/
https://medium.com/@shashikantrbl123/single-responsibility-principle-srp-4700d3c668aa
https://medium.com/@shashikantrbl123/open-closed-principle-ocp-3db28cc453bb
https://medium.com/@shashikantrbl123/liskovs-substitution-principle-lsp-ca9e218a7c54
https://stackify.com/solid-design-liskov-substitution-principle/