SOLID 원칙 - 객체지향 설계의 5가지 원칙
SOLID는 로버트 마틴(Robert C. Martin)이 정리한 객체지향 설계의 5가지 원칙이다. 이 원칙들을 따르면 유지보수하기 쉽고, 확장 가능하며, 이해하기 쉬운 소프트웨어를 만들 수 있다.
S - 단일 책임 원칙 (Single Responsibility Principle)
클래스는 단 하나의 책임만 가져야 한다. “클래스를 변경하는 이유는 단 하나여야 한다.”
위반 사례
public class Employee {
private String name;
private double salary;
// 1. 직원 정보 관리
public void setName(String name) { this.name = name; }
public String getName() { return name; }
// 2. 급여 계산 (회계 부서 요구사항)
public double calculatePay() {
return salary * 1.1; // 세금 계산 로직
}
// 3. 리포트 생성 (경영진 요구사항)
public String generateReport() {
return "Employee: " + name + ", Salary: " + salary;
}
// 4. DB 저장 (IT 부서 요구사항)
public void save() {
// 데이터베이스에 저장
}
}
문제점:
- 회계 부서가 급여 계산 방식 변경 요청 → Employee 수정
- 경영진이 리포트 형식 변경 요청 → Employee 수정
- IT 부서가 DB 스키마 변경 → Employee 수정
하나의 클래스가 여러 이유로 변경된다.
개선 사례
// 1. 직원 정보만 관리
public class Employee {
private String name;
private double salary;
public String getName() { return name; }
public double getSalary() { return salary; }
}
// 2. 급여 계산 책임
public class PayCalculator {
public double calculatePay(Employee employee) {
return employee.getSalary() * 1.1;
}
}
// 3. 리포트 생성 책임
public class EmployeeReporter {
public String generateReport(Employee employee) {
return "Employee: " + employee.getName();
}
}
// 4. 저장 책임
public class EmployeeRepository {
public void save(Employee employee) {
// 데이터베이스에 저장
}
}
핵심
- 책임 = 변경의 이유
- 하나의 클래스는 하나의 액터(Actor)에게만 책임져야 한다
- 클래스가 작아지고, 테스트하기 쉬워진다
O - 개방-폐쇄 원칙 (Open-Closed Principle)
확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다. 기능을 추가할 때 기존 코드를 수정하지 않아야 한다.
위반 사례
public class PaymentService {
public void processPayment(String paymentType, int amount) {
if (paymentType.equals("CREDIT_CARD")) {
// 신용카드 결제 처리
System.out.println("신용카드로 " + amount + "원 결제");
} else if (paymentType.equals("BANK_TRANSFER")) {
// 계좌이체 처리
System.out.println("계좌이체로 " + amount + "원 결제");
} else if (paymentType.equals("KAKAO_PAY")) {
// 카카오페이 추가 시 기존 코드 수정 필요!
System.out.println("카카오페이로 " + amount + "원 결제");
}
// 새로운 결제 수단 추가 시 계속 if-else 추가...
}
}
문제점:
- 새로운 결제 수단 추가 시 기존 코드 수정 필요
- if-else 블록이 계속 늘어남
- 테스트 범위가 계속 커짐
개선 사례
// 추상화
public interface PaymentProcessor {
void process(int amount);
}
// 각 결제 수단별 구현
public class CreditCardProcessor implements PaymentProcessor {
@Override
public void process(int amount) {
System.out.println("신용카드로 " + amount + "원 결제");
}
}
public class BankTransferProcessor implements PaymentProcessor {
@Override
public void process(int amount) {
System.out.println("계좌이체로 " + amount + "원 결제");
}
}
// 새로운 결제 수단 추가 - 기존 코드 수정 없음!
public class KakaoPayProcessor implements PaymentProcessor {
@Override
public void process(int amount) {
System.out.println("카카오페이로 " + amount + "원 결제");
}
}
// 사용하는 쪽
public class PaymentService {
private final PaymentProcessor processor;
public PaymentService(PaymentProcessor processor) {
this.processor = processor;
}
public void processPayment(int amount) {
processor.process(amount); // 변경 없이 확장 가능
}
}
OCP 위반 징후
- 다운 캐스팅이 필요한 경우
if (animal instanceof Dog) { ((Dog) animal).bark(); } - 비슷한 if-else/switch 블록이 여러 곳에 존재
// 여러 클래스에 동일한 분기문이 반복됨 if (type.equals("A")) { ... } else if (type.equals("B")) { ... }
핵심
- 추상화(인터페이스, 추상 클래스)를 활용
- 변하는 부분과 변하지 않는 부분을 분리
- 전략 패턴, 템플릿 메서드 패턴 등으로 구현
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 getArea() {
return width * height;
}
}
// 정사각형은 직사각형이다? (IS-A 관계)
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 void resize(Rectangle rectangle) {
rectangle.setWidth(10);
rectangle.setHeight(5);
// Rectangle이면 50, Square이면 25 (예상과 다름!)
assert rectangle.getArea() == 50; // Square 전달 시 실패!
}
문제점:
- Square가 Rectangle을 대체할 수 없음
- 클라이언트의 기대를 깨뜨림
개선 사례
// 공통 인터페이스로 분리
public interface Shape {
int getArea();
}
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;
}
@Override
public int getArea() {
return width * height;
}
}
public class Square implements Shape {
private final int side;
public Square(int side) {
this.side = side;
}
@Override
public int getArea() {
return side * side;
}
}
LSP 위반 징후
- 명시된 명세에서 벗어난 값을 리턴
// 부모: 양수 반환 // 자식: 음수 반환 → 위반 - 명시된 명세에서 벗어난 예외를 발생
// 부모: IOException만 던짐 // 자식: RuntimeException 던짐 → 위반 - 명시된 명세에서 벗어난 기능을 수행
// 부모: 파일에 데이터 저장 // 자식: 아무것도 안 함 (빈 구현) → 위반
핵심
- 상속보다 조합(Composition)을 고려
- 자식은 부모의 계약(Contract)을 지켜야 함
- “IS-A” 관계가 아니면 상속하지 않기
I - 인터페이스 분리 원칙 (Interface Segregation Principle)
클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안 된다. 하나의 범용 인터페이스보다 여러 개의 구체적인 인터페이스가 낫다.
위반 사례
// 뚱뚱한 인터페이스
public interface Worker {
void work();
void eat();
void sleep();
}
// 사람은 모든 메서드 사용
public class Human implements Worker {
@Override
public void work() { System.out.println("일한다"); }
@Override
public void eat() { System.out.println("밥 먹는다"); }
@Override
public void sleep() { System.out.println("잔다"); }
}
// 로봇은 eat(), sleep()이 필요 없음
public class Robot implements Worker {
@Override
public void work() { System.out.println("일한다"); }
@Override
public void eat() {
// 사용하지 않는 메서드 강제 구현
throw new UnsupportedOperationException();
}
@Override
public void sleep() {
throw new UnsupportedOperationException();
}
}
문제점:
- Robot은 필요 없는 메서드를 구현해야 함
- 인터페이스 변경 시 모든 구현체에 영향
개선 사례
// 역할별로 인터페이스 분리
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public interface Sleepable {
void sleep();
}
// 사람: 모든 인터페이스 구현
public class Human implements Workable, Eatable, Sleepable {
@Override
public void work() { System.out.println("일한다"); }
@Override
public void eat() { System.out.println("밥 먹는다"); }
@Override
public void sleep() { System.out.println("잔다"); }
}
// 로봇: 필요한 인터페이스만 구현
public class Robot implements Workable {
@Override
public void work() { System.out.println("일한다"); }
}
실무 예시 - Spring의 인터페이스 분리
// Spring Data JPA
public interface CrudRepository<T, ID> {
<S extends T> S save(S entity);
Optional<T> findById(ID id);
void delete(T entity);
// ... CRUD 기본 메서드
}
public interface PagingAndSortingRepository<T, ID> extends CrudRepository<T, ID> {
Page<T> findAll(Pageable pageable);
// ... 페이징 관련 메서드
}
public interface JpaRepository<T, ID> extends PagingAndSortingRepository<T, ID> {
void flush();
// ... JPA 특화 메서드
}
// 필요한 수준의 인터페이스만 사용
public interface UserRepository extends CrudRepository<User, Long> {
// 페이징이 필요 없으면 CrudRepository만 사용
}
핵심
- 인터페이스를 클라이언트 기준으로 분리
- 변경의 영향 범위를 최소화
- “뚱뚱한” 인터페이스를 여러 개의 “날씬한” 인터페이스로
D - 의존 역전 원칙 (Dependency Inversion Principle)
고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다.
- 고수준 모듈: 비즈니스 로직, 정책을 담당 (무엇을 할지)
- 저수준 모듈: 구체적인 구현 (어떻게 할지)
위반 사례
// 저수준 모듈 (구체적인 구현)
public class MySQLDatabase {
public void save(String data) {
System.out.println("MySQL에 저장: " + data);
}
}
// 고수준 모듈이 저수준 모듈에 직접 의존
public class UserService {
private MySQLDatabase database = new MySQLDatabase(); // 직접 의존!
public void createUser(String name) {
// 비즈니스 로직
database.save(name);
}
}
문제점:
- UserService가 MySQLDatabase에 강하게 결합
- DB를 PostgreSQL로 변경하면 UserService도 수정 필요
- 테스트 시 실제 DB 필요
개선 사례
// 추상화 (인터페이스)
public interface Database {
void save(String data);
}
// 저수준 모듈 - 추상화에 의존
public class MySQLDatabase implements Database {
@Override
public void save(String data) {
System.out.println("MySQL에 저장: " + data);
}
}
public class PostgreSQLDatabase implements Database {
@Override
public void save(String data) {
System.out.println("PostgreSQL에 저장: " + data);
}
}
// 고수준 모듈 - 추상화에 의존
public class UserService {
private final Database database; // 인터페이스에 의존
public UserService(Database database) { // 생성자 주입
this.database = database;
}
public void createUser(String name) {
database.save(name);
}
}
// 사용 (의존성 주입)
Database db = new MySQLDatabase();
UserService userService = new UserService(db);
// DB 변경 시 UserService 수정 없음
Database db = new PostgreSQLDatabase();
UserService userService = new UserService(db);
// 테스트 시 Mock 사용 가능
Database mockDb = mock(Database.class);
UserService userService = new UserService(mockDb);
의존 방향의 역전
[Before - 의존 역전 전]
UserService → MySQLDatabase
(고수준) (저수준)
[After - 의존 역전 후]
UserService → Database ← MySQLDatabase
(고수준) (추상화) (저수준)
고수준과 저수준 모두 추상화에 의존!
Spring의 DIP 적용
@Service
public class OrderService {
private final PaymentGateway paymentGateway; // 인터페이스에 의존
public OrderService(PaymentGateway paymentGateway) {
this.paymentGateway = paymentGateway;
}
public void order(Order order) {
paymentGateway.process(order.getAmount());
}
}
// 구현체는 Spring이 주입
@Component
public class TossPaymentGateway implements PaymentGateway {
@Override
public void process(int amount) {
// 토스페이 결제 처리
}
}
핵심
- 구체적인 것이 아닌 추상적인 것에 의존
- 의존성 주입(DI)으로 구현
- 변경에 유연하고 테스트하기 쉬운 코드
SOLID 원칙의 관계
┌─────────────────────────────────────────────────────────┐
│ OCP │
│ (개방-폐쇄 원칙) │
│ 확장에 열림 │
│ ▲ │
│ ┌───────────┴───────────┐ │
│ │ │ │
│ LSP DIP │
│ (리스코프 치환) (의존 역전) │
│ 다형성 보장 추상화 의존 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ SRP ISP │
│ (단일 책임) (인터페이스 분리) │
│ │
│ → 객체가 커지지 않도록 막아준다 ← │
└─────────────────────────────────────────────────────────┘
| 원칙 | 핵심 키워드 | 효과 |
|---|---|---|
| SRP | 하나의 책임 | 변경 영향 최소화 |
| OCP | 확장 열림, 변경 닫힘 | 유연한 확장 |
| LSP | 대체 가능성 | 다형성 보장 |
| ISP | 인터페이스 분리 | 불필요한 의존 제거 |
| DIP | 추상화 의존 | 결합도 감소 |
실무 적용 가이드
1. 처음부터 완벽하게 적용하려 하지 말 것
// 처음엔 간단하게 시작
public class OrderService {
public void order(Order order) {
// 직접 구현
}
}
// 변경이 필요할 때 리팩토링
// "세 번째 변경 요청이 오면 추상화를 고려하라" - 마틴 파울러
2. 테스트하기 어려우면 SOLID 위반 의심
// 테스트하기 어려운 코드 = 설계 문제 신호
public class Service {
private final Database db = new MySQLDatabase(); // DIP 위반
// Mock 불가능 → 테스트 어려움
}
3. 과도한 추상화 경계
// 변경 가능성이 없는 곳에 불필요한 인터페이스
public interface StringUtils { // 과도한 추상화
String toUpperCase(String s);
}
// 단순히 유틸리티 클래스로 충분
public class StringUtils {
public static String toUpperCase(String s) { ... }
}
SOLID는 도구이지 목표가 아니다. 변경이 예상되는 곳에 적절히 적용하고, 과도한 추상화는 피하라.
댓글