logo

3부

<br/>

서론. SOLID

3부는 SOLID 원칙을 다룬다.

<br/>

7장. Single Responsibility Principle (S, SRP, 단일책임원칙)

단일 책임원칙은 의미가 가장 제대로 전달되지 못한 원칙중 하나이다.

프로그래머가 이 원칙을 듣는다면,
모든 모듈이 단 하나의 일만 해야 한다는 의미로 받아들이기 쉽다.

역사적으로 SRP는 아래와 같이 기술되어 왔다

단일 모듈은 변경의 이유가 , 오직 하나 뿐이어야아한다.

키워드는 변경의 이유이다.
그럼 변경의 이유는 무엇일까?
소프트웨어의 시스템은 이해관계자의 만족시키기 위해 변경한다.
따라서 변경의 이유는 이해관계자를 가리킨다.

하나의 모듈은 하나의 사용자, 이해관계자 에대해서만 책임져야한다.

아쉽게도 사용자, 이해관계자는 한명을 지칭하지만, 이는 한명이 아닌 집단이다.
그리고 이 집단을 액터라 정의하자.

SRP의 최종 버전은 다음과 같다

하나의 모듈은, 하나의 액터에 대해서만 책임져야한다.

그렇다면 모듈이란 무엇일까?
모듈은 함수와 데이터 구조로 응집된 집합이다.

SRP를 이해하기 위해, SRP가 위배된 상황을 살펴보자.

SRP를 다시 보자
하나의 모듈은, 하나의 액터에 대해서만 책임져야한다.

징후1 : 우발적 중복

Employee 클래스가 있다고 가정하자.

class Employee{
 + calculatePay()
 + reportHours()
 + save()
 - regularHours()
}

Employee 클래스는 서비스에서 다양한 액터가 접근한다.

  • 회계팀은 월급을 계산하는데, calculatePay() 메소드를 호출한다.
  • 인사팀은 업무시간을 리포트 받는데, reportHours() 메소드를 호출한다.
  • 개발팀에서는 새로운 직원을 저장하는데, save() 메소드를 호출한다.

Employee 클래스에 접근하는
회계팀, 인사팀, 개발팀 3가지의 액터를 현재 확인 할 수 있다.
하나의 모듈에, 3개의 액터에 대한 책임을 가지고 있다.

문제가 있어보이는가?
SRP를 위배하는 케이스를 살펴보자

class Employee{
    public void calculatePay(){
        //..
        regularHours()
        //..
    }
    
    public void reportHours(){
     //..
        regularHours()
     //..
    }
    
    private void reqularHours(){
    }
}

calculatePay()는 reqularHours() 메소드를 호출한다.
reportHours()는 reqularHours() 메소드를 호출한다.

어느날 우리의 액터중에 하나인 회계팀의 요구사항이 변경하였다.
그 요구사항을 파악하니 caculatePay()가 호출하는 reqularHours() 메소드 내부를 변경하는 일이다.
개발자는 reqularHours()를 수정하였고, 정상적으로 배포되었다. 회계팀은 만족하였다

다음날 인사팀은 서비스의 기능을 사용하였고 reportHours() 메소드를 호출하였다.
그리고 잘못된 데이터를 응답받는다.

하나의 액터에 의한 변경이, 다른 액터의 문제를 발생시켰다.
이러한 문제는 서로 다른 액터가 의존하는 코드를 너무 가까이 배치했기 때문에 발생하는것이다.
두액터가 하나의 모듈에 의존하여, 하나의 모듈이 2개의 책임을 가지는것이다.

징후2 : 병합

두 액터에게서 요구사항의 변경이 발생했다.
서로 다른 2개발자는 각자의 컴퓨터에서,
브랜치를 만들고 Employee 클래스를 요구사항에 맞게 수정한다.

그리고 이들의 소스코드는 서로 충돌한다.
두액터가 하나의 모듈에 의존하여, 하나의 모듈이 2개의 책임을 가졌기 때문이다.

하나의 모듈이, 하나의 책임을 가졌다면 두 코드의 충돌은 발생하지 않을것이다.

해결책

위의 문제들의 가장 확실한 해결책은, 데이터와 메소드를 분리하는 것이다

PayCaculator --> EmployData
HourReporter --> EmployData
EmployeeSaver --> EmployData

class PayCaculator{
    + caculatePay()
}

class HourReporter{
    + reportHours()
}

class EmployeeSaver{
    + save()
}

class EmployData{
}

Employee의 데이터와 메소드를 분리하였으며, 각자의 책임을 가지는 클래스를 정의하였다.
따라서 액터의 요구사항의 변경에 분리된 모듈만 변경될 것이다.

하지만 이 해결책은 세 가지 클래스에 대한 인스턴스를 참조 해야하는 단점이있다.

