공부/객체 지향 프로그래밍

[OOP] SOLID 원칙

dev_jiwonpark 2025. 7. 4. 00:16

이전 포스팅에서 객체지향의 4대 특성에 대해서 공부해봤는데 이번에는 객체 지향 프로그래밍에서 유지보수성과 확장성을 높이기 위한 5가지 설계 원칙인 SOLID 원칙에 대해서 공부해보려고 한다.

우선 위키에 정리되어있는 SOLID 원칙의 정의 부터 살펴보자!

두문자 약어 개념
S SRP 단일 책임 원칙 (Single responsibility principle)
한 클래스는 하나의 책임만 가져야 한다.
O OCP 개방-폐쇄 원칙 (Open/closed principle)
소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
L LSP 리스코프 치환 원칙 (Liskov substitution principle)
프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
I ISP 인터페이스 분리 원칙 (Interface segregation principle)
특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
D DIP 의존관계 역전 원칙 (Dependency inversion principle)
추상화에 의존해야지, 구체화에 의존하면 안된다.

참고: https://ko.wikipedia.org/wiki/SOLID_(%EA%B0%9D%EC%B2%B4_%EC%A7%80%ED%96%A5_%EC%84%A4%EA%B3%84)

 

SOLID (객체 지향 설계) - 위키백과, 우리 모두의 백과사전

위키백과, 우리 모두의 백과사전. 컴퓨터 프로그래밍에서 SOLID란 로버트 C. 마틴[1][2]이 2000년대 초반[3]에 명명한 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙을 마이클 페더스가 두문자

ko.wikipedia.org

일단 위에 정의만 읽어서는 저게 왜 필요한 원칙이며 어떤 경우에 필요한건지 어떤 문제를 해결하는지 확 와닿지 않는다..

하지만 와닿지 않아도! 의지를 가지고! 공부할 필요가 있다.

왜냐하면 내가 공부중인 디자인 패턴이 이 SOLID 원칙에 기반하여 만들어진 것들이기 때문에..  디자인 패턴에 대해서 더 잘 이해하기 위해선 공부해야한다.!

 

이번에 SOLID를 공부할때 정의도 중요하지만 이 원칙을 지켰을 때 지키지 않았을 때 의 차이를 느껴보면서 공부해보려고 한다.

 

1. 단일 책임 원칙 (SRP: Single Responsibility Principle)

단일 책임 원칙은 클래스는 단 하나의 책임만 가져야한다는 것이다.

 

만약 클래스가 하나의 책임만 가지지 않는다면 ?

class UserManager {
  saveUserToDB(user) { /* DB 저장 */ }
  sendWelcomeEmail(user) { /* 이메일 전송 */ }
  logActivity(user) { /* 로그 기록 */ }
}

DB 변경, 이메일 형식변경, 로그 방식 변경 중 하나만 바뀌어도 클래스 전체가 영향을 받게 된다.

또한 테스트도 어려워진다. 기능이 섞여 있어 단위 테스트가 애매..해진다.

 

만약 하나의 책임만 가지게 된다면?

class UserSaver { save(user) { /* DB 저장 */ } }
class EmailSender { sendWelcome(user) { /* 이메일 */ } }
class ActivityLogger { log(user) { /* 로그 */ } }

기능별로 나눴기 때문에 변경이 한곳에만 집중되게 된다.

테스트도 간단하지며 유지보수도 용이해진다.

 

때문에 하나의 클래스는 하나의 기능을 담당하게 되고 하나의 책임만 수행하게끔 따로따로 설계하라는 원칙인 것이다.

 

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

확장에는 열려 있고, 변경에는 닫혀 있어야 한다.

 

"확장에 열려있다" 는 것은 변경사항이 있을때 유연하게 코드를 추가해 기능을 확장할 수 있다는 의미이고

"변경에는 닫혀있어야한다" 라는 것은 새로운 변경사항이 생겼을 때 객체를 직접적으로 수정하는것을 제한한다는 것이다.

 

만약 이를 지키지 않은 경우에는?

function calculateDiscount(userType: string): number {
  if (userType === "regular") return 0;
  else if (userType === "vip") return 0.1;
  else if (userType === "vvip") return 0.2;
}

새로운 사용자 타입이 생길 경우 -> 조건문을 추가적으로 작성해줘야한다.

기존 함수 수정이 필수이기 때문에 변경이 누적되고 유지보수가 어려워진다..!

 

하지만 OCP를 지켰을 경우에는?

interface DiscountStrategy {
  getDiscount(): number;
}

class RegularUser implements DiscountStrategy {
  getDiscount() { return 0; }
}
class VIPUser implements DiscountStrategy {
  getDiscount() { return 0.1; }
}
class VVIPUser implements DiscountStrategy {
  getDiscount() { return 0.2; }
}

새로운 할인 정책을 추가한다고 가정해보자! 

기존에는 유저 타입이 무엇인지에 조건문으로 비교해가면서 할인을 해줬는데 

이젠 기존 클래스 수정 없이 새로운 유저 클래스를 만들기만 하면 된다!

그래서 코드 변경은 최소화 되며 확장성은 증가하게 되는 것이다. 

 

