SOLID 원칙 - 객제지향을 잘 하려면 SOLID를 기억해라

2025. 12. 21. 20:47·객채지향 개발론

0. 들어가며

객체지향의 4대 특성(캡슐화, 상속, 추상화, 다형성)은 요리를 만들기 위한 불, 물, 칼과 같은 주방 도구라고 할 수 있습니다.
물의 수압을 높이면 과일이나 채소를 자를 수도 있고 가스레인지 대신 과자 한 봉지의 열량으로도 물을 끓일 수 있으며, 젓가락 대신 나뭇가지를 깎아 사용할 수도 있습니다.

하지만 과일이나 채소를 자를 때는 칼을 사용하는 것이 가장 좋은 방법이고, 음식을 조리할 때는 가스레인지와 냄비가 가장 적합합니다.
아무리 좋은 도구가 있어도 올바르게 사용하지 않으면 요리를 만드는 과정은 비효율적일 수밖에 없습니다.

도구를 올바르게 사용하는 법이 있는 것처럼 객체지향의 특성을 올바르게 사용하는 방법 역시 존재합니다.
객체지향 언어를 이용해 객체지향 프로그램을 어떻게 설계해야 하는지에 대한 원칙, 그것이 바로 SOLID 원칙입니다.

객체지향 언어의 시초라 불리는 Simula67이 1960년에 발표된 이후, 50년이 넘는 시간 동안 수많은 시행착오가 축적되었습니다.
그 과정에서 객체지향의 정수라고 할 수 있는 다섯 가지 설계 원칙이 정리되었는데, 이것이 바로 SOLID 원칙입니다.

 

로버트 C. 마틴

SOLID는 로버트 C. 마틴(Robert C. Martin)이 2000년대 초반 제시한 객체지향 설계의 다섯 가지 기본 원칙을 마이클 페더스(Michael Feathers)가 두문자어로 정리하여 소개한 개념입니다.

  • SRP : 단일책임 원칙 (분류)
  • OCP : 개방 폐쇄 원칙 (교체)
  • LSP : 리스코프 치환 법칙(교체)
  • ISP: 인터페이스 분리 법칙 (분류)
  • DIP : 의존성 역전 원칙(교체)

이 원칙들은 하늘에서 갑자기 떨어진 개념이 아니라,
“응집도는 높이고 결합도는 낮추라”는 고전적인 설계 원칙을 객체지향 관점에서 재정의한 것이라고 볼 수 있습니다.

응집도(Cohesion)
하나의 모듈이나 클래스가 하나의 책임에 얼마나 집중되어 있는가

결합도(Coupling)
다른 모듈이나 클래스와 얼마나 강하게 의존하고 있는가

이번 글에서는 객체지향 프로그램을 잘 설계하기 위한 SOLID 5대 원칙을 하나씩 살펴보겠습니다.

앞서 말씀드렸듯이 SOLID는 객체지향의 4대 특성을 발판으로 하며
디자인 패턴의 뼈대이자 스프링 프레임워크의 근간이 되는 철학이기도 합니다.

제가 생각하는 OOP의 핵심 키워드는 두 가지입니다.

  • 분류 : 수십억 라인의 코드를 어떻게 관리할 것인가
  • 교체 : 사용하던 라이브러리가 갑자기 사라진다면 어떻게 대응할 것인가

이 두 가지 문제를 잘 해결하기 위해 제안된 설계 원칙이 바로 SOLID입니다.

 


1.SRP (Single Responsibility Principle) - 하나의 클래스, 하나의 책임만

단일책임 원칙

“어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다.”
— 로버트 C. 마틴


정의

하나의 클래스는 하나의 책임만 가져야 한다는 원칙입니다.
여기서 책임이란 단순한 기능 하나가 아니라, 변경의 이유를 의미합니다.


특징

  • 클래스의 역할이 명확해집니다
  • 변경 시 영향 범위가 작아집니다
  • 테스트와 유지보수가 쉬워집니다

장점

  • 코드 가독성이 향상됩니다
  • 변경에 강한 구조를 만들 수 있습니다
  • 사이드 이펙트가 줄어듭니다

