Programming/Java

SOLID 객체 지향 프로그래밍의 5가지 원칙

Jan92 2023. 8. 14. 22:31

객체 지향 프로그래밍의 5가지 원칙 SOLID와 각각에 대한 예시

최근에 객체 지향 프로그래밍을 개발하는 것에 있어 스스로 근본적인 원칙에 대해서 잘 알고 있는지, 또 잘 적용하고 있는지에 대한 의문이 들었는데요.

결론적으로 유지 보수와 확장성이라는 궁극적인 목적을 고려하지 않은 채, 잘 못된 방향으로의 습관적 코딩을 하고 있었다는 반성을 하게 되었습니다.

 

'객체 지향 프로그래밍의 5가지 원칙인 SOLID'에 대해서는 이미 잘 정리되어 있는 글들이 많지만 내용을 다시 한번 정리하며 스스로 앞으로의 방향성을 잡아가기 위해 기록한 내용입니다.

 

 


SOLID

객체 지향 프로그래밍 5가지 원칙 SOLID

객체 지향 프로그래밍에서 코드의 유연성, 확장성 및 유지보수성을 높이기 위한 가이드라인이라고 할 수 있는데요.

SRP(단일 책임 원칙), OCP(개방 폐쇄 원칙), LSP(리스코프 치환 원칙), ISP(인터페이스 분리 원칙), DIP(의존 역전 원칙)의 앞글자를 따서 만들어졌습니다.

 

SOLID 원칙은 디자인 패턴(Design Pattern)과 서로 밀접하게 연관되어 있으며, 디자인 패턴은 SOLID 원칙을 실제로 적용하는 방법을 제공하는 것으로도 볼 수 있습니다.

 

 


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

'하나의 클래스(객체)는 하나의 책임만 가져야 한다.'

이 부분에 대해서는 '클래스가 변경되는 이유가 한 가지여야 한다.'는 것으로 받아들이는 것이 더 정확하다고 볼 수 있는데요.

 

클래스가 한 가지 책임만을 가진다면, 그 클래스의 변경은 해당 책임에 관련된 것들 뿐이기 때문에 코드의 변경 범위가 제한됩니다.

또한 하나의 책임에 대한 변경이 다른 책임에 대해 영향을 미치지 않으며, 변경이 발생했을 때 수정의 대상이 명확해진다는 장점이 있습니다.

 

이해를 조금 더 돕기 위해 아래 예시를 살펴보겠습니다.

 

class CommentManager {
    public void addComment(Comment comment) {
        //댓글을 추가하는 코드
        //댓글 알림을 발송하는 코드
    }
}

다음과 같은 CommentManager 클래스가 있을 때, 해당 클래스는 '댓글을 추가하는 것''댓글에 대한 알림을 발송하는 것'이라는 두 가지 책임을 가지고 있다고 볼 수 있습니다.

따라서 댓글 추가에 대한 기능의 수정이 필요할 때도 CommentManager 클래스에 대한 수정이 필요하며, 댓글 알림 발송에 대한 기능의 수정이 필요할 때도 CommentManager 클래스에 대한 수정이 필요하게 되는데요.

 

이 경우 하나의 책임에 대한 변경이 다른 책임과 관련된 코드에 대해서도 영향을 미칠 수 있으며, 클래스가 가진 책임이 불명확해진다는 문제도 생기게 됩니다.

 

 

class CommentManager {
    public void addComment(Comment comment) {
        //댓글을 추가하는 코드
    }
}

class NotificationManager {
    public void sendNotification(Comment comment) {
        //댓글 알림을 발송하는 코드
    }
}

따라서 '댓글을 추가하는 것''댓글에 대한 알림을 발송하는 것' 두 가지 책임을 다음과 같이 분리하게 되는데요.

이렇게 분리가 되었을 때 각각의 클래스는 하나의 책임만을 가지며, 다른 책임에 서로 영향을 주지 않게 됩니다.

 

결론적으로 단일 책임 원칙은 모듈화(Modularity)와 결합도(Coupling)를 관리하는데 도움을 주며, 유지보수를 쉽게 만들어주는 등의 영향을 주게 되는데요.

하지만 단일 책임 원칙에서 말하는 '책임'의 범위가 명확하게 지정된 것이 아니기 때문에 프로세스에 따라, 또 개발하는 개발자에 따라 기준이 달라질 수 있다는 점은 알아두어야 하는 부분입니다.

 

 


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

'확장에는 열려 있어야 하며, 변경에는 닫혀 있어야 한다.'

쉽게 클래스나 모듈 등에서 기존의 코드를 변경하지 않으면서(Closed), 기능을 추가할 수 있도록(Open) 설계가 되어야 한다는 원칙입니다.

 

개방 폐쇄 원칙에서의 핵심은 기존의 코드를 건드리지 않고 기능을 확장한다는 것인데요.

결론을 먼저 이야기하자면, 개방 폐쇄 원칙은 추상화 및 다형성의 활용과 연관성이 높습니다.

 

조금 더 자세한 내용은 아래 예시를 통해 살펴보도록 하겠습니다.

 

