개발 일기

[Java] SOLID 원칙 본문

Back-End/Java

[Java] SOLID 원칙

개발 일기장 주인 2025. 4. 14. 12:49

✅ SOLID 원칙이란?

어떤 분야든, 잘하기 위해선 '원칙'이 있다.
스포츠에는 기본 자세가 있고, 요리에는 레시피가 있으며, 수학에는 공리(axiom)가 존재한다. 이러한 원칙은 그 분야를 체계적이고 효율적으로 다룰 수 있게 돕는 일종의 '교범'이다.

 

객체지향 프로그래밍(OOP)도 마찬가지다. 유지보수하기 쉬운, 확장 가능한(유지보수성, 확장성, 재사용성) 소프트웨어를 만들기 위해서는 단순히 클래스를 나누는 것만으로는 부족하다. 객체지향 설계의 5대 원칙, SOLID가 바로 그 교범이다.

 

이번 글에서는 이 SOLID 원칙이 무엇이고, 왜 중요한지, 그리고 Java 코드 예시와 함께 실제로 어떻게 적용할 수 있는지를 정리해보았다.


1️⃣   S - 단일 책임 원칙 (SRP, Single Responsibility Principle)

각 클래스는 하나의 책임(임무, 목적)만 갖고 있어야 한다.
// 잘못된 예
public class UserService {
    public void saveUser(User user) { /* 회원가입 */ }
    public void sendWelcomeEmail(User user) { /* 이메일 발송 */ }
}

// 개선
public class UserRepository {
    public void saveUser(User user) { /* 회원가입 */ }
}

public class EmailService {
    public void sendWelcomeEmail(User user) { /* 이메일 발송 */ }
}

 

 

지키지 않았을때 문제점
위의 잘못된 예를 보면, UserService는 회원가입한 유저의 정보를 저장하는 일뿐만 아니라, 이메일을 발송하는 로직까지 함께 담당하고 있다.

  • 이처럼 하나의 클래스가 여러 책임을 가지게 되면, 해당 클래스에 다양한 메서드가 혼재되어 클래스의 역할을 직관적으로 파악하기 어려워진다.
  • 또한, 책임의 수만큼 수정해야 할 부분이 늘어나고, 하나의 책임을 수정하려는 변경이 다른 책임에까지 의도치 않게 영향을 미칠 수 있어 테스트와 리팩토링도 점점 더 까다로워진다.

지켜진다면?

  • 각 클래스가 무엇을 위한 클래스인지 더 명확
  • 책임별로 클래스가 분리되어 있어 필요한 기능만 따로 재사용하거나 수정하는 것이 훨씬 용이

2️⃣  O - 개방-폐쇄 원칙 (OCP, Open/Closed Principle)

확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.
// 잘못된 방식
public class ReportGenerator {
    public void generateReport(String type) {
        if (type.equals("PDF")) {
            System.out.println("Generating PDF report...");
        } else if (type.equals("HTML")) {
            System.out.println("Generating HTML report...");
        }
        // If we need to add another format, we have to modify this method.
    }
}

// 개선된 방식
public interface Report {
    void generate();
}

public class PDFReport implements Report {
    @Override
    public void generate() {
        System.out.println("Generating PDF report...");
    }
}

public class HTMLReport implements Report {
    @Override
    public void generate() {
        System.out.println("Generating HTML report...");
    }
}

public class XMLReport implements Report {
    @Override
    public void generate() {
        System.out.println("Generating XML report...");
    }
}

public class Main {
    public static void main(String[] args) {
        Report pdfReport = new PDFReport();
        pdfReport.generate();  // Generating PDF report...

        Report htmlReport = new HTMLReport();
        htmlReport.generate();  // Generating HTML report...

        Report xmlReport = new XMLReport();
        xmlReport.generate();  // Generating XML report...
    }
}

 

지켜지지 않았을때 문제점
이 구조는 새로운 리포트 형식이 추가될 때마다 generateReport() 내부를 수정해야 하므로, 기존 코드를 변경해야 하는 문제가 발생한다. 이는 코드의 변경에 닫혀 있어야 한다는 OCP 원칙을 위반.

  • 처음에는 PDF, HTML 형식만 생성하면 됐다고 했지만 추후에 XML이 추가된다면? generateReport의 로직이 수정되어야 한다.
    → 확장에 불리
  • 의도치 않게 기존 로직에도 영향을 줄 수도 있다. 그에 따라 이미 해당 메소드를 사용 중인 곳들에서 연쇄적으로 부작용 발생 가능
    → 의도치 않은 버그 가능성 (유지 보수성)

