SOLID 객체 지향 프로그래밍 및 설계의 5가지 기본 원칙

객체 지향 프로그래밍 및 설계의 5가지 기본 원칙이다.

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

앞 알파벳만 따와서 이를 SOLID라도 한다.

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

  • 소프트웨어의 설계 부품(클래스, 함수, 모듈 등)은 단 하나의 책임만 가져야 한다.
  • 설계를 잘한 프로그램은 기본적으로 새로운 요구사항에 프로그램 변경에 영향을 받는 부분이 적다.
  • 즉, 응집도가 높도, 결합도는 낮은 프로그램을 뜻한다.
  • 만약 한 클래스가 수행할 있는 기능. 즉, 책임 많아진다면 클래스 내부의 함수끼리 강한 결합이 발생할 가능성이 높아진다. 이는 유지보수에 비용이 증가하게 되므로써 책임을 분리시킬 필요가 있다.
  • 클래스는 하나의 책임을 수행하고 여러개를 수행한다면 쪼갠다.

단일 책임 원칙을 위반한 예제

아아래 코드는 직원에 따라 업무에 대한 예제이다.

package com.devkuma.tutorial.solid.srp.bad;

public interface Employee {

    // 경리팀 업무
    void calculatePay();

    // 인사팀 업무
    void reportHours();

    // DB 관린자 업무
    void save();
}

위에 코드는 세 가지 메서드는 각각 다른 액터에 대한 책임을 지고 있으며, SRP를 위반하고 있다.
즉, 액터의 다른 코드는 분할되어야 한다.

단일 책임 원칙을 위반한 예제의 해결책

위에 예제에 대한 해결책은 아래와 같다.

공유 데이터 클래스

package com.devkuma.tutorial.solid.srp.good;

// 공유 데이터
public class Employee {
    private Integer id;
    private String name;
    private Integer salary;

    public Employee(Integer id, String name, Integer salary) {
        this.id = id;
        this.name = name;
        this.salary = salary;
    }
}

인사팀 업무 클래스

package com.devkuma.tutorial.solid.srp.good;

// 인사팀 업무
public abstract class HourReporter {

    private Employee employee;

    public HourReporter(Employee employee) {
        this.employee = employee;
    }

    abstract void reportHours();
}

경리팀 업무 클래스

package com.devkuma.tutorial.solid.srp.good;

// 경리팀 업무
public abstract class PayCalculator {

    private Employee employee;

    public PayCalculator(Employee employee) {
        this.employee = employee;
    }

    abstract void reportHours();
}

DB 관린자 업무 클래스

package com.devkuma.tutorial.solid.srp.good;

// DB 관린자 업무
public abstract class EmployeeSaver {

    private Employee employee;

    public EmployeeSaver(Employee employee) {
        this.employee = employee;
    }

    // 개발팀에서 사용한다.
    abstract void save();
}

공유 데이터는 한 곳에 모으고, 각 액터의 다른 함수는 각각에 업무에 맞는 클래스로 이동시킨다.

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

  • 기존 코드를 변경하지 않고(Closed) 기능을 수정하거나 추가할 수 있도록(Open) 설계를 해야 한다.
  • 소프트웨어는 확장(기능)에는 열려 있어야 하고, 주변 변화에는 닫혀 있어야 한다. 코드의 직접적인 수정은 없어야 한다.

개방-폐쇄 원칙을 위반한 예제

아래 코드는 직원들의 정보를 관리하는 예제 프로그램이다.

package com.devkuma.tutorial.solid.ocp.bad;

public class Employee {
    private String description;

    private String[] names;

    public Employee(String description, String[] names) {
        this.description = description;
        this.names = names;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public String getDescription() {
        return description;
    }

    public void setNames(String[] names) {
        this.names = names;
    }

    public String[] getNames() {
        return names;
    }
}
package com.devkuma.tutorial.solid.ocp.bad;

public class EmployeePrinter {

    public void print(Employee employee) {
        System.out.println(employee.getDescription());
        for (String name : employee.getNames()) {
            System.out.println(name);
        }
    }
}
package com.devkuma.tutorial.solid.ocp.bad;

public class Main {

