BackEnd/Java

SOLID 원칙이란?

Potwings 2024. 12. 31. 23:57

객체지향에서 소프트웨어를 변경에 유연한 구조, 이해하기 쉬운 구조로 만들기 위한 5가지 원칙으로 각 원칙들의 앞글자를 따서 SOLID 원칙이라 불린다.

 

SRP - 단일 책임의 원칙

하나의 클래스에서 하나의 책임만 가져야 한다

 

책임은 매우 주관적인 단어라 이해하기 조금 어려울 수 있는데

좀 더 쉽게 표현하자면 하나의 클래스의 변경은 오직 하나의 이유만으로 이루어져야 한다라고 말할 수 있다.

 

만일 SRP 원칙을 지키지 않을 경우 어떤 문제가 발생하는지 예시를 통해 알아보자

YG쇼핑몰에서는 회원가입을 했을 경우 가입 환영 이메일을, 주문을 완료했을 경우 주문 내역 알림 메일을 발송해준다.

각각 sendWelcomeMail, sendOrderMail 메소드로 기능을 제공하며, 둘 다 메일을 발송해야 하므로 sendMail을 공통으로 사용한다.

    public class EmailSender {

      public void sendWelcomeMail() {
        // 가입 환영 메일 발송 - sendMail 메소드 활용
      }

      public void sendOrderMail() {
        // 주문 내역 알림 메일 발송 - sendMail 메소드 활용
      }

      public void sendMail() {
        // 이메일 발송 메소드
      }
    }

어느 날 sendOrderMail 메소드를 수정하는 과정에서 sendMail 메소드의 내용도 변경이 발생하였다. sendMail 메소드가 변경되었으므로 이에 맞춰 sendWelcomeMail 메소드도 변경이 불가피해졌다. sendWelcomeMail 기능에는 변경사항이 없는데도 변경해야 하는 불필요한 작업이 발생한 것이다.

또, 해당 클래스에 또 다른 기능들이 있었더라면 클래스의 변경이 발생함에 따라 기능이 수정되지 않은 메소드들도 불필요하게 컴파일이 발생하게 된다.

 

만일 하나의 클래스에서 하나의 책임만 가지고 있도록 분리해두었더라면, 주문 내역 알림 메일 발송 기능을 수정하였을 때 sendWelcomeMail 메소드의 수정은 발생하지 않았을 것이고, 불필요한 컴파일도 발생하지 않았을 것이다.

 


OCP - 개방 - 폐쇄 원칙

클래스는 확장에는 열려있어야하며 수정에는 닫혀있어야 한다.

 

확장에는 열려있어야 한다는 건 새로운 요구 사항이 발생할 경우 유연하게 기능을 확장할 수 있어야 한다는 내용이고,

수정에는 닫혀있어야 한다는 건 새로운 기능을 추가 시 기존의 코드는 수정이 발생하면 안된다 라는 내용이다.

그렇다면 어떻게 유연한 기능 추가가 가능하되 기존 코드의 수정이 발생하지 않도록 할 수 있을까?

우린 객체 지향의 특징 중 추상화, 다형성을 활용하여 쉽게 해결할 수 있다.

 

예시를 통해 더 자세히 알아보자

YG쇼핑몰에서는 결제 수단을 추가하려고 한다, 기존에는 신용카드, 계좌이체만 지원하였으나 이제 카카오페이 결제도 추가하고자 한다.

먼저 OCP를 고려하지 않고 작성한 코드를 보자

    // OCP 위반 예시
    // 결제 처리기
    public class PaymentProcessor {
        public void doPayment(String type, int amount) {
            if (type.equals("CREDIT_CARD")) {
                  // 신용카드 결제 처리
                processCreditCardPayment(amount);
            } else if (type.equals("BANK_TRANSFER")) {
                // 계좌이체 결제 처리
                processBankTransferPayment(amount);
            }
        }
    }

위와 같이 코드가 작성되어 있다면 우리는 매번 if문에 조건을 추가하여 결제 수단을 추가해야 한다. 그렇게 되면 기능이 추가될 때마다 기존 코드의 변경이 발생하며 이는 곧 OCP를 위반하는 것이 된다.

 

이제 추상화와 다형성을 활용하여 OCP를 준수하도록 수정해보자

결제 수단의 공통되는 기능을 인터페이스로 추출하여 추상화를 진행하고, PaymentProcessor에서 각각의 떤떤 결제 수단 구현체든지 활용할 수 있도록 다형성을 적용할 것이다.

    // OCP 준수 예시
    // 결제 처리를 위한 인터페이스
    public interface PaymentStrategy {
        void processPayment(int amount);
    }

    // 신용카드 결제 구현체
    public class CreditCardPayment implements PaymentStrategy {
        @Override
        public void processPayment(int amount) {
            System.out.println("신용카드 결제: " + amount + "원");
        }
    }

    // 계좌이체 결제 구현체
    public class BankTransferPayment implements PaymentStrategy {
        @Override
        public void processPayment(int amount) {
            System.out.println("계좌이체: " + amount + "원");
        }
    }

    // 결제 처리기
    public class PaymentProcessor {
        private PaymentStrategy paymentStrategy;

        public void setPaymentStrategy(PaymentStrategy paymentStrategy) {
            this.paymentStrategy = paymentStrategy;
        }

        public void processPayment(int amount) {
            paymentStrategy.processPayment(amount);
        }
    }

 

