본문 바로가기
Software Engineering

소프트웨어 설계 기법: SOLID란?

by kmmguumnn 2019. 4. 1.

소프트웨어 설계의 5가지 원칙으로 SOLID라는 것이 있다. 흔히 객체지향 설계 기법으로 알려져 있지만, 꼭 객체지향 소프트웨어 설계에만 한정되는 것은 아니고 절차적 프로그래밍 기법으로도 적용할 수 있다.

 

설계 원칙을 만들고 공부하고 적용하는 이유는 무엇일까?

예측하지 못한 변경사항이 발생하더라도 유연하고 확장성이 있도록 시스템 구조를 설계하기 위해서다.

좋은 설계란, 기본적으로 시스템에 새로운 요구사항이나 변경이 있을 때 가능한 한 영향받는 부분을 줄이는 것이다. 즉 잘 설계한 시스템은 이해하기 쉽고, 바꾸기도 쉽고, 재사용하기도 쉽다.

 


 

1. 단일 책임 원칙 (SRP; Single Responsibility Principle)

객체는 단 하나의 책임만을 가져야 한다.

어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다.

(책임 = 변경 사유)

같은 이유로 변화하는 것끼리 묶고, 다른 이유로 변화하는 것끼리는 분리하라.

 

SRP에서 말하는 '책임'의 기본 단위는 객체를 의미한다. 즉, 객체는 단 하나의 책임만 가져야 한다는 뜻이다.

그렇다면 '책임'이란 무엇인가? '해야 하는 것', '할 수 있는 것', '해야 하는 것을 잘할 수 있는 것' 정도의 의미다. 객체에 책임을 할당할 때는 어떤 객체보다도 해당 작업을 잘할 수 있는 객체에 책임을 할당해야 한다. 또한 객체는 책임에 수반되는 모든 일을 (다른 객체가 아닌) 자신만이 수행할 수 있어야 한다.

책임을 많이 질수록, 클래스 내부에서 서로 다른 역할을 수행하는 코드끼리 강하게 결합될 가능성이 높아진다. 이렇게 되면 결국 기존 시스템에서 변경이 일어날 때 영향받는 부분이 많아지게 되고, 이는 곧 나쁜 설계이다.

 

public class Student {
    public void getCourses() { ... }
    public void addCourse(Course c) { ... }
    public void save() { ... }
    public Student load() { ... }
    public void printOnReportCard() { ... }
    public void printOnAttendanceBook() { ... }
}

위의 `Student` 클래스는 너무 많은 책임을 수행한다. 가장 잘할 수 있는 책임만 가지도록 해야 한다. 이 경우에는 `Student` 클래스가 수강 과목을 추가(addCourses)하고 조회(getCourses)하는 책임만 갖도록 하는 것이 SRP를 따르는 설계다. 만약 `Student` 클래스를 변경해야 한다면, 그 사유는 오직 '수강 과목 추가'나 '조회'와 관련된 것이어야만 한다.

클래스마다 변경해야 하는 사유가 오직 하나만 있도록 하는 것이 바람직하다. 그래야만 결합도는 낮추고 응집도를 높일 수 있으며, 시스템에서 한 부분을 변경해도 그것과 전혀 상관없는 다른 부분에는 영향을 미치지 않게 될 것이다.

 

결론은, 한 클래스에 너무 많은 책임을 부여하지 말고 단 하나의 책임만 수행하도록 하여, 변경 사유가 될 수 있는 것(=책임)을 하나로 만들어야 한다. 이를 책임 분리(≒ 관심사의 분리; separation of concerns)라 한다. 책임 분리를 통해 클래스들이 책임을 적절하게 분담하도록 변경하면, 어떤 변화가 생겼을 때 영향을 최소화 할 수 있다.

 

어찌보면 JSP 파일 안에 SQL을 넣지 않는 것, 결과를 계산하는 모듈 안에 HTML을 포함시키지 않는 것, 비즈니스 규칙은 데이터베이스 스키마를 모르도록 하는 것 등은 모두 SoC의 존재 이유라고 볼 수 있다. 관련 없는 모듈의 변화에 영향 받지 않도록 하기 위함이다(The Single Responsibility Principle by Robert C. Martin).

 

또 다른 측면으로는, 하나의 책임이 여러 개의 클래스들로 분산되어 있는 경우(→ 산탄총 수술)에도 SRP에 따라 설계를 변경해야 하는 경우도 있다. 책임이 분산되어 있는 여러 클래스들 하나하나를 모두 변경하지 않으면 프로그램이 정상적으로 동작하지 않고 에러가 발생한다. 이러한 횡단 관심 문제는 AOP를 통해 해결할 수 있다.

 


설계할 때의 기본 원칙은 응집도는 높이고 결합도는 낮추는 것으로 세우는 것이 좋다. 관련된 것들을 한 곳에 두어 응집도를 높이면, 자연스럽게 결합도는 낮아진다. 따라서 한 클래스로 하여금 단일 책임을 갖게 하는 SRP에 따라 설계를 하면, 응집도는 높아지고 결합도는 낮아진다.

 

 

 

 