지켜진다면?

  • 이처럼 공통 인터페이스(Report) 를 두고, 각 리포트 타입을 개별 클래스로 분리하면, 기존 코드를 수정하지 않고도 새로운 리포트 타입을 쉽게 확장 가능
  • 또한 수정하면서 발생할 수 있는 문제들로 부터 자유로운 설계를 할 수 있다.

3️⃣  L - 리스코프 치환 원칙 (LSP, Liskov Substitution Principle)

자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다.
→ 자식은 최소한 부모가 하는 일은 모두 다 해야 한다.
// Parent class Bird
public class Bird {
    public void fly() {
        System.out.println("Bird is flying");
    }
}

// Child class Penguin that violates LSP
public class Penguin extends Bird {
    @Override
    public void fly() {
        // Penguins cannot fly
        throw new UnsupportedOperationException("Penguins cannot fly");
    }
}

public class Main {
    public static void main(String[] args) {
        Bird bird = new Bird();
        bird.fly(); // Bird is flying

        Bird penguin = new Penguin();
        penguin.fly(); // Throws UnsupportedOperationException
    }
}

// Interface for birds that can fly
public interface Flyable {
    void fly();
}

// Base class Bird
public class Bird {
    public void eat() {
        System.out.println("Bird is eating");
    }
}

// Class for a bird that can fly
public class Sparrow extends Bird implements Flyable {
    @Override
    public void fly() {
        System.out.println("Sparrow is flying");
    }
}

// Class for a bird that cannot fly
public class Penguin extends Bird {
    // Penguins do not implement Flyable
}

public class Main {
    public static void main(String[] args) {
        Bird sparrow = new Sparrow();
        sparrow.eat(); // Bird is eating
        ((Flyable) sparrow).fly(); // Sparrow is flying

        Bird penguin = new Penguin();
        penguin.eat(); // Bird is eating
        // ((Flyable) penguin).fly(); // Compilation error, Penguin is not Flyable
    }
}

 

지켜지지 않았을때 문제점

'새'라는 부모 클래스에 fly()라는 메소드를 들고 있고 새 클래스를 상속받은 펭귄은 새의 자식 클래스이기 때문에 fly()메소드를 물려받는다. 그러나 펭귄은 날지 못하기 때문에 부모 클래스의 메소드인 fly()를 실행하면 예외가 발생한다.

 

지켜진다면?
따라서 fly()는 독립된 인터페이스로 분리하여 날 수 있는 조류들만 구현하도록 한다.


4️⃣  I - 인터페이스 분리 원칙 (ISP, Interface Segregation Principle)

자신이 사용하지 않을 메소드를 구현하도록 강요받지 말아야 한다.
// 잘못된 예
interface Worker {
    void work();
    void eat();
}

// 개선
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() { }
}

 

지켜지지 않았을때 문제점

인터페이스 Worker는 work()와 eat() 메서드를 모두 정의하고 있는데 이때 Robot 클래스는 work()만 필요하지만, Worker 인터페이스를 구현하기 때문에 eat() 메서드까지 구현해야 한다.

  • 그런데 Robot은 eat() 메서드를 사용하지 않으므로, 이를 불필요하게 구현
  • 또한, 다른 클래스에서 필요하지 않은 메서드를 구현해야 하므로 클래스가 특정 인터페이스에 과도하게 의존적
  • 이런 상황에서는 인터페이스를 구현한 모든 클래스가 해당 인터페이스에 정의된 모든 메서드를 구현해야 하는 부담을 가지게 되어, 클래스의 책임이 불필요하게 늘어나며 코드의 결합도가 높아지고 응집도가 낮아진다.
     확장성, 유지보수성을 떨어뜨리며 재사용성을 저해합니다.

지켜진다면?

인터페이스 Worker를 Workable과 Eatable로 분리하면, Robot 클래스는 Workable만 구현하고, Human 클래스는 Workable과 Eatable을 모두 구현

  • 해당 인터페이스를 구현하는 클래스들이 자신이 자신이 필요한 메서드만 구현하게 되므로, 불필요한 코드가 줆
  • 각 인터페이스가 책임을 분리하므로, 기능 변경이나 추가 시 각각의 책임만 수정하면 되어 유지보수가 훨씬 수월
  • 필요한 인터페이스만을 구현하여, 다른 클래스에서도 필요한 인터페이스만 재사용하기 유리
  • 새로운 기능을 추가할 때, 기존 클래스를 수정하지 않고도 새로운 인터페이스를 추가하여 확장가능

5️⃣  D - 의존 역전 원칙 (DIP, Dependency Inversion Principle)

고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다.
// 잘못된 예
public class OrderService {
    private MysqlDatabase mysql = new MysqlDatabase();
    public void saveOrder() { mysql.save(); }
}

