[디자인패턴] 3. SOLID 원칙

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

1.1 책임의 의미

객체 지향 설계관점에서는 SRP에서 말하는 책임의 기본단위는 객체를 말한다. 즉, 객체는 단 하나의 책임만을 가져야한다는 의미이다.

그렇다면 책임이란 무엇일까? 책임을 여러관점에서 해석할 수 있지만 보통 “해야 하는 것”이나 “할 수 있는 것”으로 간주된다. 객체에 책을 할달할 때는 어떤 객체보다도 작업을 잘할 수 있는 객체에 책임을 할당해야한다.

다시 책임을 한마디로 정리해보자면 객체가 해야하는 것을 잘하는 것이라고 할 수 있다.

예를 들어 학생 클래스가 수강 과목을 추가하거나 조회하고, 데이터베이스에 객체 정보를 저장하거나 데이터베이스에서 객체정보를 읽는 작업도 처리하고, 성적표와 출석부도 출력하는 일도 한다고 가정해보자. 그렇다면 아래와 같은 클래스로 코드를 작성할 수 있을 것이다.

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

위의 코드를 보면 학생 클래스가 너무나 많은 책임을 수행하고 있다. 학생 클래스에서 가장 잘할 수 있는 것을 생각해보면 수강 과목을 추가하거나 조회하는 것이다. 나머지 기능들은 학생 클래스가 아닌 다른 클래스에서 더 잘할 수 있을 여지가 많다. 그래서 학생 클래스는 수강과목을 조회하고, 추가하는 책임만을 수행하도록 하는 것이 바람직하다.

public class Student {
    
    public void getCourses(){}                  // 학생 클래스에서 잘할 수 있는 것
    public void addCourse(Course course){}      // 학생 클래스에서 잘할 수 있는 것
    // public void save(){}                     // 다른 클래스에서 잘할 수 있는 것
    // public Student load(){}                  // 다른 클래스에서 잘할 수 있는 것
    // public void printOnReportCard(){}        // 다른 클래스에서 잘할 수 있는 것
    // public void printOnAttendanceBook(){}    // 다른 클래스에서 잘할 수 있는 것
    
}

1.2 변경

SRP를 따르는 실효성 있는 설계가 되려면 책임을 좀 더 현실적인 개념으로 파악할 필요가 있다. 설계원칙을 공부하는 이유는 예측하지 못한 변경사항이 발생하더라도 유연하고 확장성이 있도록 시스템 구조를 설계하기 위해서이다. 좋은 설계란 기본적으로 시스템에 새로운 요구사항이나 변경이 있을 때 가능한 영향을 받는 부분을 줄여야 한다. 어떤 클래스가 잘 설계되었는지를 판단하려면 언제 변경되어야 하는지를 물어보는 것이 좋다.

그럼 이전에 살펴본 학생클래스는 언제 변경되어야할까? 이 물음을 답하기 위해서는 학생 클래스의 변경 이유를 찾아보는 것이 좋다.

위의 질문이 학생 클래스를 변경해야하는 이유가 될 수 있다.

변경의 영향

위의 그림의 빨간 선처럼 또한 책임이 많아질수록 클래스 내부에서 서로 다른 역할을 수행하는 코드끼리 강하게 결합될 가능성이 높다. 예를 들어 현재 수강과목을 조회하는 코드와 데이터베이스의 학생정보를 가져오는 코드가 연결될 수 도 있고, 학생이 수강 과목을 추가하는 코드와 데이터베이스 학생 정보를 갱신하는 코드가 서로 연결될 수도 있다.

1.3 책임 분리

학생 클래스는 여러 책임을 수행하므로 학생 클래스의 도움을 필요로 하느느 코드도 많을 수밖에 없다. 학생 수강 과목 목록을 사용하는 코드, 신입생 정보를 데이터베이스에 기록하는 코드, 성적표와 출석부를 필요로 하는 코드 등등에서 사용될 것이다. 만약 학생 클래스에서 변경사항이 생기면 학생 클래스를 직접적이든 간접적이든 사용하는 모든 코드를 다시 테스트해야만 한다. 이렇게 모든 코드를 테스트하는 문제를 해결하려면 한 클래스에 너무 많은 책임을 부여하지 말고 단 하나의 책임만을 수행하도록 하여 변경 사유가 될 수 있는 것을 하나로 만들어야 한다. 이것을 책임 분리라고 한다.