이제 새로운 결제 수단을 추가하고 싶다면 기존 코드를 수정할 필요 없이 PaymentStrategy 인터페이스를 구현하는 새로운 클래스만 만들면 된다.

    public class KakaoPayPayment implements PaymentStrategy {
        @Override
        public void processPayment(int amount) {
            System.out.println("카카오페이 결제: " + amount + "원");
        }
    }

 

이렇게 결제 시스템을 구현해두면 우리는 기존 코드의 수정 없이 새로운 결제 방식을 얼마든지 추가할 수 있다.

 


LSP - 리스코프 치환 원칙

자식 타입은 언제나 부모 타입으로 교체될 수 있어야 한다.

 

서브 타입은 항상 부모 타입이 가지고 있는 기능들을 동일하게 수행할 수 있어야 한다는 말이다. 이는 객체 지향의 특징 중 다형성의 오버라이딩을 좀 더 잘 활용하기 위한 원칙으로 보면 된다.

 

부모 타입으로 선언한 변수에 자식 타입의 구현체를 할당한 후 메소드를 실행하면 오버라이딩한 메소드를 실행하게 된다. 만일 각각의 구현체들이 부모와 다른 기능을 수행하게 된다면, 우리는 구현체를 변경 할 때마다 변경되는 구현체의 동작을 매번 확인해야하며 이는 매우 비효율적이다. 따라서 우리는 LSP 원칙을 준수하여 이러한 비효율적인 일이 생기지 않도록 하는 것이다.

 

예시를 통해 자세히 알아보자

YG쇼핑몰에서는 일반 고객에게는 5%의 기본 할인을, VIP 고객에게는 15%의 기본 할인을 제공한다.

이를 아래와 같이 코드를 작성하여 구현하였다.

    // LSP 위반 예시
    class Member {
            public void login() {
                // 공통 로그인 로직
            }

        public int discount(int price) {
          return (int)(price * 0.95); // 5% 할인
        }
    }

    class VIPMember extends Member {
        @Override
        public int discount(int price) {
          return (int)(price * 0.85); // 15% 할인
        }
    }

이 코드는 얼핏 보기에는 문제가 없어보이나, LSP 원칙을 위반하고 있다.

부모 타입인 Member 클래스에서 5%의 할인을 제공하고 있는데, 만일 여기서 자식 타입인 VIPMember로 구현체가 교체될 경우 15%의 할인이 적용되며 기존과 기능이 달라진다. 이 경우 구현체마다 기능이 다르므로 부모 타입에 있는 기능임에도 불구하고 구현체를 변경할 때마다 동일하게 동작하는지 확인이 필요하게 될 것이다.

 

이제 LSP를 준수하도록 기능을 수정해보자.

    // LSP 준수 예시
    abstract class Member {

      boolean login() {
        // 공통 로그인 로직
      }

        // 고객마다 다른 할인율은 각 클래스에서 구현
      abstract int discount(int price);
    }

    class NormalMember extends Member {

      @Override
      int discount(int price) {
        return (int) (price * 0.95); // 5% 할인
      }
    }

    class VIPMember extends Member {

      @Override
      int discount(int price) {
        return (int) (price * 0.85); // 15% 할인
      }
    }

공통되는 기능인 login은 부모 클래스인 Member에서 관리하되, 경우에 따라 다른 discount 메소드는 각 회원 타입이 자신에게 맞는 할인 정책을 구현하여 사용하도록 수정하였다.

이렇게 할 경우 부모 타입인 Member를 자식 타입인 NormalMember와 VIPMember가 대체하더라도 기존 부모 타입에서 제공하는 기능은 기존과 동일하게 동작한다.

 


ISP - 인터페이스 분리의 원칙

인터페이스의 동작을 고려하여 인터페이스를 잘 분리해야 한다.

 

언뜻보면 위에서 보았던 SRP와 유사하다 볼 수 있지만 조금은 다르다. 구현체에서 사용하지 메소드를 구현하면 안된다는 것이다. 만일 사용하지 않는 메소드가 인터페이스에 있어 구현해야 한다면 인터페이스 분리를 통해 구현체에게 필요한 메소드만 제공해야 한다는 내용이다.

 

아래 코드를 통해 더 자세히 이해해보자

UserOperations라는 인터페이스를 생성하여 유저들(판매자, 구매자, 관리자)들이 사용하는 기능들을 정의해두었다. 이 중 구매자에 대해 구현을 예시로 들어보자

    // ISP 위반 예시
    interface UserOperations {
        void purchase();      // 구매자 기능
        void sell();         // 판매자 기능
        void manageUsers();  // 관리자 기능
    }

    // 구매자 클래스 구현
    class Buyer implements UserOperations {
        @Override
        public void purchase() {
            // 구매 로직
        }

        @Override
        public void sell() {
                // 구매자에게 불필요한 로직
            // Do nothing
        }

        @Override
        public void manageUsers() {
                // 구매자에게 불필요한 로직
            // Do nothing
        }
    }