    public static void main(String[] args) {
        Employee employee = new Employee("직원 정보", new String[]{"devkuma", "araikuma", "kimkc"});
        new EmployeePrinter().print(employee);
    }
}

위에 예제에서는 Employee 객체의 names데이터 구조를 변경되면 EmployeePrinter 객체의 구현도 변경해야 한다.

개방-폐쇄 원칙을 위반한 예제의 해결책

위에 예제에 대한 해결책은 아래와 같다.

package com.devkuma.tutorial.solid.ocp.good;

public class Employee {
    private String description;

    private String[] names;

    public Employee(String description, String[] names) {
        this.description = description;
        this.names = names;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public String getDescription() {
        return description;
    }

    public void setNames(String[] names) {
        this.names = names;
    }

    public String[] getNames() {
        return names;
    }

    public void printNames() {
        for (String name : names) {
            System.out.println(name);
        }
    }
}
package com.devkuma.tutorial.solid.ocp.good;

public class EmployeePrinter {

    public void print(Employee employee) {
        System.out.println(employee.getDescription());
        employee.printNames();
    }
}

위에 코드를 보면 Employee 객체의 names의 반복하는 메소드를 갖게 된다.
EmployeePrinter 객체에서는 Employee 객체의 변경이 있어도 인터페이스가 동일한면 변경할 필요 없이 확장 가능하게 되었다.

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

  • 자식 클래스는 부모클래스에서 가능한 행위를 수행할 수 있어야 한다.
  • 특정 메소드가 상위 타입을 인자로 사용한다고 할 때, 그 타입의 하위 타입도 문제 없이 정상적으로 작동을 해야 한다는 것이다.

리스코프 치환 원칙 예제

아래 Animal는 상속을 위한 최상의 클래스이다.

package com.devkuma.tutorial.solid.lsp;

public class Animal {

    void run(Integer speed) {
        System.out.println("running at " + speed + " km/h");
    }
}

리스코프 치환 원칙을 따른 예제

아래의 Dog 객체는 LSP를 따른다고 할 수 있다.

package com.devkuma.tutorial.solid.lsp.bad;

import com.devkuma.tutorial.solid.liskovsubstitution.Animal;

public class Sloth extends Animal {

    void run(Integer speed) throws Exception {
        throw new Exception("Sorry, I'm too lazy to run");
    }
}

리스코프 치환 원칙을 따르지 않은 예제

아래의 Sloth 객체는 Animal으로 대체할 수 없기 때문에 LSP를 위반하고 있다고 할 수 있다.

package com.devkuma.tutorial.solid.lsp.good;

import com.devkuma.tutorial.solid.liskovsubstitution.Animal;

public class Dog extends Animal {

    void bark() {
        System.out.println("bow-wow");
    }

    void run(Integer speed) {
        System.out.println("running at " + speed + " km/h");
    }
}

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

  • 한 클래스는 자신이 사용하지 않는 인터페이스를 구현하지 말아야 한다. 하나의 일반적인 인터페이스 보다는 여러 개의 구체적인 인터페이스가 낫다. (= 불필요한 종속성 제거)
  • 즉, 자신이 사용하지 않는 기능(인터페이스)에는 영향을 받지 말아야 한다는 의미이다.
  • 예를 들어, Print 인터페이스에 print, scan을 분리해야 한다.

인터페이스 분리 원칙 예제

아래 Animal는 구현을 위한 인터페이스이다.

package com.devkuma.tutorial.solid.isp.ex1;

public interface Animal {
    void run();

    void eat();

    void cry();
}

아래의 코드에서는 Dog 클래스는 Animal 인터페이스를 모든 메소드를 구현하고 있어서 문제 없다.

package com.devkuma.tutorial.solid.isp.ex1.good;

import com.devkuma.tutorial.solid.isp.Animal;

public class Dog implements Animal {
    @Override
    public void run() {
        System.out.println("run");
    }

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