책임분리

위의 그림의 초록, 빨강, 노랑, 파랑 선처럼 각각의 기능별로 책임을 부여하게 되면 테스트를 해야되는 코드의 양을 줄일 수 있다.

학생 클래스의 경우 변경 사유가 될 수 있는 것은 학생의 고유 정보, 데이터베이스 스키마, 출력 형식의 변화 3가지 이다. 따라서 학생 클래스는 학생 고유의 역할을 수행하게 변경하고, 학생 클래스의 인스턴스를 데이터베이스에 저장하거나 읽어들이는 역할을 담당하는 학생 DAO 클래스, 출석부와 성적표의 출력을 담당하는 성적표 클래스와 출석부 클래스로 분리하는 편이 좋다. 이렇게 클래스들이 책임을 적절하게 분담하도록 변경하면 어떤 변화가 생겼을 때 영향을 최소화할 수 있다.

아래의 그림은 위의 설명을 통해 개선한 것이다.

개선된 디자인

1.4 산탄총 수술

지금까지는 한 클래스가 여러가지 책임을 가진 상황을 살펴봤다. 그런데 하나의 책임이 여러 개의 클래스로 분산되어 있는 경우에도 단일 책임에 입각해 설계를 변경해야 하는 경우도 있다. 이것을 산탄총 수술이라는 용어를 사용한다. 산탄총 수술은 산탄이 사방으로 퍼져날라가 동물에게 맞았을 때 수술해야하는 부위가 많아지는 것 처럼 어떤 변경이 있을 때 하나가 아닌 여러 클래스를 변경해야한다는 점에서 착안되었다.

여러 개의 클래스에 책임이 분산된 경우 클래스 하나하나를 모두 변경하지 않으면 프로그램이 정상적으로 동작하지 않고 에러가 발생되기 쉽다.

하나의 책임이 여러 클래스로 분리되어 있는 예는 로깅, 보안, 트랜잭션과 같은 횡단관심으로 분류할 수 있는 기능이 대표적이다. 횡단관심에 속하는 기능은 대부분 시스템 핵심 기능안에 포함되는 부가기능이다. 아래의 그림과 같이 부가 기능에 변경사항이 발생하면 해당 부가기능을 실행하는 모든 핵심 기능에도 변경사항이 적용되어야 한다.

횡단 관심

만약 시스템에서 실행하는 특정 메서드들의 실행 로그를 데이터베이스에 저장한다고 생각해보자. 분명 메서드에 로그기능을 실행하는 코드가 삽입되어 있을 것이다. 로그를 데이터베이스에 저장하지 않고 파일로 저장하는 경우에도 우선 로그 기능이 삽입된 메서드를 찾고 삽입된 로그 코드를 적절하게 변경해야한다.

이러한 방식은 산탄총 수술하는 것과 같이 변경될 곳을 모두 찾아 수정해야되는 작업이다. 이러한 문제를 해결하기 위한 방법은 부가 기능을 별개의 클래스로 분리해 책임을 담당하게 하는 것이다. 즉, 여러 곳에 흩어진 공통 책임을 한 곳에 모으면서 응집도를 높인다.

하지만 이런 독립 클래스를 구현하더라도 구현된 기능을 호출하고, 사용하는 코드는 해당 기능을 사용하는 코드 어딘가에 포함될 수 밖에 없다.

1.5 관심지향 프로그래밍과 횡단 관심 문제

