객체지향 설계의 5대 핵심 원칙(SOLID)

SEPTEMBER·2025년 1월 27일

SOLID 원칙은 객체 지향 설계의 5대 핵심 원칙으로, 유지보수성과 확장성이 뛰어난 소프트웨어를 설계하는 데 도움을 줍니다. 이번 포스팅에서는 SOLID의 각 원칙을 예제 코드와 함께 정리해 보겠습니다. 단순히 설명글만 읽는 것보다, 예제 코드를 함께 살펴보는 방식이 이해를 돕고 기억에도 오래 남을 것이라 기대합니다.

그럼, 각 원칙의 핵심 개념과 이를 구현한 예제 코드를 통해 SOLID 원칙을의존 관계를 맺을 때 자주 변화하는 차근차근 알아보겠습니라다변화가 거의 없는 .

< SOLID 요
인터페이스나 추상 클래스에 의존약 >

원칙설명구현 방법
SRP하나의 클래스는 하나의 책임만 가져야 한다기능을 분리하여 클래스 설계
OCP확장에는 열려 있고, 수정에는 닫혀 있어야 한다추상화와 다형성 활용
LSP서브타입은 기반 타입으로 대체 가능해야 한다부모 클래스의 규약을 따르는 설계
ISP인터페이스는 클라이언트에 맞게 분리해야 한다작은 단위의 인터페이스 설계
DIP고수준 모듈과 저수준 모듈 모두 추상화에 의존해야 한다인터페이스나 추상 클래스 활용

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

"한 클래스는 단 하나의 책임만 가져야 한다."

하나의 클래스가 하나의 역할만 수행할 때 코드의 유지보수성이 높아집니다.

<위반 사례>

class Report {
    public String generateReport() {
        return "보고 내용";
    }

    public void saveToFile(String content) {
        System.out.println("보고 내용을 파일로 저장합니다.");
    }
}
  • Report 클래스가 리포트 생성과 파일 저장 두 가지 책임을 가지고 있습니다.

<SRP 준수>

class Report {
    public String generateReport() {
        return "보고 내용";
    }
}

class FileSaver {
    public void saveToFile(String content) {
        System.out.println("보고 내용을 파일로 저장합니다.");
    }
}
  • Report 클래스는 리포트 생성만 담당하고, FileSaver 클래스는 파일 저장만 담당합니다.

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

"확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다."

이 원칙의 핵심은 기존 코드를 변경하지 않고도 기능을 확장할 수 있는 구조를 설계하는 것입니다.

<위반 사례>

class DiscountCalculator {
    public double calculateDiscount(String type, double price) {
        if (type.equals("Regular")) {
            return price * 0.9;
        } else if (type.equals("VIP")) {
            return price * 0.8;
        }
        return price;
    }
}
  • 위 코드에서 Regular와 VIP 외에 새로운 할인 타입을 추가하려면 calculateDiscount 메서드에 else if 조건을 추가해야 하므로, 기존 코드를 수정해야 합니다. 이는 OCP 원칙을 위반한 설계입니다.

<OCP 준수>

interface Discount {
    double apply(double price);
}

class RegularDiscount implements Discount {
    @Override
    public double apply(double price) {
        return price * 0.9;
    }
}

class VIPDiscount implements Discount {
    @Override
    public double apply(double price) {
        return price * 0.8;
    }
}

class DiscountCalculator {
    public double calculateDiscount(Discount discount, double price) {
        return discount.apply(price);
    }
}
  • 위 코드에서 처럼 OCP 원칙은 주로 상속이나 인터페이스를 활용하여 구현합니다.
  • 새로운 할인 타입을 추가하고 싶을 때 기존의 코드를 수정하는 대신에 Discount 인터페이스를 구현한 클래스를 추가하기만 하면 됩니다.

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

"자식 클래스는 부모 클래스의 행위를 대체할 수 있어야 한다."

