BackEnd

[BackEnd] 객체지향 설계의 SOLID 원칙

hej090224 2025. 7. 22. 09:53

SOLID란?

SOLID는 객체지향 프로그래밍에서 코드를 유지보수하기 쉽고, 확장 가능하며,

이해하기 쉽게 작성 할 수 있게 만든 SRP, OCP, LSP, ISP, DIP 이 다섯가지 원칙의

앞글자를 따서 약자 SOLID라고 부릅니다.

이 원칙은 클래스 설계와 코드 구조를 더 유연하고 깔끔하게 할 수 있게 해줍니다.

각 원칙 이 무슨 원칙인지 알아보겠습니다.

 

1. S - SRP

첫 번쨰 원칙은 SRP입니다.

SRP는 Single Responsibility Principle(단일 책임 원칙)의 약자입니다.

이 원칙의 정의는 "클래스는 오직 하나의 책임만 가져야 한다." 입니다.

여기서 책임이란 변경 이유 정도로만 생각해도 괜찮을 거 같습니다.

이 원칙은 하나의 클래스는 하나의 기능 또는 역할만 담당해야 한다. 즉

해당 기능이 변경 될 이유가 딱 하나만 있어야 한다고 설명 할 수 있겠습니다.

 

이 원칙을 사용하는 목적은

  • 코드의 명확성을 높이고
  • 변경에 우연하게 대응할 수 있도록 하며
  • 버그 발생 확률을 줄이고 테스트 용이성을 높입니다.

등이 있습니다.

 

이 원칙의 장점은 우선 버그 추적과 수정이 보다 쉬워집니다.

또한 기능 단위로 유닛을 테스트할 수 있고, 유지보수가 쉬워져서 비용이 낮아집니다.

그리고 책임이 분리되어 조합이 쉽기 때문에 코드 재사용성이 증가하게 됩니다.

 

장점도 있는 만큼 단점과 주의할 점도 있습니다.

과도하게 사용하면 클래스 수가 너무 많아질 수 있습니다. 또한

경계가 애매한 책임 분리는 오히려 혼란을 초례할 수 있습니다.

 

public class UserManager {
    public void createUser() {...}  
    public void saveUserToDatabase() {...}  
    public void sendWelcomeEmail() {...}  
}

위 코드는 SRP를 사용하지 않은 안 좋은 예시 입니다.

한 클래스에 사용자 생성, DB 저장, 메일 전송이라는 세 가지 책임이 섞여있습니다.

 

class UserCreator { void createUser() {...} }
class UserRepository { void saveUserToDatabase() {...} }
class EmailService { void sendWelcomeEmail() {...} }

위 코드처럼 한 클래스에 한 가지 책임만 주는것이

SRP를 잘 지켜서 개발한 좋은 예시라고 할 수 있습니다.

2. O - OCP

두 번쨰 원칙은 OCP입니다.

OCP는 Open/Closed Principle(개방/폐쇄 원칙)의 약자 입니다.

이 원칙은 "소프트웨어 요소는 확장에는 열려있어야 하고, 변경에는 닫혀 있어야 한다."

라는 정의를 가지고 있습니다. 즉 

새로운 기능 추가는 변경 없이 하고, 확장만으로 가능하게 하자. 

라고 말할 수 있습니다.

 

이 원칙을 사용하게 되는 목적은

  • 새로운 요구사항에 기존 코드를 건드리지 않고 대응
  • 기능 추가 시 안정성 유지

등이 있습니다.

 

이 원칙의 장점은 기존 기능에 영향 없이 안전하게 확장 할수 있고

변경 범위가 축소되어서 버그 발생률이 낮아집니다. 또한

리팩토링 없이도 새로운 기능을 추가 할수 있어서 용이합니다.

 

이 원칙 또한 단점과 주의점이 있습니다.

우선 확장 가능하게 설계하려면 경험이 조금 필요하기 때문에 초기 설계가 어렵습니다.

또한 불필요한 추상화로 인해서 구조가 복잡해질 수 있습니다.

 

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

위 코드는 OCP를 지키지 않은 안 좋은 코드의 예시입니다.

기능을 확장 할 때마다 기존 코드를 수정해야 하는 코드입니다. 

변경에 닫혀 있어야 하는데 매번 열려있게 되는 잘못된 예시입니다.

 

interface Notifier {
    void send();
}

class EmailNotifier implements Notifier {
    public void send() {
    }
}

class SmsNotifier implements Notifier {
    public void send() {
    }
}

class NotificationService {
    private Notifier notifier;

    public NotificationService(Notifier notifier) {
        this.notifier = notifier;
    }

    public void notifyUser() {
        notifier.send();
    }
}

