SOLID 객체지향 설계 원칙 완전 정복

김현우·2025년 4월 21일

객체지향 프로그래밍(OOP)은 캡슐화, 상속, 다형성이라는 세 가지 기둥을 중심으로 동작함
하지만 OOP만으로는 좋은 설계를 만들기엔 부족함
그래서 등장한 것이 바로 SOLID 원칙
이 글에서는 SOLID 원칙 각각의 철학, 예시, 깨지기 쉬운 사례, 리팩토링 예시 등을 포함해 완전 정리해보았음


SOLID란?

SOLID는 객체지향 설계의 5가지 핵심 원칙의 앞 글자를 딴 것임

약어원칙 이름설명 요약
SSRP (단일 책임 원칙)하나의 클래스는 하나의 책임만 가져야 함
OOCP (개방/폐쇄 원칙)확장에는 열려 있고, 수정에는 닫혀 있어야 함
LLSP (리스코프 치환 원칙)하위 클래스는 상위 클래스를 대체할 수 있어야 함
IISP (인터페이스 분리 원칙)클라이언트는 자신이 사용하지 않는 인터페이스에 의존하면 안 됨
DDIP (의존 역전 원칙)추상화에 의존해야 하며, 구체화에 의존하면 안 됨

S: SRP (Single Responsibility Principle) - 단일 책임 원칙

정의

하나의 클래스는 하나의 책임만 가져야 하며, 하나의 변경 이유만 가져야 함

잘못된 예시

public class Report {
    public String createReport() { ... }
    public void printReport() { ... } // 출력 책임까지 가짐
    public void saveToDatabase() { ... } // 저장 책임까지 가짐
}

문제:

  • 생성, 출력, 저장이라는 서로 다른 책임을 하나의 클래스에서 모두 처리함
  • 출력 방식이 바뀌거나 DB가 바뀌면 이 클래스도 수정해야 함

개선 예시

public class Report {
    public String createReport() { ... }
}

public class ReportPrinter {
    public void print(String content) { ... }
}

public class ReportRepository {
    public void save(String content) { ... }
}

장점:

  • 하나의 변경 사유가 한 클래스에만 영향을 줌
  • 유지보수와 테스트가 쉬워짐

O: OCP (Open/Closed Principle) - 개방/폐쇄 원칙

정의

확장에는 열려 있고, 수정에는 닫혀 있어야 한다
즉, 새로운 기능을 추가하더라도 기존 코드를 변경하지 않도록 설계해야 함

잘못된 예시

public class NotificationService {
    public void send(String type, String message) {
        if (type.equals("email")) {
            // send email
        } else if (type.equals("sms")) {
            // send sms
        }
    }
}

문제:

  • 새로운 알림 방식이 추가되면 send 메서드를 계속 수정해야 함

개선 예시 (OCP 준수)

public interface Notifier {
    void send(String message);
}

public class EmailNotifier implements Notifier {
    public void send(String message) { ... }
}

public class SMSNotifier implements Notifier {
    public void send(String message) { ... }
}

public class NotificationService {
    private List<Notifier> notifiers;

    public NotificationService(List<Notifier> notifiers) {
        this.notifiers = notifiers;
    }

    public void notifyAll(String message) {
        for (Notifier notifier : notifiers) {
            notifier.send(message);
        }
    }
}

장점:

  • 기능 확장이 가능해도 기존 코드는 전혀 건드릴 필요 없음
  • 테스트, 유지보수에 유리함

L: LSP (Liskov Substitution Principle) - 리스코프 치환 원칙

정의

프로그램의 객체는 자식 클래스로 바꿔도 정상적으로 작동해야 함

위반 예시

public class Bird {
    public void fly() { ... }
}

public class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("펭귄은 날 수 없음");
    }
}

문제:

  • 상속 구조는 유지하지만, 실제로는 부모 클래스의 행위를 보장하지 못함

개선 예시

public abstract class Bird {
    public abstract void move();
}

public class Sparrow extends Bird {
    public void move() {
        fly();
    }
    private void fly() { ... }
}

public class Penguin extends Bird {
    public void move() {
        swim();
    }
    private void swim() { ... }
}

장점:

  • 상속 구조가 일관성을 가짐
  • 하위 타입이 상위 타입을 완전히 대체 가능

I: ISP (Interface Segregation Principle) - 인터페이스 분리 원칙

정의

클라이언트는 자신이 사용하지 않는 인터페이스에 의존하지 말아야 함

잘못된 예시

public interface MultiFunctionDevice {
    void print();
    void scan();
    void fax();
}

프린터 클래스에서 fax()를 쓸 일이 없음에도 불구하고 구현해야 함

개선 예시

public interface Printer {
    void print();
}

public interface Scanner {
    void scan();
}

public interface Fax {
    void fax();
}

장점:

  • 각 기능을 필요로 하는 클래스만 해당 인터페이스를 구현하면 됨
  • 인터페이스가 응집도 높고, 구현 클래스도 명확해짐

D: DIP (Dependency Inversion Principle) - 의존 역전 원칙

정의

고수준 모듈은 저수준 모듈에 의존하지 말고, 둘 다 추상화에 의존해야 함
즉, 구현이 아닌 인터페이스에 의존해야 함

위반 예시

public class MySQLDatabase {
    public void save(String data) { ... }
}

public class UserService {
    private MySQLDatabase db = new MySQLDatabase();

    public void registerUser(String user) {
        db.save(user);
    }
}

문제:

  • MySQL 외 다른 DB로 교체 시 코드 변경 필요
  • 테스트도 어렵고 유연성 부족

개선 예시

public interface UserRepository {
    void save(String user);
}

public class MySQLDatabase implements UserRepository {
    public void save(String user) { ... }
}

public class UserService {
    private UserRepository repo;

    public UserService(UserRepository repo) {
        this.repo = repo;
    }

    public void registerUser(String user) {
        repo.save(user);
    }
}

장점:

  • 추상화에 의존 → 구현체는 쉽게 바꿀 수 있음
  • 테스트 시 가짜(Fake) Repository도 주입 가능

결론: SOLID는 OOP의 실천법

원칙요약핵심 키워드
SRP하나의 책임만 가지도록응집도, 변경 이유 분리
OCP확장에 열려, 변경에는 닫힘유지보수성
LSP자식은 부모를 완전히 대체 가능상속의 일관성
ISP작고 명확한 인터페이스 사용클라이언트 최적화
DIP추상화에 의존느슨한 결합, DI 활용

SOLID는 무조건 지켜야 하는 법칙이 아니라, 지향해야 할 설계 철학
모든 프로젝트에서 완벽하게 지킬 수는 없지만,
이 원칙들을 바탕으로 한 코드는 확장성, 유지보수성, 테스트 용이성이 매우 뛰어남


참고: 실제 적용 시 체크리스트

  • 클래스가 너무 많은 책임을 가지고 있지는 않은가? (SRP)
  • 새로운 기능을 추가할 때 기존 클래스를 수정하고 있지는 않은가? (OCP)
  • 상속을 했는데 오히려 기능 제한이 생기지는 않는가? (LSP)
  • 인터페이스가 너무 많은 기능을 강제하고 있지는 않은가? (ISP)
  • 구체 클래스에 직접 의존하고 있지는 않은가? (DIP)

좋은 설계는 SOLID 원칙을 의식하는 것에서 시작됨
현업에서 자주 마주치는 리팩토링 이슈는 대부분 이 다섯 가지 원칙 중 하나 이상을 위반했을 가능성이 높음
코드를 읽고, 설계하고, 리팩토링할 때 항상 이 다섯 가지 질문을 떠올려보기 바람

0개의 댓글