본문 바로가기
Study/디자인 패턴의 아름다움

[디자인 패턴의 아름다움] 3. 설계 원칙 - 3.1~3.5 정리 (SOLID)

by Nahwasa 2024. 4. 13.

스터디 메인 페이지

목차

    - ☆ 표시가 붙은 부분은 스터디 중 나온 얘기 혹은 제 개인적인 생각이나 제가 이해한대로 적어놓은 것으로, 책에 나오지 않는 내용입니다. 따라서 책에서 말하고자 하는 바와 다를 수 있습니다.

    - 모든 이미지의 출처는 디자인패턴의 아름다움(왕정 저) 책 입니다.

     


     

    CHAPTER 03. 설계 원칙

    • 설계 원칙에 대한 이해가 충분하지 않으면, 불필요하게 독단적이고 엄격한 사용으로 이어져 결국 역효과를 낳을 것이다.
    • ☆ 이전에 TDD 스터디 때 TDD와 SOLID 관점에서 생각하며 리팩토링 해보는 라이브 코딩을 스터디에 진행해본 적 있다. 해당 내용은 'TDD, Mock, SOLID 얘기 - 도시 가스 요금 계산' 에 있다.

     

    3.1 단일 책임 원칙 (SRP)

    • single responsibility principle
    • 클래스와 모듈은 하나의 책임 또는 기능만을 가지고 있어야 한다는 설계 원칙
    • ☆ 오브젝트 책 : 하나의 변경 이유만을 가져야 한다.
    • 거대하고 포괄적인 클래스를 설계하는 대신, 작은 단위와 단일 기능을 가진 클래스를 설계해야 한다.
    • 실제 소프트웨어 개발에서 클래스에 단일 책임이 있는지를 판별하기란 쉬운 일이 아니다. 이하 몇 가지 결정 원칙
      • 클래스에 코드, 함수 또는 속성이 너무 많아 코드의 가독성과 유지 보수성에 영향을 미치는 경우 클래스 분할을 고려해야 한다.
      • 클래스가 너무 과하게 다른 클래스에 의존한다면, 높은 응집도와 낮은 결합도의 코드 설계 사상에 부합하지 않으므로 클래스 분할을 고려해야 한다.
      • 클래스에 private 메서드가 너무 많은 경우 이 private 메서드를 새로운 클래스로 분리하고 더 많은 클래스에서 사용할 수 있도록 public 메서드로 설정하여 코드의 재사용성을 향상시켜야 한다.
      • 클래스의 이름을 비즈니스적으로 정확하게 지정하기 어렵거나 Manager, Context 처럼 일반적인 단어가 아니면 정의하기 어려울 경우 책임 정의가 충분히 명확하지 않음을 의미할 수 있다.
      • 응집도가 낮을 경우 이러한 속성과 해당 메서드를 분할하는 것을 고려할 수 있다.
    • 클래스를 최대한 작게 나누는 것이 항상 좋은 건 아니다. 클래스 분할로 인해 코드의 유지 보수성이 극히 낮아지는 결과를 초래할 수 있다. 설계 원칙을 적용하든 디자인 패턴을 적용하든 그 목표는 코드의 가독성, 확장성, 재사용성, 유지 보수성을 향상시키는 것이다.

     


     

    3.2 개방 폐쇄 원칙 (OCP)

    • open-closed principle
    • 확장할 때는 개방, 수정할 때는 폐쇄 원칙
    • 새로운 기능을 추가할 때 기존의 모듈, 클래스, 함수를 수정하기보다는 기존 코드를 기반으로 모듈, 클래스, 함수 등을 추가하는 방식으로 코드를 확장해야 한다는 뜻
    • 코드의 수정이 기존에 작성되었던 코드와 단위 테스트를 깨뜨리지 않는 한, 이는 개방 폐쇄 원칙을 위반하지 않는다고 판단해도 무방하다.
    • 새로운 기능을 추가할 때 소프트웨어 단위에 해당하는 모듈, 클래스, 메서드의 코드를 전혀 수정하지 않는 것은 불가능하다. 실행 가능한 프로그램을 빌드하려면 클래스를 생성하고 조합해야 하며, 일부 초기화 작업을 수행해야 한다. 따라서 수정을 아예 안 하는 것이 아니라 수정을 가능한 한 상위 수준의 코드에서 진행하고, 코드의 핵심 부분이나 복잡한 부분, 공통 코드나 기반 코드가 개방 폐쇄 원칙을 충족하는 방향으로 노력해야 한다.
    • 개방 폐쇄 원칙은 '공짜'가 아니다. 코드의 확장성은 종종 코드의 가독성을 떨어뜨린다.

     

    몇 가지 가이드라인

    • 코드를 작성할 때 현재 코드에 앞으로 요구 사항이 추가될 가능성이 있는지 판단하는 데 더 많은 시간을 할애한다.
      • 코드 구조를 미리 설계해 확장 가능하도록 구성하면, 추후 요구사항이 변경되더라도 유연하게 추가 가능하고, 코드의 수정을 최소화하면서 요구 사항을 만족시킬 수 있다.
    • 변경 가능한 부분과 변경할 수 없는 부분을 잘 식별해야 한다.
      • 변경되는 사항을 기존 코드와 분리할 수 있도록 변수 부분을 캡슐화하고, 상위 시스템에서 사용되는 변경되지 않을 추상 인터페이스를 제공해야 한다.

     

    확장 포인트

    • 개방 폐쇄 원칙 기반의 높은 확장성을 지원하는 코드를 작성하는 방법의 핵심은 확장 포인트를 미리 준비해두는 것이다.
    • 금융 시스템, 전자 상거래 시스템, 물류 시스템과 같은 비즈니스 시스템을 개발하는 경우 가능한한 많은 확장 포인트를 준비하기 위해 비즈니스에 대한 충분한 이해가 있어야 한다.
    • 하지만 추후 요구될 가능성이 거의 없는 사항들까지 미리 준비하는 것은 과도한 설계라 할 수 있다.
    • 단기간 내에 진행할 수 있는 확장, 코드 구조 변경에 미치는 영향이 비교적 큰 확장, 구현 비용이 많이 들지 않는 확장에 대해 확장 포인트를 미리 준비하는 것이 일반적으로 추천되는 방법이다.

     


     

    3.3 리스코프 치환 원칙 (LSP)

    • Liskov substitution principle
    • 하위 유형 또는 파생 클래스의 객체는 프로그램 내에서 상위 클래스가 나타나는 모든 상황에서 대체 가능하며, 프로그램이 원래 가지는 논리적인 동작이 변경되지 않으며 정확성도 유지된다.
    • 예를들어 네트워크 데이터를 전송하는 이하 예제를 보면, 아래와 같은 방식이라면 LSP를 만족한다. SecurityTransporter 객체는 Transporter가 나타나는 모든 위치에서 대체될 수 있다. 코드가 의도했던 논리적 동작이 변경되지 않으며 정확성도 그대로 유지된다.
    public class Transporter {
    	...    
    	public Response sendRequest(Request request) {
        	// ... 네트워크 데이터 전송하는 코드
        }
    }
    
    public class SecurityTransporter extends Transporter {
    	...
        @Override
        public Response sendRequest(Request request) {
        	if (...) {
            	request.addPayload("app-id", appId);
                request.addPayload("app-token", appToken);
            }
            return super.sendRequest(request);
        }
    }

     

    • 하지만 이하의 예제처럼 구현된다면, SecurityTransporter는 예외를 발생시킬 수 있다. 즉, 전체 프로그램의 논리적 동작이 변경되므로 LSP를 만족하지 않는다. 단, 다형성은 만족한다.
    public class Transporter {
    	...    
    	public Response sendRequest(Request request) {
        	// ... 네트워크 데이터 전송하는 코드
        }
    }
    
    public class SecurityTransporter extends Transporter {
    	...
        @Override
        public Response sendRequest(Request request) {
        	if (...) {
            	throw new NoAuthorizationRuntimeException(...);
            }
            request.addPayload("app-id", appId);
            request.addPayload("app-token", appToken);
            return super.sendRequest(request);
        }
    }

     

    • 상위 클래스의 단위 테스트를 통해 하위 클래스의 코드를 확인하는 방법도 있다. 만약 일부 단위 테스트가 실행되지 않으면 하위 클래스의 설계와 구현이 상위 클래스의 계약을 완전히 준수하지 않고 하위 클래스가 LSP를 위반할 수 있음을 의미한다.
    • ☆ LSP 위반과 관련된 유명한 예시가 있는데, 직사각형과 정사각형 관련 예시이다. 이하 코드를 보면 현실 세계에서 정사각형은 직사각형이 맞지만, 이하의 예시처럼 짠 코드에서 직사각형이 사용되던 부분에 정사각형 객체를 쓰면 안될 것임을 알 수 있다.
    class Rectangle {
    	...
        public void setWidth(int width) {
        	this.width = width;
        }
        
        public void setHeight(int height) {
        	this.height = height;
        }
    }
    
    class Square extends Rectangle {
    	...
        @Override
        public void setWidth(int width) {
        	this.width = width;
            this.height = width;	// 정사각형 이어야 하므로
        }
        
        @Override
        public void setHeight(int height) {
        	this.height = height;
            this.width = height;	// 정사각형 이어야 하므로
        }
    }

     

     

     

    LSP 위반하는 안티 패턴

    • 하위 클래스가 구현하려는 상위 클래스에서 선언한 기능을 위반하는 경우
      • 예 : 상위 클래스에서 금액 ASC로 주문을 정렬하게 구성되어 있는데, 하위 클래스에서 생성 날짜에 따라 주문을 정렬하도록 재정의하는 경우
    • 하위 클래스가 입력, 출력 및 예외에 대한 상위 클래스의 계약을 위반하는 경우
      • 예 : 상위 클래스에서 오류 발생 시 null, 값을 못 얻을 시 빈 컬렉션 반환하는데 하위 클래스에서는 둘 다 null을 반환하는 경우
      • 예 : 상위에서는 모든 정수 입력이 가능하지만, 하위에서는 음의 정수는 예외 발생시키는 경우
      • 예 : 상위에서는 ArgumentNullException만 발생시키는데, 하위에서는 다른 예외도 발생시킬 수 있는 경우
    • 하위 클래스가 상위 클래스의 주석에 나열된 특별 지침을 위반하는 경우

     


     

    3.4 인터페이스 분리 원칙 (ISP)

    • interface segregation principle
    • 클라이언트는 필요하지 않은 인터페이스를 사용하도록 강요되어서는 안 된다.
    • ☆ 즉, 어떠한 클라이언트가 자신이 안 쓸 메서드를 사용할 수 있으면 안된다고 보면 될 듯.
    • ☆ 책 113p에 나온 코드가 결국 ISP를 지킬 수 있도록 짜는 한 방법임.
    • 인터페이스
      • API나 기능의 집합 : 마이크로 서비스의 인터페이스나 클래스 라이브러리 기능을 설계할 때, 인터페이스 또는 기능의 일부가 호출자 중 일부에만 사용되거나 전혀 사용되지 않는다면 불필요한 항목을 강요하는 대신, 인터페이스나 기능에서 해당 부분을 분리하여 해당 호출자에게 별도로 제공해야 하며, 사용하지 않는 인터페이스나 기능에는 접근하지 못하게 해야 한다.
      • 단일 API 또는 기능 : API나 기능은 가능한 한 단순해야 하며 하나의 기능에 여러 다른 기능 논리를 구현하지 않아야 한다. 판별하는 것은 시노리오에 따라 달라질 수 있다.
      • 객체지향 프로그래밍의 인터페이스

     

    객체지향 프로그래밍에서의 인터페이스

    • Redis, Kafka의 설정 정보에 핫업데이트를 지원하려 한다. 그리고 MySql과 Redis의 설정 정보를 보여주는 기능도 지원하려고 한다.
    • [A] 이하의 코드는 ISP를 따르지 않는 방식으로 짠 코드이다.
    public interface Config {
    	void update();
        String outputInPlainText();
        Map<String, String> output();
    }
    
    public class RedisConfig implements Config { ... }
    public class KafkaConfig implements Config { ... }
    public class MysqlConfig implements Config { ... }

     

    • [B] 이하는 ISP를 만족시킬 수 있도록 짠 코드이다.
    public interface Updater {
    	void update();
    }
    
    public interface Viewer {
    	String outputInPlainText();
        Map<String, String> output();
    }
    
    public class RedisConfig implements Updater, Viewer { ... }
    public class KafkaConfig implements Updater { ... }
    public class MysqlConfig implements Viewer { ... }

     

    • 두 가지 설계에 따른 코드가 코드 크기, 구현 복잡성, 가독성이 모두 엇비슷하다고 가정할 때, [B]의 설계가 [A]보다 더 우수하다고 할 수 있다.
      • 더 유연하기 때문에 확장성이 높고 재사용하기 쉽다.
      • [A]는 쓸모없는 작업을 수행한다.

     


     

    3.5 의존 역전 원칙 (DIP)

    • dependency inversion principle
    • 상위 모듈은 하위 모듈에 의존하지 않아야 하며, 추상화에 의존해야만 한다. 또한 추상화가 세부 사항에 의존하는 것이 아니라, 세부 사항이 추상화에 의존해야 한다.
    • ☆ 상위, 하위 둘 모두 추상화에 의존해야 한다.
    • ☆ DIP 설명 (3.5.4) 이전에 3.5.1~3.5.3에서 IoC, DI, DI Container(IoC Container)에 대해 얘기한다. 헷갈리는 개념이라 같이 설명하는 것 같다.

     

    제어 반전 (IoC)

    • 예 : template method 패턴, 의존성 주입
    • 프레임워크를 통한 제어 반전의 경우, 프레임워크는 객체를 조합하고 전체 실행 흐름을 관리하기 위한 확장 가능한 코드 골격을 제공한다. 프로그래머가 프레임워크를 사용할 때는 제공되는 확장 포인트에 비즈니스 코드를 작성하는 것만으로 전체 프로그램이 실행된다.
      • 제어 : 프로그램의 실행 흐름을 제어하는 것
      • 역전(반전) : 역전이 되는 대상은 프레임워크를 사용하기 전에 직접 작성했던 전체 프로그램 흐름의 실행을 제어하는 코드
      • 프레임워크를 사용한 후 전체 프로그램의 실행 흐름은 프레임워크에 의해 제어되고, 흐름의 제어는 프로그래머에서 프레임워크로 역전된다.
    • 제어 반전은 특정한 기술이 아니라 일반적으로 프레임워크를 사용할 때 만나게 되는 보편적인 설계 사상에 가깝다.

     

    의존성 주입 (DI)

    • 제어 반전과 달리 의존성 주입은 특정한 프로그래밍 기술이다.
    • 의존성 주입 : new 예약어를 사용하여 클래스 내부에 종속되는 클래스의 객체를 생성하는 대신, 외부에서 종속 클래스의 객체를 생성한 후 생성자, 함수의 매개변수 등을 통해 클래스에 주입하는 것을 의미한다.

     

    의존성 주입 프레임워크

    • DI 도입 시 클래스 내부가 아니라 상위 코드로 옮겨진다는 차이점은 있으나, 여전히 객체 생성, 조합, 의존성 주입 등의 코드 논리는 프로그래머가 직접 작성해야만 한다.
    • 실제 소프트웨어 개발 시에는 수십, 수백 개의 클래스가 필요할 수 있으므로 DI가 매우 복잡해진다. 직접 코드를 작성하는 방식으로 진행한다면 오류가 발생하기 쉽고 개발 리소스도 많이 든다.
    • DI는 비즈니스 논리에 속하지 않기 때문에 프레임워크에 의해 자동으로 완성되는 코드 형태로 완전히 추상화될 수 있다. 이러한 프레임워크를 의존성 주입 프레임워크라고 한다.

     

    의존 역전 원칙 (DIP)

    • 상위 모듈은 하위 모듈에 의존하지 않아야 하며, 추상화에 의존해야만 한다. 또한 추상화가 세부 사항에 의존하는 것이 아니라, 세부 사항이 추상화에 의존해야 한다.
    • ☆ OCP와 DIP 둘 다 추상화가 핵심이다. 이 때 의존성 관점으로는 DIP가 해결되고, 확장 관점에서 OCP가 해결된다.
    •   '역전'이라는게 들어간건 로버트 마틴이 의존성 방향이 전통적인 절차형 프로그래밍과 반대 방향이라서 그렇게 단어를 넣은걸로 알고있다. 예를들어 스프링부트 프로젝트에서 대충 3계층짜리 layered architecture를 쓴다고 해보자. 이 때 repository를 interface로 뺄 경우 DIP가 적용되고, 이하처럼 의존성 방향이 역전된다. 또한 상위 모듈(Service)는 Repository interface라는 '추상화에 의존'하며, 세부 사항(MemoryRepository, JpaRepository)이 추상화(Repository interface)에 의존하여 위 DIP 설명에 맞게 작동된다.

     

     

    댓글