위 코드는 OCP 원칙을 잘 준수한 좋은 예시의 코드 입니다.

기능이 바뀌거나 새로 추가하더라도, 기존 NotificationService 코드는 수정하지 않아도 됩니다.

새로운 클래스만 만들어서 끼워 넣기만 하면 됩니다.

3. L - LSP

세 번쨰 원칙은 LSP입니다.

LSP는 Liskov Substitution Principle(리스코프 치환 원칙)의 약자 입니다.

이 원칙은 "자식 클래스는 부모 클래스의 기능을 대체해도 제대로 동작해야 한다"

라는 정의를 가지고 있습니다. 즉

하위 클래스가 상위 클래스의 행동 계약을 꺠지 않아야 한다는 원칙 입니다.

 

이 원칙을 사용하는 목적은

  • 다형성의 안정성 보장
  • 상속 구조의 일관성을 유지
  • 코드의 예측 가능성 확보

등이 있습니다.

 

이 원칙의 장점은 다형성을 활용해서 유연한 설계를 할수 있고

상속을 사용 사 코드 재사용성이 증가하게 됩니다. 또한

API 교체나 테스트 시 일관성을 보장할수 있습니다.

 

이 원칙 또한 단점과 주의점이 있습니다.

우선 하위 클래스가 상위 클래스의 규칙을 어기면 예상치 못한 오류가 발생 할 수 있습니다.

억지로 상속을 사용하기 보단 상속보다 합성을 고려해야 합니다.

 

class Bird {
    void fly() {
        System.out.println("날아갑니다");
    }
}

class Ostrich extends Bird {
    void fly() {
        throw new UnsupportedOperationException();
    }
}

위 코드는 LSP를 위반한 안 좋은 예시의 코드입니다. 

Ostrich 클래스는 Bird 클래스니까 어디서든 Bird 처럼 사용될 수 있어야 합니다.

하지만 fly()를 호출하면 프로그램이 예외를 던지게 됩니다.

즉 Ostrich 클래스는 Bird 클래스를 완전히 대체할 수 없기 떄문에 LSP를 위반하게 됩니다.

즉 이 코드는 다형성의 이점이 사라지고 조건문만 늘어나게 되는 안좋은 예시의 코드입니다.

 

abstract class Bird {
}

interface Flyable {
    void fly();
}

class Sparrow extends Bird implements Flyable {
    public void fly() {
        System.out.println("참새가 납니다");
    }
}

class Ostrich extends Bird {
}

위 코드는 LSP를 잘 준수한 예시 코드 입니다.

Bird는 모든 새의 공통적인 특징인 두 다리, 알 낳기 등의 공통적인 특성만 정의하게 됩니다.

날 수 있는 새는 Flyable 인터페이스로 따로 정의했습니다.

Ostrich는 Bird라고 정의 할 수 있지만 Flyable이 아닙니다.

그래서 fly()를 절대 호출하지 않기 때문에

Ostrich라는 하위 클래스가 Bird라는 상위 클래스를 대체 할 수 있게 됩니다.

이는 LSP를 잘 지킨 코드라고 할수 있습니다

4. I - ISP

네 번쨰 원칙은 ISP입니다.

ISP는 Interface Segregation Principle(인터페이스 분리 원칙)의 약자 입니다.

이 원칙은 "클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안된다." 라는 정의를 가집니다.

쉽게 말해 인터페이스를 사용할 때 필요한 기능만 강제하도록 하고

작고 구체적으로 나눠야 한다는 원칙입니다.

 

이 원칙을 사용하게 되는 목적으로는

  • 구현체가 불필요한 의존을 하지 않도록 함
  • 구현 클래스에서 쓸모없는 코드 발생을 줄이기 위해서

등이 있습니다.

 

이 원칙의 장점은

필요한 기능만 구현하여 간결하고 명확한 코드를 만들수 있습니다.

또한 강한 결합을 줄여서 테스트가 쉬워집니다.

기능별로 유연한 모듈화가 가능하게 됩니다.

 

이 원칙에도 단점과 주의점이 있습니다.

인터페이스가 너무 작고 많아지면 오히려 구조가 복잡해질 수 있습니다.

또한 인터페이스의 목적이 불분명해지는 경우가 생길 수 있습니다.

 

interface Worker {
    void work();
    void eat();
}

class Robot implements Worker {
    public void work() {
    }

    public void eat() {
        throw new UnsupportedOperationException();
    }
}

위 코드는 ISP를 잘 지키지 않은 안 좋은 예시의 코드 입니다.

Robot은 먹지 못하지만 강제로 eat()메서드를 주입 당하게 됩니다.

이건 Robot 입장에서 보면 eat()은 쓸모없는 메서드 입니다.