단점

  • 클래스 수가 증가할 수 있습니다
  • 처음 설계 시 과하다고 느껴질 수 있습니다

예시

SRP를 위반한 경우

class UserService {
    void saveUser(User user) {
        // 사용자 저장
    }

    void sendEmail(User user) {
        // 이메일 전송
    }
}

위 코드에서 UserService는 사용자 저장과 이메일 전송이라는 두 가지 책임을 맡고 있습니다.
하나의 클래스가 서로 다른 역할을 동시에 수행하고 있는 셈입니다.

이런 경우,

  • 사용자 저장 방식이 변경되거나
  • 이메일 전송 정책이 변경될 때

서로 관련 없는 이유로도 UserService를 수정해야 하는 상황이 발생합니다.
즉, 변경의 이유가 두 가지가 되면서 코드가 변경에 취약해지고 유지보수가 어려워집니다.

이처럼 하나의 클래스가 여러 책임을 가지게 되면
SRP(단일 책임 원칙)를 위반하게 되며
결과적으로 코드의 응집도는 낮아지고 결합도는 높아지는 문제가 발생합니다.


SRP를 적용한 경우

class UserService {
    void saveUser(User user) {
        // 사용자 저장
    }
}

class EmailService {
    void sendEmail(User user) {
        // 이메일 전송
    }
}

 

SRP를 적용한 경우 사용자 저장과 이메일 전송이라는 책임을 각각의 클래스가 나누어 가지도록 설계했습니다.

UserService는 사용자 정보를 저장하는 책임만을 가지며,
EmailService는 이메일을 전송하는 역할만 담당합니다.

이렇게 책임을 분리하면

  • 사용자 저장 로직이 변경되더라도 EmailService에는 영향이 없고
  • 이메일 전송 정책이 변경되더라도 UserService는 수정할 필요가 없습니다.

즉, 각 클래스는 하나의 이유로만 변경되게 되며 SRP(단일 책임 원칙)를 만족하는 구조가 됩니다.
그 결과 코드의 응집도는 높아지고 결합도는 낮아져 유지보수와 확장이 쉬운 설계를 만들 수 있습니다.


2. OCP (Open-Closed Principle) - 확장에는 열려 있고, 변경에는 닫혀라

개방 폐쇄 원칙

“소프트웨어 엔티티는 확장에 대해서는 열려 있어야 하고,
변경에 대해서는 닫혀 있어야 한다.”
— 로버트 C. 마틴


정의

OCP는 기존 코드를 수정하지 않고도 기능을 확장할 수 있어야 한다는 원칙입니다.
즉 새로운 요구사항이 생겼을 때 기존 코드를 고치는 방식이 아니라새로운 코드를 추가하는 방식으로 확장할 수 있어야 합니다.


특징

  • 변경보다 확장을 우선합니다
  • 추상화(인터페이스, 추상 클래스)를 적극적으로 사용합니다
  • 다형성을 기반으로 동작합니다

장점

  • 기존 코드의 안정성이 높아집니다
  • 기능 추가 시 버그 발생 가능성이 줄어듭니다
  • 유지보수와 확장이 쉬워집니다

단점

  • 초기 설계 난이도가 높아질 수 있습니다
  • 과도한 추상화는 코드 복잡도를 증가시킬 수 있습니다

예시

OCP를 위반한 경우

class DiscountService {
    int discount(String grade, int price) {
        if (grade.equals("VIP")) {
            return price * 20 / 100;
        } else if (grade.equals("GOLD")) {
            return price * 10 / 100;
        }
        return 0;
    }
}

위 코드에서 DiscountService는
회원 등급에 따라 할인 정책을 직접 분기 처리하고 있습니다.

이런 경우

  • 새로운 회원 등급이 추가되거나
  • 할인 정책이 변경될 때마다

if-else 문을 계속 수정해야 합니다.
즉, 기능 확장을 위해 기존 코드를 반복해서 변경해야 하는 구조입니다.

이처럼 새로운 요구사항이 생길 때마다
기존 클래스를 수정해야 한다면
OCP(개방–폐쇄 원칙)를 위반하고 있다고 볼 수 있습니다.


