Published on

객체지향 설계 원칙(SOLID)을 각각 구현한 개인적인 생각

Authors
  • avatar
    Name
    김민석
    Twitter

Introduction

동기

프로그래밍을 하다 보면 코드가 점점 복잡해지고 어려워지는 순간이 온다. 기능이 추가될 때마다 기존 코드를 수정해야 하거나, 여러 클래스가 서로 밀접하게 얽혀 있어서 변경이 어려운 경험한 적이 있다.

이런 문제들은 대부분 객체지향 설계 원칙(SOLID)을 제대로 적용하지 않았기 때문이라고 생각한다. 객체지향 프로그래밍에서 코드의 핵심 가이드라인이라고 생각 든다.

이 글에서는 각 원칙을 실무 코드에 어떻게 적용할 수 있는지 자바로 구현 하며, 적용 전후의 차이를 기록으로 남기려고 한다.

  • "내가 왜 이렇게 코드를 짜야 하는지 몰랐다면?"
  • "내 코드가 더 깔끔하고 유지보수하기 쉬워지려면 어떻게 해야 할까?"

이 글을 쓰고 나면 위 질문에 대한 명확한 답을 얻을 수 있기를 목표로 하고 있다.

SRP - 단일 책임 원칙

(위반하는 경우)

  • 민석 클래스 같은 경우 많은 역할과 책임을 지니게 된다
  • 민석 클래스는 여자친구의 역할과 책임을 하는 동시에, 아들, 사원 역할과 책임까지 한 번에 지니게 된다.
  • 이런 경우 역할과 책임을 분리하라는 것이 단일 책임 원칙이다.

image.png

(준수하는 경우)

  • 역할에 따라 클래스 이름이 정해져 이해하기 좋아졌다.
  • 사원은 회사를 나오더라도 여자친구 - 남자친구 관계, 어머니 - 아들 관계, 직장 상사 - 사원 관계는 아무런 영향도 받지 않게 된다.
image.png

또 다른 상황

  • 고객 주문을 처리하는 상황이다.
  • 현재 주문을 저장하고 고객에게 이메일 알림을 보내는 기능이 있다.

시간이 지나면서 시스템이 확장되었고, 이메일 전송 로직에 변경이 생기거나 주문 저장 방식이 바뀔 때마다 서비스 코드를 수정해야 하는 상황이 빈번해졌다. 이런 문제를 해결하기 위해 단일 책임 원칙을 적용해보자.

문제

OrderService 클래스가 두 가지 책임(주문 저장, 이메일 전송)을 가지고 있다.

기능이 변경되면 여러 메서드에 영향을 미치므로 유지보수가 어렵다.

구현

  • 기존 코드.
public class OrderService {
    public void saveOrder(Order order) {
        System.out.println("주문이 저장되었습니다: " + order);
    }

    public void sendEmail(Order order) {
        System.out.println("고객에게 이메일이 전송되었습니다: " + order.getCustomerEmail());
    }
}
  • 개선 코드.
public class OrderService {
    public void saveOrder(Order order) {
        System.out.println("주문이 저장되었습니다: " + order);
    }
}

public class EmailService {
    public void sendEmail(Order order) {
        System.out.println("고객에게 이메일이 전송되었습니다: " + order.getCustomerEmail());
    }
}

해결

OrderServiceEmailService가 각각 하나의 책임만 가지게 되었다.

변경 사항이 생기더라도 각 클래스의 변경 범위가 줄어들어 유지보수가 쉬워졌다.

정리

SRP를 적용함으로써 코드의 응집도가 높아지고, 변경의 영향을 줄일 수 있었다.

  • 각 클래스가 하나의 책임만 가지기 때문에, 변경 사항이 발생해도 관련된 부분만 수정하면 된다.
    • ex) 이메일 전송 로직이 변경되더라도 OrderService는 영향을 받지 않는다.
  • 새로운 기능을 추가하거나 기존 기능을 수정할 때, 기존 클래스를 변경하지 않고 새로운 클래스를 추가하는 방식으로 작업할 수 있다.


OCP - 개방/폐쇄 원칙

(위반하는 경우)

  • 호랑이는 먹이를 던져주면 끝이고, 코끼리는 직접 먹여줘야 한다.
  • 새로운 동물이 추가되니 사육사의 행동에 변화가 오는 것이다.
image.png

(준수하는 경우)

  • 사육사와 동물 사이에 인터페이스를 둠으로써 다양한 동물이 생긴다고 해도 객체 지향 세계의 사육사는 동물 습관에 영향을 받지 않게 된다.
  • 다양한 동물이 생긴다고 하는 것은 확장에는 개방되어 있는 것이고, 사육사 입장에서는 주변의 변화에 폐쇄되어 있는 것이다.