3. 리스코프 치환 원칙 (LSP: Liskov Substitution Principle)

부모 클래스 객체를 자식 클래스로 대체해도 문제가 없어야 한다.

이는 상속받은 하위 클래스가 상위 클래스의 역할을 완전히 대체할 수 있어야 한다는 뜻이다.


만약.. 자식 클래스가 부모 클래스의 규칙을 깨버리게 되면 어떻게 될까?

class Bird {
  fly() {
    console.log("날아갑니다!");
  }
}

class Ostrich extends Bird {
  fly() {
    throw new Error("타조는 날 수 없습니다!");
  }
}

function makeItFly(bird: Bird) {
  bird.fly();
}

makeItFly(new Bird());      // 정상
makeItFly(new Ostrich());   // 런타임 에러 발생

이 코드의 문제점은 Ostrich는 Bird를 상속했지만 Bird가 기대하는 행동을 깨트렸다. 

makeItFly는 Bird를 받아 fly를 호출했는데 Ostrich에서는 예외가 발생함.

이는 LSP를 위반 한것이다. 왜냐면 자식이 부모를 대체하지 못하고 기능이 일관되지 않기 때문이다..!

 

그럼 LSP를 지켰을 때의 구조는 어떨까

interface Flyable {
  fly(): void;
}

class Bird {}

class FlyingBird extends Bird implements Flyable {
  fly() {
    console.log("훨훨 날아요!");
  }
}

class Ostrich extends Bird {
  walk() {
    console.log("빠르게 달립니다!");
  }
}

function makeItFly(bird: Flyable) {
  bird.fly();
}

makeItFly(new FlyingBird());  // 정상
// makeItFly(new Ostrich());  // 타입 오류 (실행 전에 걸러짐)

우선 인터페이스 Flyable

이 인터페이스는 "날수 있는 애" 들을 위한 규칙이다.

Flyable이라고 하려면 반드시 fly() 메서드를 구현해야 한다.

즉, "나는 Flyable!" = "나는 날 수 있어요!" 가 되는 것이다.

interface Flyable {
  fly(): void;
}

 

Bird는 기본 새 클래스 이며 아무 기능이 없다. 실제 행동은 자식들이 알아서 구현한다.

FlyingBird는 날 수 있는 새 이고 Bird의 자식이다.

implements Flyable → 나는 "Flyable한 새" 라고 선언했으니
꼭 fly() 메서드를 구현해야 한다.

 

Ostrich는 타조 – 날 수 없는 새이다.

얘도 Bird의 자식이지만.. Flyable 이 아니다..

때문에 날수 있다는 약속을 하지 않은것이다!

 

그럼 함수에선 이렇게 사용될 수 있다.

function makeItFly(bird: Flyable) {
  bird.fly();
}

 

makeItFly 함수는 Flyable한 애들만 받는다.

즉, 날수 있다면 fly()를 호출한다.

 

때문에 makeItFly(new FlyingBird()); 에서 FlyingBird는 Flyable 이기때문에 훨훨 날아요! 가 출력되는 것이다.

하지만 makeItFly(new Ostrich()); 에서 Ostrich는 Flyable가 아니기 때문에 컴파일 단계에서 막히게 된다.

 

위 예제에서는 날 수 있는새 & 날 수 없는 새로 역할을 나누고 예상과 다른 행동이 없으므로 다형성의 안정성 확보하게 된다.

이게 바로 LSP를 지킨 상태인 것이다. 

만약 첫번째 예제 처럼 자식 클래스에서 부모 메서드를 오버 라이드 하면서 다른 행위를 해버리는 경우 LSP 원칙을 지키지 못했다고 하는 것이다. 

한마디로 자식이 부모의 역할을 제대로 수행하지 못하면 안된다는 것이다..!

 

마지막으로 다형성과 LSP를 연관지어서 마무리 해보자면

다형성은 부모 타입으로 자식 객체를 자유롭게 쓸 수 있는 것

LSP는 그 자유로운 사용이 안전하고 예측 가능하도록 보장하는 원칙

즉 LSP를 지켜야 다형성이 실전에서도 작동하게 되는것! 이다. 

 

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

하나의 거대한 인터페이스보다는, 목적에 맞는 작은 인터페이스 여러 개로 나누는 것.
=> "클라이언트(사용자)가 자신이 사용하지 않는 기능에 의존하지 않도록 설계하라."

 

앞전에 살펴본 SRP가 클래스의 책임 분리라면 ISP는 인터페이스의 책임 분리이다. 

클라이언트 관점에서 보면 나한테 필요한것만 줘! 라는 말이다. 

 

만약 ISP를 지키지 않았다면 어떻게 될까?

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

class OldPrinter implements Machine {
  print() { console.log("인쇄됨"); }

  scan() {
    throw new Error("스캔 불가");
  }

  fax() {
    throw new Error("팩스 불가");
  }
}

OldPrinter는 인쇄만 가능한 프린터인데..

인터페이스 상 scan()과 fax()도 강제로 구현해야 함 → 필요 없는 기능까지 포함하게 된다 ㅠㅠ

