프로그래밍/JAVA Spring

[JAVA 자바] chat gpt와 SOLID 원칙에 대해 공부를 해보았다(1)

hectick 2023. 3. 24. 21:39

요즘 책을 읽으면서도, 강의를 들으면서도 가끔 SOLID 원칙이 언급되길래, 한번 chat gpt와 대화하며 공부한 내용을 정리해 보았다.

 

 

🤔 SOLID 원칙이란?

 

객체 지향 프로그래밍에서 유지보수와 확장을 쉽게하고, 코드의 유연성 등을 보장하기 위해 만들어진 다섯가지 원칙이다. 이 원칙은 단일 책임 원칙(SRP), 개방-폐쇄 원칙(OCP), 리스코프 치환 원칙(LSP), 인터페이스 분리 원칙(ISP), 의존관계 역전 원칙(DIP)으로 구성된다.

 

 

 

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

 

하나의 클래스나 모듈은 하나의 책임만 가져야 한다.

 

SOLID 원칙을 정의한 로버트 마틴은 여기서 '책임'을 단순히 클래스나 모듈이 해야 할 일을 의미하는 것이 아니라, '그 클래스나 모듈이 존재하는 이유, 즉 그것을 변경하려는 이유'라고 말한다. 즉, 책임을 하나만 가져야 한다는 말은 해당 클래스나 모듈을 변경하려는 이유가 단 하나만 존재해야 한다는 뜻이다.

 

    public class UserManager {
        public void addUser(String username, String password) {
            // 유저를 데이터베이스에 추가하는 코드
            // ...
            // 이메일을 보내는 코드
            sendWelcomeEmail(username);
        }

        private void sendWelcomeEmail(String username) {
            // 이메일을 보내는 코드
            // ...
        }
    }

위의 UserManager를 변경하려는 이유에는 무엇이 있을까? 데이터베이스에 유저를 추가하는 방법이 변경되는 이유와 유저가 추가된 후 이메일을 보내는 방법이 변경되는 것이 있다. 

 

그럼 이제 단일 책임 원칙을 지키도록 수정해보자. UserRepository는 데이터베이스에 유저를 추가하는 책임만을 가지고 있으며, 이메일 전송과 관련된 책임은 EmailService이 맡았다. 따라서, 이메일을 보내는 방법을 수정하고자 한다면 EmailService만 수정하면 된다. 이렇게 되면, 코드를 변경하고자 할 때 변경되는 범위를 최소화할 수 있다는 장점이 있고, 이에 따라 유지보수성도 좋아진다.

    public class UserManager {
        private final UserRepository userRepository;
        private final EmailService emailService;

        public UserManager(UserRepository userRepository, EmailService emailService) {
            this.userRepository = userRepository;
            this.emailService = emailService;
        }

        public void addUser(String username, String password) {
            userRepository.addUser(username, password);
            emailService.sendWelcomeEmail(username);
        }
    }

    public class UserRepository {
        public void addUser(String username, String password) {
            // 유저를 데이터베이스에 추가하는 코드
            // ...
        }
    }

    public class EmailService {
        public void sendWelcomeEmail(String username) {
            // 이메일을 보내는 코드
            // ...
        }
    }

그런데 궁금한게 있어서 또 질문을 했다.

 

 

....그렇다고 한다.

 

 

 

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

 

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

 

이 원칙을 잘 적용할 경우에, 기능을 확장 또는 변경 할 때 기존의 코드를 수정하지 않고도 새로운 기능을 추가하거나 변경할 수 있다. 이렇게 하기 위해선 인터페이스를 만들고 인터페이스를 구현한 새로운 클래스를 추가하거나, 기존의 클래스를 수정하지 않고 상속 받아서 확장해 볼 수 있다.

 

