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

ww_ung·2025년 4월 4일

CS정리 & 기술면접

목록 보기
6/10

5가지 개발 원칙

  • SRP(Single Responsibility Principle): 단일 책임 원칙
  • OCP(Open Closed Priciple): 개방 폐쇄 원칙
  • LSP(Listov Substitution Priciple): 리스코프 치환 원칙
  • ISP(Interface Segregation Principle): 인터페이스 분리 원칙
  • DIP(Dependency Inversion Principle): 의존 역전 원칙

좋은 소프트웨어란 변화에 대응을 잘하는 것을 말한다.
변화에 대응을 잘하려면 시스템에 새로운 변경 사항이 있거나, 고객으로부터 새로운 요구사항이 생겼을때 소프트웨어에 적용을 시켜야하고, 그 때 영향을 받는 범위가 적은 설계를 짜야 한다.
따라서 SOLID 5개의 원칙을 적용하면 코드를 확장하고, 유지 보수 관리가 더 쉬워지며, 불필요한 복잡성을 제거해 리팩토링에 소요되는 시간을 줄임으로써 프로젝트 개발 생산성을 높일 수 있다.

SRP (Single Responsibility Principle)

단일 책임 원칙
말 그대로 클래스(객체)는 단 하나의 책임만 가져야 한다는 원칙이다.
책임이란 쉽게 말해서 '기능'이라고 이해하면 된다.
기능이 많아질수록 유지보수가 어려워지고, 다른 책임이 영향을 줄 수 있어 결합도가 증가한다.

🙅 나쁜 예시

class UserManager:
    def create_user(self, name, email):
        # 사용자 생성
        print(f"{name} 생성 완료!")

    def send_welcome_email(self, email):
        # 환영 이메일 전송
        print(f"{email} 로 환영 메일 전송!")

    def save_to_database(self, user):
        # DB 저장
        print("DB에 사용자 저장 완료!")

UserManager가 사용자 생성, 환영 이메일 전송, DB 저장 기능까지 담당하고 있다고 가정하자.
만약 이메일 시스템이나 DB방식이 바뀌면 해당 클래스도 수정해야 하므로 유지보수가 어렵다.
한 기능의 변경으로 부터 다른 기능의 변경으로의 연쇄작용을 막을 수 있다는 뜻이다.

🙆 좋은 예시

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

class UserRepository:
    def save(self, user):
        print("DB에 사용자 저장 완료!")

class EmailService:
    def send_welcome_email(self, email):
        print(f"{email} 로 환영 메일 전송!")

class UserService:
    def __init__(self, repository, email_service):
        self.repository = repository
        self.email_service = email_service

    def register_user(self, name, email):
        user = User(name, email)
        self.repository.save(user)
        self.email_service.send_welcome_email(email)

각각의 클래스가 딱 하나의 기능만을 가진다.
각각의 변경은 자기 class에서만 일어나므로 유지보수가 쉽다.

OCP (Open Closed Priciple)

개방-폐쇄 원칙
소프트웨어 요소는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.
기존 코드를 수정하지 않고도 기능을 확장할 수 있어야 한다는 의미이다.
기능 추가 요청이 오면 클래스를 확장을 통해 쉽게 구현하면서, 확장에 따른 클래스의 수정은 최소화해야 한다고 이해하면 된다.

🙅 나쁜 예시

class HelloAnimal:
    void hello(Animal animal) {
        if (animal.type.equals("Cat")) {
            System.out.println("냐옹");
        } else if (animal.type.equals("Dog")) {
            System.out.println("멍멍");
        }

동물 울음소리가 추가될때마다 elif를 직접 수정해야한다.

🙆 좋은 예시

// 추상화
abstract class Animal {
    abstract void speak();
}

class Cat extends Animal { // 상속
    void speak() {
        System.out.println("냐옹");
    }
}

class Dog extends Animal { // 상속
    void speak() {
        System.out.println("멍멍");
    }
}

class HelloAnimal {
    void hello(Animal animal) {
        animal.speak();
    }
}

그리고 만약 사자와 같이 새로운 동물이 추가된다면
새로운 클래스를 선언만 해주면 된다.

LSP (Liskov Substitution Principle)

영어 그대로 리스코프 치환 원칙이다.
LSP 원칙은 서브타입은 언제나 부모 타입으로 교체할 수 있어야 한다는 원칙이다.
상위 클래스 타입으로 객체를 선언하여 하위 클래스의 인스턴스를 받으면,
업캐스팅된 상태에서 부모의 메서드를 사용해도 동작이 '의도'대로 흘러가야 한다는 것을 의미한다.

🙅 나쁜 예시

Ostrich는 Bird를 상속받았지만, 타조는 fly()를 하지 못한다.
따라서 자기 멋대로 날지 못한다고 정의내렸지만,
Bird를 기대하고 Ostrich를 넣었던 main에서 기능이 깨진다.
이는 행동규약을 어겼고, 애초에 다형성 코드가 동작 자체가 되지 않는다

class Bird {
    public void fly() {
        System.out.println("하늘을 납니다!");
    }
}

class Ostrich extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("타조는 날 수 없습니다!");
    }
}

