본문 바로가기
개발공부방

[SOLID] 객체 지향 프로그래밍의 5가지 설계 원칙

by mwzz6 2025. 2. 5.
객체 지향 프로그래밍으로 소프트웨어를 설계하는 과정에서 효율적이고 견고하며, 유지보수가 용이한 프로그램을 만들기 위한 5가지 원칙으로 클린코드의 저자로 알려진 Robert C. Martin이 2000년대 초반 소개한 것이다.

 

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

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

 

하나의 클래스는 하나의 책임을 가져야 한다는 내용은 하나의 클래스는 한가지 일을 위해 존재하고 이를 변경하는 이유도 하나여야 함으로 이해했다. 하지만 이것이 하나의 클래스가 메서드를 하나만 가져야 한다는 말은 아니다. 메서드가 여러가지더라도 한가지 역할을 위해 존재하면 상관없다. SRP를 위반한 클래스는 하나의 클래스가 수행하는 역할이 많아 코드가 복잡해지고 역할을 직관적으로 이해하기 어려워질뿐만 아니라 수정할 이유가 많아지는 문제가 있다. 또한 테스트와 리팩토링이 까다로워지고 확장성과 유연성에 제약이 생긴다.

 

public class User {
    String name;
    String email;

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

public class UserService {
    public void saveUser(User user) {
        // Save user to database
        System.out.println("User saved to database: " + user.getName());
    }

    public void sendWelcomeEmail(User user) {
        // Send welcome email to user
        System.out.println("Welcome email sent to: " + user.getEmail());
    }

    public void logUserActivity(User user) {
        // Log user activity
        System.out.println("Logging activity for user: " + user.getName());
    }
}

 

위 코드는 SRP를 위반한 코드로 UserService는 특정 사용자의 정보를 데이터베이스에 저장하는 일, 가입한 사용자에게 환영 이메일을 보내는 일, 각 사용자의 활동을 로그에 기록하는 일, 총 3가지 일을 하고 있다.

 

public class User {
    String name;
    String email;

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

public class UserRepository {
    public void saveUser(User user) {
        // Save user to database
        System.out.println("User saved to database: " + user.getName());
    }
}

public class EmailService {
    public void sendWelcomeEmail(User user) {
        // Send welcome email to user
        System.out.println("Welcome email sent to: " + user.getEmail());
    }
}

public class UserActivityLogger {
    public void logUserActivity(User user) {
        // Log user activity
        System.out.println("Logging activity for user: " + user.getName());
    }
}

 

위 코드는 SRP를 준수하여 다시 작성한 코드로 UserService 하나에 있었던 3가지 역할을 각각 별개의 클래스로 분리했다. 클래스의 이름만으로 어떤 역할을 할지 짐작하기 쉬워졌고 코드에 변경사항이 생겼을 때 해당 클래스의 코드만 수정하면 되게 됐다. 또한 역할별로 클래스가 분리되어 있기 때문에 필요에 따라 재사용하기도 용이하다.

 

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

소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.

 

확장에는 열려 있으나 변경에는 닫혀 있다는 말이 이해하기 어려운 내용이었는데 소프트웨어에서 기능의 추가가 기존 코드를 수정하지 않고 새롭게 작성하는 것으로 가능하게 만들라는 말로 이해했다. 실제로 스프링 부트를 활용한 백엔드 프로젝트 과정에서 GitHub에 API 요청을 보내는 코드를 작성하는 상황이 있었는데, API들을 인터페이스에 작성한 후 해당 인터페이스를 구현한 클래스들을 API 요청을 보내는 방식에 따라 작성한 경험이 있다. RestTemplete으로 요청을 보내는 클래스, RestClient로 요청을 보내는 클래스 두 가지를 작성했고, 이후 새로운 요청 방식을 변경할 때도 기존의 코드를 전혀 수정할 필요없이 인터페이스를 상속받은 클래스만 구현해서 연결하면 됐다.

 

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...");
        }
    }
}

 