개방-폐쇄 원칙을 위반한 코드를 보자. 여기서 PaymentProcessor 클래스의 processPayment 메서드는, 파라미터로 들어오는 Payment(결제 방법)에 따라 상황에 따라 다른 로직을 처리한다. 이 경우, 새로운 결제 방법이 추가될 때마다 PaymentProcessor 클래스를 수정해야 한다.

    public class PaymentProcessor {

        public void processPayment(Payment payment) {
            if (payment.getPaymentMethod().equals("Credit Card")) {
                // Credit Card에 대한 로직 수행
            } else if (payment.getPaymentMethod().equals("PayPal")) {
                // PayPal에 대한 로직 수행
            } else if (payment.getPaymentMethod().equals("Google Wallet")) {
                // Google Walle에 대한 로직 수행
            } else {
                throw new IllegalArgumentException("Invalid payment method.");
            }
        }
    }

    public class Payment {

        private String paymentMethod;
        private double amount;

        // 생성자, getter, setter 생략
    }

 

인터페이스를 사용해 개방-폐쇄 원칙을 지키도록 수정해보자. 먼저, 결제 방법에 대한 인터페이스인 PaymentMethodProcessor를 만든 후, 이를 구현하는 CreditCardProcessor, PayPalProcessor, GoogleWalletProcessor 클래스를 만들어 주었다. 그리고 PaymentProcessor는 이 지불방법들을 Map에 넣어 관리하고, processPayment 메서드에 파라미터로 어떤 지불 방법이 들어오느냐에 따라 Map에서 해당하는 지불 방법을 꺼내서 실행하기만 하면 된다.

    public interface PaymentMethodProcessor {
        public void processPayment(Payment payment);
    }

    public class CreditCardProcessor implements PaymentMethodProcessor {
        @Override
        public void processPayment(Payment payment) {
            // Credit card에 대한 로직 실행
        }
    }

    public class PayPalProcessor implements PaymentMethodProcessor {
        @Override
        public void processPayment(Payment payment) {
            // PayPal payment에 대한 로직 실행
        }
    }

    public class GoogleWalletProcessor implements PaymentMethodProcessor {
        @Override
        public void processPayment(Payment payment) {
            // Google Wallet에 대한 로직 실행
        }
    }

    public class PaymentProcessor {

        private Map<String, PaymentMethodProcessor> paymentProcessors = new HashMap<>();

        public PaymentProcessor() {
            paymentProcessors.put("Credit Card", new CreditCardProcessor());
            paymentProcessors.put("PayPal", new PayPalProcessor());
            paymentProcessors.put("Google Wallet", new GoogleWalletProcessor());
        }

        public void processPayment(Payment payment) {
            PaymentMethodProcessor processor = paymentProcessors.get(payment.getPaymentMethod());
            if (processor == null) {
                throw new IllegalArgumentException("Invalid payment method.");
            }
            processor.processPayment(payment);
        }
    }

    public class Payment {

        private String paymentMethod;
        private double amount;

        // 생성자, getter, setter 생략
    }

 이렇게 되면, 새로운 결제 방법이 추가될 때는 인터페이스를 구현한 새로운 클래스를 만들면 될 뿐, 기존의 코드를 수정할 필요가 없어진다. PaymentProcessor 클래스의 Map에 새로 추가된 결제 방법을 추가하는 것은 코드의 변경이 아닌지 궁금해서 이것도 물어보았는데 답변은 다음과 같다.

 

 

 

🍏 L 리스코프 치환 원칙 (LSP, Liskov Substitution principle)

 

하위 타입은 상위 타입을 대체할 수 있어야 한다. 즉, 하위 클래스는 상위 클래스의 기능을 수행할 수 있어야 한다.

 

리스코프 치환 원칙을 위반한 코드에서는 객체 간의 상호작용이 예측하지 못한 방향으로 흐를 수 있다. 다음 코드를 보자. 여기선 Rectangle이라는 직사각형 클래스가 존재하고, 이를 상속받는 Square라는 정사각형 클래스가 존재한다.

    class Rectangle {
        protected int width;
        protected int height;

        public void setWidth(int width) {
            this.width = width;
        }

        public void setHeight(int height) {
            this.height = height;
        }

        public int getWidth() {
            return width;
        }

        public int getHeight() {
            return height;
        }

        public int getArea() {
            return width * height;
        }
    }

    class Square extends Rectangle {
        private int side;

        public void setSide(int side) {
            this.side = side;
            this.width = side;
            this.height = side;
        }

        public int getSide() {
            return side;
        }

        @Override
        public void setWidth(int width) {
            super.setWidth(width);
            super.setHeight(width);
            this.side = width;
        }

        @Override
        public void setHeight(int height) {
            super.setHeight(height);
            super.setWidth(height);
            this.side = height;
        }
    }