이런 해결책은 Facade 패턴을 통해서도 해결 할 수 있다.

EmployeeFacade --> PayCaculator
EmployeeFacade --> HourReporter
EmployeeFacade --> EmployeeSaver

PayCaculator --> EmployData
HourReporter --> EmployData
EmployeeSaver --> EmployData

class EmployeeFacade{
    + caculatePay()
    + reportHours()
    + save()
}
class PayCaculator{
    + caculatePay()
}

class HourReporter{
    + reportHours()
}

class EmployeeSaver{
    + save()
}

class EmployData{
}

EmployeeFacade에는 코드는 거의없다.
EmployeeFacade에 함수를 호출하면, 이 클래스는 분리된 3가지 모듈에 맞추어 함수호출하여 책임을 위임한다.
클라이언트 코드 입장에서는 EmployeeFacade 클래스만 알고있으면 될 것이다.

어떤 개발자는, 가장 중요한 업무 규칙은 데이터와 가깝게 배치하는 방식을 선호한다.

나도 이방식을 선호하는것 같다.

Employee --> HourReporter
Employee --> EmployeeSaver

class Employee{
    - employeeData
   + caculatePay()
   + reportHours()
   + save()
}

class HourReporter{
    + reportHours()
}

class EmployeeSaver{
    + save()
}

<br/>

8장. Open Closed Principle (O, OCP, 개방-폐쇄 원칙)

개방폐쇄 원칙은 다음과 같다.

소프트웨어 개체는 확장에는 열려있어야 하며, 변경에는 닫혀있어야한다.

좀더 풀어보자.
소프트웨어의 개체의 행위는 확장할 수 있어야하지만, 이때 개체를 변경해서는 안된다

만약 요구사항을 살짝 확장하는데, 소프트웨어를 엄청나게 수정해야한다면..?

OCP를 클래스와 모듈을 설계 할때 도움되는 원칙이라고 알고있지만,
아키텍처 컴포넌트 수준에서 OCP를 고려할떄 훨씬 중요한 의미를 가진다.

사고 실험

재무재표를 웹페이지로 보여주는 시스템이 있다고 가정하자
그리고 이해 관계자가 이 데이터를 이제는, 흑백 프린터로 출력하고 싶다고 해보자.

어떤가? 당연히 새로운 코드를 작성해야한다.

소프트웨어의 아키텍처가 훌륭하다면, 변경되는 코드의 양을 가능한한 최소화 될 것이다.
이상적인 변경량은 0이다.

SRP를 준수하여, 서로 다른 목적으로 변경되는 요소를 적절하게 분리하고,
요소사이에 의존성을 체계화함으로써(DIP) 변경량을 최소화 하는것이다.

우선 보고서 생성은 2가지로 분리 할 수 있다.

  • 하나는 보고서 데이터를 계산하는 책임과
  • 다른 하나느 보고서 데이터를 보여주는 책임이다.

package  ControllerComponent{
  Viwer --> 재무재표Contoller
  interface Viwer
}

package ViewComponent{
 웹페이지--|> Viwer
 프린터 --|> Viwer
}

package InteractorComponent{
  재무재표Contoller -> 재무재표Service인터페이스
  재무재표Service인터페이스 <|-- 재무재표Service
  재무재표Service -> 재무재표저장소인터페이스
}

package DatabaeComponent{
 재무재표저장소  --|> 재무재표저장소인터페이스
 interface 재무재표저장소인터페이스
}

중요한점을 살펴보자.
우선 A컴포넌트에서, 발생한 변경으로부터 B 컴포넌트를 보호하려면,
반드시 A컴포넌트가 B컴포넌트에 의존해야한다.

위 예제를 보자.
View의 변경으로 인해, Contoller를 보호하였다.
Interactor는 모든 변경으로부터 보호하였다.

Intercator는 OCP를 가장 잘 준수하는곳에 위치한다.
어느 컴포넌트도 Interactor를 변경 시킬 수는 없다.

왜 Interactor만 이처럼 특별한 위치에 존재하는거?

Interactor는 어플리케이션 가장 높은 수준에 정책을 가진다.
어플리케이션 비즈니스의 핵심 로직이며, 가장 변경을 하지 않아야하는 곳이다.

이렇게 우리는, 변경으로부터 컴포넌트를 보호하였다.

그럼 어떻게 보호하였는가

방향성 제어

우리는 의존성을 역전시켰다.
Viewer, 재무재표저장소인터페이스 가 보이는가?
인터페이스를 통해 의존성을 역전시켰다.
더불어 확장에 열려있는 구조 또한 완성하였다.

컴포넌트의 변경을 보호하기위해서,
의존성역전을 통해 방향성을 바꾼것이다.

