
SOLID 원칙은 객체 지향 설계의 5가지 핵심 원칙
유지보수성과 확장성을 높이기 위해 로버트 C. 마틴(Uncle Bob)이 정립한 개념
SRP : 단일 책임 원칙(Single Responsibility Principle)
OCP : 개방-폐쇄 원칙 (Open/Closed Principle)
LSP : 리스코프 치환 원칙 (Liskov Substitution Principle)
ISP : 인터페이스 분리 원칙 (Interface Segregation Principle)
DIP : 의존관계 역전 원칙 (Dependency Inversion Principle)
“한 클래스는 하나의 책임만 가져야 한다.”
예제코드
SRP를 적용하지 않은 코드
public class Employee { public void calculatePayment() { // 월급 계산 로직 } public void reportHours() { // 근무 시간 보고 로직 } public void saveDatabase() { // DB 저장 로직 } public void calculateExtraHour() { // 초과 근무 시간 계산 로직 } }SRP를 적용한 코드
public class PaymentCalculator { public void calculatePayment(Employee employee) { // 월급 계산 로직 } } public class HourReporter { public void reportHours(Employee employee) { // 근무 시간 보고 로직 } } public class EmployeeRepository { public void save(Employee employee) { // DB 저장 로직 } } public class ExtraHourCalculator { public void calculateExtraHour(Employee employee) { // 초과 근무 시간 계산 로직 } }
예제코드를 예시로 들어서 설명을 해보겠습니다.
SRP를 적용하지 않은 코드를 보면,
월급 계산 로직에서 에러가 터졌을 때,
Employee에 메서드들을 모두 확인해봐야합니다. (calculatePayment, reportHours, saveDatabase, calculateExtraHour와 같은 메서드들)
하지만, SRP를 적용한 코드를 보면,
월급 계산 로직에서 에러가 터졌을 때,
PaymentCalculator의 calculatePayment 메소드만 보면 되서, 유지보수가 용이해집니다.
Question
한 클래스당 하나의 책임? 책임이라는게 뭐지???
Answer
책임이라는 것은 사실 추상적인 말 입니다. (큰 책임과 작은 책임처럼)
Question
그렇다면, 어떻게 해야지 SRP를 잘 지킬 수 있나요?
Answer
사실 이 부분이 제일 어려운 부분이긴한데,
어떤 코드가 에러가 났을 때,
파급력이 적으면 적을수록 SRP를 잘 지켰다고 보면 됩니다.
그래서, 이 SRP가 사실 개발자들이 제일 지키기 어려운 원칙이라고 볼 수 있죠
”소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.”
예제코드
OCP를 적용하지 않은 코드
public class FileSaver { public void save(String fileName, String dbType) { if (dbType.equals("oracle")) { System.out.println(fileName + "을 Oracle에 저장합니다."); } else if (dbType.equals("mysql")) { System.out.println(fileName + "을 MySQL에 저장합니다."); } else if (dbType.equals("mongodb")) { System.out.println(fileName + "을 MongoDB에 저장합니다."); } // 새로운 DB가 추가될 때마다 else if 추가 필요! } }OCP를 적용한 코드
// DB 파일 저장 인터페이스 public interface FileSaver { void save(String fileName); } // Oracle DB 파일 저장 구현체 public class OracleFileSaver implements FileSaver { @Override public void save(String fileName) { System.out.println(fileName + "을 Oracle에 저장합니다."); } } // MySQL DB 파일 저장 구현체 public class MySQLFileSaver implements FileSaver { @Override public void save(String fileName) { System.out.println(fileName + "을 MySQL에 저장합니다."); } }
시스템을 확장할 때,
서버 코드 변경없이 클라이언트 코드의 추가만으로 확장할 수 있습니다.
이렇게 구현해서 단위 테스트를 할 때,
가짜 객체(구현체)인 Mock객체를 이용해서 체계가 명확한 단위 테스트를 할 수 있습니다.
Question
서버 코드? 클라이언트 코드? 그게 뭐지??
Answer
서버 코드는 기능 제공자를 의미합니다.
예제코드에서, 기능 제공자는 FileSaver라고 볼 수 있겠죠.
클라이언트 코드는 기능 사용자를 의미합니다.
예제코드에서, MySQLFileSaver, OracleFileSaver라고 볼 수 있겠죠.
Question
아니 확장할려면 코드를 변경해야하는데, 무슨소리이지??
Answer
이것을 이해하려면 확장과 변경에 대한 이해를 할 필요가 있습니다.
확장은 예제코드에, PostgreSQL DB를 넣고 싶다하면,
그거에 맞는 구현체(템플릿, 틀)에 넣어서 그 안에 내용만 바꿔주면 됩니다.
이런 것은 확장에 해당합니다.
-> 클라이언트 코드만 변경
변경은 PostgreSQL DB를 넣고 싶다하면,
else if문을 또 추가해줘야 합니다.
이렇게 서버의 코드를 변경하는 것이 변경입니다.
Question
엥? 난 저런 서버 코드 짠 적 없는데 왜 됨?
Answer
사실 Spring이 Spring DI Container를 통해서, 빈으로 등록하고 그것을 주입하는 과정을 이미 따로 해주고 있기 때문에, 그냥 됩니다.
@RequiredArgsConstructor가 빈을 주입받는 역할을 합니다.
”프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스를 바꿀 수 있어야 한다.”
예제코드
LSP를 적용하지 않은 코드
// 부모 클래스 public class Rectangle { protected int width; protected int height; public void setWidth(int width) { this.width = width; } public void setHeight(int height) { this.height = height; } public int getArea() { return width * height; } } // 자식 클래스 public class Square extends Rectangle { @Override public void setWidth(int width) { this.width = width; this.height = width; // 정사각형은 항상 가로=세로 } @Override public void setHeight(int height) { this.width = height; this.height = height; // 정사각형은 항상 가로=세로 } } // 사용 예시 (클라이언트 코드) public class Main { public static void main(String[] args) { Rectangle rectangle = new Square(); rectangle.setWidth(5); rectangle.setHeight(10); // 기대값: 5*10=50, 실제값: 10*10=100 (LSP 위반) System.out.println(rectangle.getArea()); } }LSP를 적용한 코드
// 도형 인터페이스 public interface Shape { int getArea(); } // 직사각형 public class Rectangle implements Shape { private int width; private int height; public Rectangle(int width, int height) { this.width = width; this.height = height; } public int getArea() { return width * height; } } // 정사각형 public class Square implements Shape { private int side; public Square(int side) { this.side = side; } public int getArea() { return side * side; } } // 사용 예시 public class Main { public static void main(String[] args) { Shape rect = new Rectangle(5, 10); Shape square = new Square(10); System.out.println(rect.getArea()); // 50 System.out.println(square.getArea()); // 100 } }
LSP가 적용되지 않은 코드에서는 부모 클래스에 대한 완전한 기능을 제공하지 않기 때문에, 부모 클래스를 재사용할 수 없습니다.
-> 부모 클래스를 사용할 수 없다는 것은 매우 비효율적입니다.
그냥 쓸모없는 클래스 하나 만드는 셈이죠.
Question
자식 클래스가 부모 클래스를 완전히 대체해야함?
Answer
네, 그렇게 해야지 부모 클래스를 100% 다 활용할 수 있습니다.
”특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.”
예제코드
ISP를 적용하지 않은 코드
// 모든 기능을 한 인터페이스에 몰아넣음 (ISP 위반) public interface Machine { void print(Document doc); void scan(Document doc); void fax(Document doc); } // 모든 기능 구현 (문제 없음) public class MultiFunctionPrinter implements Machine { public void print(Document doc) { /* ... */ } public void scan(Document doc) { /* ... */ } public void fax(Document doc) { /* ... */ } } // 단일 기능만 필요한 경우에도 불필요한 메서드 구현 강제 public class SimplePrinter implements Machine { public void print(Document doc) { /* ... */ } public void scan(Document doc) { /* 필요없지만 구현해야 함 */ } public void fax(Document doc) { /* 필요없지만 구현해야 함 */ } }ISP를 적용한 코드
// 역할별로 인터페이스 분리 public interface Printer { void print(Document doc); } public interface Scanner { void scan(Document doc); } public interface Fax { void fax(Document doc); } // 필요한 기능만 구현 public class SimplePrinter implements Printer { public void print(Document doc) { /* ... */ } } public class SimpleScanner implements Scanner { public void scan(Document doc) { /* ... */ } } // 복합기는 여러 인터페이스를 조합해서 구현 가능 public class MultiFunctionPrinter implements Printer, Scanner, Fax { public void print(Document doc) { /* ... */ } public void scan(Document doc) { /* ... */ } public void fax(Document doc) { /* ... */ } }
ISP를 사용하지 않으면, 단순하게 만들 수 있는 클래스들도, 복잡하게 만들어집니다.
하지만, ISP를 지키면, 최소한의 인터페이스로 필요없는 메소드들을 버려서 더욱 효율적으로 코드를 짤 수 있습니다.
Question
Interface를 쪼개는 조건이 무엇인가요?
Answer
구현체가 사용하지 않는 메서드까지 구현해야 한다면, 쪼개는 게 맞습니다.
”프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안된다.”
예제코드
DIP를 적용하지 않은 코드
// DIP 위반: 구체 클래스에 직접 의존 public class EmailMessenger { public void sendNotification(String message) { System.out.println("이메일 전송: " + message); } } public class NotificationService { private final EmailMessenger messenger = new EmailMessenger(); public void send(String message) { messenger.sendNotification(message); } }DIP를 적용한 코드
// 1. 추상화(인터페이스) 정의 public interface Messenger { void sendNotification(String message); } // 2. 구현체들 public class EmailMessenger implements Messenger { public void sendNotification(String message) { System.out.println("이메일 전송: " + message); } } public class SMSMessenger implements Messenger { public void sendNotification(String message) { System.out.println("SMS 전송: " + message); } } // 3. 상위 모듈(비즈니스 로직)은 추상화에만 의존 public class NotificationService { private final Messenger messenger; // 생성자 주입 public NotificationService(Messenger messenger) { this.messenger = messenger; } public void send(String message) { messenger.sendNotification(message); } } // 4. 사용 예시 public class Main { public static void main(String[] args) { Messenger messenger = new EmailMessenger(); // 또는 new SMSMessenger(); NotificationService service = new NotificationService(messenger); service.send("구입 내역 안내"); } }
이렇게 하면, 그냥 클라이언트 코드에서 내가 쓸 구현체만 딸깍 넣으면 되기 때문에 아주 좋다.
Question
근데 서버에서 코드 바꾸는 거나 클라이언트에서 코드 바꾸는거나 거기서 거기 아님??
Answer
서버 코드가 바뀔 때마다, 클라이언트의 코드가 바뀐다는 것은 유지보수가 매우 어렵기 때문에, 클라이언트 코드에서 제어하는 것을 지향합니다
이것을 제어의 역전이라 합니다.
-> Inversion Of Control IOC