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

2025. 4. 14. 17:57·CS 스터디/CS - JAVA

객체지향 프로그래밍을 배울 땐 상속이나 캡슐화, 다형성 같은 개념만으로도 충분해 보입니다. 하지만 기능이 늘어나고 유지보수의 영역이 넓어지면 점점 코드가 복잡해지고 유지보수가 어려워질 수 밖에 없습니다.

 

이러한 문제들을 줄이고, 설계의 중요한 기준점이 되는 것이 바로 SOLID원칙입니다.

이 원칙은 로버트 C. 마틴(Robert C. Martin)이 제안한 것으로, 객체지향 설계의 핵심 철학이라 할 수 있습니다.

 

SOLID를 잘 지키면 코드가 유연해지고 변경에도 강해져서, 유지보수가 쉬운 구조를 만들 수 있습니다. 규모가 커질수록 이런 설계 원칙의 중요성은 더 커질 수 밖에 없습니다.

 

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)

대부분의 디자인 패턴들은 SOLID 설계원칙을 바탕으로 만들어집니다. SOLID 단어의 순서대로 각 원칙의 개념과 장점, 그리고 잘못된 설계와 올바른 예제를 함께 비교하면서 설명해보겠습니다.

 

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


클래스는 단 하나의 책임만 가져야 한다.
  • 정의 :클래스 설계 시 변경의 이유를 하나로 제한하고, 역할을 명확히 분리함으로써 유지보수성과 확장성을 높이는 원칙
  • 장점 : 유지보수 시 다른 클래스에 영향을 미치는 범위를 최소화하고, 테스트가 간편해지며, 재사용성도 향상됨
// SRP 위배 예제: Employee 클래스가 여러 책임을 가짐 (데이터 저장, 출력)
class Employee {
    void saveToDatabase() {
        System.out.println("Saving employee to database...");
    }
    
    void printReport() {
        System.out.println("Printing employee report...");
    }
}

// 올바른 예제: 책임을 분리
class EmployeeRepository {
    void saveToDatabase() {
        System.out.println("Saving employee to database...");
    }
}

class EmployeeReport {
    void printReport() {
        System.out.println("Printing employee report...");
    }
}

 

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

확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다.
  • 정의 : 기존 코드를 수정하지 않고도 기능을 확장할 수 있도록 설계하여, 변경에는 닫혀 있고 확장에는 열려 있는 구조를 지향하는 원칙
  • 장점 : 새로운 기능 추가 시 기존 코드를 건드릴 필요가 없어, 안정성을 높이고 버그 발생 가능성을 줄일 수 있음
// OCP 위배 예제: 새로운 타입의 사원 추가시 Employee코드를 수정해야함
class Employee {
    private String name;
    private String role;

    public Employee(String name, String role) {
        this.name = name;
        this.role = role;
    }

    public void calculateSalary() {
        if (role.equals("Manager")) {
            // Manager salary calculation
        } else if (role.equals("Developer")) {
            // Developer salary calculation
        }
    }
}

// 올바른 예제: 확장에는 열려 있고 수정에는 닫혀있음
abstract class Employee {
    protected String name;

    public Employee(String name) {
        this.name = name;
    }

    public abstract void calculateSalary();
}
// Employee 클래스를 수정하지 않고 Manager 클래스를 확장하여 직책 추가
class Manager extends Employee {
    public Manager(String name) {
        super(name);
    }

    @Override
    public void calculateSalary() {
        // Manager salary calculation
    }
}
// Employee 클래스를 수정하지 않고 Developer 클래스를 확장하여 직책 추가
class Developer extends Employee {
    public Developer(String name) {
        super(name);
    }

    @Override
    public void calculateSalary() {
        // Developer salary calculation
    }
}

public class Main {
    public static void main(String[] args) {
        Employee employee1 = new Manager("John");
        Employee employee2 = new Developer("Jane");
        
        employee1.calculateSalary();
        employee2.calculateSalary();
    }
}

 

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

