SMALL
SOLID 원칙
SOLID 원칙은 객체 지향 설계를 위한 다섯 가지 기본 원칙을 말한다.
SOLID 원칙의 이론들은 추상화, 상속, 다형성, 인터페이스 등의 개념들을 재정립한 것이다. 그리고 이 5가지 원칙들은 서로 독립된 개념이 아니라, 서로 개념적으로 깊은 연관성을 가진다.
1. 단일 책임 원칙 (Single Responsibility Principle)
- 하나의 클래스는 단 하나의 책임만 가져야 한다는 원칙이다. 여기서 책임은 기능을 의미한다.
- 클래스를 변경하는 이유는 단 하나여야 한다.
- 한 객체에 책임이 많아질수록 클래스 내부에서 서로 다른 역할을 수행하는 코드끼리 강하게 결합될 가능성이 높아지게 되어 결국 시스템이 복잡해질 수 있다. 즉, A 기능을 수정하면 B도 고쳐야 하고, B를 고치다 보면 C도 수정해야 하고, 결국 다시 A를 손봐야 하는 악순환이 발생할 수 있다.
- 단일 책임 원칙을 지키면, 각 클래스마다 한 가지의 책임을 가짐으로써 책임 영역이 확실해진다. 그래서 특정 기능에 대한 변경사항이 발생했을 때, 변경 영향을 받는 클래스만 수정해주면 된다.
- SRP를 지키면 코드의 가독성과 유지보수성이 향상되며, 변경사항이 발생해도 수정해야 할 부분이 명확해진다.
[잘못된 코드]
- 아래의 코드는 클래스를 수정할 이유가 단 하나가 아닌 여러 개다. 다른 말로 하면, 책임의 갯수가 여러개다.
- 책임들 중 하나를 수정하는 작업이 다른 책임에 의도치 않은 영향을 끼칠 수도 있다. 여러 책임이 한 클래스에 얽혀 있기 때문에 리팩토링도 까다로워지게 된다.
public class UserService {
public void saveUser(User user) {
// 사용자의 정보를 데이터베이스에 저장하는 일
System.out.println("User saved to database: " + user.getName());
}
public void sendWelcomeEmail(User user) {
// 가입한 사용자에게 환영 이메일을 보내는 일
System.out.println("Welcome email send to: " + user.getEmail());
}
public void logUserActivity(User user) {
// 각 사용자의 활동을 기록하는 일
System.out.println("Logging activity for user: " + user.getName());
}
}
[올바른 코드]
- UserService 클래스 한 곳에 몰아넣었던 3가지 기능을 3개의 클래스로 분리했다.
- 각 클래스는 자신이 맡은 책임에 변경할 내용이 있을 때에만 코드를 수정하면 되고, 책임별로 클래스가 분리되어 있기 때문에 필요에 따라 재사용하기도 용이하다.
public class UserService {
public void saveUser(User user) {
// 사용자의 정보를 데이터베이스에 저장하는 일
System.out.println("User saved to database: " + user.getName());
}
}
public class EmailService {
public void sendWelcomeEmail(User user) {
// 가입한 사용자에게 환영 이메일을 보내는 일
System.out.println("Welcome email send to: " + user.getEmail());
}
}
public class UserActivityLogger {
public void logUserActivity(User user) {
// 각 사용자의 활동을 기록하는 일
System.out.println("Logging activity for user: " + user.getName());
}
}
2. 개방-폐쇄 원칙 (Open-Closed Principle)
- 확장에는 열려있어야 하고, 변경에는 닫혀있어야 한다는 원칙이다. 확장이란 새로운 기능이 추가됨을 의미한다.
- 즉, 기존의 코드를 변경하지 않으면서 기능을 추가할 수 있도록 설계해야 한다는 원칙이다.
- OCP를 만족하려면 추상화를 활용해야 한다. 추상화가 적용된 인터페이스나 부모 클래스를 통해 기능을 확장하면, 기존 코드에 영향을 주지 않고 새로운 기능을 추가할 수 있다. 다형성과 확장을 가능케 하는 객체지향의 장점을 극대화하는 설계 원칙이다.
[잘못된 코드]
- 아래의 코드는 PDF와 HTML만 레포트문서를 생성하는 프로그램이다. 만약, XML 등의 다른 형식의 문서를 생성할 필요가 있을 때마다 해당 메서드를 수정해야 한다.
- 새로운 케이스들이 추가되면 코드가 길어짐에 따라 가독성이 떨어지고, 그 과정에서 실수로 기존 코드에 영향을 주게 될 수 있다. 또한, 이 메서드가 이미 사용되던 곳들에서 에러가 발생할 수 있다.
public class ReportGenerator { // 요구받은 방식으로 레포트 문서 생성하는 프로그램
public void generateReport(String type) {
if(type.equals("PDF") {
System.out.println("Generating PDF report...");
} else if(type.equals("HTML") {
System.out.println("Generating HTML report...");
}
}
}
[올바른 코드]
- 우선, 문서를 생성하는 인터페이스를 정의해둔다. 해당 인터페이스를 적용한 클래스들이 메서드를 오버라이드해서 각자의 방식으로 문서를 생성하는 책임을 수행한다.
- 이후에도 새로운 형식이 필요하면 해당 인터페이스를 적용한 다른 클래스를 생성하면 된다. 이로써 기존의 코드를 수정하는 과정에서 발생할 수 있는 문제들로부터 자유로운 설계가 가능하다.
public interface Report {
void generate();
}
public class PDFReport implements Report {
@Override
public void generate() {
System.out.println("Generating PDF report...");
}
}
public class HTMLReport implements Report {
@Override
public void generate() {
System.out.println("Generating HTML report...");
}
}
public class XMLReport implements Report {
@Override
public void generate() {
System.out.println("Generating XML report...");
}
}
3. 리스코프 치환 원칙 (Liskov Substitution Principle)
- 서브 타입은 언제나 자신의 기반(부모) 타입으로 교체할 수 있어야 한다는 원칙이다.
- 즉, 부모 클래스의 인스턴스를 사용하는 곳에서 자식 클래스의 인스턴스로 바꿔도 프로그램이 정상적으로 동작해야 한다는 원칙이다. 한마디로 다형성을 지원하기 위한 원칙이다.
- 주의할 점은, 상속은 부모 클래스와 자식 클래스 사이에 IS-A 관계일 경우로만 제한되어야 한다. 그 외의 경우에는 합성(HAS-A 관계)을 이용하는 것이 좋다.
- 따라서, 다형성을 이용하고 싶다면 extends 대신 인터페이스로 implements하여 인터페이스 타입으로 사용하는 것이 권장되며, 상위 클래스의 기능을 이용하거나 재사용하고 싶다면 상속보다는 합성으로 구성하는 것이 권장된다.
[잘못된 코드]
- Penguin 클래스는 Bird의 일종이기 때문에 Bird 클래스를 상속 받는다. 하지만 펭귄은 날지 못하는 새이기 때문에 fly 메서드를 실행하지 못한다.
- 부모 클래스에서는 정상 동작하던 fly 메서드가 자식 클래스에서는 예외를 발생시킨다.
public class Bird {
public void fly() {
System.out.println("Bird is flying");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
// 펭귄은 날지 못함
throw new UnsupportedOperationException("Penguins cannot fly");
}
}
[올바른 코드]
- fly 메서드를 인터페이스 Flyable로 분리하여 Bird 클래스의 자식들 중 날 수 있는 것에만 적용할 수 있도록 해야 한다.
- Sparrow 클래스는 날 수 있는 새이기 때문에 Flyable 인터페이스를 구현했고, Penguin 클래스는 Flyable 인터페이스를 구현하지 않았다.
- 결과적으로 Penguin 클래스와 Sparrow 클래스가 Bird 부모 클래스를 대체해도 프로그램이 정상 작동하게 된다.
public interface Flyable {
void fly();
}
public class Bird {
public void eat() {
System.out.println("Bird is eating");
}
}
public class Sparrow extends Bird implements Flyable {
@Override
public void fly() {
System.out.println("Sparrow is flying");
}
}
public class Penguin extneds Bird {
// 펭귄은 날지 못하기 때문에 Flyable 인터페이스를 확장하지 않음
}
4. 인터페이스 분리 원칙 (Interface Segregation Principle)
- 클라이언트는 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다는 의미에서 인터페이스를 사용에 맞게끔 각기 분리해야 한다는 원칙이다.
- 즉, 하나의 인터페이스에 너무 많은 기능이 포함되지 않도록, 작은 인터페이스 여러 개로 분리해야 한다는 원칙이다. 인터페이스를 확장하는 클래스가 불필요한 기능을 강제로 구현하지 않도록 설계해야 한다.
- SRP 원칙이 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조한다고 말할 수 있다.
- 주의할 점은 인터페이스를 분리하여 구성해놓은 상태에서 나중에 수정사항이 생겨서 또 인터페이스들을 분리하는 행위는 지양된다.
[잘못된 코드]
- 노동자에게 적용하기 위한 Worker 인터페이스는 work 메서드와 eat 메서드가 있다. 이 인터페이스를 확장하는 클래스는 반드시 두 가지 메서드를 구현해야 한다.
- Robot 클래스는 식사를 하지 않기 때문에 eat 메서드는 잉여 메서드가 된다.
public interface Worker {
void work();
void eat();
}
public class Robot implements Worker {
@Override
public void work() {
System.out.println("Robot is working");
}
@Override
public void eat() {
// 로봇은 식사를 하지 못함
throw new UnsupportedOperationException("Robots do not eat");
}
}
[올바른 코드]
- Workable 인터페이스와 Eatable 인터페이스로 구분해서 Robot 클래스는 Workable 인터페이스만 확장하도록 수정했다.
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public class Robot implements Workable {
@Override
pubilc void work() {
System.out.println("Robot is working");
}
}
5. 의존 역전 원칙 (Dependency Inversion Principle)
- 상위(고수준) 모듈이 하위(저수준) 모듈에 직접 의존하지 말고, 추상화(인터페이스)에 의존해야 한다는 원칙이다.
- 즉, 객체에서 어떤 클래스를 참조해야 하는 상황이 생기면 구체적인 클래스가 아닌 "추상화된 인터페이스"에 의존하도록 설계해야 한다는 원칙이다.
- 하위 모듈의 구체적인 내용에 클라이언트가 의존하게 되면 하위 모듈에 변화가 있을 때마다 클라이언트나 상위 모듈의 코드를 자주 수정해야 하기 때문이다.
[잘못된 코드]
- 여기서 Fan 클래스는 구체적인 동작을 직접 구현하기 때문에 저수준 모듈, Switch 클래스는 이를 제어하는 추상화된 로직을 제공하므로 고수준 모듈이다.
- 아래의 코드에서는 고수준 모듈인 Switch 클래스가 저수준 모듈인 Fan 클래스에 의존적이다. spin, stop 메서드를 비롯한 Fan 클래스의 코드들이 Switch 클래스 내부에서 그대로 사용되고 있기 때문이다. 만약 이 메서드들의 이름이나 매개변수가 변경된다면 Switch 클래스의 실행부도 같이 수정되어야 한다.
- 이러한 구조에서는 Switch 클래스가 Fan 클래스 외의 다른 클래스를 다룰 수 없다.
//저수준 모듈
public class Fan {
public void spin() {
System.out.println("Fan is spinning");
}
public void stop() {
System.out.println("Fan is stopping");
}
}
//고수준 모듈
public class Switch {
private Fan fan;
public Switch(Fan fan) {
this.fan = fan;
}
public void turnOn() {
fan.spin();
}
public void turnOff() {
fan.stop();
}
}
[올바른 코드]
- Switchable 인터페이스를 생성해 Fan 클래스에서 이 인터페이스를 확장한다. 그리고 Switch 클래스는 Switchable 인터페이스에 속하는 클래스의 객체를 속성으로 가지고 그 객체의 turnOn, turnOff 메서드를 실행하게 된다.
- Switch 클래스와 Fan 클래스와 같은 구체적인 클래스에 직접 의존하지 않고 Switchable 인터페이스에 의존하게 된다. Fan 클래스의 메서드가 수정되어도 Switch 클래스에 영향을 주지 않게 되고, Switch 클래스는 Fan 클래스 뿐만 아니라 Switchable 인터페이스를 적용한 어느 클래스들도 다룰 수 있게 된다.
public interface Switchable {
void turnOn();
void turnOff();
}
// Switchable을 확장한 저수준 모듈
public class Fan implements Switchable {
@Override
public void turnOn() {
System.out.println("Fan is spinning");
}
@Override
public void turnOff() {
System.out.println("Fan is stopping");
}
}
// 고수준 모듈
public class Switch {
private Switchable device;
public Switch(Switchable device) {
this.device = device;
}
public void turnOn() {
device.turnOn();
}
public void turnOff() {
device.turnOff();
}
}