위의 문제점을 해결하는 방법은 관심지향(관점지향) 프로그래밍(Aspect-Oriented-Programming)이다. AOP는 횡단 관심을 수행하는 코드를 Aspect라는 특별한 객체로 모듈화하고 Weaving이라는 작업을 통해 모듈화한 코드를 핵심 기능에 끼워 넣을 수 있다. 이를 통해 기존의 코드를 전혀 변경하지 않고도 시스템 핵심 기능에서 필요한 부가기능을 효과적으로 이용할 수 있다. 만약 횡단관심에 변경이 생긴다면 해당 Aspect만 수정하면된다.

이것은 스프링을 공부하면서 AOP를 적용해봤을 때 봤던 내용들이다.

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

개방-폐쇠 원칙은 기존의 코드를 변경하지 않으면서 기능을 추가할 수 있도록 설계되어야 한다는 뜻이다. 이전에 살펴 본 학생 클래스에서 학생의 성적이나 출석기록을 출력하는 기능을 가지고 예를 들어보자.

성적표, 출석부 출력기능 사용

위의 그림에서 처럼 성적표와 출석부를 출력하는 기능을 클라이언트 클래스가 사용하도록 할 수 있다. 그러나 위의 방식은 OCP를 위반한다. 만약 새로운 기능인 도서 대여기록 출력을 추가하면 클라이언트 클래스를 다시 수정해야 하기 때문이다.

OCP를 위반하지 않는 설계를 할 때 가장 중요한 것은 무엇이 변하는 것인지, 무엇이 변하지 않는 것인지 구분해야한다는 점이다. 변해야하는 것은 쉽게 변할 수 있어야하고, 변하지 않아야 할 것은 변하는 것에 영향을 받지 않아야한다는 것이다.

그렇다면 어떻게 설계를 해야할까? 아래의 그림을 통해 OCP에 위배되지 않는 설계를 알아보자.

OCP를 만족하는 설계

새로운 출력 매체를 표현하는 클래스를 추가하게 하고, 이러한 변경이 있더라도 기존의 클래스(클라이언트)가 영향을 받지 않게 클라이언트 클래스가 개별적인 클래스를 처리하지 않도록 하고, 인터페이스에서 구체적인 출력 매체를 캡슐화해 처리하도록 한다.

OCP를 보는 또 하나의 관점은 클래스를 변경하지 않고도 대상 클래스의 환경을 변경할 수 있는 설계가 되어야 한다는 것이다. 이것은 특히 단위 테스트를 수행할 때 매우 중요하다.

예를 들어 테스트 대상이 되는 기능이 네트워크를 통해 웹 서비스를 사용한다고 가정해보자. 이런 경우 데이터베이스나 웹서버를 설치해야만 테스트를 진행할 수 있다. 하지만 단위 테스트의 가장 중요한 점은 빠른 시간에 자주 테스트를 해야한다는 것인데 시간이 많이 소요되는 데이터베이스나 웹서버 설치는 테스트를 회피하게 되는 요인이 된다. 따라서 테스트 대상 기능이 사용하는 실제 외부의 서비스를 흉내내는 가짜 객체를 만들어 테스트의 효율성을 높일 필요성이 있다.

실제 서비스에서 사용할 객체를 그대로 테스트할 때 위험이 따르는 경우도 존재한다. 예를 들면 어떤 기능이 데이터베이스를 사용할 때 테스트를 위해 데이터베이스에 삭제를 포함한 여러 작업을 실행한다고 가정해보자. 이 경우 실제 데이터베이스에 변경이 생기게되는 위험이 발생한다. 따라서 테스트를 위해 실제 데이터베이스 기능을 데체하는 가짜 객체를 만들 필요가 있다.

또한 테스트 대상 기능이 특정 상태에 의존해서 동작할 수 있다는 점도 고려해야한다. 예를 들어 비행기 관제 기능 중 동시에 착륙하려는 비행기 수가 1,000대인 경우 테스트를 한다면 어떻게 해야할까? 실제 1,000대의 비행기를 착륙시킬 수는 없기 때문에 1,000대의 비행기를 대신할 모의 객체를 이용하는 것이 좋다.

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