서브타입은 언제나 기반 타입으로 교체할 수 있어야 한다.
  • 정의 : 상위 타입의 객체를 하위 타입으로 대체하더라도 프로그램의 동작에 문제가 없도록, 하위 클래스가 상위 클래스의 계약을 완전히 준수하도록 만드는 원칙
  • 장점 : 하위 클래스를 사용하는 코드에서도 일관된 동작과 안정성을 보장하며, 다형성을 안전하게 활용 가능
// LSP 위배 예제: 자식 클래스에서 부모 클래스의 메서드를 오버라이드 하면서 예외를 던지고 있음
class Bird {
    public void fly() {
        System.out.println("Bird is flying");
    }
}

class Ostrich extends Bird {
    // 정상적으로 Bird 클래스의 fly() 메서드를 대체하지 못하고
    // 예외를 던지는 방식은 LSP에 위반되는 오버라이드 방식
    @Override
    public void fly() {
        // Ostrich can't fly, but it overrides the method
        throw new UnsupportedOperationException("Ostrich can't fly");
    }
}

public class Main {
    public static void main(String[] args) {
        Bird bird = new Ostrich();
        bird.fly(); // UnsupportedOperationException 발생
    }
}

// 올바른 예제: 자식 클래스에서 부모 클래스의 메서드를 오버라이드 하여 예외를 던지지 않음
// 부모 클래스
class Bird {
    public void move() {
        System.out.println("Bird is moving");
    }
}

// 자식 클래스
class Ostrich extends Bird {
    @Override
    public void move() {
        System.out.println("Ostrich is running");
    }
}

public class Main {
    public static void main(String[] args) {
        Bird bird = new Ostrich();
        bird.move(); // Ostrich is running
    }
}

 

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

클라이언트는 자신이 사용하지 않는 인터페이스에 의존하면 안된다.
  • 정의 : 클라이언트가 사용하지 않는 기능에 의존하지 않도록, 인터페이스를 세분화하여 필요한 기능만 제공받게 하는 원칙
  • 장점 : 인터페이스는 특정 클라이언트를 위한 최소한의 기능만 포함하여 불필요한 메서드 구현을 방지할 수 있으며, 클라이언트 코드가 변경에 영향을 덜 받음
// ISP 위배 예제: 사용하지 않는 메서드가 포함된 인터페이스
interface Worker {
    void work();
    void eat();
}

class Robot implements Worker {
    @Override
    public void work() {
        System.out.println("Robot is working");
    }
    // 인터페이스의 추상메서드 이므로 반드시 구현해야하지만, 
    // Robot 클래스에서는 사용하지 않아 예외로 처리 -> ISP 위반
    @Override
    public void eat() {
        // 로봇은 먹을 수 없으므로 구현할 필요가 없음
        throw new UnsupportedOperationException("Robot can't eat!");
    }
}

public class Main {
    public static void main(String[] args) {
        Worker worker = new Robot();
        worker.work();
        worker.eat();  // 예외 발생
    }
}
// 올바른 예제: 각 클라이언트가 필요한 메서드만 포함된 인터페이스
interface Workable {
    void work();
}
// 인터페이스를 Workable과 Eatable로 쪼개어 불필요한 메서드 구현 방지
interface Eatable {
    void eat();
}

class Robot implements Workable {
    @Override
    public void work() {
        System.out.println("Robot is working");
    }
}

class Human implements Workable, Eatable {
    @Override
    public void work() {
        System.out.println("Human is working");
    }

    @Override
    public void eat() {
        System.out.println("Human is eating");
    }
}

public class Main {
    public static void main(String[] args) {
        Worker robot = new Robot();
        robot.work();

        Worker human = new Human();
        human.work();
        human.eat();
    }
}

 

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

클라이언트는 자신이 사용하지 않는 인터페이스에 의존하면 안된다.
  • 정의 : 구체적인 구현이 아닌, 추상에 의존하도록 구조를 설계하여 모듈 간 결합도를 낮추는 원칙
  • 장점 : 모듈 간 결합도를 낮춰 코드의 유연성과 테스트 용이성이 높아지며, 코드 변경에도 영향을 덜 받음