class PaymentResultSender {
    private String type;

    public PaymentResultSender(String type) {
        this.type = type;
    }

    public void sendPaymentResult() {
        if (type.equals("email")) {
            //결제 결과를 email로 발송하는 코드
        } else if (type.equals("sms")) {
            //결제 결과를 sms로 발송하는 코드
        }
    }
}

다음과 같이 결제 결과를 email 또는 sms로 발송하는 PaymentResultSender가 존재한다고 했을 때, 결제 결과를 kakao talk으로 발송하는 기능이 추가된다면 기존에 구현된 sendPaymentResult() 메서드를 수정해야 합니다.

이는 개방 폐쇄 원칙에 맞지 않는 방식인데요.

 

 

interface Sender {
    void send();
}

class EmailSender implements Sender {
    public void send() { 
        //결제 결과를 email로 발송하는 코드 
    }
}

class SmsSender implements Sender {
    public void send() { 
        //결제 결과를 sms로 발송하는 코드 
    }
}

class KakaoTalkSender implements Sender {
    public void send() { 
        //결제 결과를 kakao talk으로 발송하는 코드 
    }
}

class PaymentResultSender {
    private Sender sender;

    public PaymentResultSender(Sender sender) {
        this.sender = sender;
    }

    public void sendPaymentResult() {
        sender.send(); //결제 결과를 발송하는 코드
    }
}

하지만 추상화를 활용하여 다음과 같이 Sender interface를 정의하고 EmailSender, SmsSender, KakaoTalkSender 클래스를 Sender 인터페이스를 구현하는 방식으로 사용하였을 때, 이후에 다른 발송 방식이 추가되더라도 Sender 인터페이스를 구현하기만 한다면 기존의 sendPaymentResult() 메서드를 수정할 필요가 없어지게 됩니다.

 

이처럼 개방 폐쇄 원칙을 따르면 기존의 코드를 수정하지 않으면서 기능을 확장할 수 있는데요.

이는 소프트웨어의 확장과 유지보수를 더욱 유연하게 할 수 있다는 결과를 가져오게 됩니다.

 

 


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

'하위 타입은 상위 타입을 대체할 수 있어야 한다.'

즉, 부모 클래스가 들어갈 자리에 자식 클래스를 넣어도 프로그램의 정확성을 깨뜨리지 않으면서 동작해야 한다는 의미이며, 이는 자식 클래스가 부모 클래스의 행동을 보장해야 함을 의미합니다.

 

* 여기서 '정확성'은 허용된 입력에 대해 올바르게 작동해야 한다는 것을 말합니다.

 

리스코프 치환 원칙의 예시를 살펴보면 다음과 같습니다.

 

class Account {
    protected Bigdecimal balance;

    public Account(Bigdecimal balance) {
        this.balance = balance;
    }

    public Bigdecimal getBalance() {
        return balance;
    }

    public void deposit(Bigdecimal amount) {
        balance =  balance.add(amount);
    }

    public void withdraw(Bigdecimal amount) {
        f (amount.compareTo(balance) < 0) {
            balance = balance.subtract(amount);
        } else {
            //잔고가 부족할 경우 처리
        }
    }
}

(부모 클래스, Account)

 

class SavingsAccount extends Account {
    private Bigdecimal interestRate;

    public SavingsAccount(Bigdecimal balance, Bigdecimal interestRate) {
        super(balance);
        this.interestRate = interateRate;
    }

    public void applyInterest() {
        balance = balance.multiply(interateReate);
    }
}

(자식 클래스, SavingsAccount)

 

Account 클래스는 입금, 출금 기능인 deposit(), withdraw() 메서드를 가지고 있는 클래스입니다.

그리고 SavingsAccount는 Account 클래스를 상속하며 applyInterest()라는 이자를 적용하는 추가 기능을 가지고 있는 클래스인데요.

 

기존에 Account 클래스의 인스턴스가 사용되던 곳에 SavingsAccount 클래스의 인스턴스를 사용해도 원래의 기능은 정상적으로 작동하며, 필요한 경우 applyInterest() 메서드도 추가로 사용할 수 있게 됩니다.

 

이처럼 리스코프 치환 원칙의 예시를 통해서는 부모 클래스를 자식 클래스로 바꾸더라도 원래의 동작은 변하지 않으면서 추가적인 기능을 확장할 수 있다는 것을 볼 수 있습니다.

 

 


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

'하나의 범용 인터페이스 보다는 여러 개의 구체적인 인터페이스가 낫다.'

다시 말하면 클라이언트의 목적과 용도에 적합한 인터페이스만을 제공하는 것을 목적으로 한다고 할 수 있으며, 하나의 클라이언트가 자신이 사용하지 않는 인터페이스의 메서드에 의존하지 않도록 설계해야 한다는 것을 말합니다.

 

단일 책임 원칙(SRP)이 클래스의 단일 책임을 강조한다면, 인터페이스 분리 원칙(ISP)은 인터페이스의 단일 책임을 강조하는데요.

인터페이스의 경우 클래스와 다르게 제약 없이 다중 상속이 가능하다는 점이 활용된 원칙입니다.

 