OCP를 적용한 경우

 

interface DiscountPolicy {
    int discount(int price);
}

class VipDiscountPolicy implements DiscountPolicy {
    public int discount(int price) {
        return price * 20 / 100;
    }
}

class GoldDiscountPolicy implements DiscountPolicy {
    public int discount(int price) {
        return price * 10 / 100;
    }
}

OCP를 적용한 경우 할인 정책을 인터페이스로 추상화하고 각 할인 정책을 구현 클래스로 분리했습니다.

이렇게 설계하면

  • 새로운 할인 정책이 추가되더라도 기존 코드를 수정할 필요 없이 새로운 구현 클래스만 추가하면 됩니다
  • DiscountPolicy를 사용하는 쪽에서는 어떤 할인 정책이 적용되는지 알 필요가 없습니다

즉 시스템은 확장에는 열려 있고 기존 코드는 변경에 닫혀 있는 구조가 됩니다.

그 결과 OCP를 만족하는 설계가 되며 변경에 강하고 유연한 객체지향 구조를 만들 수 있습니다.


 

3. LSP (Liskov Substitution Principle) - 자식 클래스는 부모 클래스처럼 동작해야 한다

리스코프 치환 원칙

“프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서
하위 타입의 인스턴스로 바꿀 수 있어야 한다.”
— 바바라 리스코프


정의

LSP는 부모 클래스 타입을 사용하는 곳에 자식 클래스 객체를 넣어도 프로그램의 동작이 달라지지 않아야 한다는 원칙입니다.

즉 상속은 단순히 코드를 재사용하기 위한 수단이 아니라
행위가 보장되는 관계여야 합니다.


특징

  • 상속 관계의 신뢰성을 보장합니다
  • 다형성을 안전하게 사용할 수 있습니다
  • “is-a 관계”가 논리적으로 성립해야 합니다

장점

  • 예측 가능한 코드가 됩니다
  • 런타임 오류 발생 가능성이 줄어듭니다
  • 다형성을 안정적으로 활용할 수 있습니다

단점

  • 잘못된 상속 설계를 초기에 발견하기 어렵습니다
  • 상속을 신중하게 사용해야 하므로 설계 부담이 커질 수 있습니다

예시

LSP를 위반한 경우

class Rectangle {
    protected int width;
    protected int height;

    void setWidth(int width) {
        this.width = width;
    }

    void setHeight(int height) {
        this.height = height;
    }

    int getArea() {
        return width * height;
    }
}

class Square extends Rectangle {
    @Override
    void setWidth(int width) {
        this.width = width;
        this.height = width;
    }

    @Override
    void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

이 코드에서 Square는 Rectangle을 상속받고 있지만 부모 클래스의 동작을 그대로 유지하지 못하고 있습니다.

예를 들어

  • Rectangle 타입으로 setWidth()와 setHeight()를 호출하면 너비와 높이가 독립적으로 변경될 것이라 기대하지만
  • Square 객체를 넣는 순간 그 기대가 깨지게 됩니다

즉 자식 클래스가 부모 클래스의 규약을 어기고 있으며 부모 타입을 사용하는 코드의 동작을 보장하지 못합니다.

이러한 경우 부모 타입을 자식 타입으로 치환할 수 없게 되므로 LSP(리스코프 치환 원칙)를 위반하게 됩니다.


LSP를 적용한 경우

interface Shape {
    int getArea();
}

class Rectangle implements Shape {
    private int width;
    private int height;

    Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

class Square implements Shape {
    private int side;

    Square(int side) {
        this.side = side;
    }

    public int getArea() {
        return side * side;
    }
}

LSP를 적용한 경우 무리한 상속 관계를 제거하고 공통 행위를 인터페이스로 추상화했습니다.

이렇게 설계하면