만일 하나의 인터페이스에서 유저의 모든 동작을 관리할 경우 위와 같이 구매자에게 불필요한 메소드까지도 구현을 해두어야한다. 이 상태에서 sell이나 managerUsers 메소드가 변경사항이라도 생긴다면 Buyer는 이 메소드를 사용하지도 않음에도 다시 빌드되거나 영향을 받을 수 있다.

 

이제 ISP를 준수하도록 인터페이스를 분리해보자.

    // ISP 준수 예시
    interface BuyerOperations {
        void purchase();
    }

    interface SellerOperations {
        void sell();
    }

    interface AdminOperations {
        void manageUsers();
    }

    // 구매자는 필요한 기능만 구현
    class Buyer implements BuyerOperations {
        @Override
        public void purchase() {
            // 구매 로직
        }
    }

    // 판매자는 필요한 기능만 구현
    class Seller implements SellerOperations {
        @Override
        public void sell() {
            // 판매 로직
        }
    }

    // 관리자는 필요한 기능만 구현
    class Admin implements AdminOperations {
        @Override
        public void manageUsers() {
            // 사용자 관리 로직
        }
    }

이렇게 인터페이스를 분리하면 구현체들은 자신에 유형에 맞는 기능만 구현하면 된다. 또한 코드의 가독성과 유지보수성이 향상된다.


DIP - 의존성 역전의 원칙

다른 클래스에 의존할 경우 추상화에 의존해야하며 구현체에 의존하면 안된다.

 

개발을 하다보면 다른 클래스에 있는 기능이 필요할 때 다른 클래스를 참조(의존)하여 메소드를 불러올 일이 많다. 이 때 다른 클래스를 직접 참조하는 것이 아닌 그의 추상화(추상 클래스 or 인터페이스)를 참조해야 한다는 것이다.

 

왜 구현체가 아닌 추상화를 참조해야할까? 구현체는 변동성이 크기 때문이다.

만일 기능에 변경이 발생하여 다른 구현체를 참조해야하는 경우가 발생한다 생각해보자. 그 경우 구현체에 의존하고 있다면 우리는 다른 구현체를 바라보도록 코드의 변경도 발생할 것이다. 허나 추상화에 의존하고 있다면 같은 추상화를 구현하는 구현체로는 얼마든지 치환이 가능하다.

 

결제 상황을 예시로 들어 확인해보자

현재 우리 쇼핑몰은 신용카드 결제만 지원하여 결제를 담당하는 클래스인 PaymentProcessor를 아래와 같이 구현해두었다.

    // DIP 위반 예시
    // 신용카드 결제 구현체
    public class CreditCardPayment implements PaymentStrategy {
        @Override
        public void processPayment(int amount) {
            System.out.println("신용카드 결제: " + amount + "원");
        }
    }

    // 결제 처리기
    public class PaymentProcessor {
        // 신용카드 결제 구현체에 의존
        private CreditCardPayment paymentStrategy;

        public void setPaymentStrategy(CreditCardPayment paymentStrategy) {
            this.paymentStrategy = paymentStrategy;
        }

        public void processPayment(int amount) {
            paymentStrategy.processPayment(amount);
        }
    }

이 상태에서 계좌 이체를 통한 결제까지 지원하려한다면 계좌 결제 처리를 진행하는 별도의 결제 처리기를 또 추가해야하는 작업이 발생할 것이다.

 

허나 구현체가 아닌 인터페이스(추상화)에 의존을 하고 있었다면 어떻게 되었을까?

    // DIP 준수 예시
    // 결제 처리를 위한 인터페이스
    public interface PaymentStrategy {
        void processPayment(int amount);
    }

    // 신용카드 결제 구현체
    public class CreditCardPayment implements PaymentStrategy {
        @Override
        public void processPayment(int amount) {
            System.out.println("신용카드 결제: " + amount + "원");
        }
    }

    // 결제 처리기
    public class PaymentProcessor {
        private PaymentStrategy paymentStrategy;

        public void setPaymentStrategy(PaymentStrategy paymentStrategy) {
            this.paymentStrategy = paymentStrategy;
        }

        public void processPayment(int amount) {
            paymentStrategy.processPayment(amount);
        }
    }

현재 결제 처리기에서 인터페이스를 바라보고 있기 때문에 우리는 해당 인터페이스를 구현하는 계좌 결제 구현체를 구현한 후 주입해주기만 한다면 무리없이 계좌 결제까지 처리할 수 있게 된다.

    // 계좌이체 결제 구현체
    public class BankTransferPayment implements PaymentStrategy {
        @Override
        public void processPayment(int amount) {
            System.out.println("계좌이체: " + amount + "원");
        }
    }

 

 


참고자료

https://tech.kakaobank.com/posts/2411-solid-truth-or-myths-for-developers/

https://inpa.tistory.com/entry/OOP-💠-객체-지향-설계의-5가지-원칙-SOLID#단일_책임_원칙_-_srp_single_responsibility_principle