public class Main {
    public static void main(String[] args) {
        Bird bird = new Ostrich();  // 부모 타입으로 자식 객체 사용
        bird.fly();  // 런타임 에러 발생 LSP 위반
    }
}

🙆 좋은 예시

이렇게 조류는 알을 모두 낳는다 따라서 Bird에는 날을 낳는 기능만 정의하고
나는 새들만 따로 분류하여 FlyingBird로 정의한다.

// 새는 알을 낳는 기능만 정의
class Bird {
    public void layEggs() {
        System.out.println("알을 낳습니다.");
    }
}

// 날 수 있는 새를 별도 클래스로 분리
class FlyingBird extends Bird {
    public void fly() {
        System.out.println("하늘을 납니다!");
    }
}

class Sparrow extends FlyingBird {
    // 모든 기능 문제없이 수행
}

class Ostrich extends Bird {
    // fly() 없음 → 구조적으로 날지 못함
}

ISP (Interface Segregation Principle)

인터페이스 분리 원칙으로 인터페이스를 각각 사용에 맞게 끔 잘게 분리해야한다는 설계 원칙이다.
SRP가 클래스의 단일 책임을 강조한다면 ISP는 인터페이스의 단일 책임을 강조한다고 이해하면 편하다
목적과 용도에 적합한 인터페이스를 제공해야하는것이 목표이다
(주의 : 인터페이스를 분리하여 구성 후 나중에 수정사항이 생겨서 또 분리하는 행위 ❌)

🙅 나쁜 예시

어떤 기계는 프린트만되고, 어떤기계에는 스캔 기능이 빠져있을수도 있다
하지만 machine 인터페이스에 모든 기능을 넣으면 없는 기능도 구현해야한다
-> ISP 위반

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

class OldPrinter implements Machine {
    @Override
    public void print() {
        System.out.println("문서 출력");
    }

    @Override
    public void scan() {
        throw new UnsupportedOperationException("스캔 기능 없음");
    }

    @Override
    public void fax() {
        throw new UnsupportedOperationException("팩스 기능 없음");
    }
}

🙆 좋은 예시

각각의 기계가 필요한 기능들만 상속받아서 사용하는 모습을 볼 수 있다.

// 작은 인터페이스로 분리
interface Printer {
    void print();
}

interface Scanner {
    void scan();
}

interface Fax {
    void fax();
}

// 필요한 인터페이스만 구현
class SimplePrinter implements Printer {
    @Override
    public void print() {
        System.out.println("문서 출력");
    }
}

class OfficeMultiMachine implements Printer, Scanner, Fax {
    @Override
    public void print() {
        System.out.println("문서 출력");
    }

    @Override
    public void scan() {
        System.out.println("문서 스캔");
    }

    @Override
    public void fax() {
        System.out.println("팩스 전송");
    }
}

DIP (Dependency Inversion Principle)

의존 역전 원칙으로 어떤 Class를 참조해서 사용해야하는 상황이 생긴다면,
그 Class를 직접 참조하는 것이 아니라 그 대상의 상위 요소(추상 클래스 or 인터페이스)로 참조하라는 원칙이다.
의존 관계를 맺을 때 변화가 자주 일어나는 것보다는 변화하기 어려운것, 거의 변하지 않는 것에 의존하라는 원칙이다.

🙅 나쁜 예시

OrderService는 결제를 처리해야 한다
KaKaoPay를 new로 생성해서 쓰다가 결제수단을 바꾸게 된다면 아예 클래스 필드의
변수 타입을 교체해주거나 해야한다
즉 이미 완전하게 구현된 하위 모듈을 의존하고 있다.

class KakaoPay {
    public void pay(int amount) {
        System.out.println("KakaoPay로 " + amount + "원 결제");
    }
}

class OrderService {
    private KakaoPay kakaoPay = new KakaoPay(); // 구체 클래스에 직접 의존

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

🙆 좋은 예시

따라서 더 상위 모듈인 PaymentService 인터페이스를 생성해서
모든 결제시스템들이 PaymentService 인터페이스를 implementgkdu
결제 변경에 따라 코드를 변경할 필요가 없게 구현한다.

// 추상화: 결제 인터페이스 정의
interface PaymentService {
    void pay(int amount);
}

// 하위 모듈: 구현체
class KakaoPay implements PaymentService {
    public void pay(int amount) {
        System.out.println("KakaoPay로 " + amount + "원 결제");
    }
}

class TossPay implements PaymentService {
    public void pay(int amount) {
        System.out.println("TossPay로 " + amount + "원 결제");
    }
}

// 상위 모듈: 인터페이스에만 의존
class OrderService {
    private final PaymentService paymentService;

    // 생성자 주입 (Constructor Injection)
    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    public void order(int amount) {
        paymentService.pay(amount);
    }
}

0개의 댓글