헥사고날-아키텍쳐로-구현하는-작은-스프링-부트-토이-프로젝트-우아한스터디-04
만들면서 배우는 클린 아키텍쳐 4장 정리
유스케이스 구현하기
애플리케이션, 웹, 영속성 계층을 느슨하게 결합하면 도메인 코드를 원하는대로 자유롭게 모델링할 수 있습니다.
그러나 육각형 아키텍처는 도메인 중심의 아키텍처에 적합하기 때문에 도메인 엔티티를 먼저 만든 후에 이를 바탕으로 유스케이스를 구현합니다.
도메인 모델 구현하기
Account 엔티티 클래스를 만들고 입금 및 츨금 메소드를 추가합니다.
이를 통해 Account 클래스의 인스턴스 중 하나인 출금계좌(Account)에서 돈을 출금하여(withdraw메소드 호출) Account 클래스의 인스턴스 중 또 다른 하나인 입금계좌(Account)에 돈을 입금하면(deposit메소드 호출) 한 계좌에서 다른 계좌로 송금하는 유스케이스를 구현할 수 있습니다.
Account 엔티티는 실제 계좌의 현재 상태를 나타냅니다.
Activity 엔티니는 Account의 모든 입금과 출금을 포착하는 역할을 합니다.
한 계좌에 대한 모든 Activity를 항상 메모리에 한꺼번에 올리는 것은 시간적으로나 공간적으로나 모두 비효율적이기 때문에 Account 엔티티는 일정 기간동안의 Activity들만 가지고 있는 ActivityWindow를 멤버로 가집니다.
Account의 현재 잔고를 구하기 위해 Account 엔티티는 필드멤버인 ActivityWindow가 반영되기 전까지의 잔고를 표현하는 baselineBalance를 멤버로 가집니다.
현재 총 잔고를 구하려면 baselineBalance에 AcitivityWindow의 모든 Activity의 잔고를 합하면 됩니다.
즉, Account에서 입금과 출금을 할 때, 각각 deposit과 withdraw 메소드를 호출하고, 각각의 메소드 내에서 depositActivity또는 withdrawActivitiy를 생성하여 activityWidnow에 추가해줍니다.
추가적으로 출금할 때는 withdraw메소드 내에서 잔고를 초과하는 금액은 출슴할 수 없도록 하는 비즈니스 규칙을 검사하는 메소드를 추가합니다.
이제 입금과 출금을 할 수 있는 도메인 모델(Account 엔티티)를 바탕으로 유스케이스를 구현할 수 있습니다.
유스케이스 둘러보기
일반적인 유스케이스 단계
- 입력을 받는다
- 비즈니스 규칙을 검증한다
- 모델 상태를 조작한다
- 출력을 반환한다
1. 입력을 받는다
유스케이스는 인커밍 어댑터, 즉, 컨트롤러로부터 입력을 받습니다.
입력을 받을 때 이 입력값에 대한 유효성 검증은 유스케이스의 책임이 아니기 때문에 여기서 고려하지 않습니다.
저자는 유스케이스 코드는 도메인 로직에만 집중해야 하기 때문에 여기서 입력 유효성 검증을 하면 코드가 오염된다고 말합니다.
2. 비즈니스 규칙을 검증한다
이와는 반대로 유스케이스 코드에서 비즈니스 규칙은 검증해야할 책임이 있고, 이 책임을 도메인 엔티티와 공유합니다.
3. 모델 상태를 조작한다
비즈니스 규칙이 충족되면 유스케이스는 인커밍 어댑터(컨트롤러)로부터 받은 입력을 바탕으로 모델의 상태를 변경합니다.
도메인 객체의 상태를 바꾼 다음 이를 영속성 계층에 바뀐 사항을 저장하기 위해 아웃고잉포트(리포지토리 인터페이스)를 유스케이스에서 호출하면 실제로 이를 구현한 아웃고잉 어댑터(데이터베이스 또는 다른 저장소)가 이 변경사항을 저장합니다.
4. 출력을 반환한다
마지막 단계에서는 유스케이스를 호출한 어댑터인 컨트롤러에 반환할 값을 출력객체로 변환하는 작업을 합니다.
1장에서 언급한 넓은 서비스 문제를 피하기 위해서 각 유스케이스별로 분리된 각각의 서비스를 만듭니다.
구체적인 예를 들어 보면 SendMoneyService는 인커밍 포트 인터페이스인 SendMoneyUseCase(SendMoneyController에서 호출됨)를 구현합니다.
SendMoenyService는 아웃고잉 포트 인터페이스인 LoadAccountPort를 내부에서 호출하여 영속성 계층인 데이터베이스로부터 이전 계좌정보를 불러옵니다.
또한 데이터베이스에 저장된 계좌 상태를 업데이트하기 위해서 아웃고잉 포트 인터페이스인 UpdateAccountStatePort를 호출합니다.
즉, SendMoneyService의 sendMoney 메소드를 호출하면 먼저 LoadAccountPort를 구현한 구현체를 통해 데이터베이스에서 기존 계좌 정보를 불러와서 도메인 엔티티 모델인 Account에 저장합니다.
그 다음에 입출금 행위를 수행하면서 Account의 값을 변경합니다.
이 후 Account에서 바뀐 상태를 데이터베이스에 저장해주기 위해 UpdateAccountStatePort를 구현한 구현체를 통해 데이터베이스에 바뀐 계좌 정보를 반영합니다.
입력 유효성 검증
입력 유효성 검증은 유스케이스 클래스의 책임은 아니지만 애플리케이션 계층의 책임에는 해당합니다.
호출하는 어댑터, 즉, 컨트롤러가 SendMoneyUseCase의 메소드에 필요한 입력 값을 매개변수로 전달하기 전에 입력 유효성을 검증했을 경우에는 문제가 발생합니다.
SendMoneyUseCase를 호출하는 컨트롤러가 하나일 수도 있지만 프로그램이 복잡해지다보면 SendMoneyUseCase를 호출하는 컨트롤러가 여러 개일 수도 있습니다.
그러면 SendMoneyUseCase를 호출하는 모든 컨트롤러에서 이 유효성을 검증하는 코드를 작성해야 하기 때문에 코드 중복이 심해지고, 더 나아가 유효성 검증하는 코드를 빼먹어서 유효하지 않은 입력값을 받게 되어 모델의 상태를 해치는 문제가 발생할 수 있습니다.
그래서 입력 모델을 만들어 애플리케이션 계층에서 입력 유효성을 검증하도록 해야 합니다.
SendMoneyController에서 SendMoneyUseCase를 호출하기 전에 입력 모델인 SendMoneyCommand 클래스를 생성자를 호출하여 생성자 내에서 입력 유효성을 검증할 수 있습니다.
좀 더 구체적으로 말하자면 송금하기 위해서 필요한 조건 중 하나라도 만족하지 않으면 SendMoneyCommand 클래스의 객체를 생성할 때 예외를 던져서 객체 생성을 막는 방법으로 유효성을 검증할 수 있습니다.
여기서 중요한 점은 SendMoneyCommand의 필드 멤버는 모드 final로 지정하는데 그 이유는 한 번 생성자를 통해 값이 초기화되면, 즉, 생성자를 통해 유효성이 검증된 값으로 입력이 되면, 이 후에는 유효성이 검증되지 않은 값으로 바꿀수 없도록 막기 위해서입니다.
이를 통해 SendMoneyCommand에 필드 멤버의 값은 모두 유효성이 검증되었다는 것을 보장할 수 있습니다.
SendMoenyCommand는 유스케이스 API의 일부이기 때문에 인커밍 포트 패키지에 위치합니다.
이로 인해 유효성 검증이 유스케이스의 코드 대신에 애플리케이션 코드에는 있도록 할 수 있습니다.
Bean Validation API
이러한 유효성 검증을 자바의 사실상 표준라이브러리인 Bean Validation API를 이용해 대신 처리합니다.
이 API를 이용하여 직접 유효성 코드를 구현할 필요없이 필요한 유효성 규칙들을 필드의 애너테이션으로 명시하면 유효성 규칙을 위반한 경우 예외를 던짐으로써 유효성 검증을 간단하게 할 수 있습니다.
유효성 검증을 해주는 입력 모델은 잘못된 입력을 호출자에게 돌려주는 유스케이스 보호막, 즉, 오류 방지 역할을 합니다.
생성자의 힘
유효성 검증 역할을 하는 SendMoneyCommand는 생성자에 많은 역할을 부여하고 있습니다.
클래스의 필드 멤버들이 모두 불변(final)이기 때문에 반드시 생성자에서 인자를 받아서 필드멤버들을 모두 초기화해줘야하며, 생성자의 매개변수들의 유효성 검증까지 하여 잘못된 값으로 객체를 만드는 것을 방지하고 있습니다.
빌더 사용
그런데 만약에 생성자에서 초기화해야할 파라미터가 더 많을 경우 생성자를 private으로 설정하여 접근하지 못하게 막은 다음 빌더의 build 메소드 내부에 생성자 호출을 숨길수도 있습니다.
이 때 유효성 검증 로직은 생성자에 그대로 둬서 유효하지 않은 상태의 객체를 생성하는 것은 막을 수 있습니다.
생성자 대신에 빌더 사용시 안좋은 점
그러나 이는 그렇게 바람직하지 못한 방법인데 이유는 다음과 같습니다.
프로그램을 유지보수하다보면 SendMoneyCommand에 필드멤버가 추가되는 경우도 있습니다.
그러면 생성자와 빌더에 새로운 필드를 추가해야 합니다.
그러나 실수로 빌더를 호출하는 코드에 새로운 필드를 추가하는 것을 깜빡하면 컴파일러는 이에 대해 경고하지 못합니다.
그러면 런타임에 유효성 검증 로직이 동작해서 예외가 발생하게 됩니다.
하지만 빌더 대신에 생성자를 직접 사용하면 새로운 필드를 추가하거나 삭제할 때마다 컴파일 에러를 따라서 바로 에러를 잡을 수 있습니다.
가장 좋은 에러는 컴파일 에러입니다.
왜냐하면 가장 빠르게 쉽게 발견할 수 있기 때문입니다.
런타임 에러는 컴파일 에러에 비해 에러를 바로 잡기 위해 시간과 정신력 소모가 심합니다.
그래서 왠만하면 컴파일 에러가 날 수 있게 하는 것이 더 좋습니다.
그래서 직접 생성자를 이용해서 매개변수를 입력받고 유효성을 검증하는 것이 더 좋습니다.
여러 유스케이스에서 하나의 공용 입력 모델 사용시 문제점
편의를 위해 각기 다른 유스케이스이지만 비슷한 데이터가 필요한 경우 동일한 입력 모델을 이용해 유효성을 검증하고 싶은 유혹이 생깁니다.
그러나 약간의 차이로 인해 이 두 유스케이스를 모두 커버하려면 공통의 필드멤버를 제외하고 각 특성에 맞는 1개 또는 몇 개의 필드멤버가 필요합니다.
그리고 이는 각 유스케이스에 따라서는 필요없는 값이기 때문에 null이 될 수 있습니다.
아까 앞에서 언급했듯이 입력 모델의 필드 멤버를 모두 불변으로 설정하였는데 null을 유효한 값으로 받아들인다는 것에서 벌써부터 뭔가 이상한 느낌이 듭니다.
하지만 이 것보다 더 큰 문제는 입력 유효성을 검증하는데 있습니다.
각 유스케이스가 서로 거의 비슷하더라도 결국에는 약간의 차이가 있기 때문에 그 약간의 차이를 분별해줄 유효성 검증을 해야하는데 하나의 입력 모델에서는 공통적인 유효성 검증밖에 하지 못하기 때문에 결국에 이 약간의 차이를 분별해줄 코드를 유스케이스에 넣어 오염시킬 수 밖에 없습니다.
또한 여러 복잡한 예외 상황에서 두 유스케이스를 모두 처리하는 한가지 입력모델은 예외를 어떻게 처리해야지도 고민을 많이 하게 만듭니다.
자기가 작성한 코드도 시간이 지나면 가물가물해서 이를 어떻게 처리할지 고민할텐데 하물며 자기가 만든 코드도 아닌데 이를 보고 있는 개발자의 기분은 어떨까요?
그래서 당장 눈 앞에 보이는 약간의 편의를 포기하고 당장에는 귀찮아 보여고 각 유스케이스마다 전용 입력 모델을 만드는 것이 결국에는 장기적으로 이득입니다.
각 유스케이스마다 전용 입력 모델이 있다면 다른 유스케이스와 서로 결합하지 않기 때문에 위에서 언급한 것처럼 side effec가 발생하지 않고, 명확한 의미를 지니기 때문에 나중에 유지보수하기 좋습니다.
비지니스 규칙 검증하기
입력 유효성 검증은 유스케이스 로직의 일부가 아니지만 비지니스 규칙은 유스케이스 로직의 일부입니다.
비지니스 규칙과 입력 유효성 규칙의 구분 방법
둘 사이를 구분하는 아주 실용적인 구분 방법은 다음과 같습니다.
비즈니스 규칙은 도메인 모델의 현재 상태에 접근해야만 검증할 수 있는 반면에 입력 유효성은 도메인 모델의 현재 상태에 접근할 필요없이 검증할 수 있다는 것입니다.
다시 말해서 비즈니스 규칙을 검증할 때는 도메인의 정보, 즉, 맥락이 필요하지만 유효성 검증은 애노테이션과 같이 선언만 있으면 됩니다.
때로는 이러한 구분법이 논쟁이 될 수도 있습니다.
회사에서 제공하는 비지니스 서비스에서 매우 중요한 위치를 차지하는 필드값이라도 위 방법을 적용했을 때는 유효성 검증 규칙으로 빠질 수 있습니다.
그래서 혹자는 중요도에 따라 필드멤버 값의 검증을 유효성에서 비지니스 검증 규칙으로 가져와야 한다고 이야기하는 사람도 있습니다.
하지만 이렇게 되면 유효성 검증과 비지니스 규칙 검증 방법이 불명확하여 나중에 코드를 찾을 때 어디에 있는지 찾을 때 시간이 많이 걸립니다.
반면에 위의 실용적인 방법을 이용하면 코드를 쉽게 발견할 수 있기 때문에 유지보수관점에서 더 좋습니다.
비지니스 규칙 검증 코드 위치
비지니스 규칙을 도메인 엔티티 안에 위치시키면 이 규칙을 지켜야 하는 비즈니스 로직 바로 옆에 규칙이 있기 때문에 위치를 정하는 것도 쉽고, 추론하기도 쉬워서 나중에 쉽게 찾을 수 있기 때문에 가장 좋은 방법입니다.
만약에 도메인 엔티티에서 비즈니스 규칙을 검증하기 어렵다면 유스케이스 코드에서 도메인 엔티티를 사용하기 전에 비즈니스 규칙을 검증해도 됩니다.
풍부한 도메인 모델 vs 빈약한 도메인 모델
육각형 아키텍처 스타일은 도메인 모델을 구현하는 방법에 대해서는 열려 있습니다.
그래서 도메인 주도 설계를 바탕으로 하는 풍부한 도메인 모델과 ‘빈약한’ 도메인 모델을 구현할 지 선택할 수 있습니다.
풍부한 도메인 모델(rich domain model)
유스케이스 대신에 엔티티에서 가능한 한 많은 도메인 로직이 구현됩니다.
엔티티들은 상태를 변경하는 메서드를 제공하며, 엔티티 내에서 비즈니스 규칙 검증을 통해 유효한 값만 변경하도록 허용합니다.
유스케이스는 도메인 모델의 진입점 역할을 합니다.
유스케이스는 사용자의 의도만을 표현하며 이 의도에 대해서 실제 작업을 수행하는 체계화된 도메인 엔티티 메서드들을 순서에 알맞게 호출하는 역할만을 수행합니다.
빈약한 도메인 모델(anemic domain model)
엔티티가 상태를 표현하는 필드와 이 값을 읽고 바꾸기 위한 getter, setter 메서드만 존재하고, 어떠한 도메인 로직도 가지지 않습니다.
즉, 유스케이스 클래스에 도메인 로직이 구현됩니다.
비즈니스 규칙 검증, 엔티티 상태 변경, 데이터베이스 저장하기 위해 엔티티를 전달 등 이 모든 책임이 유스케이스 클래스에 있기 때문에 풍부한 유스케이스라고 할 수 있습니다.
유스케이스마다 다른 출력 모델
유스케이스 반환 값
유스케이스는 할 일을 다하고 나면 호출자에게 값을 반환해야 하는데, 이 때 호출자에게 꼭 필요한 데이터만 출력(반환)해야 합니다.
즉, 값을 출력할 때는 보수적으로 최대한 의심하여 꼭 필수적인 최소값만 반환해야 합니다.
입력 모델에서 유스케이스들 간에 같은 모델을 공유하면 문제가 발생했듯이 출력 모델 역시 여러 유스케이스들이 공유하게 되면 문제가 발생합니다.
이렇게 되면 각 유스케이스들끼리 강하게 결합되어 공유된 출력 모델을 통해 출력할 때 한 유스케이스에서 호출 후 출력값을 반환할 때 해당 유스케이스에서는 필요없지만 다른 유스케이스에서는 필요한 값을 같이 반환해야 하기 때문에 해당 유스케이스 입장에서는 불필요한 값을 같이 반환받게 됩니다.
공유 모델은 유스케이스가 추가될 때 마다 또는 유스케이스의 변경이 있을 때마다 점점 커지데 되는데 이렇게 되면 나중에 한 유스케이스의 출력값에서 불필요한 값이 겉잡을 수 없이 많아지게 됩니다.
그래서 입력 모델에서처럼 이러한 문제를 없애기 위해서는 단일 책임 원칙을 적용하여 각 유스케이스마다 출력 모델을 분리해서 유지해여 각 유스케이스간에 결합을 없앨 수 있어서 위에서 언급한 부작용을 막을 수 있습니다.
읽기 전용 유스케이스는 어떨까?
사용자 인터페이스에 단순히 현재 계좌 잔액을 표현해야 할 때 이를 위해 새로운 유스케이스를 구현할 필요가 있을까요?
만약에 전체 프로젝트의 맥락에서 이러한 작업이 유스케이스로 분류된다면 모델의 상태를 변경하는 다른 유스케이스들처럼 어떻게든 비슷한 방식으로 읽기 전용 유스케이스를 구현해야 합니다.
하지만 그게 아니라면 실제 유스케이스와 구분하기 위해 읽기 전용 유스케이스를 쿼리로 구현할 수 있습니다.
왜냐하면 도메인의 관점에서 이 작업은 간단한 데이터 쿼리이기 때문입니다.
육각형 아키텍처 스타일에서 이를 구현하는 한 방법은 쿼리를 위한 인커밍 전용 포트를 만들고, 이를 쿼리 서비스에 구현하여 쿼리 서비스와 유스케이스 서비스를 동일한 방식으로 동작하게 하는 것입니다.
이처럼 읽기 전용 커리를 쓰기가 가능한(모델의 상태를 변경하는) 유스케이스(커맨드)와 코드 상에서 명확히 구분함으로써 CQS(Command-Query-Separation)이나 CQRS(Command-Query Responsibility Segregation)을 추구할 수 있습니다.
유지보수 가능한 소프트웨어를 만드는 데 어떻게 도움이 될까?
유스케이스별로 각각 입력 모델을 만들고, 입력 모델의 필드 멤버를 모두 불변으로 설정하고 생성자를 통해 꼼꼼한 입력 유효성 검증을 합니다.
풍부한 도메인 모델 또는 풍부한 유스케이스 모델을 통해 비즈니스 규칙 역시 꼼꼼하게 검증합니다.
그리고 입력 유효성과 비즈니스 규칙의 실용적인 구분을 통해 나중에 유지보수를 위해 코드에서 손쉽게 찾을 수 있습니다.
유스케이스별로 각각 출력 모델을 구현하여, 유스케이스 간의 결합도를 낮추고, 부작용을 방지합니다.
위의 조건에 더하여 마지막으로 읽기 전용 쿼리와 쓰기 가능한 유스케이스의 구분을 통해 유지보수 가능한 소프트웨어를 만드는데 큰 도움이 될 수 있습니다.
댓글남기기