2. 개방-폐쇄 원칙 (OCP; Open-Closed Principle)

기존의 코드를 변경하지 않으면서(closed) 기능을 추가(open)할 수 있어야 한다.

소프트웨어 엔티티가 확장에 대해서는 개방(open)되어야 하지만, 변경에 대해서는 폐쇄(closed)되어야 한다.

클래스 자체를 변경하지 않고도(closed) 그 클래스를 둘러싼 환경을 바꿀 수 있어야 한다.

 

OCP를 위반하지 않는 설계를 위해서는,

무엇이 변하는 것인지, 무엇이 변하지 않는 것인지를 구분해야 한다. 변해야 하는 것은 쉽게 변할 수 있게 하고, 변하지 않아야 할 것은 변하는 것에 의해 영향을 받지 않게 해야 한다.

 

성적표나 출석부에 학생을 출력하는 기능을 사용

 

위의 설계에서 SomeClient는 성적표나 출석부와 관련된 기능을 사용하고 있다. 만약 여기서 도서관 대여 명부와 관련된 기능을 추가하는 경우라면 어떻게 해야 할까? '도서관 대여 명부' 클래스를 만들어서 SomeClient가 이 기능을 이용하도록 할 수도 있겠으나, 이 방식은 OCP를 위반한다. 새로운 기능을 추가하기 위해 기존의 SomeClient 클래스를 수정해야 하기 때문이다. SomeClient 클래스는 변하지 않고, 도서관 대여 명부 클래스를 추가하는 부분만 변경되도록 해야 한다. 그 결과는 다음과 같다.

OCP를 만족하는 설계

 

SomeClient가 개별적인 클래스를 처리하도록 하지 않고, 인터페이스에서 구체적인 출랙 매체를 캡슐화해 처리하도록 한다.

 

클래스를 변경하지 않고도(closed) 그 클래스를 둘러싼 환경을 변경할 수 있는(open) 설계가 되어야 한다는 관점 역시 OCP라고 볼 수 있다.

 

 

 

3. 리스코프 치환 원칙 (LSP; Liskov Substitution Principle)

부모 클래스와 자식 클래스 사이의 행위가 일관성이 있어야 한다.

부모 클래스의 인스턴스를 자식 클래스의 인스턴스로 대체해도 프로그램의 의미는 변화되지 않는다.

서브타입은 언제나 자신의 기반 타입(base type)으로 교체할 수 있어야 한다.

 

LSP는 일반화 관계(상속 관계)에 대한 이야기이다.

 

자식 클래스는 최소한 자신의 부모 클래스에서 가능한 행위는 수행할 수 있어야 한다는 뜻이다. LSP를 만족하면, 부모 클래스의 인스턴스를 자식 클래스의 인스턴스로 대체해도 프로그램이 동일하게 수행된다. 이를 위해 부모 클래스와 자식 클래스의 행위는 일관적이어야 한다. 달리 말하면, 자식 클래스를 사용할 때에도 특별히 뭔가 변경할 필요 없이 마치 부모 클래스를 사용하는 것처럼 그대로 사용할 수 있어야 한다는 것이다.

 

자식 클래스가 부모 클래스 인스턴스의 행위를 일관성 있게 실행하려면 어떻게 해야 할까? 가장 직접적이고 직관적인 방법은, 부모 클래스에서 상속받은 메소드들이 자식 클래스에 오버라이드, 즉 재정의되지 않도록 하면 된다.

 

피터 코드(Peter Coad)의 상속 규칙이라는 것이 있는데, 상속의 오용을 막기 위해 상속을 사용해서는 안되는 5가지 규칙을 만들었다. 그 중 "자식 클래스가 부모 클래스의 책임을 무시하거나 재정의하지 않고 확장만 수행해야 한다"라는 원칙이 있는데, 이것이 바로 LSP를 만족시키는 방법이다(물론 유일한 방법은 아니다).

 

 

 

4. 인터페이스 분리 원칙 (ISP; Interface Segregation Principle)

인터페이스를 클라이언트에 특화되도록 분리시켜라.

클라이언트는 자신이 사용하지 않는 메소드에 의존 관계를 맺으면 안된다.

 

ISP는 클라이언트 자신이 이용하지 않는 기능에는 영향을 받지 않아야 한다는 것이다.

 

복합기의 클래스 다이어그램 (ISP 위반 사례)

 

복합기 기능을 제공하는 클래스는 비대(fat)해질 가능성이 크다. 비대한 클래스(fat class)란 가령 메소드를 수십, 수백 개 가지는 클래스를 떠올리면 되겠다. 이러한 클래스는 단지 보기 흉하다는 것 뿐만 아니라, 클라이언트가 이 비대한 클래스의 수많은 메소드들을 모두 사용하는 경우가 매우 드물다는 것이 가장 큰 문제다. 프린터 클라이언트는 복합기 클래스의 print()만 사용할 텐데, 아무 상관 없는 copy()나 fax()가 변경될 때 프린터 클라이언트도 뭔가 변경되어야만 하는 상황이 발생할 수 있다. '호출하지도 않을' 메소드에 생긴 변화에 의해서도 영향을 받는 것이다.

 