정보 은닉

재무재표Service인터페이스를 살펴보자.
재무재표Contoller재무재표Service인터페이스에서만 접근함으로써
서비스의 내부 구현을 은닉 할 수 있다.
만약 이 인터페이스가 없었다면, 서비스 구현체 대해 의존하게되며, 추이종속성을 가지게 된다.
추이 종속성은, 자신이 직접 사용하지 않는 요소에는 절대로 의존해서는 안된다는 소프트웨어의 원칙을 위배하게된다.

OCP?

OCP는 시스템의 아키텍처를 떠밪치는 원동력이다.
OCP의 목표는 시스템을 확장하기 쉬운 동시에, 변경으로 인해 시스템을 너무많은 영향을 받지 않도록 하는데 있다.

<br/>

9장. Liskov Substitution Principle (L, LSP, 리스코프 치환 원칙)

리스코프 치환 원칙은 다음을 정의한다

상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.

LSP를 준수하는

Billing -> Licence
PersonalLicence --|> Licence
BusinessLicence --|> Licence

class Billing

interface Licence{
    + calculateFee()
}

위 PersonalLicence, BusinessLicence는 Licence를 구현하여,
각자의 라이센스에 맞게, 요금을 계산하는 알고리즘을 구현한다.

이는, LSP를 준수한다.

LSP를 위반하는

LSP를 위반하는 대표적은 케이스가 정사각형/직사각형 문제이다.

User -> Rectangle
Rectangle <|-- Square 

class Rectangle{
    + setH()
    + setW()
}

class Square{
    + setSide
}

위 설계에서 Square는 Rectangle의 하위탑으로 적절하지 않다.
Rectangle은 높이, 너비와 독립적인데 비해, Square는 그렇지 않기 때문이다.

Rectangle r = new Sqaure(1)
r.setW(5)
r.setH(2)
assert(r.area(), 10);

어떤가? Square가 Rectangle을 대신 할 수는 없을 것이다.
그러면 우리는 어떤 코드를 짜야할까,
Rectangle의 내부 코드에는 아마도, 이런 코드가 있다면 assert는 fail되지 않을 것이다.

class Rectangle{
    public void setH(int a){
        this.height = a;
        if( this instanceOf Square){
            this.width = a;
        }
    }
}
//
//..

LSP를 어렵게 생각했지만, 쉽게 생각해보자.
InstanceOf를 사용하지 말라는 것이다. InstanceOf를 사용하는 순간 LSP를 위반함을 인지하다.

LSP와 아키텍처

LSP는 상속을 사용하는 가이드 정도로 인지되었지만,
시간이 지나면서 LSP는 인터페이스와 구현체에서도 더 광범위한 소프트웨어 설계 원칙으로 변해왔다.

여기서 인터페이스는 다양한 형태로 존재한다.
자바의 인터페이스와, 또는 REST 인터페이스일 수 도 있다. 이 밖에도 다양하다.

아키텍처 관점에서 LSP를 이해해보자

LSP 위반 사례

택시 서비스가 있다 가정하자.
이 택시 서비스는 서로 다른 택시업체에서 동일하게 지원한다.
택시 서비스는 사용자가 텍스트를 호출하면,
적절한 업체를 호출해, 업체의서버에 다음과 같은 REST API를 호출한다

taxiA.com/driver/홍길동/pickupAddress/성남시 판교 231/pikcupTime/15H13M/destination/서울시종로231

수많은 택시업체들은, 위의 포맷도로 택시 서비스가 호출할수 있도록 REST API를 개발하였다.
정해진 인터페이스에 맞게, 하위 타입의 택시업체들이 REST 인터페이스를 구현한 것이다.

하지만 어느날, 대기업 택시업체가 입점하면서,
위의 REST API를 준수하지 않도록 개발하였다.

taxiBIGBIG.com/driver/홍길동/pickupAddress/성남시 판교 231/pikcupTime/15H13M/dest/서울시종로231

destination을 dest로 변경하는것이다.

하위 타입인 택시 업체가, 정해진 인터페이스를 준수하지 않은것이다.
LSP가 위반 되었다.

소스코드에는 다음과같은 기능이 추가된다.
instanceOf와 유사해보이지 않는가?