더보기
💡의존성 주입(DI) 방식의 종류
1. 생성자 주입
(Construction Injection) : 생성자를 통한 의존성 주입
2. 세터 주입 (Setter Injection) : 세터 메서드를 통한 의존성 주입
3. 인터페이스 주입 (Interface Injection) : 인터페이스를 통한 의존성 주입
// DIP 위배 예제: 고수준 모듈이 저수준 모듈에 의존
class LightBulb {
    public void turnOn() {
        System.out.println("Light is ON");
    }

    public void turnOff() {
        System.out.println("Light is OFF");
    }
}
// LightBulb가 아닌 다른 객체에 Switch 클래스를 달기 위해서 
// 고수준 클래스인 Switch클래스를 수정해야함 -> DIP 위반
class Switch {
    private LightBulb bulb;  // 고수준 모듈이 저수준 모듈에 의존

    public Switch(LightBulb bulb) {
        this.bulb = bulb;
    }

    public void operate() {
        System.out.println("Switching light...");
        bulb.turnOn();
    }
}

public class Main {
    public static void main(String[] args) {
        LightBulb bulb = new LightBulb();
        Switch s = new Switch(bulb);
        s.operate();
    }
}
// 올바른 예제: 고수준 모듈이 인터페이스에 의존하도록 수정
interface Switchable {
    void turnOn();
    void turnOff();
}

class LightBulb implements Switchable {
    @Override
    public void turnOn() {
        System.out.println("Light is ON");
    }

    @Override
    public void turnOff() {
        System.out.println("Light is OFF");
    }
}

class Fan implements Switchable {
    @Override
    public void turnOn() {
        System.out.println("Fan is ON");
    }

    @Override
    public void turnOff() {
        System.out.println("Fan is OFF");
    }
}

class Switch {
    private Switchable device;  // 고수준 모듈이 인터페이스에 의존

    public Switch(Switchable device) {
        this.device = device;
    }

    public void operate() {
        System.out.println("Switching device...");
        device.turnOn();
    }
}

public class Main {
    public static void main(String[] args) {
        Switchable bulb = new LightBulb();
        Switchable fan = new Fan();

        Switch s1 = new Switch(bulb);
        s1.operate();  // Light is ON

        Switch s2 = new Switch(fan);
        s2.operate();  // Fan is ON
    }
}

'CS 스터디 > CS - JAVA' 카테고리의 다른 글

Java의 원시 타입과 참조 타입  (0) 2025.04.20
equals()와 hashcode()  (0) 2025.04.08
Java의 예외처리  (0) 2025.04.07
final, finalize, finally의 차이와 활용  (0) 2025.04.01
String, StringBuilder, StringBuffer 특징 및 성능비교  (0) 2025.03.26
'CS 스터디/CS - JAVA' 카테고리의 다른 글
  • Java의 원시 타입과 참조 타입
  • equals()와 hashcode()
  • Java의 예외처리
  • final, finalize, finally의 차이와 활용
BIS's tech Blog
BIS's tech Blog
Welcome to Kanghyun's tech blog :)
  • BIS's tech Blog
    벼익숙의 기술 블로그
    BIS's tech Blog
  • 전체
    오늘
    어제
    • 분류 전체보기 (67)
      • 알고리즘 (53)
        • 백준 (44)
        • 프로그래머스 (9)
      • CS 스터디 (14)
        • CS - JAVA (11)
        • CS - DB (3)
  • 블로그 메뉴

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

    • GitHub
  • 공지사항

  • 인기 글

  • 태그

    너비 우선 탐색
    dp
    DFS
    프로그래머스
    백준
    Lv2
    자료구조
    알고리즘 고득점 kit
    깊이 우선 탐색
    동적 계획
    bottom-up
    기술질문
    재귀
    java
    Top-Down
    Baekjoon
    CS
    완전탐색
    BFS
    브루트포스
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
BIS's tech Blog
객체 지향 설계 5대 원칙 (SOLID)
상단으로

티스토리툴바