LSP는 일반화 관계에 대한 내용이며 자식 클래스는 최소한 자신의 부모 클래스에서 가능한 행위는 수행할 수 있어야 한다는 뜻이다. LSP를 만족하면 프로그램에서 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스로 대체해도 프로그램의 의미는 변화하지 않는다. 이를 위해서는 부모 클래스와 자식 클래스 사이는 행위가 일관되어야 한다.

위에서 설명한 LSP를 이해하기 위해서는 일반화 관계를 다시 알아볼 필요가 있다. 일반화 관계는 “A is a kind of B”라는 관계가 반드시 성립되어야만 하는데 예를 들어 포유류와 원숭이 관계를 생각해보자. 원숭이는 포유류다. 그렇기 때문에 “원숭이 is a kind of 포유류”라는 관계가 성립될 수 있다. 따라서 부모 클래스는 포유류, 자식 클래스는 원숭이로 설정할 수가 있다.

포유류 <------------- 원숭이

객체지향 관점에서 일반화 관계는 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 별다른 변경 없이 사용할 수 있을 때 성립한다.

그렇다면 다시 포유류에 대해 생각해보자.

위의 설명에 포유류라는 단어 대신 원숭이로 대체해보면 문제가 없다. 따라서 원숭이와 포유류는 행위에 일관성이 있다고 볼 수 있다. 하지만 오리너구리를 대입해보면 말이 되지 않는다. 왜냐면 오리너구리는 알을 낳아 번식하기 때문이다. 하지만 나머지는 해당한다.

객체 지향 관점에서는 “A is a kind of B”를 만족해야하는데 오리 너구리의 경우에는 만족하지 못하는 설명이 되었다. LSP를 만족하기 위해서는 보무 클래스의 인스턴스를 자식 클래스의 인스턴스로 대신할 수 있어야 한다.

자식 클래스가 부모 클래스 인스턴스의 행위를 일관성있게 실행하려면 어떻게 해야할까?

이를 위해서는 부모 클래스의 행위를 더 명확하게 정의할 수 있는 수단이 필요하다. 어떤 클래스의 행위를 일종의 방정식 형태로 기술해 자식 클래스의 인스턴스가 이 방정식을 만족하는지 점검해본다. 만약 만족한다면 자식 클래스가 부모 클래스의 행위를 일관성있게 실행한다고 말할 수 있다.

이제 코드를 통해 LSP에 대해 좀더 알아보자.

public class Bag {
    private int price;
    
    public void setPrice(int price) {
        this.price = price;
    }
    
    public int getPrice() {
        return price;
    }
}

Bag클래스는 가격을 설정하고, 가격을 조회하는 기능을 가지고 있다. 따라서 Bag클래스의 행위는 다음과 같이 표현할 수 있다.

가격은 설정된 가격 그대로 조회된다.

이를 좀더 형식적으로 작성하면 다음과 같다.

// 모든 Bag 객체 b와 모든 정수 값 p에 대해
[b.setPrice(p)].getPrice() == p;

여기서 [객체.메서드(인자 리스트)]는 메서드가 실행된 후의 b객체를 나타낸다. 이런 경우 Bag클래스의 행위를 손상하지 않고 일관성 있게 실행하는 클래스를 만드려면 어떻게 해야할까?

가장 직접적이고 직관적인 방법은 슈퍼클래스에서 상속받은 메서드들이 서브클래스에 오버라이드, 즉 재정의되지 않도록 하면 된다. 아래의 코드는 Bag클래스를 상속받아 가방 가격을 할인 받을 수 있게 하는 DiscountedBag클래스를 구현한 것이다.

public class DiscountedBag extends Bag {
    private double discountedRate = 0;
    
    public void setDiscountedRate(double discountedRate) {
        this.discountedRate = discountedRate;
    }
    
    public void applyDiscount(int price) {
        super.setPrice(price - (int) (discountedRate * price));
    }
}

DiscountedBag클래스는 할인율을 설정해서 할인된 가격을 계산하는 기능이 추가되었다. 기존의 Bag클래스에 있던 가격을 설정하고 조회하는 기능은 변경 없이 그대로 상속받았다.