if(taxiAgent.getUrl().startsWith("taxiBIGBIG.com"){
    //..
}

<br/>

10장. Interface Segregation Principal (I, ISP, 인터페이스 분리 원칙)

인터페이스 분리 원칙은 어렵지 않다.

OPS <-- user1
OPS <-- user2
OPS <-- user3

class OPS{
    + ops1()
    + ops2()
    + ops3()
}

다수의 사용자가 OPS 클래스의 메소드를 사용한다.
그런데 user1은 ops1()만, user2()는 ops2()만, user3()는 ops3()만 호출한도 가정해보자.

OPS는 정적 타입 언어인 클래스이다.
user1은 ops2(), ops3() 메소드를 전혀 사용하지 않지만,
ops2(), ops3() 메소드의 변경에도, 의존성에 의해 user1 클래스도 재컴파일 되어야 할 것이다.

이러한 문제를 해결하기 위해서 인터페이스를 다음과 같이 분리한다

user1 -->U1OPS 
user2 -->U2OPS 
user3 -->U3OPS 

U1OPS <|-- OPS
U2OPS <|-- OPS
U3OPS <|-- OPS

interface U1OPS{
 + ops1()
}

interface U2OPS{
 + ops2()
}

interface U3OPS{
 + ops3()
}

class OPS{
    + ops1()
    + ops2()
    + ops3()
}

<br/>

11장. Dependency Inversion Principle (D, DIP, 의존성역전의 법칙)

의존서 역전 법칙은 다음을 의미한다.

추상화에 의존하며, 구체화된것에 의존하지 말자

자바를 예로 들면,
참조하는 클래스가 오직 인터페이스 또는 추상클래스여야 함을 의미한다.

이 아이디어는 다소 비현실적인데,
예를들어 String 클래스를 보자.

String 클래스는 추상화 된것은 아니나, 우리가 자유롭게 참조하여 사용한다.
우리는 java.lang.String에 대한 의존성을 벗어 날 수 없다.

하지만 String 클래스는 매우 안정적이다.
String 클래스가 변경되는 일은 거의 없으며, 있더라도 엄격하게 통제된다.

이러한 이유로 DIP를 논할때는, 안정성이 보장되는 환경에 대해서는 무시되는 편이다.

우리는 변동성이 큰 구현체 에대한 의존을 피해야 할것이다.

안정된 추상화

추상인터페이스에 변경은, 구현체들의 수정을 발생시킨다.
반대로 구현체의 변경은 대다수의 경우 추상화된 인터페이스를 변경시키지 않는다.

따라서 인터페이스는 구현체보다 변동성이 낮다.

안전적인 소프트웨어 아키첵터란 변동성이 큰 구현체에 의존하는 일을 지양하고,
안정된 추상인터페이스를 선호하는 아키텍처라는 뜻이다.

이 원칙을 위해 다음가 같은 코딩 규칙을 명시한다

  • 변동성이 큰 구현 클래스를 참조하지마라
  • 변동성이 큰 구체 클래스로부터 파생하지말라
    • 상속은 소스코드에 의존하는 관계중 가장 강력한 결합을 낳는다.
    • 상속은 신중하게 사용해야하며, 변동성인 구현클래스를 상속한다는것은 또다른 변동성을 낳겠다는 의미이다.
  • 구체 함수를 오버라이딩 하지마라
    • 대체로 구체함수는 소스코드의 의존성을 필요로한다.
    • 구체함수를 오버라이딩 한다는것인 이런 의존성을 제거 할 수없게되며, 실제로는 그 의존성을 상속받는다.
  • 구체적이며 변동성이 큰 구현체를 직접 언급하지마라

팩토리

변동 가능성있는 구현체에 대한 의존을 피해야한다

하지만 우리는 객체를 생성해야하며, 이를 위해선 변동가능성이 높은 의존성이 생길수 밖에 없다.

따라서 추상팩토리 패턴을 사용하라.
객체를 생성하는곳과, 사용하는곳을 분리하라.

class Application
interface ServiceFactory{
    + makeService()
}
interface Service

Application -> Service
Application --> ServiceFactory

ServiceFactory <|-- ServiceFactoryImpl
Service <|-- ConcreteImpl
ServiceFactoryImpl -> ConcreteImpl

Application은 Service 인터페이스를 통해 ConcreteImpl을 사용한다.
Application에서는 어떤식으로든 ConcreteImpl의 인스턴스를 생성해야한다.

하지만, 추상팩토리를 사용하여 생성하는곳과, 사용하는곳을 분리하였다
makeService() 메소드를 호출하여 ConcreteImpl의 추상화된 구현체를 주입받는다.
ServiceFactoryImpl 또한 추상화시켜, Application은 추상화된 ServiceFactory에 의존한다

결과적으로 Application은 추상화된것에만 의존한다.

Spring의 의존성 주입은 이를 아주 잘보여준다.
Spring을 통해 Bean을 생성하고, 우리는 추상화된 인스턴스를 Autowired로 주입받는다.
Spring 내부적으로 추상팩토리를 사용하여, 구체화된 인스턴스에 대한 의존을 끊을수있도록 지원해주는 것이다.

CommentCount 0
이전 댓글 보기
등록
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
TOP