OCP를 위반한 코드로 요구받은 방식으로 레포트 문서를 생성하는 클래스이다. 해당 클래스는 이후 생성할 레포트 방식이 추가될 경우 기존의 메서드를 수정해야하는 단점이 있다. 수정 과정에서 기존의 메서드 뿐만 아니라 해당 메서드를 사용하는 다른 곳까지 영향을 줄 수 있다.

 

public interface Report {
    void generateReport();
}

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

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

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

 

다음은 OCP를 준수하도록 수정한 코드로 Report를 인터페이스로 둔 후 생성할 레포트 방식마다 Report를 상속받은 클래스를 작성하는 방식으로 변경했다. 기존에 PDF, HTML 두 가지 레포트 종류만 존재하는 상황에서 XML 방식을 추가한다고 했을 때, 기존의 코드를 전혀 수정할 필요없이 새로운 레포트 방식을 추가할 수 있다.

 

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

프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.

 

이름만 들었을 때 직관적이지 않은 원칙으로 자식 클래스는 언제나 부모 클래스로 대체할 수 있어야한다는 내용이다. 간단하게 자식 클래스는 부모 클래스가 하는 일은 다 할 수 있어야 한다는 말이다. 이는 단순히 부모 클래스의 메서드를 오버라이딩하는 것으로 해결할 수 있는 문제가 아니고 부모 자식 관계가 적절한지 판단하는 것에서 시작한다.

 

public class Rectangle {
    int width;
    int height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

public class Square extends Rectangle {
    int width;
    int height;

    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;
    }

    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height;
    }

    @Override
    public int getArea() {
        return width * height;
    }
}

public class Test {
    public static void main(String[] args) {
        Rectangle rectangle = new Rectangle();
        rectangle.setWidth(4);
        rectangle.setHeight(5);

        System.out.println(rectangle.getArea());  // 20

        Square square = new Square();
        square.setWidth(4);

        System.out.println(square.getArea());  // 16

        square.setHeight(5);
        System.out.println(square.getArea());  // 25

        Rectangle rectangle2 = new Square();
        rectangle2.setWidth(4);
        rectangle2.setHeight(5);

        System.out.println(rectangle2.getArea());  // 20이 아닌 25 출력
    }
}

 

다음 코드는 정사각형 클래스가 직사각형 클래스를 상속받아서 구현한 것으로 직사각형은 너비와 높이를 설정하는 setter 메서드와 넓이를 구하는 getArea 메서드를 가지고 있다. 이때 다형성을 활용해 Rectangle rectangle2 = new Square()로 객체를 생성하면 부모인 직사각형의 역할을 수행할 수 있을 것으로 기대했지만 자식인 정사각형으로 넓이를 출력하는 것을 볼 수 있다.

 

public class Square {
    int width;
    int height;

    public void setWidth(int width) {
        this.width = width;
        this.height = width;
    }

    public void setHeight(int height) {
        this.width = height;
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

public class Rectangle extends Square {
    int width;
    int height;

    @Override
    public void setWidth(int width) {
        this.width = width;
    }

    @Override
    public void setHeight(int height) {
        this.height = height;
    }

    @Override
    public int getArea() {
        return width * height;
    }
}

public class Test {
    public static void main(String[] args) {
        Rectangle rectangle = new Rectangle();
        rectangle.setWidth(4);
        rectangle.setHeight(5);

        System.out.println(rectangle.getArea());  // 20

        Square square = new Square();
        square.setWidth(4);

        System.out.println(square.getArea());  // 16

        square.setHeight(5);
        System.out.println(square.getArea());  // 25

        Square square2 = new Rectangle();
        square2.setWidth(4);

        System.out.println(square2.getArea());  // 16이 아닌 0 출력

        square2.setHeight(5);
        System.out.println(square2.getArea());  // 25이 아닌 20 출력
    }
}

 

그럼 상속 관계가 반대라서 그런게 아니냐라고 할 수 있지만 반대로 직사각형 클래스가 정사각형 클래스를 상속받아 구현해도 Square square2 = new Rectangle()과 같이 객체를 생성하면 정사각형의 넓이를 출력할 것을 기대하지만 직사각형의 넓이로 출력되는 것을 볼 수 있다. 두 가지 모두 자식 클래스가 부모 클래스로 대체되지 못하고 있다.

 

public interface Shape {
    void setWidth(int width);

    void setHeight(int height);

    int getArea();
}

public class Rectangle implements Shape {
    private int width;
    private int height;

    @Override
    public void setWidth(int width) {
        this.width = width;
    }

    @Override
    public void setHeight(int height) {
        this.height = height;
    }

    @Override
    public int getArea() {
        return width * height;
    }
}

public class Square implements Shape {
    int width;
    int height;

    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;
    }

    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height;
    }