정사각형 Square 클래스에서는 너비와 높이가 같아야 한다. 그래서 이를 설정하는 setWidth()와 setHeight() 메서드를 Rectangle로부터 오버라이딩하여 너비와 높이가 같아지도록 재정의 해주었다. 상식적으로는 정사각형은 직사각형에 포함되므로 언뜻 보기엔 자연스러워 보인다.

 

하지만 아래의 resize 메서드를 만나면 어떻게 될까? 이 메서드는 Rectangle 객체의 너비를 1씩 증가시키면서, Rectangle의 너비가 높이보다 큰 값을 갖도록 조정하는 메서드이다.

        public void resize(Rectangle r) {
            while (r.getWidth() <= r.getHeight()) {
                r.setWidth(r.getWidth() + 1);
            }
        }

 

여기에 파라미터로 Rectangle을 상속받는 Square를 넘겨보겠다.

            Rectangle rectangle = new Square();
            resize(rectangle);

이 코드는 무한 루프에 빠지게 된다. 하위 클래스인 Square의 객체는 상위 클래스인 Rectangle의 객체를 대체할 수 없기 때문이다. Square 에서는 setWidth와 setHeight 메서드가 같은 값을 설정함을 위에서 보았다. resize 메서드에 Square를 넘기게 된다면, 반복문의 조건에 계속 걸려서 반복문을 탈출하지 못하게된다.

 

이제 리스코프 치환 원칙을 지키도록 바꿔보자.

    interface Shape {
        int getArea();
    }

    class Rectangle implements Shape {
        protected int width;
        protected int height;

        public void setWidth(int width) {
            this.width = width;
        }

        public void setHeight(int height) {
            this.height = height;
        }

        public int getWidth() {
            return width;
        }

        public int getHeight() {
            return height;
        }

        @Override
        public int getArea() {
            return width * height;
        }
    }

    class Square implements Shape {
        private int side;

        public void setSide(int side) {
            this.side = side;
        }

        public int getSide() {
            return side;
        }

        @Override
        public int getArea() {
            return side * side;
        }
    }

변경된 코드에서는 Square가 Rectangle을 상속받지 않고, 두 클래스가 각각 Shape라는 인터페이스를 구현하도록 하였다. 이렇게 되면, 자식인 Square과 Rectangle은 모두 부모인 Shape으로 대체되었을 때도, getArea 메서드를 정상적으로 사용할 수 있게 된다.

 

다시 생각해보면, 정사각형은 가로와 세로의 길이가 항상 같아야 하기 때문에, 정사각형이 직사각형을 상속하게 된다면, 직사각형에서 가로와 세로를 각각 사용하던 기능을 제한하게 된다. 결국 Square과 Rectangle은 대체할 수 없는 관계였던 것이다. 이런 경우에는 상속 대신에 인터페이스를 사용하거나, 상속 구조 자체를 다시 설계하는 것이 좋다.

 


나머지 두 원칙은 2편에 쓰겠다.

 

chat gpt... 재미있네

 

 

👇chat gpt와 SOLID 원칙에 대해 공부를 해보았다 2편이 업로드 되었다!👇

 

[JAVA 자바] chat gpt와 SOLID 원칙에 대해 공부를 해보았다(2)

👇chat gpt와 SOLID 원칙에 대해 공부를 해보았다 1편👇 [JAVA 자바] chat gpt와 SOLID 원칙에 대해 공부를 해보았다(1) 요즘 책을 읽으면서도, 강의를 들으면서도 가끔 SOLID 원칙이 언급되길래, 한번 chat g

finger-ineedyourhelp.tistory.com