Software Engineering

소프트웨어 아키텍처 ( Software Architecture ) 와 커피

aiden0729

 

사실 소프트웨어 아키텍처는 그 창의성에 따라서 무궁무진 할 수 있다고 생각한다. 다만 목적이나 규모, 최초 설계 등을 많이 답습하는 만큼 다소 정형화된 기준도 존재한다.

 

 

이는 물론 앞서 설명한 OOP 그리고 SOLID를 효과적으로 구현하기 위한 방법과도 깊게 연관이 있다.

 

https://aiden0729.tistory.com/30

 

OOP ( Object-Oriented Programming )와 커피머신

OOP ( Object-Oriented Programming ) 는 기본적으로 4가지 원칙을 제시한다. 1) 추상화2) 캡슐화3) 상속4) 다형성 아래는 OOP 원칙을 커피머신에 비교하여 설명하려고 노력하였다. 1. 추상화 ( Abstraction ) 직접

aiden0729.tistory.com


https://aiden0729.tistory.com/31

 

SOLID 원칙과 커피머신

앞서 OOP를 커피머신의 예를들어 설명하려고 했다. https://aiden0729.tistory.com/30 OOP ( Object-Oriented Programming )와 커피머신OOP ( Object-Oriented Programming ) 는 기본적으로 4가지 원칙을 제시한다. 1) 추상화2)

aiden0729.tistory.com

 

 

 

위의 OOP와 SOLID 같이 대표적인 소프트웨어 아키텍처도 커피생산에 비유해서 설명해보려고 한다.

 

 

 

 

1. 3계층 아키텍처 (Three-Tier Architecture)

 

 

아마 가장 흔하게 쓰이는 아키텍처 중 하나이지 않을까 생각한다. 도메인이나 비즈니스보다는 정확히 기능(service) 에 따라 독립성을 가져간 케이스다.

 

독립성이라고하면 많이 어려워보이지만 결국 '분업'과도 같다. 커피머신으로 예를 들면 커피머신 전부를 한 번에 구축하는 것이 아니라 버튼( controller )  / 커피 종류 판단, 생산 ( service )  / 물, 컵 재고 파악 ( repository ) 정도로 나뉜다. 

 

해당 방식은 간결하지만, 결국 service에 모든 로직이 올라가기 때문에, 로직이 복잡해지는 순간 관리 및 독립성 유지가 많이 어려워진다. 다만 또 다른 관점에서보면 하나의 도메인 서비스를 간결하게 분리하여 RestAPI로 만드는 등의 작업을 하면 되기 때문에, 사용방법에 따라 독립성에 유리하게끔 구현할 수도 있다. 다만 상세 API가 구체화 되는건 좋지만 프로젝트 크기나 목적 비해 다소 복잡할 수도 있다.  

 

# controller.py (사용자 요청 처리)
from service import CoffeeService

service = CoffeeService()

def make_coffee():
    result = service.brew("americano")
    print(result)

make_coffee()
# service.py (비즈니스 로직)
from repository import CoffeeRepository

class CoffeeService:
    def __init__(self):
        self.repo = CoffeeRepository()

    def brew(self, coffee_type):
        ingredients = self.repo.get_ingredients(coffee_type)
        return f"Making {coffee_type} with {ingredients}"
# repository.py (데이터 저장소)
class CoffeeRepository:
    def get_ingredients(self, coffee_type):
        return {
            "americano": "water + beans",
            "latte": "milk + water + beans"
        }.get(coffee_type, "unknown")

 

 

 

 

2. DDD ( Domain-Driven Design )

 

3계층이 '기능'만을 분리하는데 초점을 두었다면, DDD부터는 이름과 같이 Domain 에 신경을 쓴다. 예시에서는 '커피 생산'이라는 것이 DDD의 중심 주제가 될 것이고, 이 '커피 생산'을 최적화하기 위해 설계를 한다. 

 

일단 이 커피생산을 확실하게 진행하기 위해서 필요한건 무엇일까 ? 

바로 모든 프로세스를 정의하고 관장하는 '매니저'이다. app.py 라는 최초 실행자에서 요청이 오면 매니저가 이를 확인하고 일을 나누고 지시한다. 이를 DDD에서는 UseCase로 부른다.

 

 

이 UseCase는 위의 설명한 3계층과 다소 비슷한 업무를 동시에 매니징한다. Repository에서 관련된 재료 (Repository) 가 충분한지 확인한다. 또한 Factory (Domain) 에서 어떤 Coffee를 만들지 확인한다. 다만 여전히 Domain 중심이기 때문에 UseCase보다는 Factory (Domain)의 행동력이 강하다. 즉 create_coffee 등의 액션은 Factory (Doamin) 단에서 실행된다. 

 

 

 

 

 

최초 실행자 ( app.py ) 가 실행되면, 고객이 커피를 시킨것과 동일하다. ( 예제에서는 americano ) 

# app.py (애플리케이션 계층, 유스케이스)
from application.make_coffee_usecase import MakeCoffeeUsecase
from infrastructure.coffee_repository import CoffeeRepository

usecase = MakeCoffeeUsecase(CoffeeRepository())
result = usecase.execute("americano")
print(result)

 