    @Override
    public int getArea() {
        return width * height;
    }
}

public class Test {
    public static void main(String[] args) {
        Rectangle rectangle = new Rectangle();
        rectangle.setWidth(4);
        rectangle.setHeight(5);

        System.out.println(rectangle.getArea());  // 20

        Square square = new Square();
        square.setWidth(4);

        System.out.println(square.getArea());  // 16

        square.setHeight(5);
        System.out.println(square.getArea());  // 25

        Shape rectangle2 = new Rectangle();
        rectangle2.setWidth(4);
        rectangle2.setHeight(5);

        System.out.println(rectangle2.getArea());  // 20

        Shape square2 = new Square();
        square2.setWidth(4);

        System.out.println(square2.getArea());  // 16

        square2.setHeight(5);
        System.out.println(square2.getArea());  // 25
    }
}

 

다음은 Shape 인터페이스를 활용해 setter와 getArea 메서드의 껍데기만 작성한 후 이를 각각 직사각형 클래스와 정사각형 클래스가 상속받도록 변경했다. 다음과 같이 각 도형의 타입을 부모 타입인 Shape으로 설정해도 잘 동작하여 자식 클래스의 타입이 언제든 부모 클래스로 대체될 수 있음을 볼 수 있다. 실제로 도형은 원과 같이 너비와 높이가 없는 도형도 있으니 실제로는 좀 더 세분화해서 구현해야 한다.

 

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

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

 

간단한 원칙으로 클래스는 자신이 사용하지 않을 메서드를 구현하도록 강요받지 말아야 한다. 이를 위해 하나의 큰 인터페이스가 아닌 역할에 맞게 세분화한 인터페이스 여러 개를 사용하는 것이 낫다.

 

public interface Worker {
    void work();

    void eat();
}

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

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

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

    @Override
    public void eat() {
        // Robots do not eat
        throw new UnsupportedOperationException("Robots do not eat.");
    }
}

 

다음 코드에서 Worker 인터페이스는 일하기와 먹기 두가지 메서드를 갖고 있다. 사람은 일하기와 먹기가 모두 가능하지만 로봇은 일하기만 가능하고 먹지는 않는데 Worker 인터페이스를 상속받기 때문에 의미없는 eat 메서드를 오버라이딩하게 되고 이를 수행하지 않는다는 의미로 예외를 던지고 있다.

 

public interface Workable {
    void work();
}

public interface Eatable {
    void eat();
}

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

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

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

 

다음은 Worker 인터페이스를 Workable과 Eatable 인터페이스로 분리해서 사람은 둘 다 구현하고 로봇은 Workable만 구현하도록 수정한 코드이다. 이제 로봇은 불필요한 eat 메서드를 구현할 필요가 없어졌다.

 

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

프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안된다.

 

개인적으로 SOLID 원칙 중 개념적으로 가장 어려운 원칙으로 간단하게 구체적인 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 내용으로 이해했다. 백엔드 개발을 해보지 않았으면 전혀 감이 안잡힐 수 있는데 개발을 하다보면 많은 클래스들이 다른 클래스를 사용해서 구현하게 되는 상황이 생긴다. 예를 들면 MVC 패턴으로 구조를 짰으면 Controller는 Service를 Service는 다시 Repository를 사용하는 상황이 있었을 것이다. 이때 구체적인 특정 Service 클래스나 특정 Respository 클래스를 사용하게 되면 추후 데이터베이스를 변경하는 등 구체적인 클래스가 변경될 경우 이를 사용하는 다른 클래스들에서도 변경된 클래스로 다시 작성을 해야한다. 이를 방지하기 위해 구체적인 클래스 대신 인터페이스를 사용해서 간접적으로 연결하고 구체적인 클래스는 외부에서 연결을 해줘 연결 관계를 느슨하게 해줘야한다. 이때 의존성을 내부가 아닌 외부에서 설정해주는 것을 의존성 주입(DI, Dependency Injection)이라고 하며 DIP를 쉽게 구현하는 방식이 된다.

 