image.png

또 다른 상황

  • 상품의 가격을 계산하는 기능이 있다.
  • 기본 가격에서 할인 정책을 적용하는 기능이 포함되어 있는데, 새로운 할인 정책이 추가될 때마다 기존 코드를 수정해야 하는 문제가 발생한다.

문제

  • 새로운 할인 정책을 추가할 때마다 PriceCalculator 클래스를 수정해야 하므로 코드가 복잡해지고 오류 발생 가능성이 높아진다.

구현

  • 기존 코드.
public class PriceCalculator {
    public double calculatePrice(Product product, String discountType) {
        double price = product.getPrice();
        if ("seasonal".equals(discountType)) {
            price *= 0.9;
        } else if ("clearance".equals(discountType)) {
            price *= 0.7;
        }
        return price;
    }
}
  • 개선 코드.
public interface DiscountPolicy {
    double applyDiscount(double price);
}

public class SeasonalDiscount implements DiscountPolicy {
    @Override
    public double applyDiscount(double price) {
        return price * 0.9;
    }
}

public class ClearanceDiscount implements DiscountPolicy {
    @Override
    public double applyDiscount(double price) {
        return price * 0.7;
    }
}

public class PriceCalculator {
    public double calculatePrice(Product product, DiscountPolicy discountPolicy) {
        return discountPolicy.applyDiscount(product.getPrice());
    }
}

해결 + 정리

  • 새로운 할인 정책을 추가할 때 기존 코드를 수정할 필요 없이 DiscountPolicy 인터페이스를 구현하는 클래스만 추가하면 된다.
  • OCP를 적용함으로써 코드의 확장성이 높아지고, 변경이 필요한 범위가 최소화되었다.


LSP - 리스코프 치환 원칙

(위반하는 경우)

Father 아들 = new Son();
Father= new Daughter();
  • 상위 클래스의 객체 참조 변수에 하위 클래스의 인스턴스를 할당한다.
    • 아들과 딸이 아버지의 역할을 하고 있다는 의미기도 하다.
  • 아들과 딸은 Father 타입의 객체이기 때문에 Father 객체가 가진 행위(메서드)를 할 수 있어야 한다.
  • 아버지와 아들 딸의 관계는 리스코프 치환 원칙을 위배하고 있는 것이다.
image.png

(준수하는 경우)

Animal 핑구 = new Penguin()
  • 귄 한 마리가 태어나 핑구라는 이름을 갖고 Animal 타입으로 동물의 행위를 할 수 있다.
  • 동물과 펭귄 구조는 리스코프 치환 원칙을 만족하고 있는 것이다.
  • 하위 클래스의 인스턴스는 상위 타입 객체 참조 변수에 대입해 상위 클래스의 인스턴스 역할을 하는 데 문제가 없어야 한다.
image.png
List<Integer> list1 = new ArrayList<Integer>();
List<Integer> list2 = new LinkedList<Integer>();
List<Integer> list3 = new Vector<Integer>();
  • List 타입으로 생성된 ArrayList, LinkedList 등은 List 인터페이스의 add(), remove() 메서드 등을 공통적으로 사용할 수 있다.

또 다른 상황

  • 회사의 직원 관리 시스템에서 정규직 직원계약직 직원을 관리하고 있다.
  • 직원의 급여를 계산하는 메서드를 구현하는 과정에서 계약직 직원은 급여 계산 방식이 다르다는 문제에 직면했다.

문제

ContractEmployee 클래스가 부모 클래스의 기능을 제대로 대체하지 못하고, calculateSalary 메서드를 호출할 때 예외가 발생한다.

구현

  • 기존 코드.
public class Employee {
    public double calculateSalary() {
        return 5000;
    }
}

public class ContractEmployee extends Employee {
    @Override
    public double calculateSalary() {
        throw new UnsupportedOperationException("계약직 직원은 다른 방식으로 급여가 지급됩니다.");
    }
}
  • 개선 코드.
public abstract class Employee {
    public abstract double calculateSalary();
}

public class FullTimeEmployee extends Employee {
    @Override
    public double calculateSalary() {
        return 5000;
    }
}

public class ContractEmployee extends Employee {
    @Override
    public double calculateSalary() {
        return 3000;
    }
}

해결 + 정리

  • 부모 클래스인 Employee를 대체해도 문제없이 동작하며, 메서드 호출 시 예외가 발생하지 않는다.
  • LSP를 적용하여 서브 클래스부모 클래스를 완전히 대체할 수 있도록 개선하였다.


ISP - 인터페이스 분리 원칙

(위반하는 경우)

  • 운송 수단 인터페이스와 구현하는 자동차, 비행기 클래스가 있다.