이렇게 사용하지 않는 기능을 강제로 구현하게 되면 ISP를 위반하는게 됩니다.

이럴땐 예외처리 하거나 빈 메서드로 채우는 것이 바람직 합니다.

interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

class Robot implements Workable {
    public void work() {
    }
}

class Human implements Workable, Eatable {
    public void work() {
    }

    public void eat() {
    }
}

위 코드는 좋은 예시의 코드입니다.

필요한 기능만을 Workable과 Eatable 등 역할별로 분리하였고

인터페이스가 작고 명확하게 분리되어 있어서, 클래스가

불필요한 메서드를 강제로 구현하지 않아도 됩니다.

코드 확장성과 재사용성이 향상되는 우연한 구조를 사용하여

위 코드는 ISP 원칙을 잘 준수한 좋은 예시의 코드라고 할수 있겠습니다.

5. D - DIP

마지막 원칙은 DIP입니다.

DIP는 Dependency Inversion Principle(의존 역전 원칙)의 약자로

"상위 모듈이 하위 모듈에 의존하지 않고, 추상화에 의존해야 한다" 라는

정의를 가지고 있습니다.

쉽게 말해서 큰 틀을 만드는 상위 코드가 세부적인 동작을 하는 하위 코드에

의존해서는 안되고 둘 다 추상적인 약속에 의존해야 한다는 말 입니다.

 

이 원칙을 사용하는 목적은

  • 유연한 구조 확보
  • 구현체 교체와 테스트, 확장이 쉬워짐

등이 있습니다.

 

이 원칙의 장점은

모듈간 결합도를 줄여서 유지보수가 용이해지게 됩니다.

또한 Mock 객체 사용이나 테스트가 보다 쉬워지고,

구조를 더 확장 가능하게 만들어 준다는 장점이 있습니다.

 

이 원칙 또한 단점과 주의점이 있습니다.

우선 추상화와 DI를 이해해야 하고

인터페이스가 과도하게 남발될 가능성도 있습니다.

그리고 너무 추상화 하게 되면 코드를 추적하는데 어려움을 겪을 수 있습니다.

 

class MySQLDatabase {
    public void saveData() {
        System.out.println("MySQL에 저장함");
    }
}

class DataService {
    MySQLDatabase db = new MySQLDatabase();

    public void process() {
        db.saveData();
    }
}

위 코드는 안 좋은 예시 입니다.

우선 DataService가 MySQL이라는 구체적인 클래스에 직접적으로 의존하여 있어서

DataService는 무조건 MySQL을 써야 합니다. 나중에 MongoDB나 Oracle로 바꾸고 싶어도

결합도가 너무 강해 바꾸지 못하게 됩니다. 이는 DIP를 준수하지 않은 안 좋은 예시의 코드입니다.

 

interface Database {
    void saveData();
}

class MySQLDatabase implements Database {
    public void saveData() {
        System.out.println("MySQL에 저장함");
    }
}

class DataService {
    Database db;

    public DataService(Database db) {
        this.db = db;
    }

    public void process() {
        db.saveData();
    }
}

위 코드는 DIP를 잘 준수하여 작성된 코드입니다.

Database라는 약속만 보고 만들었기 때문에

결합도가 낮아서 DataService는 MySQL이나 MongoDB 등

아무거나 쓸수 있게 됩니다.

또한 나중에 새로운 DB가 생겨도 DataService는 고치지 않아도 됩니다.

이는 DIP를 잘 준수하여 작성된 올바른 예시의 코드입니다.

 

마무리

마지막으로 SOLID 원칙을 한번 정리하자면

객체지향 설계에서 SOLID 원칙은 유지보수성과 확장성을 높이기 위한

가장 기본적이면서도 중요한 다섯 가지 원칙인데, 다음과 같습니다.

 

S - SRP는 클래스 하나가 하나의 역할만 하도록 하는 원칙입니다.

O - OCP는 코드를 변경하지 않고도 기능을 확장할 수 있도록 하는 원칙입니다.

L - LSP는 하위 클래스가 상위 클래스의 역할을 완전히 대체할 수 있어야 한다는 원칙입니다.

I - ISP는 불필요한 기능 강제를 피하기 위해 인터페이스를 작고 명확하게 나누는 데 초점을 둔 원칙입니다.

D - DIP는 구현이 아닌 추상에 의존함으로써 코드의 유연성과 테스트 용이성을 높여주는 원칙입니다.

 

이 다섯 가지 원칙을 잘 지키면 

변화에 강하고, 읽기 쉬우며, 유지보수가 쉬운코드를

작성하는 데에 큰 도움이 됩니다.

그렇기에 이 SOLID 원칙은 객체지향 설계를 하는데에

매우 중요한 개념이라고 할 수 있습니다.