인터페이스 분리 원칙의 예시를 살펴보면 아래와 같습니다.

 

interface Device {
    void powerOn();
    void poweroff();
    void playSound();
    void displayImage();
}

class Television implements Device {
    public void powerOn() { 
        //전원 켜기 
    }
    public void powerOff() { 
        //전원 끄기 
    }
    public void playSound() { 
        //TV 소리 재생 
    }
    public void displayImage() { 
        //TV 화면 표시 
    }
}

class Radio implements Device {
    public void powerOn() { 
        //전원 켜기 
    }
    public void powerOff() { 
        //전원 끄기 
    }
    public void playSound() { 
        //Radio 소리 재생 
    }
    public void displayImage() { 
        //Radio 화면 표시가 불가능 
    }
}

위와 같이 범용적으로 사용하기 위한 Device 인터페이스를 정의하고 Televesion, Radio 클래스는 Device 인터페이스를 구현하도록 하였습니다.

하지만 Redio 클래스에서는 화면을 표시하는 displayImage() 메서드가 필요하지 않은데요.

이처럼 Redis 클래스가 자신이 사용하지 않는 displayImage() 메서드에 의존하게 되며, 인터페이스 분리 원칙에 적합하지 않게 됩니다.

 

 

interface Powerable {
    void powerOn();
    void powerOff();
}

interface Soundable {
    void playSound();
}

interface Displayable {
    void displayImage();
}

class Television implements Powerable, Soundable, Displayable {
    public void powerOn() { 
        //전원 켜기 
    }
    public void powerOff() { 
        //전원 끄기 
    }
    public void playSound() { 
        //TV 소리 재생 
    }
    public void displayImage() { 
        //TV 화면 표시 
    }
}

class Radio implements Powerable, Soundable {
    public void powerOn() { 
        //전원 켜기 
    }
    public void powerOff() { 
        //전원 끄기 
    }
    public void playSound() { 
        //Radio 소리 재생 
    }
}

따라서 다음 예시와 같이 전원과 관련된 Powerable, 소리 출력과 관련된 Soundable, 화면 출력과 관련된 Displayable 인터페이스를 따로 분리하여 각각의 클라이언트에 필요한 인터페이스만 구현하도록 작업할 수 있습니다.

 

 


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

'고수준 모듈은 저수준 모듈에 의존해서는 안되며, 둘 모두 추상화된 인터페이스나 추상 클래스에 의존해야 한다.'

이 원칙은 추상화된 것은 구체적인 것에 의존하면 안 된다는 개념을 내포하고 있습니다.

 

의존 역전 원칙은 고수준 비즈니스 로직이 저수준 세부사항에 의존하지 않도록 하는 것을 지향하며, 이를 위해 고수준 모듈과 저수준 모듈 사이의 직접적인 의존성을 끊고 둘 다 추상화된 인터페이스나 추상 클래스에 의존하도록 하는 것입니다.

 

고수준 모듈과 저수준 모듈 모두 추상화된 인터페이스 또는 추상 클래스에 의존하기 때문에 변화에 대응하기 쉬운 구조를 갖출 수 있습니다.

 

class Database {
    public void connect() { 
        //데이터베이스 연결 
    }
    
    public void disconnect() { 
        //데이터베이스 연결 해제 
    }
    
    public void executeQuery(String query) { 
        //쿼리 실행 
    }
}

class Application {
    private Database database;

    public Application() {
        this.database = new Database();
    }

    public void processDate() {
        database.connect();
        //데이터 처리 로직
        database.disconnect();
    }
}

다음 코드에스는 고수준 모듈인 Application 클래스가 저수준 모듈인 Database 클래스에 직접적으로 의존하고 있는 것을 볼 수 있는데요.

 

 

interface DatabaseConnection {
    void connect();
    void disconnect();
    void executeQuery(String query);
}

class Database implements DatabaseConnection {
    public void connect() { 
        //데이터베이스 연결 
    }
    public void disconnect() { 
        //데이터베이스 연결 해제 
    }
    public void executeQuery(String query) { 
        //쿼리 실행 
    }
}

class Application {
    private DatabaseConnection databaseConnection;

    public Application(DatabaseConnection databaseConnection) {
        this.databaseConnection = databaseConnection;
    }

    public void processData() {
        databaseConnection.connect();
        //데이터 처리 로직
        databaseConnection.disconnect();
    }
}

수정된 예시에서는 DatabaseConnection이라는 인터페이스를 추가하여 Application에서 Database에 직접적으로 의존하지 않고 추상화된 인터페이스를 의존하도록 변경되었습니다.

 

예시와 같이 모듈 간의 동작을 인터페이스를 통해 추상화에 의존하게 함으로써 시스템의 유연성이 확장되며, 변경이 발생하더라도 변경에 대한 영향을 최소화할 수 있습니다.

 

 

 

 

< 참고 자료 >

https://mangkyu.tistory.com/194

https://inpa.tistory.com/entry/OOP-💠-객체-지향-설계의-5가지-원칙-SOLID