상세 컨텐츠

본문 제목

[Architecture] 5부 아키텍처 : 19장 정책과 수준

프로그래밍/Architecture

by jisooo 2024. 1. 14. 17:48

본문

 

 

소프트웨어 시스템이란 정책을 기술한 것이다.

컴퓨터 프로그램은 각 입력을 출력으로 변환하는 정책을 상세히 기술한 설명서이다.

대다수의 주요 시스템에서

하나의 정책은 이 정책을 서술하는 여러 개의 조그만 정책들로 쪼갤 수 있다.

주문의 발송처리 정책을 예시로 생각해보자.

각 주문에 대해 송장정보를 등록하는 정책,

입력된 송장 데이터가 유효하게 데이터베이스에 적용되어도 되는지 검증하는 정책,

입력된 데이터를 정제하여 적절한 데이터구조로 변환하는 정책,

주문의 구매자나 수신자에게 발송 알림을 전달하는 정책 등등...

소프트웨어 아키텍처를 개발하는 기술에는

이러한 정책들을 신중하게 분리하고,

정책이 변경되는 양상에 따라 정책을 재편성하는 일도 포함된다.

동일한 이유로 동일한 시점에 변경되는 정책은

동일한 수준에 위치하며, 동일한 컴포넌트에 속해야 한다.

서로 다른 이유로, 혹은 다른 시점에 변경되는 정책은

다른 수준에 위치하며, 반드시 다른 컴포넌트로 분리해야 한다.

아키텍처 개발은 위처럼 정책에 따라 컴포넌트들을 재편성하여 "비순환 방향 그래프"로 구성하는 기술을 포함한다.

이 장에서도 정책의 수준에 따라 컴포넌트를 편성하여 비순환 방향 그래프를 예시로 설명하고 있다.

그래프에서 정점(node)는 동일한 수준의 정책을 포함하는 컴포넌트에 해당한다.

방향이 있는 간선(edge)은 컴포넌트 사이의 의존성을 나타낸다.

간선은 다른 수준에 위치한 컴포넌트를 서로 연결한다.

이러한 의존성은 소스 코드, 컴파일 타임의 의존성이다.

자바의 경우 import문, 루비에서는 require 구문을 생각하면 된다.

이러한 의존성은 컴파일러가 제대로 동작하기 위해서 필요하다.

좋은 아키텍처라면 컴포넌트의 의존성은 항상 저수준 컴포넌트가 고수준 컴포넌트에 의존하도록 설계해야 한다.

수준

이 장에서는 정책을 수준의 단계로 구분짓는다.

"입/출력"에서 가장 거리가 멀어질수록 정책의 수준은 높아지고,

"입/출력"에서 가장 거리가 가까울수록 정책의 수준은 최하위 수준에 위치한다.

이제 위에서 설명한 비순환 방향 그래프의 개념과, 수준의 개념을 도입하여 아래 그래프를 이해해보자.

Read Char는 문자를 입력하는 컴포넌트,

Write Char는 문자를 출력하는 컴포넌트이다.

이들은 입/출력에 해당하는 컴포넌트이므로 저수준의 컴포넌트라고 할 수 있다.

반면 Translate 컴포넌트는

문자 입력을 받아서 테이블을 참조하여 문자를 번역하고, 번역된 문자를 출력 장치로 기록한다.

번역 컴포넌트는 이 시스템에서 입/출력에 가장 멀리 위치해있으므로

이 시스템에서 최고 수준의 컴포넌트라고 할 수 있다.

위 그래프에서 굽은 실선 화살표와 데이터 흐름 화살표를 주목해보자.

굽은 실선 화살표는 소스 코드의 의존성을 나타내고,

문자 읽기 -> 번역 -> 문자 출력 흐름으로 흐르는 화살표는 데이터의 흐름을 나타낸다.

즉 데이터 흐름과 소스 코드 의존성은 항상 같은 방향을 가르키지 않는다는 사실을 알 수 있다.

소스 코드 의존성은 그 수준에 따라 결합되어야 하며,

데이터 흐름을 기준으로 결합되어서는 안된다.

단순히 데이터 흐름에 따라 소스 코드 의존성을 작성하면,

아래의 예시와 같이 잘못된 아키텍처가 설계될 수 있다.

function encrypt() {
  while(true)
    writeChar(translate(readChar()));
}

 

 

무엇이 잘못되었을까?

encrypt()는 위 그래프 상에서 확인했듯이 고수준 함수이다.

(코드 예시에서는 함수로 표현되었지만, 실무에서 클래스나 컴포넌트로 언제든 확장되는 개념일 수 있다.)