// Bag 클래스
Bag bag1 = new Bag();
Bag bag2 = new Bag();

bag1.setPrice(50000);
System.out.println(bag1.getPrice());

bag2.setPrice(bag1.getPrice());
System.out.println(bag2.getPrice());
// DistcountedBag 클래스
DiscountedBag bag3 = new DiscountedBag();
DiscountedBag bag4 = new DiscountedBag();

bag3.setPrice(50000);
System.out.println(bag3.getPrice());

bag4.setPrice(bag3.getPrice());
System.out.println(bag4.getPrice());

위의 두 코드는 Bag클래스 객체와 DiscountedBag클래스 객체를 사용해 작성되었다. 위의 두 코드의 실행결과는 동일하다. Bag클래스의 setPrice()getPrice()메서드가 DiscountedBag클래스에서 재정의되지 않았기 때문이다. 즉, Bag클래스와 DiscountedBag클래스 간의 상속관계가 LSP를 위반하지 않았다는 것을 의미한다.

그렇다면 만약 setPrice()메서드를 아래의 코드와 같이 오버라이드하면 위의 코드처럼 동일한 결과를 가져올까? 결론부터 말하자면 그렇지 않다. 수정된 DiscountedBag 클래스가 일반적으로 방정식 [b.setPrice(p)].getPrice() == p를 만족하지 못하기 때문이다. 이유는 할인율이 0이 아닐 때는 setPrice()메서드를 실행한 후 DiscountedBag객체의 price속성 값이 p에서 discountRate * price을 차감한 결과가 되며 이는 p와 같지 않기 때문이다.

즉, 아래의 코드는 Bag클래스의 setPrice()를 재정의한 DiscountedBag클래스의 구현은 Bag클래스의 행위와 일관되지 않으므로 LSP에 만족하지 않는다.

public class DiscountedBag extends Bag {
    private double discountedRate;
    
    public void setDiscountedRate(double discountedRate) {
        this.discountedRate = discountedRate;
    }
    
    // 메서드 오버라이드
    public void setPrice(int price) {
        super.setPrice(price - (int) (discountedRate * price));
    }
}

이전에 피터코드의 상속규칙에서 “서브 클래스가 슈퍼 클래스의 책임을 무시하거나 재정의하지 않고 확장만 수행한다.”라는 규칙을 배운적이 있다. 이는 슈퍼 클래스의 메서드를 오버라이드하지 않는 것과 같은 의미이다.

즉, 피터코드의 상속규칙을 지키는 것은 LSP를 만족시키는 하나의 방법이다.

LSP를 만족시키는 가장 간단한 방법은 재정의를 하지 않는 것이다.

4. 의존역전 원칙(Dependency Inversion Principle)

객체 사이에 서로 도움을 주고 받으면 의존관계가 발생한다. DIP는 그러한 의존관계를 맺을 때의 가이드라인에 해당한다.

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

예를 들어보자. 아이가 장난감을 가지고 노는 경우에 어떨 때는 자동차를 어떨 때는 레고를 가지고 놀게 될 것이다. 구체적인 장난감은 변하기 쉬운 것이지만 장난감을 가지고 노는 것은 변하기 어려운 것이다. 아래의 그림은 장난감 클래스에 DIP를 적용한 것이다.

DIP

객체지향 관점에서는 이와 같이 변하기 어려운 추상적인 것들을 표현하는 수단으로 추상클래스와 인터페이스가 있다. DIP를 만족하려면 어떤 클래스가 도움을 받을 때 구체적인 클래스보다는 인터페이스나 추상클래스와 의존관계를 맺도록 설계해야한다. DIP를 만족하는 설계는 변화에 유연한 시스템이 된다.

DIP를 만족하면 의존성 주입이라는 기술로 변화를 쉽게 수용할 수 있는 코드를 작성할 수 있다. 의존성 주입이란 말 그대로 클래스 외부에서 의존되는 것을 대상 객체의 인스턴스 변수에 주입하는 기술이다. 의존성 주입을 이용하면 아래의 코드와 같이 대상 객체를 변경하지 않고도 외부에서 대상 객체의 외부 의존 객체를 바꿀수 있다.

