객체지향의 5가지 원칙 SOLID

객체지향의 5가지 원칙인 SRP, OCP, LSP, ISP, DIP 에 대해 알아봅니다.


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

클래스는 단 하나의 목적을 가져야 하며, 클래스를 변경하는 이유는 단 하나의 이유여야 한다.
이는 한 클래스에서 여러 목적을 가지면 안 된다는 것입니다.

SRP를 준수하지 않은 예제

아래는 클래스에서 여러 목적을 가지고 있는 예제입니다.
유저와 관련된 목적만을 가져야 하는데 물품 구매와 관련된 목적을 가지고 있습니다. 이는 코드가 길어질수록 유지보수가 힘듭니다.

class UserService {
    void signIn() {
        System.out.println("로그인 완료");
    }
    void signUp() {
        System.out.println("회원가입 완료");
    }
    void buyItem() {
        System.out.println("물품 구매 완료");
    }
}

SRP를 준수한 예제

아래와 같이 목적 별로 클래스를 분리하여 각각 생성하고 기능을 추가합니다.
이는 기능이 많아지더라도 어떤 클래스에 특정 기능이 구현되어 있을지 쉽게 확인할 수 있습니다. 이를 통해 유지보수하기 쉬운 코드를 짤 수 있습니다.

class UserService {
    void signIn() {
        System.out.println("로그인 완료");
    }
    void signUp() {
        System.out.println("회원가입 완료");
    }
}

class ItemService {
    void buyItem() {
        Systtem.out.println("물품 구매 완료");
    }
}

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

OPC는 클래스는 확장에는 열려 있고, 변경에는 닫혀 있어야 한다는 것입니다.
이는 기능이 추가될 때 기존 클래스 변경 없이 새로운 클래스로 인터페이스를 확장시켜 기능을 추가한다는 것입니다.

OCP를 준수하지 않은 에제

아래는 확장에 개방적이지 못하며 기능이 추가될 때 마다 새로운 메서드를 추가하고 있습니다.
좋은 코드라고 생각할 수 있지만, 이는 결제 기능이 매우 많아질 경우 관리하기 매우 어렵습니다. 카드와 현금의 결제를 위해 결제 클래스 자체를 변경할 필요가 없다는 것입니다.

class PayService {
    void cardPay() {
        System.out.println("카드 결제");
    }
    
    void moneyPay() {
        System.out.println("현금 결제");
    }
}

OCP를 준수한 예제

아래와 같이 PayService 인터페이스를 사용하여 새로운 결제 방식이 추가될 때마다 구현체를 만들어 확장해 사용합니다.
즉, 객체에 올바른 책임과 역할을 부여하게 됩니다. 결제에 대한 방식은 카드 클래스와 현금 클래스가 알아서 확장해서 사용할 뿐 결제 클래스 자체가 변경되지 않습니다.

interface PayService {
    void pay();
}

class cardPayService implements PayService {
    @Override 
    void pay() {
        System.out.println("카드 결제");
    }
}

class moneyPayService implements PayService {
    @Override
    void pay() {
        System.out.println("현금 결제");
    }
}

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

상위 타입의 객체를 하위 타입으로 바꾸어도 프로그램은 일관되게 동작해야 한다.
이 말은 특정 객체가 슈퍼 타입이라면 해당 타입의 서브 타입의 클래스로 바꾸어도 똑같이 동작해야 한다는 것입니다. 이는 객체지향 프로그래밍에서 흔히 사용하는 서브클래싱과 서브타이핑에 관련된 개념입니다.

  • 슈퍼 타입: 슈퍼(부모) 클래스
  • 서브 타입: 서브(자식) 클래스

LSP를 준수하지 않은 예제

아래는 직사각형 클래스와 정사각형 클래스가 있습니다. 만약 특정 객체를 슈퍼 타입인 Rectangle로 선언 후 사용하다가, 서브 타입인 Square로 타입을 변경하고 똑같은 값으로 너비와 높이를 설정할 경우 슈퍼 타입일 때와 서브 타입일 때의 getArea 값은 다르게 출력됩니다.
즉, LSP를 준수하기 위해서는 자식 클래스 is 부모 클래스 가 성립해야 하며, 부모 클래스의 성질이나 행동을 자식 클래스가 가져야 한다. 하지만 이를 지키기는 쉽지 않고 그렇기 때문에 서브클래싱과 서브타이핑을 잘 사용하는 것은 대단히 어렵다. (GO언어는 이를 지원 하지 않음)

class Rectangle {
    int width;
    int height;
    int setWidth(int width) {
        this.width = width;
    };
    int setHeight(int height) {
        this.height = height;
    }
    int getArea() {
        return width * height;
    }
}

class Square extends Rectangle {
    @Override
    int setWidth(int width) {
        this.width = width;
        this.height = width;
    }
    @Override 
    int setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

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

클래스는 자신이 사용하지 않는 메소드는 구현하지 않아야 한다.
이 말은 인터페이스를 구현할 때 인터페이스에 클래스에 관련 없는 메소드가 있으면 안 된다는 것입니다. 즉, 여러 개의 인터페이스로 나누어 구현하거나 또는 모든 클래스에 공통된 메소드가 아니라면 클래스 내에서 새로 메소드를 생성해야 합니다.

ISP를 준수하지 않은 예제

일반 사용자는 공지사항 인터페이스의 작성 기능을 사용하지 못하므로 ISP를 준수하지 못하고 있다.

interface Notice {
    void write();
    void read();
}

class User implements Notice {
    @Override
    void read() {
        System.out.println("공지 확인");
    }
}

class Admin implements Notice {
    @Override
    void write() {
        System.out.println("공지 작성");
    }
    @Override
    void read() {
        System.out.println("공지 확인");
    }
}

ISP를 준수한 예제

이와 같이 공통된 메소드만을 인터페이스에 정의해두고 관리자만이 공지 작성을 할 수 있도록 새로운 메소드를 추가하거나 또는 메소드가 많아질 경우에는 거대한 인터페이스보단 여러 개의 인터페이스로 분리하는 것이 좋다.

interface Notice {
    void read();
}

class User implements Notice {
    @Override
    void read() {
        System.out.println("공지 확인");
    }
}

class Admin implements Notice {
    @Override
    void read() {
        System.out.println("공지 확인");
    }
    void write() {
        System.out.println("공지 작성");
    }
}

DIP (Dependency Inversion Principle, 의존 역전 법칙)

클라이언트는 추상화(인터페이스)에 의존해야 하며, 구체화(구현된 클래스)에 의존해선 안된다.
즉, 구현된 클래스가 아닌 상속받는 클래스 또는 인터페이스에 의존하자는 것입니다. 예시를 보면 이해하기 쉽습니다.

interface ItemService {
    void buy();
}

class ItemServiceImpl implements ItemService {
    @Override
    void buy() {
        System.out.println("구매 완료");
    }
}

class ItemController {
    private ItemService itemService;
    public ItemController(ItemService itemService) {
        this.itemService = itemService;
    }
}

ItemController a = new ItemController(new ItemServiceImpl());

위 코드의 ItemController에서는 실제 사용되는 객체인 itemService는 실제로는 구현체인 ItemServiceImpl를 사용하지만 추상화된 ItemService에 의존해서 생성됩니다. 객체는 생성자 주입방식을 통해 주입해야 합니다.

Leave a comment