유지보수성과 확장성을 높이기 위한 5가지 설계 원칙으로, Clean Architecture의 핵심이다.
단일 책임 원칙은 하나의 클래스는 하나의 책임만 가져야 한다는 원칙이다. 즉, 단일 모듈은 변경 이유가 오직 하나뿐이어야 한다.
// 나쁜 구조: 한 클래스에 여러 책임(계산, 출력, 저장)을 모두 담음
// 영수증 포맷 변경, 가격 계산 규칙 변경 등 변경 이유가 서로 다름
class OrderService {
private final List<OrderLine> lines = new ArrayList<>();
public void addLine(String name, int unitPrice, int qty) {
lines.add(new OrderLine(name, unitPrice, qty));
}
public int totalPrice() { // 도메인 로직
return lines.stream().mapToInt(l -> l.unitPrice * l.qty).sum();
}
public String toReceipt() { // 출력 로직
return "TOTAL: " + totalPrice() + " KRW";
}
public void saveToDb() { // 영속화 로직
// DB 저장 코드
}
}
class OrderLine {
final String name; final int unitPrice; final int qty;
OrderLine(String name, int unitPrice, int qty) { this.name=name; this.unitPrice=unitPrice; this.qty=qty; }
}
// 좋은 구조: 책임을 역할별로 분리
class Order { // 계산 책임
private final List<OrderLine> lines = new ArrayList<>();
public void addLine(String name, int unitPrice, int qty) {
lines.add(new OrderLine(name, unitPrice, qty));
}
public int totalPrice() {
return lines.stream().mapToInt(l -> l.unitPrice * l.qty).sum();
}
}
record OrderLine(String name, int unitPrice, int qty) {}
class OrderPrinter { // 출력 책임
public String toReceipt(Order order) {
return "TOTAL: " + order.totalPrice() + " KRW";
}
}
class OrderRepository { // 저장 책임
public void save(Order order) { /* DB 코드 */ }
}
개방-폐쇄 원칙은 소프트웨어 요소 (클래스, 모듈, 함수, etc.)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다는 원칙이다. 새로운 요구사항을 구현하는 데 있어 코드를 전반적으로 수정해야 한다면 좋은 설계라고 할 수 없다.
// 나쁜 구조: 새로운 도형이 추가될 때마다 if-else 수정 필요
// 새 도형을 추가할 때마다 기존 계산 코드를 수정해야 함
class AreaCalculator {
public double area(Object shape) {
if (shape instanceof Rectangle r) return r.w * r.h;
else if (shape instanceof Circle c) return Math.PI * c.r * c.r;
// 새로운 도형 추가 시 여기를 계속 수정해야 함
throw new IllegalArgumentException("Unknown shape");
}
}
class Rectangle { double w, h; }
class Circle { double r; }
// 좋은 구조: 다형성에 의존, 확장에는 열려 있고 수정에는 닫힘
interface Shape { double area(); }
class Rectangle implements Shape {
private final double w, h;
Rectangle(double w, double h) { this.w=w; this.h=h; }
public double area() { return w * h; }
}
class Circle implements Shape {
private final double r;
Circle(double r) { this.r=r; }
public double area() { return Math.PI * r * r; }
}
class AreaCalculator {
public double sumArea(List<Shape> shapes) {
return shapes.stream().mapToDouble(Shape::area).sum();
}
}
리스코프 치환 원칙은 자식 클래스는 부모 클래스를 대체할 수 있어야 한다는 원칙이다. 즉, 하위 클래스의 인스턴스는 상위 객체 참조 변수에 대입해 상위 클래스의 인스턴스 역할을 하는 데 문제가 없어야 한다.
// 나쁜 구조: 상위 타입 계약(LIFO)을 위반
// RandomPopStack.pop()이 무작위 요소를 반환해 LIFO를 위반함
interface Stack<E> {
void push(E e);
E pop();
E peek();
boolean isEmpty();
}
class RandomPopStack<E> implements Stack<E> {
private final List<E> list = new ArrayList<>();
private final Random rnd = new Random();
public void push(E e) { list.add(e); }
public E pop() { return list.remove(rnd.nextInt(list.size())); } // 계약 위반
public E peek() { return list.get(list.size()-1); }
public boolean isEmpty() { return list.isEmpty(); }
}
// 좋은 구조: 상위 계약을 유지하면서 기능 확장
class ArrayStack<E> implements Stack<E> {
private final Deque<E> dq = new ArrayDeque<>();
public void push(E e) { dq.push(e); }
public E pop() { return dq.pop(); }
public E peek() { return dq.peek(); }
public boolean isEmpty() { return dq.isEmpty(); }
}
class BoundedStack<E> implements Stack<E> {
private final Stack<E> delegate;
private final int capacity;
private int size = 0;
BoundedStack(Stack<E> delegate, int capacity) { this.delegate=delegate; this.capacity=capacity; }
public void push(E e) {
if (size >= capacity) throw new IllegalStateException("Full");
delegate.push(e); size++;
}
public E pop() { size--; return delegate.pop(); }
public E peek() { return delegate.peek(); }
public boolean isEmpty() { return size==0; }
}
인터페이스 분리 원칙은 하나의 범용적인 인터페이스보다는 여러 개의 구체적인 인터페이스를 만드는 것이 좋다는 원칙이다. 즉, 사용하지 않는 것에 의존하지 않아야 한다. 사용하지 않는 함수가 존재한다면, 인터페이스를 분리해야 한다.
// 나쁜 구조: 범용 인터페이스가 너무 커서 불필요한 메서드 구현 강제
// print/scan/fax를 모두 강제하여, print만 필요해도 scan과 sendFax를 억지로 구현해야 함
interface Machine {
void print(String doc);
String scan();
void sendFax(String number, String doc);
}
class SimplePrinter implements Machine {
public void print(String doc) { System.out.println(doc); }
public String scan() { throw new UnsupportedOperationException(); }
public void sendFax(String n, String d) { throw new UnsupportedOperationException(); }
}
// 좋은 구조: 인터페이스를 역할별로 분리
interface Printer { void print(String doc); }
interface Scanner { String scan(); }
interface Fax { void sendFax(String number, String doc); }
class SimplePrinter implements Printer {
public void print(String doc) { System.out.println(doc); }
}
class AllInOneMachine implements Printer, Scanner, Fax {
public void print(String doc) { /* 구현 */ }
public String scan() { return "scanned"; }
public void sendFax(String number, String doc) { /* 구현 */ }
}
의존 역전 원칙은 고수준 모듈은 저수준 모듈에 의존하면 안 된다는 원칙이다. 즉, 자신보다 변하기 쉬운 것에 의존하면 안 되며, 구체화가 아닌 추상화에 의존해야 한다.
// 나쁜 구조: 고수준 모듈이 저수준 구현에 직접 의존
// SMS로 교체 등 구현 교체가 어려움
class NotificationService {
private final EmailSender sender = new EmailSender(); // 구체 클래스 의존
public void sendWelcome(String user) {
sender.send(user, "Welcome!");
}
}
class EmailSender {
void send(String to, String msg) { /* SMTP 코드 */ }
}
// 좋은 구조: 추상에 의존, 구체는 외부에서 주입
// MessageSender라는 추상에 의존하고, 구체 (Email, SMS)는 조립 구간에서 주입
interface MessageSender { void send(String to, String message); }
class EmailSender implements MessageSender {
public void send(String to, String message) { /* SMTP 코드 */ }
}
class SmsSender implements MessageSender {
public void send(String to, String message) { /* SMS 코드 */ }
}
class NotificationService {
private final MessageSender sender;
NotificationService(MessageSender sender) { this.sender = sender; }
public void sendWelcome(String user) { sender.send(user, "Welcome!"); }
}
class App {
public static void main(String[] args) {
MessageSender sender = new EmailSender(); // 구체 선택
NotificationService svc = new NotificationService(sender);
svc.sendWelcome("kim@example.com");
}
}