public class Kid {
    private Toy toy;
    
    public void setToy(Toy toy) {
        this.toy = toy;
    }
    
    public void play() {
        System.out.println(toy.toString());
    }
}
public class Robot extends Toy {
    public String toString() {
        return "Robot";
    }
}

public class Lego extends Toy {
    public String toString() {
        return "Lego";
    }
}

public class Main {
    public static void main(String[] args){
        Kid kid = new Kid();
        
        Toy robot = new Robot();
        kid.setToy(robot);
        kid.play();
        
        // 아이의 마음이 바뀌어 레고를 가지고 놀게 되었다.
        Toy lego = new Lego();
        kid.setToy(lego);
        kid.play();
        // 기존의 코드에 영향을 받지 않고 장난감을 바꿀수 있다.
    }
}

5. 인터페이스 분리 원칙(Interface Segregation Principle)

예를 들어 설명해보자면 아래의 그림과 같이 프린터, 팩스, 복사기 기능이 모두 포함된 복합기가 있다고 가정해보자.

복합기의 클래스 다이어그램

위의 그림처럼 복합기 기능을 제공하는 클래스는 매우 비대해질 가능성이 크다. 이 비대한 클래스의 모든 기능을 클라이언트가 동시에 사용하는 경우는 거의 없다. 클라이언트의 필요에 따라 프린터 기능만을 또는 팩스 기능만 그리고 복사기 기능만 사용할 수도 있다. 따라서 프린터 기능만을 사용하는 클라이언트에서는 팩스 기능의 변경으로 인해 발생하는 문제에 영향을 받지 않아야한다. 클라이언트와 무관하게 발생한 변화로 클라이언트 자신이 영향을 받지 않으려면 범용의 인터페이스보다는 클라이언트에 특화된 인터페이스만을 사용해야만한다. 즉, ISP는 인터페이스를 클라이언트에 특화되도록 분리시키라는 설계 원칙이다.

그렇다면 ISP원칙에 위배되지 않게 복합기를 설계하면 다음 그림과 같은 모습이 된다.

복합기 클래스에 ISP를 적용한 예

복합기를 사용하는 객체들마다 자신이 관심을 갖는 메서드만있는 인터페이스를 제공받도록 설계했다. 이렇게 설계하면 인터페이스가 일종의 방화벽 역할을 수행하여 클라이언트는 자신이 사용하지 않는 메서드에 변화로 인한 영향을 받지 않게 된다.

SRP와 ISP 사이의 관계를 생각해보자. 어떤 클래스가 단일 책임을 수행하지 않고, 여러 책임을 수행하게 되면 당연히 방대한 메서드를 가진 비대한 클래스가 될 가능성이 높아지고, 비대한 인터페이스가 제공될 것이다. 이렇게 비대한 클래스를 SRP에 따라 단일 책임을 갖는 여러 클래스로 분할하고, 각자의 인터페이스를 제공한다면 ISP도 만족할 수 있다.

그렇다면 ISP는 SRP를 만족하면 성립할까? 아니다 반드시 그렇다고는 볼 수 없다.

만약 게시판의 여러기능을 구현한 메서드를 제공하는 클래스가 있다면 이 클래스는 글쓰기, 읽기, 수정, 삭제를 위한 메서드가 제공될 것이다. 그러나 클라이언트에 따라서는 게시판의 이러한 기능 중 일부분만을 사용할 수도 있다. 예를 들어 일반 사용자는 게시판 삭제기능이 없지만 관리자는 삭제를 할 수 있다. 이런 경우 게시판 클래스는 게시판에 관련된 책임을 수행하므로 SRP를 만족한다. 그러나 이 클래스의 모든 메서드가 들어있는 인터페이스가 클라이언트에 상관없이 사용된다면 ISP에 위배되기 때문이다.