image.png
interface 운송수단 {
    void go();
    void fly();
}

class 자동차 implements 운송수단 {

    @Override
    public void go() {
        // ...
    }

    @Override
    public void fly() {
        // ...
    }
}

class 비행기 implements 운송수단 {

    @Override
    public void go() {
        // ...
    }

    @Override
    public void fly() {
        // ...
    }
}
  • 운송수단이라는 추상 클래스 또는 인터페이스에는 go()fly()라는 추상 메서드가 존재한다.
  • 자동차 클래스와 비행기 클래스가 운송수단의 구현체로 구현한다면, Car는 날지 못함에도 불구하고 fly() 메서드를 구현하게 된다.
  • 자동차 클래스의 목적에 맞지 않는 fly() 메서드로 인해 인터페이스 분리 원칙을 위반한다.

(준수하는 경우)

image.png
interface 이동 {
    void go();
}

interface 날다 extends 이동 {
    void fly();
}

class 자동차 implements 이동 {

    @Override
    public void go() {
        // ...
    }
}

class 비행기 implements 날다 {

    @Override
    public void go() {
        // ...
    }

    @Override
    public void fly() {
        // ...
    }
}
  • 운송수단‘이동'‘날다' 인터페이스로 분리하여 go() 메서드와 fly() 메서드를 별도로 구현하도록 한다.
  • 자동차 클래스는 ‘이동’ 인터페이스를 구현하여 go() 메서드만 사용할 수 있도록 한다.
  • ‘날다’ 인터페이스는 ‘이동’ 인터페이스를 상속하고, ‘비행기’ 클래스는 ‘날다’ 인터페이스를 구현하여 go()fly() 모두를 구현할 수 있도록 한다.

또 다른 상황

  • 멀티 기능 프린터를 개발하고 있는데, 인쇄, 스캔, 팩스 기능을 제공해야 한다. \
  • 그러나 모든 클라이언트가 모든 기능을 필요로 하지는 않는다.
  • 한 가지 인터페이스에 모든 기능을 포함시키면 사용하지 않는 메서드를 구현해야 하는 문제가 발생한다.

문제

  • Printer 인터페이스가 너무 크고, BasicPrinter 클래스가 사용하지 않는 메서드를 구현해야 한다.

구현

  • 기존 코드.
public interface Printer {
    void printDocument(String document);
    void scanDocument(String document);
    void faxDocument(String document);
}

public class BasicPrinter implements Printer {
    @Override
    public void printDocument(String document) {
        System.out.println("문서를 출력 중입니다: " + document);
    }

    @Override
    public void scanDocument(String document) {
        throw new UnsupportedOperationException("스캔 기능을 지원하지 않습니다.");
    }

    @Override
    public void faxDocument(String document) {
        throw new UnsupportedOperationException("팩스 기능을 지원하지 않습니다.");
    }
}
  • 개선 코드.
public interface Printer {
    void printDocument(String document);
}

public interface Scanner {
    void scanDocument(String document);
}

public interface Fax {
    void faxDocument(String document);
}

public class BasicPrinter implements Printer {
    @Override
    public void printDocument(String document) {
        System.out.println("문서를 출력 중입니다: " + document);
    }
}

해결 + 정리

  • 인터페이스가 분리되어 각 클래스가 필요한 기능만 구현할 수 있다.
    • BasicPrinter 클래스는 인쇄 기능만 구현하고, 스캔이나 팩스 기능을 지원하지 않도록 할 수 있다.
  • ISP를 적용하여 인터페이스가 더 작고, 사용하지 않는 기능을 구현할 필요가 없어진 것이다.


DIP - 의존성 역전 원칙

(위반하는 경우)

image.png
  • 자동차는 현재 스노우 타이어에 의존하고 있다.
  • 스노우 타이어는 계절이 바뀌면 일반 타이어로 교체해야 할 것이다.
class SnowTire {
    public void print() {
        System.out.println("스노우 타이어");
    }
}

class Car {
    private SnowTire snowTire;

    public Car() {
        this.snowTire = new SnowTire();
    }

    public void printTire() {
        snowTire.print();
    }
}
  • Car는 SnowTire에 의존하고 있다.
  • 만약, 계절이 바뀌어 스노우 타이어를 일반 타이어로 교체할 경우 자동차는 영향을 받게 된다.

계절이 바뀐 경우

class RegularTire {
    public void print() {
        System.out.println("일반 타이어");
    }
}

class SnowTire {
    public void print() {
        System.out.println("스노우 타이어");
    }
}

class Car {
    private RegularTire regularTire; // 수정

    public Car() {
        this.regularTire = new RegularTire(); // 수정
    }