그리고 writeChar(), readChar() 함수는 입/출력을 담당하는 저수준 함수인데,

encrypt() 함수가 위의 입/출력 담당의 함수(writeChar(), readChar())에 의존하고 있기 때문이다.

즉 위의 예시상으로는 고수준 함수(클래스 또는 컴포넌트)가

저수준 함수(클래스 또는 컴포넌트)에 의존하고 있는 모양이다.

우리가 이 장에서 배운 내용은 그 반대이다.

즉 저수준 컴포넌트가 고수준 컴포넌트에 의존하도록 아키텍처를 설계해야한다는 내용이다.

조금 더 실무에 알맞게,

위를 java 클래스 수준의 예시로 바꿔서 표현해보면 아래와 같다.

encrypt나 translate는 각각 업무 규칙을 담은 도메인 서비스 클래스에서 정의한다면,

writeChar, readChar는 입/출력값을 적절한 형태로 변환하는 매퍼 클래스로 다시 대입해서 생각해볼 수 있다.

(예시에서 mapper의 역할은

1) 입력값을 서비스에 전달하여 데이터를 처리할수 있도록 적절한 Dto로 변환하는 역할과

2) 업무 규칙 비즈니스 로직을 거쳐, 서비스에서 리턴한 데이터를 원하는 출력값에 맞게 적절히 변환하는 역할로 예시를 든다.)

 

import com.xx.xx.xxxxx.TranslateService;
import com.xx.xx.xxxxx.request.mapper.RequestMapper;
import com.xx.xx.xxxxx.response.mapper.ResponseMapper;


public class EncrptService {
    private final TranslateService translateService;
    private final RequestMapper requestMapper;
    private final ResponseMapper responseMapper;

    public EncryptResponse encrypt(InputData input) {
        while(true)
            responseMapper.mapToResponse(translateService.translate(requestMapper.read(input)));
    }
}

public class RequestMapper {
    public EncryptRequest mapToRequest(InputData input) {
        // do something...
        return request;
    }
}

public class ResponseMapper {
    public EncryptResponse mapToResponse(EncryptEntity entity) {
        // do something...
        return response;
    }
}

 

 

 

 

EncrptService, TranslateService는 입/출력과 가장 멀리 위치한 클래스로 고수준 컴포넌트이다.

ReadMapper, WriteMapper는 데이터를 입/출력에 원하는 형태로 변환하는 클래스로,

입/출력에 가장 가까이 위치한 클래스이므로 저수준 컴포넌트라고 할 수 있다.

(이 예시에서 ReadMapper, WriteMapper는 인터페이스가 아닌 구체적인 구현 클래스로 작성되어있다고 가정한다.)

import문과 EncryptService의 encrypt 메서드를 보면

고수준 EncrypeService가 저수준 컴포넌트인 ReadMapper, WriteMapper에 의존하는 것을 볼 수 있고

이 역시 첫번째 예시와 마찬가지로 고수준 컴포넌트가 저수준 컴포넌트에 의존하는 구조라

잘못된 설계라고 할 수 있다.

위 구조에서 만약 입/출력 정책에 대한 변경이 생긴다면,

ReadMapper, WriteMapper 클래스는 물론이고,

해당 구현클래스를 직접 참조하는 EncryptService 역시 같이 변경되어야 할 가능성이 크다.

이제 아래 19.2의 클래스 다이어그램을 보면 위 아키텍처를 개선한 모습이라고 할 수 있다.

개선 전의 EncryptService 클래스도 위 개선된 아키텍처에 대입해보면,

아래와 같이 코드로 표현할 수 있다.

EncrptService는 추상 인터페이스(RequestMapper, ResponseMapper)의 선언에만 의존하고 있고,

변동성이 큰 저수준의 구체 클래스에는 의존하지 않고 있다.

 

 

 

 

 

public class EncrptService {
    private final TranslateService translateService;
    private final RequestMapper requestMapper;
    private final ResponseMapper responseMapper;

    public EncryptResponse encrypt(InputData input) {
        while(true)
            responseMapper.mapToResponse(translateService.translate(requestMapper.read(input)));
    }
}

