SOLID 원칙 심화 — 코드로 증명하는 객체지향 설계 5원칙
SOLID 원칙 심화 — 코드로 증명하는 객체지향 설계 5원칙
"SOLID 원칙을 코드로 설명해주세요"
면접에서 SOLID를 물으면 정의를 읊는 건 누구나 합니다. 차별점은 코드 입니다. 위반 코드를 보여주고, 왜 문제인지 설명하고, 어떻게 리팩토링하는지 보여줄 수 있어야 합니다.
이 글에서는 각 원칙마다 위반 → 리팩토링 쌍을 보여줍니다.
S — Single Responsibility Principle (단일 책임 원칙)
클래스를 변경해야 하는 이유는 하나만 있어야 한다.
"책임 = 변경의 이유"라는 게 핵심입니다. 메서드가 하나라도 변경 이유가 두 가지면 SRP 위반입니다.
위반 예시
// 변경 이유가 3가지: 주문 로직 변경, 영수증 포맷 변경, 이메일 서비스 변경
public class OrderService {
public void placeOrder(Order order) {
// 1. 주문 비즈니스 로직
validateStock(order);
calculateTotal(order);
saveToDatabase(order);
// 2. 영수증 생성 (포맷이 바뀌면 이 클래스를 수정해야 함)
String receipt = generateReceipt(order);
// 3. 이메일 발송 (메일 서비스가 바뀌면 이 클래스를 수정해야 함)
sendEmail(order.getCustomerEmail(), receipt);
}
private String generateReceipt(Order order) {
return "주문번호: " + order.getId() + "\n"
+ "합계: " + order.getTotal() + "원";
}
private void sendEmail(String to, String body) {
// SMTP 설정, 전송 로직...
}
}
영수증 포맷을 바꿔야 할 때도, 이메일 서비스를 교체할 때도, 주문 로직을 수정할 때도 — 전부 이 클래스를 건드려야 합니다.
리팩토링
// 각 책임을 별도 클래스로 분리
public class OrderService {
private final ReceiptGenerator receiptGenerator;
private final NotificationSender notificationSender;
public OrderService(ReceiptGenerator receiptGenerator,
NotificationSender notificationSender) {
this.receiptGenerator = receiptGenerator;
this.notificationSender = notificationSender;
}
public void placeOrder(Order order) {
// 이 클래스의 유일한 책임: 주문 비즈니스 로직
validateStock(order);
calculateTotal(order);
saveToDatabase(order);
String receipt = receiptGenerator.generate(order);
notificationSender.send(order.getCustomerEmail(), receipt);
}
}
// 영수증 생성 책임
public class ReceiptGenerator {
public String generate(Order order) {
return "주문번호: " + order.getId() + "\n"
+ "합계: " + order.getTotal() + "원";
}
}
// 알림 발송 책임
public class NotificationSender {
public void send(String to, String body) {
// SMTP 설정, 전송 로직
}
}
이제 영수증 포맷이 바뀌면 ReceiptGenerator만, 메일 서비스가 바뀌면 NotificationSender만 수정하면 됩니다.
O — Open-Closed Principle (개방-폐쇄 원칙)
확장에는 열려 있고, 수정에는 닫혀 있어야 한다.
새 요구사항이 생겼을 때 ** 기존 코드를 수정하지 않고 새 코드를 추가 **해서 해결할 수 있어야 합니다.
위반 예시
public class DiscountCalculator {
public double calculate(Order order) {
// 새 할인 유형이 추가될 때마다 이 메서드를 수정해야 함
return switch (order.getCustomerType()) {
case "REGULAR" -> order.getTotal() * 0.05;
case "VIP" -> order.getTotal() * 0.10;
case "EMPLOYEE" -> order.getTotal() * 0.20;
// 새 고객 유형 추가 → 기존 코드 수정 필요
default -> 0;
};
}
}
PARTNER, WHOLESALE 같은 새 유형이 추가될 때마다 이 switch문을 수정해야 합니다.
리팩토링 — 전략 패턴
// 할인 전략 인터페이스
public interface DiscountStrategy {
double calculate(Order order);
}
// 각 전략을 별도 클래스로
public class RegularDiscount implements DiscountStrategy {
public double calculate(Order order) {
return order.getTotal() * 0.05;
}
}
public class VipDiscount implements DiscountStrategy {
public double calculate(Order order) {
return order.getTotal() * 0.10;
}
}
public class EmployeeDiscount implements DiscountStrategy {
public double calculate(Order order) {
return order.getTotal() * 0.20;
}
}
// 계산기는 더 이상 수정할 필요 없음
public class DiscountCalculator {
private final Map<String, DiscountStrategy> strategies;
public DiscountCalculator(Map<String, DiscountStrategy> strategies) {
this.strategies = strategies;
}
public double calculate(Order order) {
DiscountStrategy strategy = strategies.getOrDefault(
order.getCustomerType(),
_ -> 0 // 기본 전략: 할인 없음
);
return strategy.calculate(order);
}
}
새 고객 유형이 추가되면? PartnerDiscount 클래스를 만들고 Map에 등록만 하면 됩니다. DiscountCalculator는 전혀 수정하지 않습니다.
L — Liskov Substitution Principle (리스코프 치환 원칙)
하위 타입은 상위 타입을 대체할 수 있어야 한다.
상위 클래스 타입으로 참조하는 코드가 하위 클래스 인스턴스로 교체해도 정상 동작 해야 합니다.
위반 예시 — 직사각형과 정사각형
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 area() { 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;
}
}
왜 위반인가요?
// 상위 타입(Rectangle)의 계약: width와 height는 독립적
public void resize(Rectangle rect) {
rect.setWidth(5);
rect.setHeight(10);
assert rect.area() == 50; // Rectangle이면 당연히 50
// Square를 넘기면? area()는 100 → 계약 위반!
}
Rectangle을 기대하는 코드에 Square를 넣으면 동작이 달라집니다. 이게 LSP 위반입니다.
리팩토링 — 상속 대신 공통 인터페이스
// 공통 인터페이스
public interface Shape {
int area();
}
// 각자 독립적인 구현
public class Rectangle implements Shape {
private final int width;
private final int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public int area() { return width * height; }
}
public class Square implements Shape {
private final int side;
public Square(int side) {
this.side = side;
}
public int area() { return side * side; }
}
Square가 Rectangle을 상속하지 않으므로 LSP 위반의 여지가 없습니다. 둘 다 Shape의 계약(area() 반환)을 정확히 지킵니다.
**핵심 : "IS-A 관계"가 현실 세계에서 성립한다고 해서 코드에서도 상속이 맞는 건 아닙니다. 정사각형은 수학적으로 직사각형이지만, ** 행동(behavior)이 다르면 상속하면 안 됩니다.
I — Interface Segregation Principle (인터페이스 분리 원칙)
클라이언트가 사용하지 않는 메서드에 의존하면 안 된다.
뚱뚱한 인터페이스를 쪼개라는 원칙입니다.
위반 예시
// 모든 기능을 하나의 인터페이스에 몰아넣음
public interface Worker {
void work();
void eat();
void sleep();
void attendMeeting();
}
// 로봇은 eat(), sleep()이 필요 없는데 구현해야 함
public class Robot implements Worker {
public void work() { /* 작업 수행 */ }
public void eat() { /* 할 수 없음 — 빈 구현 */ }
public void sleep() { /* 할 수 없음 — 빈 구현 */ }
public void attendMeeting() { /* 참석 */ }
}
Robot이 eat()과 sleep()에 빈 구현을 넣어야 합니다. 이런 빈 메서드가 쌓이면 코드의 의도가 흐려지고, 실수로 호출했을 때 아무 일도 일어나지 않아 버그를 추적하기 어려워집니다.
리팩토링
// 역할별로 인터페이스를 분리
public interface Workable {
void work();
}
public interface Feedable {
void eat();
}
public interface Sleepable {
void sleep();
}
public interface MeetingAttendable {
void attendMeeting();
}
// 사람: 모든 인터페이스 구현
public class HumanWorker implements Workable, Feedable,
Sleepable, MeetingAttendable {
public void work() { /* 작업 */ }
public void eat() { /* 식사 */ }
public void sleep() { /* 수면 */ }
public void attendMeeting() { /* 회의 참석 */ }
}
// 로봇: 필요한 것만 구현
public class Robot implements Workable, MeetingAttendable {
public void work() { /* 작업 수행 */ }
public void attendMeeting() { /* 참석 */ }
}
각 클라이언트 코드는 자신이 필요한 인터페이스만 의존합니다:
// 작업을 시킬 수 있는 것만 필요한 코드
public void assignTask(Workable worker) {
worker.work();
// eat(), sleep()에 대해 알 필요 없음
}
D — Dependency Inversion Principle (의존성 역전 원칙)
상위 모듈이 하위 모듈에 의존하면 안 된다. 둘 다 추상화에 의존해야 한다.
위반 예시
// 상위 모듈(OrderService)이 하위 모듈(MySQLRepository)에 직접 의존
public class OrderService {
private final MySQLOrderRepository repository; // 구체 클래스에 의존
public OrderService() {
this.repository = new MySQLOrderRepository(); // 직접 생성
}
public void placeOrder(Order order) {
// 비즈니스 로직...
repository.save(order);
}
}
public class MySQLOrderRepository {
public void save(Order order) {
// MySQL 관련 코드...
}
}
문제점:
- DB를 PostgreSQL로 교체하려면
OrderService를 수정해야 함 - 테스트할 때 실제 MySQL이 필요함 (목 객체 교체 불가)
OrderService가MySQLOrderRepository의 변경에 취약함
리팩토링
// 추상화 — 상위 모듈과 하위 모듈 모두 이 인터페이스에 의존
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(String id);
}
// 상위 모듈: 추상화에만 의존
public class OrderService {
private final OrderRepository repository; // 인터페이스에 의존
public OrderService(OrderRepository repository) { // 생성자 주입
this.repository = repository;
}
public void placeOrder(Order order) {
repository.save(order);
}
}
// 하위 모듈: 추상화를 구현
public class MySQLOrderRepository implements OrderRepository {
public void save(Order order) { /* MySQL 코드 */ }
public Optional<Order> findById(String id) { /* ... */ }
}
public class PostgreSQLOrderRepository implements OrderRepository {
public void save(Order order) { /* PostgreSQL 코드 */ }
public Optional<Order> findById(String id) { /* ... */ }
}
Before: OrderService → MySQLOrderRepository
(상위 → 하위)
After: OrderService → OrderRepository ← MySQLOrderRepository
(상위 → 추상화 ← 하위)
이것이 "역전"의 의미입니다. 의존 방향이 바뀌었습니다. 하위 모듈이 추상화를 구현하는 형태로요.
Spring에서의 DIP
Spring의 DI(Dependency Injection) 컨테이너가 DIP를 자연스럽게 구현해줍니다:
@Service
public class OrderService {
private final OrderRepository repository;
// Spring이 OrderRepository 구현체를 자동 주입
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
@Repository
public class JpaOrderRepository implements OrderRepository {
// Spring Data JPA가 구현 제공
}
SOLID 원칙은 서로 연결되어 있다
SRP → 클래스를 작게 유지
↓
ISP → 인터페이스도 작게 유지
↓
DIP → 구체 클래스가 아닌 작은 인터페이스에 의존
↓
OCP → 새 구현체를 추가해서 확장 (기존 코드 수정 없음)
↓
LSP → 새 구현체가 기존 계약을 지킴
한 원칙을 잘 지키면 다른 원칙도 자연스럽게 따라옵니다.
과도한 SOLID는 오히려 독
원칙을 알면 반드시 해야 할 경고도 있습니다:
// 이런 건 과설계
public interface StringProvider { String get(); }
public interface StringValidator { boolean validate(String s); }
public interface StringFormatter { String format(String s); }
public interface StringSaver { void save(String s); }
// 문자열 하나 저장하는데 인터페이스 4개?
SOLID는 복잡성이 충분할 때 적용해야 효과가 있습니다. 간단한 코드에 억지로 원칙을 적용하면 오히려 코드가 복잡해집니다.
- ** 변경이 예상되지 않으면** 직접 의존해도 됩니다
- ** 구현체가 하나뿐이면** 인터페이스가 꼭 필요하지 않습니다
- 3번 이상 반복되기 전까지는 추상화를 미루는 게 좋습니다
정리
| 원칙 | 한 줄 정의 | 위반 신호 |
|---|---|---|
| SRP | 변경 이유가 하나만 | 한 클래스가 여러 팀에 의해 수정됨 |
| OCP | 확장에 열려 있고 수정에 닫혀 있음 | if/switch에 새 분기가 계속 추가됨 |
| LSP | 하위 타입이 상위 타입을 대체 가능 | 하위 클래스에 빈 메서드나 UnsupportedOperationException |
| ISP | 사용하지 않는 메서드에 의존하지 않음 | 구현체에 빈 메서드가 많음 |
| DIP | 추상화에 의존 | new ConcreteClass()가 비즈니스 로직에 산재 |
공부하면서 가장 중요하다고 느낀 건, SOLID가 "항상 적용해야 하는 규칙"이 아니라 "복잡성이 일정 수준을 넘었을 때의 처방전" 이라는 것입니다. 면접에서 원칙을 설명한 후 "하지만 과도한 추상화도 경계해야 합니다"라고 덧붙이면, 실무 감각이 있다는 인상을 줄 수 있습니다.