클라이언트와 무관하게 발생한 변화로 클라이언트 자신이 영향을 받지 않으려면, 범용(general)의 인터페이스보다는 특화된 인터페이스를 사용해야 한다. ISP란 즉, 인터페이스를 클라이언트에 특화되도록 분리시키라는 의미이다. 위의 복합기 클래스에 ISP를 적용한 예는 다음과 같다.

복합기 클래스에 ISP를 적용

 

클라이언트에게 딱 필요한 메소드만 있는 인터페이스를 제공한다. 인터페이스가 일종의 방화벽 역할을 수행하며, 클라이언트는 자신이 사용하지 않는 메소드에 생긴 변화로 인한 영향을 받지 않게 된다. 즉 필요하지 않은 메소드로부터 클라이언트를 보호하는 것이다.

 

또 다른 예시를 다이어그램으로 살펴보자.

격리되지 않은 등록 시스템

 

이 예시에서 Enrollment Report Generator는 prepareInvoice나 postPayment 같은 메소드를 사용하지 않을 것이 명백하다고 하자. 마찬가지로 Accounts Receivable은 getName이나 getDate는 사용하지 않는다. DIP를 적용하여 개선한다면 다음과 같을 것이다.

격리된 등록 시스템

 

 

 

5. 의존 역전 원칙 (DIP; Dependency Inversion Principle)

의존 관계를 맺을 때, 변화하기 쉬운 것 또는 자주 변화하는 것보다는 변화하기 어려운 것, 거의 변화가 없는 것에 의존하라.

자주 변경되는 구체 클래스 대신 인터페이스나 추상 클래스에 의존하라.

 

  • 변하기 어려운 것
    정책, 전략과 같은 어떤 큰 흐름이나 개념 같은 추상적인 것  
    => 추상클래스, 인터페이스
  • 변하기 쉬운 것
    구체적인 방식, 사물 등

객체지향 관점에서는, 변하기 어려운 추상적인 것들을 표현하는 수단으로 추상클래스인터페이스가 있다.

추상클래스와 인터페이스는 보통 자신에게서 유도된 구체적인 클래스보다 훨씬 덜 변한다. 그러므로 DIP를 만족하려면, 어떤 클래스가 도움을 받을 때 구체적인 클래스보다는 추상적인 것(인터페이스나 추상클래스)과 의존 관계를 맺도록 설계해야 한다. DIP를 만족하는 설계는 변화에 유연한 시스템이 된다.

 

  • 만약 어떤 클래스에서 상속받아야 한다면, 부모 클래스를 추상 클래스로 만든다.
  • 어떤 클래스의 참조(reference)를 가져야 한다면, 참조 대상이 되는 클래스를 추상 클래스로 만든다.
  • 어떤 메소드를 호출해야 한다면, 호출되는 메소드를 추상 메소드로 만든다.

DIP를 만족하면, 의존성 주입(Dependency Injection)을 통해 변화를 쉽게 수용할 수 있는 코드를 작성할 수 있다. 의존성 주입을 이용하면, 다음 코드와 같이 대상 객체를 변경하지 않고도 대상 객체의 외부 의존 객체를 바꿀 수 있다.

public class Kid {
    private Toy toy;	// Toy는 abstract. 즉 변하기 어려운 것이다.
    
    public void setToy(Toy toy) {
        this.toy = toy;
    }
    
    public void play() {
        System.out.println(toy.toString());
    }
}

 

만약 Kid 객체와 의존 관계를 맺고 있는 Toy 객체를 다른 객체로 바꾸고 싶다면(원래는 Robot 객체였는데, Lego 객체로 바꾼다고 하자), Toy를 상속받는 또 다른 객체를 생성한 뒤 Kid의 setToy()를 통해 의존 객체를 손쉽게 바꿀 수 있다.


그렇다면 Vector나 String은 모두 구체(concrete) 클래스니까, 사용하면 DIP를 위반하는 것일까? 아니다. 이렇게 생각해보자. 의존하면 안되는 것은 '자주 변경되는' 구체 클래스다. 앞으로 변하지 않을 구체 클래스에 의존하는 것은 일반적으로 안전하다. Vector나 String은 앞으로 변경되지 않을 가능성이 높은 것이니 의존해도 문제가 없다.

 

우리가 의존하면 안되는 것은, '자주 변경되는' 클래스다. 지금 막 개발 중인 구체 클래스나, 변할 가능성이 높은 비즈니스 로직을 담은 클래스 같은 것들 말이다. 이런 클래스의 인터페이스를 만든 다음, 이 인터페이스에 의존하게끔 하는 것이 바람직하다.

 


참고 자료

 

  • (UML과 GoF 디자인 패턴 핵심 10가지로 배우는) 자바 객체지향 디자인 패턴
  • UML 실전에서는 이것만 쓴다 : Java 프로그래머를 위한 UML

댓글