부모 클래스 타입으로 자식 클래스를 참조했을 때, 프로그램이 정상적으로 작동해야 한다는 원칙입니다. 즉, 자식 클래스는 부모 클래스가 제공하는 기능과 일관성을 유지해야 하며, 부모 클래스의 동작을 깨뜨리면 안 됩니다.

<위반 사례>

class Animal {
    public void walk() {
        System.out.println("걷기");
    }
}

class Bird extends Animal {
    @Override
    public void walk() {
        throw new UnsupportedOperationException("새는 걷기보다 날아다닙니다.");
    }
}
  • Bird 클래스가 walk() 메서드를 재정의하면서 "걷는 기능을 사용할 수 없다"고 예외를 던집니다.
  • 부모 클래스 Animal은 walk()를 호출할 수 있다고 가정했는데, Bird에서 이 가정이 깨졌습니다. 따라서 Bird를 Animal로 대체했을 때, 프로그램이 깨질 가능성이 생깁니다.

<LSP 준수>

// 부모 클래스
class Animal {
    public void walk() {
        System.out.println("걷기");
    }
}

// 자식 클래스
class Bird extends Animal {
    public void fly() {
        System.out.println("날기");
    }
}
  • Bird 클래스는 walk() 메서드를 그대로 상속받아 사용합니다. 즉, "걷는" 동작을 유지합니다.
  • "날다"라는 새로운 동작은 fly()라는 별도의 메서드로 추가됩니다. 이렇게 하면, Bird 객체를 Animal로 대체하더라도 동작에 문제가 생기지 않습니다.

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

"특정 클라이언트를 위한 인터페이스 여러 개가, 범용 인터페이스 하나보다 낫다."

클라이언트가 자신이 사용하지 않는 메서드에 의존하지 않도록 인터페이스를 분리해야 합니다.

<위반 사례>

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

class Developer implements Worker {
    @Override
    public void work() {
        System.out.println("코드를 작성하는 중입니다.");
    }

    @Override
    public void eat() {
        System.out.println("식사를 합니다.");
    }
}

class Robot implements Worker {
    @Override
    public void work() {
        System.out.println("부품을 조립하는 중입니다.");
    }

    @Override
    public void eat() {
        throw new UnsupportedOperationException("로봇은 음식을 먹지 않습니다.");
    }
}
  • Robot은 eat 메서드를 구현할 필요가 없지만, 인터페이스 때문에 강제로 구현해야 합니다.

<ISP 준수>

interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

class Developer implements Workable, Eatable {
    @Override
    public void work() {
        System.out.println("코드를 작성하는 중입니다.");
    }

    @Override
    public void eat() {
        System.out.println("삭사를 합니다.");
    }
}

class Robot implements Workable {
    @Override
    public void work() {
        System.out.println("부품을 조립하는 중입니다.");
    }
}
  • Workable과 Eatable을 분리하여 각 클래스가 필요한 인터페이스만 구현하도록 합니다.

5. 의존관계 역전 원칙 (DIP: Dependency Inversion Principle)

"고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다."

자주 변화하는 구체적인 구현체가 아니라 변화가 거의 없는
인터페이스나 추상 클래스에 의존하도록 설계해야 합니다.

<위반 사례>

class Keyboard {
}

class Computer {
    private Keyboard keyboard;

    public Computer() {
        this.keyboard = new Keyboard();
    }
}
  • Computer 클래스는 Keyboard라는 구체 클래스에 의존하고 있습니다. 키보드의 종류를 변경하려면 Computer 클래스를 수정해야 합니다.

<DIP 준수>

interface InputDevice {
}

class Keyboard implements InputDevice {
}

class Mouse implements InputDevice {
}

class Computer {
    private InputDevice inputDevice;

    public Computer(InputDevice inputDevice) {
        this.inputDevice = inputDevice;
    }
}
  • Computer는 InputDevice 인터페이스에 의존하므로, 키보드나 마우스와 같은 다양한 입력 장치를 쉽게 교체할 수 있습니다.

0개의 댓글