나중에 Machine 인터페이스가 바뀌면? → 모든 구현 클래스가 영향을 받게 된다!

이는 SRP 위반, ISP 위반, 결합도 증가까지 일어나는 것이다.

 

ISP 를 지켰다면!?

interface Printer {
  print(): void;
}

interface Scanner {
  scan(): void;
}

interface Fax {
  fax(): void;
}

// 인쇄만 가능한 프린터
class OldPrinter implements Printer {
  print() {
    console.log("구형 프린터 인쇄 중...");
  }
}

// 복합기
class MultiFunctionPrinter implements Printer, Scanner, Fax {
  print() { console.log("인쇄!"); }
  scan() { console.log("스캔!"); }
  fax() { console.log("팩스!"); }
}

 

각 클래스는 자신이 필요한 기능만 구현하면 되고 코드가 변경되도 다른 클래스에는 영향을 주지 않게 된다.

때문에 테스트, 유지보수, 확장이 모두 편해지게 된다!

 

여기서 ISP의 주의점은 다음과 같다.

인터페이스는 정책이기 때문에 한번 정해지면 웬만하면 변경되지 않아야한다. 

처음부터 클라이언트의 관점에서, “누가 이 인터페이스를 쓰게 될까?”를 고려해서 설계해야 한다.

 

5. 의존 역전 원칙 - DIP (Dependency Inversion Principle)

우선 위키에서는 다음과 같이 DIP를 설명하고 있다.
소프트웨어 모듈들을 분리하는 특정 형식을 지칭하며
이 원칙을 따르면, 상위 계층(정책 결정)이 하위 계층(세부 사항)에 의존하는 전통적인 의존관계를 반전(역전)시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립되게 할 수 있다. 

쓰읍.. 도대체 이게 무슨 말인걸까...

한줄로 정리하자면 전통적인 의존관계를 ‘역전’시켜라! 이지 않을까 싶다. 

그럼.. 전통적인 의존관계가 도대체 뭘까?

//하위계층(세부 구현)
class MySQLDatabase {
  save(data: string) {
    console.log(`MySQL에 저장: ${data}`);
  }
}

//상위계층(정책 결정, 핵심 비즈니스 로직)
class UserService {
  db = new MySQLDatabase(); // 직접 의존

  register(user: string) {
    this.db.save(user);
  }
}

 

UserService는 핵심 로직이 있는 모듈인데 MySQLDatabase라는 구체적인 구현에 직접 연결되어 있다.

만약 MySQL에서 MongoDB로 바꾸려면..? 

UserService의 내부 구조를 수정해야한다..ㅠ

 

이게 바로 전통적인 구조이다.

상위 계층이 하위 계층을 사용함 → “의존하고 있음”

 

DIP는 이런 전통적인 관계를 '역전' 시키자고 하는 것이다.

상위 계층이 하위 계층을 참조하지 않게 하고 대신 상위/하위 모두 인터페이스(추상화)에 의존하게 하자는 것!

 

만약 이 DIP를 지킨 경우는 어떨까?

// 추상화 계층
interface Database {
  save(data: string): void;
}

// 하위계층은 이 추상화에 의존함
class MySQLDatabase implements Database {
  save(data: string) {
    console.log(`MySQL에 저장: ${data}`);
  }
}

// 상위계층도 이 추상화에만 의존함
class UserService {
  constructor(private db: Database) {}

  register(user: string) {
    this.db.save(user);
  }
}

 

 

이렇게 된다면 UserService는 더 이상 MySQLDatabase에 의존하지 않고 추상화 계층인 Database만 의존하기 때문에

나중에 MongoDB, Redis 등 어떤 구현체로도 갈아끼우기 가능하다.

그래서 DIP는 모듈 간의 분리 설계 원칙이고 원래 상위 계층이 하위 계층을 직접 사용 하던 방식이 아니라 추상화를 매개로 사용하게끔 만듦으로써 상위 계층의 핵심 로직은 변화에 영향을 받지 않게 된다. 

그래서 하위, 상위 계층 모두 추상화에 의존하도록 관계를 '역전'시킨다는 것이다.

 

비유하자면 아래와 같다.

상위 계층 = 셰프
하위 계층 = 도구 (후라이팬, 인덕션 등)
인터페이스(추상화) = "열로 조리되는 조리도구"라는 규격

셰프가 "나는 후라이팬 아니면 못 써!" 하지 않고, "난 열로 조리되는 도구만 있으면 돼!" 라고 말하는 것이다. 

Reference

https://inpa.tistory.com/entry/OOP-%F0%9F%92%A0-%EA%B0%9D%EC%B2%B4-%EC%A7%80%ED%96%A5-%EC%84%A4%EA%B3%84%EC%9D%98-5%EA%B0%80%EC%A7%80-%EC%9B%90%EC%B9%99-SOLID

 

💠 객체 지향 설계의 5가지 원칙 - S.O.L.I.D

객체 지향 설계의 5원칙 S.O.L.I.D 모든 코드에서 LSP를 지키기에는 어려움. 리스코프 치환 원칙에 따르면 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대신하더라도 의도에 맞게 작동되어

inpa.tistory.com