객체지향 설계에서 유지보수성과 확장성을 높이기 위한 5가지 설계 원칙이다.
구성은 다음과 같다.
하나의 클래스는 하나의 책임만 가져야 한다.
즉, 클래스가 변경되어야 하는 이유는 하나여야 한다.
public class Invoice {
private String customer;
private double amount;
public Invoice(String customer, double amount) {
this.customer = customer;
this.amount = amount;
}
public double calculateTax() {
return amount * 0.1;
}
public void printInvoice() {
System.out.println("Customer: " + customer);
System.out.println("Amount: " + amount);
System.out.println("Tax: " + calculateTax());
}
public void saveToDatabase() {
System.out.println("Saving invoice to database...");
}
}
Invoice 클래스가
까지 모두 담당한다.
즉, 하나의 클래스가 너무 많은 역할을 가지고 있다.
책임을 역할별로 분리한다.
기능을 나누면 수정 영향 범위가 줄고, 유지보수가 쉬워진다.
소프트웨어 요소는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다.
public class Sorter {
public void sort(int[] arr, String algorithm) {
if (algorithm.equals("bubble")) {
bubbleSort(arr);
} else if (algorithm.equals("quick")) {
quickSort(arr, 0, arr.length - 1);
} else if (algorithm.equals("merge")) {
mergeSort(arr, 0, arr.length - 1);
} else {
System.out.println("Unsupported sort algorithm.");
}
}
private void bubbleSort(int[] arr) { /* ... */ }
private void quickSort(int[] arr, int low, int high) { /* ... */ }
private void mergeSort(int[] arr, int low, int high) { /* ... */ }
}
Sorter 클래스가 정렬 알고리즘 이름을 문자열로 받아서
if-else로
를 구분한다.
새 정렬 알고리즘이 추가될 때마다 Sorter 코드를 직접 수정해야 한다.
SortStrategy 인터페이스를 만들고,
public interface SortStrategy {
void sort(int[] arr);
}
public class BubbleSort implements SortStrategy {
public void sort(int[] arr) {
System.out.println("Using Bubble Sort");
// 실제 버블 정렬 알고리즘 구현
}
}public class QuickSort implements SortStrategy {
public void sort(int[] arr) {
System.out.println("Using Quick Sort");
// 실제 퀵 정렬 알고리즘 구현
}
}public class MergeSort implements SortStrategy {
public void sort(int[] arr) {
System.out.println("Using Merge Sort");
// 실제 병합 정렬 알고리즘 구현
}
}가 이를 구현하도록 만든다.
Sorter는 SortStrategy에만 의존하고, 실제 알고리즘은 주입받는다.
public class Sorter {
private SortStrategy strategy;
public Sorter(SortStrategy strategy) {
this.strategy = strategy;
}
public void sort(int[] arr) {
strategy.sort(arr);
}
}
새 기능을 추가할 때 기존 코드를 고치지 않고,
새 클래스를 추가하는 방식으로 확장할 수 있어야 한다.
public class Main {
public static void main(String[] args) {
int[] data = {5, 2, 8, 1, 3};
// Bubble Sort 사용
Sorter sorter = new Sorter(new BubbleSort());
sorter.sort(data);
// Quick Sort로 변경 (기존 코드 수정 없이 확장)
sorter = new Sorter(new QuickSort());
sorter.sort(data);
}
}
자식 클래스는 부모 클래스를 대체할 수 있어야 한다.
즉, 부모 타입 자리에 자식 객체를 넣어도 프로그램이 정상 동작해야 한다.
Bird 클래스는 fly()를 가진다.
class Bird {
public void fly() {
System.out.println("Bird is flying");
}
}
class Ostrich extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Ostriches can't fly");
}
}
그런데 Ostrich가 Bird를 상속받으면서 fly()에서 예외를 던진다.
타조는 새이지만 날 수 없기 때문에, 부모의 행동 계약을 깨뜨린다.
public class Main {
public static void makeBirdFly(Bird bird) {
bird.fly();
}
public static void main(String[] args) {
Bird bird = new Bird();
Bird ostrich = new Ostrich();
makeBirdFly(bird); // OK
makeBirdFly(ostrich); // Runtime error! violates LSP
}
}
이 경우 Bird 자리에 Ostrich를 넣으면 런타임 오류가 발생한다.
인터페이스를 더 적절하게 분리한다.
interface Bird {
void layEggs();
}
interface FlyingBird extends Bird {
void fly();
}
class Sparrow implements FlyingBird {
@Override
public void fly() {
System.out.println("Sparrow is flying");
}
@Override
public void layEggs() {
System.out.println("Sparrow lays eggs");
}
}class Ostrich implements Bird {
@Override
public void layEggs() {
System.out.println("Ostrich lays eggs");
}
}상속은 단순한 “is-a 관계”만 보면 안 되고,
행동까지 대체 가능해야 한다.
public class Main {
public static void makeFlyingBirdFly(FlyingBird bird) {
bird.fly();
}
public static void main(String[] args) {
FlyingBird sparrow = new Sparrow();
Bird ostrich = new Ostrich();
makeFlyingBirdFly(sparrow); // OK
// makeFlyingBirdFly(ostrich); // 컴파일 에러 – LSP 위반을 방지
}
}
클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안 된다.
즉, 큰 인터페이스(비만 인터페이스) 하나보다 작은 인터페이스 여러 개가 낫다.
interface Worker {
void work();
void eat();
}
Worker 인터페이스가
를 모두 가진다.
class HumanWorker implements Worker {
@Override
public void work() {
System.out.println("Human is working");
}
@Override
public void eat() {
System.out.println("Human is eating");
}
}
class RobotWorker implements Worker {
@Override
public void work() {
System.out.println("Robot is working");
}
@Override
public void eat() {
throw new UnsupportedOperationException("Robots don't eat");
}
}
사람은 괜찮지만, 로봇은 먹지 않으므로 eat()를 구현할 수 없다.
그래서 RobotWorker는 예외를 던지게 된다.
인터페이스를 분리한다.
interface Workable {
void work();
}
interface Eatable {
void eat();
}
class HumanWorker implements Workable, Eatable {
@Override
public void work() {
System.out.println("Human is working");
}
@Override
public void eat() {
System.out.println("Human is eating");
}
}
class RobotWorker implements Workable {
@Override
public void work() {
System.out.println("Robot is working");
}
}
인터페이스는 사용하는 대상에 맞게 잘게 나누어야 한다.
고수준 모듈은 저수준 모듈에 직접 의존하면 안 되고,
둘 다 추상화(인터페이스) 에 의존해야 한다.
Computer 클래스가 내부에서 Keyboard를 직접 생성한다.
class Keyboard {
public String getInput() {
return "User input";
}
}
class Computer {
private Keyboard keyboard;
public Computer() {
this.keyboard = new Keyboard(); // 직접 생성 = 강한 결합
}
public void inputFromKeyboard() {
System.out.println(keyboard.getInput());
}
}
이렇게 되면 Computer는 Keyboard와 강하게 결합된다.
입력 장치를 터치스크린, 마우스 등으로 바꾸기 어렵다.
InputDevice 인터페이스를 만들고,
// 추상화
interface InputDevice {
String getInput();
}
이 이를 구현한다.
// 저수준 구현들
class Keyboard implements InputDevice {
@Override
public String getInput() {
return "Keyboard input";
}
}
class TouchScreen implements InputDevice {
@Override
public String getInput() {
return "Touch input";
}
}
Computer는 InputDevice를 생성자 주입받는다.
// 고수준 모듈
class Computer {
private InputDevice inputDevice;
// 생성자 주입
public Computer(InputDevice inputDevice) {
this.inputDevice = inputDevice;
}
public void inputFromDevice() {
System.out.println(inputDevice.getInput());
}
}
구체 클래스에 직접 의존하지 말고,
인터페이스 같은 추상화에 의존해야 유연한 설계가 된다.
public class Main {
public static void main(String[] args) {
InputDevice keyboard = new Keyboard();
InputDevice touch = new TouchScreen();
Computer pc1 = new Computer(keyboard);
Computer pc2 = new Computer(touch);
pc1.inputFromDevice(); // Keyboard input
pc2.inputFromDevice(); // Touch input
}
}
SOLID는 객체지향 설계의 5대 원칙으로, 유지보수성과 확장성을 높이기 위한 기준이다.
SRP는 하나의 클래스가 하나의 책임만 가지도록 하고,
OCP는 기존 코드를 수정하지 않고 확장 가능하도록 설계한다.
LSP는 자식 클래스가 부모 클래스를 안전하게 대체할 수 있어야 함을 의미한다.
ISP는 불필요하게 큰 인터페이스를 나누어 필요한 기능만 구현하게 하고,
DIP는 구체 구현이 아니라 추상화에 의존하도록 만들어 결합도를 낮춘다.
세금 계산, 출력, 저장을 한 클래스에 몰아넣지 말기
if-else로 기능 추가하지 말고 전략/인터페이스로 확장하기
“새는 난다”를 모든 새에게 강제하면 안 됨
로봇에게 eat() 강요하지 말기
컴퓨터가 키보드 구현체에 직접 묶이지 않게 하기