public interface RequestMapper {
    public EncryptRequest mapToRequest(InputData input);

public interface ResponseMapper {
    public EncryptResponse mapToResponse(EncryptEntity entity);

}

public class EncryptRequestMapper implements RequestMapper {
    public EncryptRequest mapToRequest(InputData input) {
        // do something...
        return request;
    }
}

public class EncryptResponseMapper implements ResponseMapper {
    public EncryptResponse mapToResponse(EncryptEntity entity) {
        // do something...
        return response;
    }
}

 

다시 19.2의 클래스 다이어그램의 예시로 대입해서 살펴보자면,

Encrypt 클래스와 CharReader, CharWriter 인터페이스를 둘러싼 점선으로 둘러쌓인 원은

경계를 나타낸다.

이 경계를 횡단하는 의존성은 모두 경계 안쪽으로 향한다.

이 경계로 묶인 영역이 이 시스템에서 최고 수준의 구성요소이다.

ConsoleReader, ConsoleWriter는 클래스로 표현했고,

이들은 입/출력에 가까이 위치한 클래스이므로 저수준 컴포넌트이다.

CharReader, CharWriter 인터페이스를 작성함으로써,

의존성의 방향이 ConsoleReader -> CharReader, ConsoleWriter -> CharWriter로 역전된 모습을 주목하자.

여기서 "의존성 역전 원칙"이 적용된 것을 알 수 있다.

또한 추후 입/출력에 대한 저수준 정책이 빈번하게 변경될 때,

(실제로 실무를 하면 입/출력에 대한 변경은 자주 겪는 일이다.)

CharReader, CharWriter 인터페이스를 구현한 다른 변경된 구현체를 주입함으로써 확장에 개방된 구조이고,

변경된 구현체로 갈아끼우더라도,

고수준 정책인 Encrypt 클래스는 변경에 영향을 받지 않도록 보호될 수 있게 설계된 점을 이해할 수 있다.

이 점에서 "개방 폐쇄 원칙"이 적용된 것을 알 수 있다.

또한, 위 개선된 구조는

변동성이 큰 구현체에 의존하지 않고, 안정된 추상 인터페이스를 선호하여

구체적인 구현체에 변동이 생기더라도 그 구현체가 구현하는 인터페이스는

대다수의 경우 변경될 필요가 없다.

따라서 인터페이스를 참조하는 고수준 컴포넌트 역시 변경될 필요가 없다는 점에서

"안정된 추상화 원칙"에 상통하는 내용임을 알 수 있다.

고수준 컴포넌트인 Encrypt 클래스에서는

저수준 컴포넌트인 CharReader, CharWriter의 내부 구현을 알지 못하고 알 필요가 없다.

그저 암호화 로직 중에 입/출력 로직을 수행할 때, 어떤 메서드 정의를 호출하면 되는지 정도까지만 알면 된다.

이 구조에서 고수준의 암호화 정책을 저수준의 입/출력 정책으로부터

분리시킨 방식에 주목해야 한다.

이 방식 덕분에 이 암호화 정책을 더 넓은 맥락에서 사용할 수 있다.

입/출력에 변화가 생기더라도, 고수준의 컴포넌트는 이들과 추상 컴포넌트인 인터페이스로 느슨하게 결합 되어 있기 때문에

암호화 정책이나 번역에 대한 정책은 영향을 거의 받지 않는다.

정책을 컴포넌트로 묶는 기준은 정책이 변경되는 방식에 달려 있다는 사실을 기억해야 한다.

예전 장에서부터 계속 공부했던

단일 책임 원칙, 공통 폐쇄 원칙에 따르면

동일한 이유로 동일한 시점에 변경되는 정책들을 같은 컴포넌트로 함께 묶여야 한다.

고수준 정책, 즉 입/출력과 멀리 떨어진 정책은

저수준 정책에 비해 덜 빈번하게 변경되고, 보다 중요한 이유로 변경되는 경향이 있다.

저수준 정책, 즉 입/출력과 가까운 정책은 더 빈번하게 변경되며, 보다 긴급성을 요구하고,

덜 중요한 이유로 변경되는 경향이 있다.

이처럼 모든 소스 코드의 의존성 방향이 고수준 정책을 향하게 할수 있도록

정책을 분리했다면, 변경에 대한 영향도를 줄일 수 있다.

시스템의 최저 수준에서 중요하지 않지만 긴급한 변경이 발생했더라도,

보다 높은 위치의 중요한 수준에 미치는 영향은 거의 없게 된다.

이 논의는 앞 장에서 강조한 개념처럼 "플러그인 아키텍처"에 대입해서 생각해볼 수 있다.

저수준 컴포넌트가 고수준 컴포넌트에 "플러그인"되아야 한다는 관점으로 바라볼 수 있다.

Encryption 컴포넌트는 IO Devices 컴포넌트를 전혀 알지 못하지만,

IO Devices는 Encryption 컴포넌트에 의존한다.

#클린아키텍처 #cleanArchitecture #정책과수준 #의존성역전원칙 #단일책임원칙 #공통폐쇄원칙 #안정된추상화원칙

관련글 더보기

댓글 영역