  • Shape를 사용하는 쪽에서는 구현체가 Rectangle인지 Square인지 알 필요가 없고
  • 어떤 객체가 들어오더라도 동일한 방식으로 안전하게 사용할 수 있습니다

즉 모든 구현체가 상위 타입(Shape)의 규약을 정확히 지키게 되며 LSP를 만족하는 구조가 됩니다.


4. ISP (Interface Segregation Principle) - 작은 인터페이스가 큰 인터페이스보다 낫다

인터페이스 분리 원칙

“클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안 된다.”
— 로버트 C. 마틴


정의

 

ISP는 하나의 범용 인터페이스보다 여러 개의 역할 중심 인터페이스가 더 낫다는 원칙입니다.

즉 구현 클래스가 사용하지도 않는 메서드를 억지로 구현하게 만드는 인터페이스는 잘못된 설계입니다.


특징

  • 인터페이스의 책임이 명확해집니다
  • 구현 클래스의 부담이 줄어듭니다
  • 역할(Role) 중심 설계가 가능합니다

장점

  • 변경에 대한 영향 범위가 줄어듭니다
  • 불필요한 의존성을 제거할 수 있습니다
  • 코드의 응집도가 높아집니다

단점

  • 인터페이스의 개수가 늘어날 수 있습니다
  • 처음 설계 시 역할 분리가 어렵게 느껴질 수 있습니다

예시

ISP를 위반한 경우

interface Machine {
    void print();
    void scan();
    void fax();
}

class Printer implements Machine {
    public void print() {
        // 출력 기능
    }

    public void scan() {
        // 사용하지 않음
    }

    public void fax() {
        // 사용하지 않음
    }
}

위 코드에서 Machine 인터페이스는 출력, 스캔, 팩스라는 여러 기능을 한꺼번에 가지고 있습니다.

이런 경우

  • Printer 클래스는 출력 기능만 필요함에도 불구하고
  • 사용하지 않는 scan(), fax() 메서드까지
    강제로 구현해야 합니다

즉 클라이언트가 필요하지 않은 기능에 의존하게 되며이는 ISP(인터페이스 분리 원칙)를 위반한 설계입니다.


ISP를 적용한 경우

 

interface Printable {
    void print();
}

interface Scannable {
    void scan();
}

interface Faxable {
    void fax();
}

class Printer implements Printable {
    public void print() {
        // 출력 기능
    }
}

ISP를 적용한 경우 인터페이스를 기능(역할) 단위로 분리했습니다.

이렇게 설계하면

  • Printer는 출력이라는 역할에만 집중할 수 있고
  • 필요하지 않은 메서드에 의존하지 않아도 됩니다

즉, 각 구현 클래스는 자신이 필요한 인터페이스만 선택적으로 구현하게 되며 ISP를 만족하는 구조가 됩니다.

그 결과 불필요한 의존성이 제거되고 변경에 강한 유연한 설계를 만들 수 있습니다.


5. DIP (Dependency Inversion Principle) - 상위 모듈은 하위 모듈에 의존하지 말고, 추상화에 의존하라

의존 역전 원칙

“고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다.”
“추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.”
— 로버트 C. 마틴

 


정의

DIP는 구현 클래스가 아니라 추상화(인터페이스, 추상 클래스)에 의존해야 한다는 원칙입니다.

즉, 비즈니스 로직을 담당하는 고수준 모듈이
구체적인 구현체에 직접 의존하지 않도록 설계해야 합니다.


특징

  • 의존성이 구체 클래스가 아닌 추상화로 향합니다
  • 구현 교체가 쉬운 구조를 만들 수 있습니다
  • DI(의존성 주입)와 밀접한 관련이 있습니다

장점

  • 구현 변경에 유연하게 대응할 수 있습니다
  • 테스트 코드 작성이 쉬워집니다
  • 결합도가 낮은 구조를 만들 수 있습니다

단점

  • 구조가 한 단계 더 복잡해질 수 있습니다
  • 추상화 개념에 익숙하지 않으면 이해가 어려울 수 있습니다

예시

DIP를 위반한 경우

class OrderService {
    private final KakaoPay kakaoPay = new KakaoPay();

    void pay(int amount) {
        kakaoPay.pay(amount);
    }
}

class KakaoPay {
    void pay(int amount) {
        // 카카오페이 결제
    }
}

위 코드에서 OrderService는 결제 기능을 수행하기 위해 KakaoPay라는 구체 클래스에 직접 의존하고 있습니다.

이런 경우