    public void printTire() {
        regularTire.print(); // 수정
    }
}
  • 타이어만 교체했을 뿐인데 Car 클래스에서 수정이 일어난다.
  • Car 클래스에서 구체적인 Tire 클래스를 의존하고 있기 때문이다.
  • 두 클래스 간의 결합도가 높은 것이다.

(준수하는 경우)

image.png
  • 자동차가 구체적인 타이어들이 아닌 추상화된 타이어 인터페이스에만 의존하게 변경한다.
  • 자동차는 사용하고 있는 타이어를 변경하더라도 자동차는 그 영향을 받지 않는 형태로 구성된다.
  • 또한, 구체적인 타이어 클래스들은 타이어 인터페이스에 의존하게 되었다. 의존 관계의 방향이 역전된 것이다.
interface Tire {
    void print();
}

class RegularTire implements Tire {

    @Override
    public void print() {
        System.out.println("일반 타이어");
    }
}

class SnowTire implements Tire {
    
    @Override
    public void print() {
        System.out.println("스노우 타이어");
    }
}

class WideTire implements Tire {

    @Override
    public void print() {
        System.out.println("광폭 타이어");
    }
}

class Car {
    private Tire tire;

    public Car(Tire tire) {
        this.tire = tire;
    }

    public void printTire() {
        tire.print();
    }
}
  • 구체적인 Tire 클래스(Regular, Snow, Wide)는 Tire 인터페이스를 구현하고 있다.
  • Car 클래스는 인스턴스를 생성할 때 Tire 타입의 객체를 매개변수로 받는다.
public class Main {
    public static void main(String[] args) {
        Car regularTireCar = new Car(new RegularTire());
        Car snowTireCar = new Car(new SnowTire());
        Car wideTireCar = new Car(new WideTire());

        regularTireCar.printTire();
        snowTireCar.printTire();
        wideTireCar.printTire();
    }
}
  • 타이어를 교체하더라도 Car 클래스에는 어떠한 영향도 가지 않게 되었다.

또 다른 상황

  • 고객 정보를 데이터베이스에 저장하는 시스템을 개발하고 있다.
  • 기존 코드에서는 CustomerService 클래스가 Database 클래스에 직접 의존하고 있어, 데이터 저장소가 변경되면 CustomerService 코드를 수정해야 하는 문제가 있다.

문제

  • CustomerService 클래스가 Database 클래스에 직접 의존하고 있는 것이다.

구현

  • 기존 코드
public class CustomerService {
    private Database database = new Database(); // 직접 Database에 의존하고 있음

    public void saveCustomer(Customer customer) {
        database.save(customer);
    }
}

public class Database {
    public void save(Customer customer) {
        System.out.println("고객 정보를 데이터베이스에 저장 중입니다: " + customer);
    }
}
  • CustomerService 클래스가 Database 클래스에 직접 의존하고 있다.

    • CustomerService 클래스는 Database 객체를 직접 생성하고 사용한다.
    • 만약 Database 클래스가 변경되거나 새로운 저장소(MySQL, MongoDB 등)로 바꿔야 한다면, CustomerService 클래스의 코드를 직접 수정해야 하는 것이다.
  • 결과적으로, 새로운 기능 추가 시 기존 코드를 수정해야 하므로 OCP도 위반하게 된다.

  • 개선 코드

public interface CustomerRepository {
    void save(Customer customer);
}

public class DatabaseRepository implements CustomerRepository { // 새로운 클래스
    @Override
    public void save(Customer customer) {
        System.out.println("고객 정보를 데이터베이스에 저장 중입니다: " + customer);
    }
}

public class CustomerService {
    private final CustomerRepository repository;

    public CustomerService(CustomerRepository repository) {
        this.repository = repository;
    }

    public void saveCustomer(Customer customer) {
        repository.save(customer);
    }
}
public class Main {
    public static void main(String[] args) {
        // 기존 DatabaseRepository 사용
        CustomerService customerService1 = new CustomerService(new DatabaseRepository());
        customerService1.saveCustomer(new Customer("짱구"));

        // 새로운 FileRepository 사용
        CustomerService customerService2 = new CustomerService(new FileRepository());
        customerService2.saveCustomer(new Customer("철수"));

        // 새로운 MongoRepository 사용
        CustomerService customerService3 = new CustomerService(new MongoRepository());
        customerService3.saveCustomer(new Customer("맹구"));
    }
}

해결 + 정리

  • 이제 CustomerService는 구체적인 구현체(DatabaseRepository)가 아니라 인터페이스(CustomerRepository)에 의존한다.
  • 새로운 데이터 저장소를 추가하고 싶다면, CustomerRepository 인터페이스를 구현하는 새로운 클래스를 만들기만 하면 되는 것이다.
  • 기존의 CustomerService 코드는 수정할 필요가 없다.