    @Override
    public void cry() {
        System.out.println("cry");
    }
}

그러나, Lizard 클래스의 cry 메서드에 대해서는 아무 처리가 없어 Animal 인터페이스에 불필요하게 의존하고 있어 인터페이스 분리의 원칙을 위반하고 있다고 할 수 있다.

package com.devkuma.tutorial.solid.isp.bad;

import com.devkuma.tutorial.solid.isp.ex1.Animal;

public class Lizard implements Animal {
    @Override
    public void run() {
        System.out.println("run");
    }

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

    // cry 메서드에 대한 처리가 없다. Animal에 불필요하게 의존하고 있다.
    @Override
    public void cry() {
        // Don't call this method
    }
}

인터페이스 분리 원칙 해결책

해결책으로는 아래와 같이 공통 부분만을 따로 뽑아내서, 보다 세세한 인터페이스에 분리하는 것으로 불필요한 의존을 없애는 것이 가능하다.

아래 Animal는 구현을 위한 최상의 인터페이스이다.

package com.devkuma.tutorial.solid.isp.ex2;

public interface Animal {
    void run();

    void eat();
}

Mammal(포유류) 인터페이스에서는 대한 Animal 인터페이스를 상속 받아 cry 메소드를 새로 추가하였다.

package com.devkuma.tutorial.solid.isp.ex2;

interface Mammal extends Animal {

    void cry();
}

Reptile(파충류) 인터페이스에서는 대한 Animal 인터페이스를 상속 받아 따로 메소드를 만들지 않았다.

package com.devkuma.tutorial.solid.isp.ex2;

interface Reptile extends Animal {
}

Dog 클래스에서는 Mammal 인터페이스의 run, eat 메소드를 구현하였고, Mammal 인터페이스의 cry도 구현하였다.

package com.devkuma.tutorial.solid.isp.ex2;

public class Dog implements Mammal {
    @Override
    public void run() {
        System.out.println("run");
    }

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

    @Override
    public void cry() {
        System.out.println("cry");
    }
}

Lizard 클래스에서는 Mammal 인터페이스의 run, eat 메소드를 구현하였다.

package com.devkuma.tutorial.solid.isp.ex2;

public class Lizard implements Reptile {
    @Override
    public void run() {
        System.out.println("run");
    }

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

DIP: 의존성 역전 원칙 (Dependency Inversion)

  • 의존 관계를 맺을 때, 변화하기 쉬운것 보단 변화하기 어려운 것을 의존해야 한다는 원칙이다.
  • 상위 모듈은 하위 모듈에 의존해서는 안되며, 둘 다 추상에 의존해야 한다. (= 하위 모듈 변경에 상위 모듈이 영향을 받지 않도록 함)
  • 추상(Interfaces/Abstraction 클래스)은 구현의 상세(Class)에 의존하지 않아야 하고, 구현의 상세가 추상에 의존해야 한다.

의존성 역전 원칙의 예제

아래 HttpClient는 구현을 위한 상의 추상 클래스이다.

package com.devkuma.tutorial.solid.dip;

abstract public class HttpClient {

    public String get(String arg) {
        return arg;
    }
}

아래의 코드에서는 DataProvider(상위 모듈)가 CustomHTTPClient(하위 모듈)에 의존한 상태이며, DIP의 원칙에 반하고 있다고 할 수 있다.

package com.devkuma.tutorial.solid.dip.good;

import com.devkuma.tutorial.solid.dip.HttpClient;

public class CustomHttpClient extends HttpClient {

}
package com.devkuma.tutorial.solid.dip.good;

import com.devkuma.tutorial.solid.dip.HttpClient;

public class DataProvider {

    public HttpClient httpClient;

    public DataProvider(CustomHttpClient customHttpClient) {
        this.httpClient = customHttpClient;
    }

    public String getData() {
        return httpClient.get("URL");
    }
}

의존성 역전 원칙의 해결책

모듈이 추상(Abstract)에 의존하도록 한다.

package com.devkuma.tutorial.solid.dip.bad;

import com.devkuma.tutorial.solid.dip.HttpClient;
import com.devkuma.tutorial.solid.dip.good.DataFetchClient;

public class DataProvider {
    public HttpClient httpClient;

    public DataProvider(HttpClient httpClient) {
        this.httpClient = httpClient;
    }

    public String getData() {
        return httpClient.get("URL");
    }
}

참고