public class MySQLDatabase {
    public void connect() {
        System.out.println("Connecting to MySQL database...");
    }
}

public class UserService {
    private MySQLDatabase database;

    public UserService() {
        // MySQLDatabase라는 구체적인 클래스에 의존하고 있음
        this.database = new MySQLDatabase();
    }

    public void registerUser() {
        database.connect();
        System.out.println("사용자 등록 완료");
    }
}

public class Test {
    public static void main(String[] args) {
        UserService service = new UserService();

        service.registerUser();
    }
}

 

다음 코드는 DIP에 위반된 코드로 UserService가 MySQL이라는 구체적인 데이터베이스에 의존하고 있다. 데이터베이스를 PostgreSQL로 변경할 경우 UserService의 코드를 수정해야하는 문제가 발생했다.

 

public interface Database {
    void connect();
}

public class MySQLDatabase implements Database {
    @Override
    public void connect() {
        System.out.println("Connecting to MySQL database...");
    }
}

public class UserService {

    // database 타입을 인터페이스로 변경
    private Database database;

    public UserService() {
        // 여전히 MySQLDatabase라는 구체적인 클래스에 의존하고 있음
        this.database = new MySQLDatabase();
    }

    public void registerUser() {
        database.connect();
        System.out.println("사용자 등록 완료");
    }
}

public class Test {
    public static void main(String[] args) {
        UserService service = new UserService();

        service.registerUser();
    }
}

 

그럼 데이터베이스에 대한 인터페이스만 작성하면 해결할 수 있을까? 아쉽게도 인터페이스 타입으로 객체를 생성할 수 없기 때문에 UserService의 데이터베이스 타입을 인터페이스로 변경해도 여전히 생성자에서 구체적인 데이터베이스 객체를 넣어줘야하고 여전히 PostgreSQL로 데이터베이스를 변경하려고 하면 UserService의 코드도 수정해야한다.

 

public interface Database {
    void connect();
}

public class MySQLDatabase implements Database {
    @Override
    public void connect() {
        System.out.println("Connecting to MySQL database...");
    }
}

public class PostgreSQLDatabase implements Database {
    @Override
    public void connect() {
        System.out.println("Connecting to PostgreSQLDatabase database...");
    }
}

public class UserService {
    private Database database;

    public UserService(Database database) {
        // UserService는 Database 인터페이스에만 의존하고 있고 실제로 어떤 Database가 연결되는지 전혀 모름
        this.database = database;
    }

    public void registerUser() {
        database.connect();
        System.out.println("사용자 등록 완료");
    }
}

public class Test {
    public static void main(String[] args) {

        // MySQL을 사용할 경우 외부에서 적용(의존성 주입)
        UserService service = new UserService(new MySQLDatabase());
        service.registerUser();

        // PostgreSQL로 변경해도 UserService를 전혀 수정하지 않아도 됨
        UserService service2 = new UserService(new PostgreSQLDatabase());
        service2.registerUser();
    }
}

 

다음은 UserService의 생성자에서 데이터베이스를 받으면 해당 데이터베이스를 사용하도록 변경한 코드이다. UserService는 추상적인 데이터베이스 인터페이스에만 의존하고 있고 어떤 데이터베이스를 쓰는지 전혀 모르는 상태이다. 실제로 사용할 데이터베이스는 외부 클래스인 Test에서 생성자에 넣어주고 있고 사용할 데이터베이스를 변경할 경우 UserService를 전혀 수정할 필요없이 변경이 가능하다. 이처럼 구체적인 클래스를 외부에서 넣어주는 것을 의존성 주입(DI, Dependency Injection)이라고 하고 위 코드는 생성자 주입을 통해 DIP 원칙을 지켰다.

'개발공부방' 카테고리의 다른 글

[OOP] 객체 지향 프로그래밍의 4가지 특징  (0) 2025.02.05