이미지 출처: smashingtips.com
SOLID 원칙은 객체 지향 프로그래밍에서 높은 품질의 소프트웨어를 개발하기 위한 다섯 가지 중요한 원칙을 가리키는 용어다. 이 원칙들은 소프트웨어를 더 이해하기 쉽고, 유지보수가 용이하며, 유연하게 확장할 수 있도록 설계하는 데 도움을 준다. SOLID 원칙은 특히 대규모 시스템 설계와 다양한 디자인 패턴을 이해하고 적용하는 데 필수적인 개념이다.
SOLID 원칙이란 객체지향 설계에서 지켜줘야 할 5개의 소프트웨어 개발 원칙( SRP, OCP, LSP, ISP, DIP )을 말한다.
인용: Inpa Dev 👨💻:티스토리
객체 지향 프로그래밍에 대해선 이전 포스트에서 아주 구체적으로 다뤘다. 해당 키워드에 대한 지식이 없다면, 바로 아래 링크에서 꼭 읽어볼 것을 추천한다.
[Computer Science] 객체 지향 프로그래밍 정복하기 (OOP, Object-Oriented Programming)
이 원칙들은 서로 독립적이지만, 개념적으로 연관되어 있다. 하나의 원칙을 지키는 것이 다른 원칙의 준수로 이어지며, 그 결과 더 나은 객체 지향 설계를 구현할 수 있다.
단일 책임 원칙은 클래스는 오직 하나의 책임만 가져야 한다는 원칙이다. 여기서 책임이란 '하나의 기능 담당'을 의미하며, 하나의 클래스는 하나의 기능을 중심으로 설계되어야 한다. 이렇게 하면 기능 변경 시 수정해야 할 코드가 줄어들고, 코드의 유지보수성이 향상된다.
예를 들어, ReportGenerator
라는 클래스가 보고서의 생성, 저장, 그리고 출력 기능을 모두 담당하고 있다면, 이 클래스는 SRP 원칙을 위반하고 있는 것이다. 각 기능을 별도의 클래스로 분리하여, ReportGenerator
는 보고서 생성만 담당하고, 저장은 ReportSaver
, 출력은 ReportPrinter
라는 클래스로 각각 나누는 것이 SRP 원칙에 부합하는 설계이다.
class ReportGenerator {
public String generateReport() {
// 보고서 생성 로직
}
}
class ReportSaver {
public void saveReport(String report) {
// 보고서 저장 로직
}
}
class ReportPrinter {
public void printReport(String report) {
// 보고서 출력 로직
}
}
이렇게 분리하면, 저장 방법이 바뀌어도 ReportGenerator
나 ReportPrinter
클래스는 수정할 필요가 없게 되어 유지보수가 쉬워진다.
개방 폐쇄 원칙은 클래스는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다는 원칙이다. 즉, 새로운 기능이 필요할 때 기존 코드를 수정하지 않고도 기능을 확장할 수 있도록 설계해야 한다.
기능을 확장하면서 기존 코드를 변경하지 않으려면, 주로 추상화를 사용한다. 예를 들어, 여러 종류의 도형(원, 사각형, 삼각형 등)을 그리는 프로그램을 설계한다고 가정해보자. OCP 원칙에 따라 Shape
라는 추상 클래스를 정의하고, 각 도형 클래스는 이 Shape
클래스를 상속받아 자신만의 draw()
메서드를 구현한다.
abstract class Shape {
abstract void draw();
}
class Circle extends Shape {
@Override
void draw() {
System.out.println("Drawing a Circle");
}
}
class Rectangle extends Shape {
@Override
void draw() {
System.out.println("Drawing a Rectangle");
}
}
class Triangle extends Shape {
@Override
void draw() {
System.out.println("Drawing a Triangle");
}
}
이제 새로운 도형이 추가되더라도 기존의 Shape
클래스나 다른 도형 클래스들을 수정할 필요 없이, 새로운 클래스만 추가하면 된다.
리스코프 치환 원칙은 서브 타입은 언제나 기반(부모) 타입으로 교체할 수 있어야 한다는 원칙이다. 즉, 하위 클래스의 인스턴스는 언제나 상위 클래스의 인스턴스를 대신할 수 있어야 하며, 프로그램의 동작은 정상적으로 유지되어야 한다.
LSP 원칙은 다형성을 보장하기 위한 중요한 원칙이다. 예를 들어, Bird
라는 상위 클래스와 이를 상속받는 Penguin
과 Sparrow
라는 하위 클래스가 있다고 가정해보자. 모든 Bird
클래스는 fly()
메서드를 가지고 있지만, 펭귄은 날 수 없기 때문에 fly()
메서드를 오버라이딩하여 예외를 던진다면, LSP 원칙을 위반하게 된다.
class Bird {
void fly() {
System.out.println("Flying...");
}
}
class Sparrow extends Bird {
@Override
void fly() {
System.out.println("Sparrow flying...");
}
}
class Penguin extends Bird {
@Override
void fly() {
throw new UnsupportedOperationException("Penguins can't fly!");
}
}
이 경우, Penguin
클래스를 Bird
타입으로 대체할 수 없으므로, LSP 원칙에 위배된다. 이를 해결하기 위해 Bird
클래스를 상속받는 것이 아니라, 새로운 NonFlyingBird
클래스를 정의하여 Penguin
을 이 클래스에서 상속받게 설계할 수 있다.
인터페이스 분리 원칙은 인터페이스는 그 인터페이스를 사용하는 클라이언트에 맞게 최소한으로 분리되어야 한다는 원칙이다. 즉, 클라이언트가 자신이 사용하지 않는 메서드에 의존하지 않도록, 인터페이스를 작고 구체적으로 나누어야 한다.
한 인터페이스가 너무 많은 메서드를 제공하면, 그 인터페이스를 구현하는 클래스는 필요 없는 메서드까지 구현해야 하는 불편함이 생긴다. 예를 들어, Worker
라는 인터페이스가 eat()
, work()
, sleep()
메서드를 가지고 있다고 가정해보자. 이 인터페이스를 로봇(Robot) 클래스가 구현하려고 할 때, 로봇은 eat()
과 sleep()
메서드가 필요 없을 수 있다. 이 경우 인터페이스를 분리하여 문제를 해결할 수 있다.
interface Workable {
void work();
}
interface Eatable {
void eat();
}
interface Sleepable {
void sleep();
}
class HumanWorker implements Workable, Eatable, Sleepable {
@Override
public void work() { /* 작업 로직 */ }
@Override
public void eat() { /* 식사 로직 */ }
@Override
public void sleep() { /* 수면 로직 */ }
}
class RobotWorker implements Workable {
@Override
public void work() { /* 작업 로직 */ }
// 로봇은 eat()과 sleep() 메서드가 필요 없음
}
이렇게 하면 각 클래스는 자신이 필요한 메서드만 구현하게 되어 더 깔끔하고 유연한 설계를 할 수 있다.
SRP와 ISP는 비슷한 목적을 가지고 있지만, 그 적용 대상과 방식이 다르다. SRP는 클래스나 모듈의 책임을 명확히 하고, ISP는 인터페이스를 작고 구체적으로 만들어 클라이언트가 불필요한 의존성을 가지지 않도록 한다.
의존 역전 원칙은 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다는 원칙이다. 즉, 구체적인 클래스에 의존하지 말고, 추상화된 인터페이스나 상위 클래스에 의존하도록 설계해야 한다.
예를 들어, Button
클래스가 Lamp
클래스를 직접 제어한다고 가정해보자. 이 경우 Button
클래스는 Lamp
클래스에 강하게 결합되어 있으므로, DIP 원칙을 위반하고 있다.
class Lamp {
public void turnOn() {
System.out.println("Lamp is on");
}
public void turnOff() {
System.out.println("Lamp is off");
}
}
class Button {
private Lamp lamp;
public Button(Lamp lamp) {
this.lamp = lamp;
}
public void press() {
if (/* some condition */) {
lamp.turnOn();
} else {
lamp.turnOff();
}
}
}
DIP 원칙을 적용하면, Button
클래스는 Lamp
클래스가 아니라 Switchable
이라는 추상화된 인터페이스에 의존하게 된다.
interface Switchable {
void turnOn();
void turnOff();
}
class Lamp implements Switchable {
@Override
public void turnOn() {
System.out.println("Lamp is on");
}
@Override
public void turnOff() {
System.out.println("Lamp is off");
}
}
class Button {
private Switchable device;
public Button(Switchable device) {
this.device = device;
}
public void press() {
if (/* some condition */) {
device.turnOn();
} else {
device.turnOff();
}
}
}
이제 Button
클래스는 Lamp
클래스뿐만 아니라 Fan
이나 TV
와 같은 다른 Switchable
장치들도 제어할 수 있게 되었다.