UseCase가 app.py의 요청을 받아 재료(Repository)와 Factory(Domain)를 확인한다. 

# application/make_coffee_usecase.py (유스케이스 계층)
from domain.service.coffee_factory import CoffeeFactory

class MakeCoffeeUsecase:
    def __init__(self, repo):
        self.repo = repo
        self.factory = CoffeeFactory()

    def execute(self, coffee_type):
        ingredients = self.repo.get_ingredients(coffee_type)
        coffee = self.factory.create(coffee_type, ingredients)
        return coffee.describe()
# domain/service/coffee_factory.py (도메인 서비스)
from domain.model.coffee import Coffee

class CoffeeFactory:
    def create(self, coffee_type, ingredients):
        return Coffee(coffee_type, ingredients)
# infrastructure/coffee_repository.py (인프라스트럭처 계층)
class CoffeeRepository:
    def get_ingredients(self, coffee_type):
        return {
            "americano": "water + beans",
            "latte": "milk + water + beans"
        }.get(coffee_type, "unknown")

 

 

마지막으로 Core ( Entity ) 에서 생산할 그 객체 자체를 정의해놓았으며, 해당 정보를 참고하여 Factory(Domain)가 요청 받은 Coffee를 제작한다. 

# domain/model/coffee.py (도메인 모델)
class Coffee:
    def __init__(self, name, ingredients):
        self.name = name
        self.ingredients = ingredients

    def describe(self):
        return f"{self.name} with {self.ingredients}"

 

 

 

3계층과 가장 다른점은 UseCase의 매니징 역할, 그리고 Core ( Entity ) 객체를 확실히 정의한 부분일 것이다. 이러면 같은 로직으로 Coffee 이외의 Tea, Juice 등의 추가적인 기능이 붙을 수 있으며, Core( Entity ) 를 유지하고도 Domain 부분 등만 수정하여 카페라떼, 카페모카 등 다양한 방식의 커피의 생산도 가능할 것이다. 

 

다만 확장성과 독립성 모두 좋고 OOP와 SOLID 모두 다소 쉽게 지킬 수 있지만, 결국 하나의 도메인 서비스를 위해 많은 수준의 코드와 모듈을 만들어야하므로 대규모 프로덕트 적용 시에 고민해보는 것이 좋다. 

 

 

 

 

 

 

 

3. 클린 아키텍처(Clean Architecture)

 

 

클린 아키텍쳐란 '비즈니스 규칙은 바깥 세상에 전혀 의존하지 않아야 한다'의 모토로 만들어졌다.

3계층과 DDD는 설계의 목적 자체가 꽤 직관적이었으나, 클린 아키텍처는 개인적으로 다소 모호하게 느껴질 수도 있을 것 같다.

특히 '밖에서 안으로만' 이라는 의존성도 존재한다. 여전히 모호한데, 개인적으로는 엄청난 '탑다운' 시스템 정도로 생각한다. 

코어, 도메인 이외의 모든 것들은 다 '대체 가능한 도구'일 뿐이다.

 

DDD와 클린 아키텍처는 기본적으로 보완적인 개념에 가깝다고 생각하나, 기본 철학은 다르므로 비교를 해보자면

DDD는 이름과 같이 '도메인'에 의존을 많이 한다. UseCase가 매니징을 하지만 단순히 Domain의 의사를 전달 및 통합하는 역할을 하기도 한다. 커피로 따지면 굉장히 DDD는 똑똑한 바리스타가 생산을 하고, Clean Architecture는 똑똑한 매니저가 커피머신을 다루는 행위 정도로 치환할 수도 있을 것 같다. 

 

앞서 DDD에서 설명했듯 DDD의 실행 주체는 Domain이며, 그로 인해 create_coffee() 등의 액션로직 또한 도메인에 존재한다. 하지만 Clean Architecture는 이 create의 권한 또한 UseCase, 즉 매니저에게 위임한다. 

 

 

다만 이렇게 하나의 모듈이 거의 모든 권한을 가져가버리는 것은 OOP나 SOLID를 위반할 확률이 높아짐도 의미한다. 따라서 추가적인 기능등은 service 등으로 나누거나 Domain 내의 로직 등으로 위임하기도 한다. 하지만 여전히 액션에 관련된건 철저히 UseCase가 책임진다. 

 

 

또한 3계층 및 DDD랑 다른 부분은 Repository, FrameWork 등 거의 모든 부분을 Port와 Adapter로 분리한다는 점이다. Clean Architecture는 UseCase상에서 Action이 일어나므로 단순히 역할 정의만하는 Domain과 데이터소스인 DB가 분리될 수 있는 것이다. 

따라서 이런 DB의 분리는 다양한 데이터소스와의 연동성도 제공하며 ( MySQL에서 PostgreSQL로 옮기는 등 ), DB의 역할이 명확하기 때문에 Mock이나 Dummy 데이터를 사용하여 쉽고 명확한 테스트가 가능하다. 또한 LangChain -> LlamaIndex 등으로 Framework를 옮기다고 가정할 때 해당 Adapter 등을 바꿔주는 등의 작업만 한다면 Domain 및 Core는 큰 수정 없어도 동일하게 작동한다고 할 수 있겠다.