// 개선
public interface Database {
    void save();
}

public class MysqlDatabase implements Database {
    public void save() { System.out.println("MySQL 저장"); }
}

public class OrderService {
    private final Database database;
    public OrderService(Database database) {
        this.database = database;
    }

    public void saveOrder() { database.save(); }
}

 

지켜지지 않았을때 문제점

  • 결합도 증가 → 유지보수 이슈
    : 고수준 모듈(OrderService)이 저수준 모듈(MysqlDatabase)에 직접 의존하게 되면, OrderService 클래스는 MysqlDatabase와 강하게 결합하여 데이터베이스 구현이 MySQL에서 타 RDB로 바뀌게 되면 OrderService 클래스 자체를 수정해야한다.

  • 확장성 이슈
    : 새로운 데이터베이스 구현체나 다른 저장소를 사용하려면, OrderService 클래스 자체를 수정해야 하므로, 시스템 확장에 한계

지켜진다면?

  • 결합도 감소, 유연성 증가
    고수준 모듈인 OrderService는 Database라는 추상화 인터페이스에 의존하게 되므로, 특정 데이터베이스 구현체(MysqlDatabase, PostgresDatabase, 등)에 의존하지 않게 된다.
    즉, OrderService는 데이터베이스에 대한 구체적인 구현을 알지 못하고, 추상화된 인터페이스인 Database만 알고 있습니다. 이로 인해 시스템의 유연성이 증가

🎯 요약 한 줄씩:

  • S: 하나의 책임만
  • O: 확장은 OK, 변경은 NO
  • L: 부모 대신 자식 OK
  • I: 꼭 필요한 인터페이스만
  • D: 구현보다 인터페이스에 의존

GPT에게 물어본 실제와 가까운 예시

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

하나의 클래스(또는 모듈)는 하나의 책임만 가져야 한다

📌 실무 예시

  • 회원가입 기능에서 "회원 저장", "이메일 전송", "로그 남기기"까지 한 클래스에서 처리하면
    👉 이메일 API가 바뀌거나 로깅 방식이 바뀔 때 모든 로직을 같이 수정해야 해서 복잡해짐
  • 기능별로 클래스를 나누면 변경이 간단하고 테스트도 쉬움

💬 한 클래스가 한 가지 일만 하게 해야, 수정할 때 안전하다.


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

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

📌 실무 예시

  • 리포트 다운로드 기능이 있는데, PDF만 지원하던 상황에서 Excel 추가 요청이 옴
  • 기존 코드 건드리지 않고, ExcelReportService 클래스를 새로 만들고 등록만 하면 끝
    👉 기존 기능에 영향 없이 확장 가능

💬 새로운 요구사항은 '추가'만으로 해결되게 만들어야 한다.


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

자식 클래스는 부모 클래스를 대체할 수 있어야 한다

📌 실무 예시

  • FileUploader라는 추상 클래스가 있어요.
    이 클래스는 모든 파일 업로더가 공통적으로 구현해야 할 기능을 정의하고 있음:
    • upload(file)
    • getUploadUrl()
  • S3Uploader, GCSUploader(구글 클라우드) 같은 자식 클래스는 모두 이 메서드를 잘 구현하고 있어요.
  • 그런데 LocalUploader라는 자식 클래스는
    getUploadUrl()이 의미가 없어서 예외를 던지거나 비워둠.

 

💬 “부모 클래스의 기능이 자식 클래스에서 제대로 동작하지 않으면, 상속을 잘못한 거예요. 그럴 땐 상속 대신 구성(Composition)을 고려해야 해요.”


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

불필요한 메서드 구현을 강요하지 말아야 한다

📌 실무 예시

  • DataStorage 인터페이스에 saveToDB(), saveToCache()가 다 있음
  • 그런데 어떤 구현체는 DB만 쓰고, 어떤 구현체는 Redis만 씀
  • Redis만 쓰는 클래스도 saveToDB() 구현을 강제로 해야 해서 쓸모없는 코드 생김

💬 필요한 기능만 인터페이스로 나눠서, 깔끔하게 구현할 수 있게 해야 한다.


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

상위 모듈은 하위 구현이 아니라 추상화에 의존해야 한다

📌 실무 예시

  • OrderService가 직접 MysqlOrderRepository를 생성해서 사용하면
    👉 추후에 MongoDB로 바꾸고 싶을 때 서비스 코드도 같이 수정해야 함
  • 인터페이스 OrderRepository에만 의존하게 하면 구현체만 바꾸면 끝!

💬 핵심 로직은 ‘구현체’가 아닌 ‘인터페이스’에만 의존해야 유연하다.