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 위반 징후

  1. 다운 캐스팅이 필요한 경우
    if (animal instanceof Dog) {
        ((Dog) animal).bark();
    }
    
  2. 비슷한 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 위반 징후

  1. 명시된 명세에서 벗어난 값을 리턴
    // 부모: 양수 반환
    // 자식: 음수 반환 → 위반
    
  2. 명시된 명세에서 벗어난 예외를 발생
    // 부모: IOException만 던짐
    // 자식: RuntimeException 던짐 → 위반
    
  3. 명시된 명세에서 벗어난 기능을 수행
    // 부모: 파일에 데이터 저장
    // 자식: 아무것도 안 함 (빈 구현) → 위반
    

핵심

  • 상속보다 조합(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는 도구이지 목표가 아니다. 변경이 예상되는 곳에 적절히 적용하고, 과도한 추상화는 피하라.