  • 결제 수단을 네이버페이로 변경하거나
  • 테스트용 결제 로직으로 교체하려면

OrderService 코드를 직접 수정해야 합니다.

즉, 고수준 모듈이 저수준 모듈에 강하게 의존하게 되어
DIP(의존 역전 원칙)를 위반하게 됩니다.


DIP를 적용한 경우

 

interface Payment {
    void pay(int amount);
}

class KakaoPay implements Payment {
    public void pay(int amount) {
        // 카카오페이 결제
    }
}

class OrderService {
    private final Payment payment;

    OrderService(Payment payment) {
        this.payment = payment;
    }

    void pay(int amount) {
        payment.pay(amount);
    }
}

DIP를 적용한 경우,
결제 수단을 인터페이스로 추상화하고
OrderService는 구체 클래스가 아닌 Payment에 의존하도록 변경했습니다.

이렇게 설계하면,

  • 결제 수단이 변경되더라도 OrderService는 수정할 필요가 없고
  • 테스트 시에도 가짜 구현체를 쉽게 주입할 수 있습니다

즉, 의존성의 방향이 역전되어,
저수준 모듈이 추상화에 의존하는 구조가 되며
DIP를 만족하는 설계가 됩니다.


마치며

이번 글에서 SOLID 원칙에 대해 정리하면서, 이 원칙이 단순한 코드 규칙이 아니라 객체지향 설계의 핵심이라는 것을 다시 느꼈습니다.
객체지향의 특성과 설계 원칙, 디자인 패턴을 제대로 이해하고 활용하기 위해서는 SOLID 원칙이 필수적입니다.

특히 스프링 프레임워크는 이러한 객체지향의 특성, 설계 원칙, 디자인 패턴 위에 구현되어 있기 때문에, 원칙을 지키며 코드를 설계하는 것이 곧 안정적이고 확장 가능한 애플리케이션을 만드는 길임을 깨달았습니다.

앞으로는 SOLID 원칙을 기준으로 단순한 기능 구현을 넘어 설계와 구조를 고민하는 객체지향 개발자로 성장하려고 합니다.

 

 

저작자표시 비영리 (새창열림)

'객채지향 개발론' 카테고리의 다른 글

객체지향 개발론 02  (8) 2024.12.29
객체지향 개발론 01  (10) 2024.12.22
'객채지향 개발론' 카테고리의 다른 글
  • 객체지향 개발론 02
  • 객체지향 개발론 01
깊은바다속꼬북이
깊은바다속꼬북이
  • 깊은바다속꼬북이
    CodeBlossom
    깊은바다속꼬북이
  • 전체
    오늘
    어제
    • 분류 전체보기 (53) N
      • 라이징 캠프 (4)
      • 객채지향 개발론 (3)
      • 스프링 (10) N
      • 네트워크 (2)
      • 자바 (16)
      • 자료구조 (3)
      • 운영체제 (0)
      • 데이터베이스 (4)
      • 디자인패턴 (7)
      • JSP (1)
      • 개발 알쓸신잡 (2)
      • 일반 교양 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    한국어 검색
    jvm 클래스 로더
    jit-compiler
    java data area
    java
    JUnnit5
    java 버전별 특징
    어댑터 패턴(Adapter Pattern)
    스프링
    싱글턴 패턴(Singleton Pattern)
    프로그램밍 언어
    JVM
    자료구조
    개발 교훈
    MySQL 파서
    템플릿 메서드 패턴(Template Method Pattern)
    전략 패턴(Strategy Pattern)
    spring
    자바 Socket 클래스
    디자인 패턴
    mockito라이브러리
    디자인패턴
    junnit5프레임워크
    GC
    객체지향
    개발자 철학
    백엔드
    트랜잭션 전파레벨
    개발 철학
    MySQL 옵티마이저
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
깊은바다속꼬북이
SOLID 원칙 - 객제지향을 잘 하려면 SOLID를 기억해라
상단으로

티스토리툴바