Clean-Architecutre #5-2부 - Hexagonal 관점에서 본 클린 아키텍처의 구조

Clean-Architecutre #5-2부 - Hexagonal 관점에서 본 클린 아키텍처의 구조

클린 아키텍처의 의존성에 따른 경계구조와 청소 정책

17. 경계: 선 긋기

경계란?

소프트웨어의 요소를 서로 분리하고 의존성을 관리하는 것이다.
가능한 핵심적인 업무 로직이 담긴 도메인을 외부의 변화로부터 보호하는 것이다.
너무 일찍 내려진 결정사항들(디비, 라이브러리, 등) 결합도를 증가시켜 변화시에 인적자원의 소모를 증가시킨다.

경계선은 DIP 로 핵심 어플리케이션이 외부 선택사항들에 최대한 독립적으로 개발될 수 있게 해준다.
저수준에서 고수준을 바탕으로 개발되어야 한다.(의존성 역전 원칙, 안정된 추상화 원칙)


18. 경계 해부학

시스템 아키텍처는 컴포넌트를 분리하는 경계에 의해서 정의되며 경계는 다양한 형태로 존재한다.

  • 경계 횡단하기
    • 소스 코드의 변경은 의존하는 다른 소스 코드에 영향을 주기 때문에 경계를 그어 이러한 소스 코드의 의존성을 관리하는 것이다.
  • 두려운 단일체 *
  • 배포형 컴포넌트
    • 아키텍처의 경계가 물리적으로 드러나는 방법으로 가장 단순한 형태는 동적 링크 라이브러리(DLL, Dynamic Link Library)이다.
    • 모든 함수가 동일한 프로세서와 주소 공간에 위치하며 컴포넌트 분리, 의존성 관리는 단일체 구조와 같은 전략을 사용한다
  • 스레드
    • 모든 함수가 동일한 프로세서와 주소 공간에 위치하며 컴포넌트 분리, 의존성 관리는 단일체 구조와 같은 전략을 사용한다
  • 로컬 프로세스
    • 로컬 프로세스 간 분리 전략은 저수준 프로세스가 고수준 프로세스를 의존하게 만들고 저수준 프로세스가 플러그인 될 수 있도록 만드는 것이다.
  • 서비스
    • 물리적인 형태로 가장 강력한 형태를 띠는 경계로 서비스 자체는 프로세스이며 시스템 콜에 의해 동작한다.
    • 서비스 간 통신은 네트워크를 통해 이루어진다고 가정하고 많은 비용이 들기 때문에 잦은 통신은 지양해야 한다.

솔직히 클린 아키텍처가 그냥 아키텍트의 바이블이라고 얘기들이 많았는데 이정도로 추상적이고 애매할 줄은 몰랐다.
저자가 이번 경계 해부학에 대한 단원을 클린아키텍처 스타일인 추상체로 작성했나라는 생각이 들었다. 아님 번역이 잘못됐거나.


19. 정책과 수준

🌟 정책

  • 좋은 아키텍쳐라면 각 컴포넌트를 연결할 때 의존성의 방향이 컴포넌트의 수준을 기반으로 연결되도록 만들어야 한다.
  • 저수준 컴포넌트가 고수준 컴포넌트에 의존하도록 설계되어야 한다.

🌟 수준

  • adapter부분에서 멀어질수록, application의 내부에 들어올수록 고수준이다.
  • 저수준으로 내려갈수록 변동 가능성이 높아지고 변동되어도 고수준에 영향이 가지 않게 설계해야 한다.
  • 저수준 컴포넌트는 고수준의 Interface를 보고 요구사항을 파악해 구현해야 한다.

20. 업무 규칙

클린 아키텍처에서는 업무 규칙(핵심) 과 각종 어댑터(플러그인)으로 나뉜다.
이번 장에서는 업무 규칙의 종류에 대해 알아본다.

핵심 업무 규칙은 우리가 구현하려고 하는 시스템의 수익구조이다.

  • 프로그램이 없더라도 핵심 업무 규칙은 계속 존재한다. (은행이 이자를 계산하는게 프로그램이 아니라 사람이 해도 같은 것 처럼)
  • 이런 규칙에는 핵심 업무 데이터가 필수적으로 수반되는데, 이 데이터는 핵심 업무 규칙을 구현하는데 필요한 데이터다. ex)고객의 잔액, 이자율 등

🌟 엔티티

엔티티핵심 업무 데이터를 포함하고 있으며, 핵심 업무 규칙을 구현한다.

  • 핵심 업무 데이터를 직접 포함하거나 데이터에 쉽게 접근할 수 있다.
  • 엔티티의 인터페이스는 핵심 업무 데이터를 기반으로 동작하는 핵심 업무 규칙을 구현한 함수들로 구성된다.

엔티티는 일종의 불가침 영역이다. 독립적인 영역으로 어떤 DB의 변화나 다른 영역의 변화에 영향을 받지 않아야 한다.

  1. 핵심 비즈니스 규칙: 엔티티는 어플리케이션의 핵심 비즈니스 로직을 포함하며, 이 로직은 사용자 인터페이스(UI), 데이터베이스, 외부 시스템 등 외부 요인에 영향을 받지 않아야 합니다.

  2. 데이터베이스 독립성: 엔티티는 데이터베이스의 스키마나 구조에 의존적이지 않습니다. 데이터베이스에 저장되는 구체적인 형태와는 무관하게, 엔티티는 순수한 비즈니스 로직과 관련된 데이터를 포함해야 합니다.

  3. 프레임워크 독립성: 엔티티는 특정 프레임워크에 의존하지 않아야 합니다. 프레임워크는 도구일 뿐이며, 핵심 비즈니스 로직은 프레임워크와 독립적으로 작성되어야 합니다.

프로그램이 없으면 사람이 계산했을것이다 란 말의 뜻은 핵심 업무 규칙은 프로그램이 없어도 존재한다는 것이다.
엔티티는 그래서 순수한 자바로 작성하는게 좋다고 생각된다.
프래임워크나 롬복 라이브러리, DB JPA도 다 선택사항이기 때문이다.
선택사항은 최대한 미뤄야 좋다.

좋다. 그렇다면 순수 자바객체로 엔티티를 만들어보자. 간단하게 대출을 담당하는 Loan 비즈니스를 만들것이다.

public class Loan {
  private BigDecimal amount;
  private double interestRate; // 이자율
  private LocalDate loanDate;
  private LocalDate repaymentDate;

  // ... (생성자, getter, setter 등)

  // 1. 대출금 이자 계산
  public BigDecimal calculateInterest() {
    long daysBetween = ChronoUnit.DAYS.between(loanDate, repaymentDate);
    return amount.multiply(new BigDecimal(interestRate * daysBetween));
  }

  // 2. 상환액 계산
  public BigDecimal calculateTotalRepayment() {
    return amount.add(calculateInterest());
  }

  // 3. 대출 기간 확인
  public long loanPeriodInDays() {
    return ChronoUnit.DAYS.between(loanDate, repaymentDate);
  }
}


  • Loan은 대출 비즈니스의 핵심 데이터인 대출 잔액, 이자율, 빌린 날짜와 갚을 날짜를 가지고 있다.
  • 내부에서 대출금 이자를 계산하는 함수, 상환액을 계산하는 함수, 대출 기간을 확인하는 함수를 가지고 있다.

✒︎ Q1. id를 가지고 있지 않아도 되는가?

  • id는 데이터베이스에 저장할 때 필요한 값이다. 엔티티는 데이터베이스와 관련이 없이 깨끗한 POJO여야 한다.
  • 그 자체로 비즈니스 핵심 로직의 정수인 것이다. 그래서 id는 필요없다.

김현우_GPT_entity_id 흠.. 트레이드 오프란 말이지?

id를 가지고 있으면 db조회, update용 객체를 만들지 않아도 사용이 가능하고 변환이 쉽다는 의미이고,
순수 POJO로만 유지하면 아키텍처상 아름답고 유지보수가 편하고, 변화에 유연하다는 장점이 있다.
(갑자기 JPA를 변경하더라도 가능)

나는 오염없는 Entity를 원한다. 그렇다면 DB용 객체를 생성해야겠지?


@Entity
@Table(name = "loan")
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class LoanJpaEntity {

  @Id @GeneratedValue
  private Long id;
  private BigDecimal amount; // 대출금액
  private LocalDate loanDate; // 대출일자
  private LocalDate repaymentDate; // 상환일자
}

대표적인 예시로 Loan의 Infra를 JPA를 사용한다고 했을 때, JPA Entity를 만들어봤다.
JPA Entity는 DB와 매핑되는 객체이다. 그래서 DB의 id를 가지고 있어야 한다.
이쯤되면 LoanJpaEntity의 개발 시점은 infra를 JPA로 사용할 때 만들어지는 것이다.
저수준의 객체라고 볼 수 있다.

✒︎ Q2. LoanJpaEntity와 오염없는 Loan을 어떻게 변환해서 사용하는가?

  • LoanJpaEntity는 Infra Adapter에 속하고 Loan은 Domain에 속한다.

Request부터 Loan 생성 예제를 만들어보려고 한다. Mapstruct를 사용해서 Loan 엔티티를 저장을 위해 Jpa엔티티로 변환하려고 한다.

@RestController
@RequestMapping("/loans")
public class LoanController {

  @Autowired
  private CreateLoanUseCase createLoanUseCase;

  @PostMapping
  public ResponseEntity<Void> createLoan(@RequestBody LoanRequest loanRequest) {
    Loan loan = new Loan(
            loanRequest.getAmount(),
            loanRequest.getLoanDate(),
            loanRequest.getRepaymentDate()
    );

    createLoanUseCase.createLoan(loan);
    return ResponseEntity.status(HttpStatus.CREATED).build();
  }
}

  • LoanRequest 를 받아서 핵심 도메인인 Loan으로 변환시키고 Service 레이어로 비즈니스 로직 수행을 부탁한다.
  • Controller영역에서 DTO -> 서비스레이어 사용 도메인으로 변환시키는 역할을 수행시키면 Service에서 비즈니스로직만 수행할 수 있다.
  • Controller는 Usecase밖에 모른다. 실질적인 Service구현체는 Application에 있다.

public interface CreateLoanUseCase {

  //대출 생성
  Loan createLoan(Loan loan);
}


@Service
@RequiredArgsConstructor
public class LoanServiceImpl implements CreateLoanUseCase {

  private final SaveLoanUseCase saveLoanUseCase;

  @Override
  public Loan createLoan(Loan loan) {
    return saveLoanUseCase.save(loan);
  }
}


  • Service 레이어에서는 Loan을 저장하는 비즈니스 로직만 수행한다.
  • 여기서 직접 Jpaentity로 변환시키면 JPA에 의존성이 생기기 때문에 Infra Adapter에게 위임한다.
public interface SaveLoanUseCase {
  Loan save(Loan loan);
}

public interface LoanJpaRepository
        extends JpaRepository<BankAccountEntity, Long> {
}


@Repository
@RequiredArgsConstructor
public class LoanJpaAdapter
        implements SaveLoanUseCase {

  private final LoanMapper loanMapper;
  private final LoanJpaRepository repository;

  @Override
  public void save(Loan loan) {
    LoanJpaEntity entity = loanMapper.toEntity(loan);
    repository.save(entity);
  }
}



  • Mapstruct로 domain -> JpaEntity로 변환시키고 JpaRepository에 저장한다.

확실히 Usecase, Service단에서는 순수 POJO 객체 Loan만 사용하고 Infra Adapter에서 데이터변환객체를 사용하는 것을 볼 수 있다.
Jpa가 변해도 Infra Adapter만 변경하면 되고, Service단과 핵심 어플리케이션 로직,데이터에는 변화가 없다.

loan
│
├─ adapter
│  ├─ in
│  │  ├─ web          (웹 요청을 처리하는 컨트롤러 및 DTO)
│  │  │  └─ <LoanController> (웹 요청을 처리하기 위한 컨트롤러)
│  │  └─ ui           
│  │
│  └─ out
│     └─ persistence  
│       └─ jpa
│           └─ jpaEntity  
│           │   └─ <LoanJpaEntity> (@Entity 어노테이션이 있는 JPA 엔티티)
│           └─ mapper    
│               └─ <LoanMapper> (Domain과 JPA Entity를 변환하기 위한 MapStruct Mapper)
│
├─ application
│  ├─ port
│  │  ├─ in           
│  │  │  └─ <CreateLoanUseCase> (대출 생성에 관련된 비즈니스 로직을 정의하는 인터페이스)
│  │  └─ out          
│  │      └─ <SaveLoanUseCase> (대출 정보를 저장하기 위한 로직을 정의하는 인터페이스)
│  │
│  └─ service         
│      └─ <LoanService> (CreateLoanUseCase와 SaveLoanUseCase를 구현하는 서비스 클래스)
│
└─ domain             
└─ <Loan> (핵심 도메인 로직과 데이터를 포함하는 도메인 클래스)

  • 위 생성한 클래스들을 package로 분류해보면 위와 같은 구조가 나올 것이다.

✒︎ Q3. 클린 아키텍처의 Entity랑 흔히 말하는 Domain이랑 차이점이 있나?

김현우_domain_entity_차이점

답변이 만족스러워서 넘어갈 수 있었다. 역시 똑똑하군 GPT4


🌟 유스케이스

엔티티는 순수한 결정체라면, 유스케이스의 명세는 시스템의 사용설명서이다.
엔티티 내부의 핵심 업무 규칙을 어떻게, 언제 호출할지 명세한다.

자동화된 시스템이 사용되는 방법

  • 유스케이스는 사용자가 제공해야 하는 입력
  • 사용자에게 보여줄 출력
  • 해당 출력을 생성하기 위한 처리단계를 기술한다.

유스케이스에서 중요한건 무엇을 한다 이지 어떻게 한다가 아니다.
유스케이스만 봐서는 이 어플리케이션이 웹으로 전달되는지, 모바일로 하는지, 콘솔 기반인지 몰라야 한다.

public interface ReceiveLoanUseCase {
  Loan receive(LoanRequest request);
}
  • 위 유스케이스는 대출을 받는 용도이다.
  • 모바일인지,웹인지 어디서 사용하는진 모르겠고 대출을 받는 행위, 그리고 그 행위를 위한 데이터를 주고 받기만 명시되어있다.
  • 어떻게 받는지는 명시되어있지 않다. (어떤 방식으로 받는지는 Adapter에서 결정된다.)

@Service
@RequiredArgsConstructor
public class LoanService implements ReceiveLoanUseCase {

  private final LoanSaveUseCase loanSaveUseCase;//OUT PORT

  @Override
  public Loan receive(LoanRequest request) {
    // 대출 로직 처리
    Loan loan = new Loan(request.getAmount(), request.getDuration());
    loanSaveUseCase.save(loan);
    return loan;
  }
}


위의 LoanService는 대출을 받는 행위(receive 메서드)를 구현하고 있다.
유스케이스의 관점에서 이 메서드 내에서 어떤 로직(대출 요청을 검증하거나 대출 금액을 계산하는 등)이 수행되는지는 중요하지 않다.
중요한 것은 이 메서드를 통해 대출을 받을 수 있다는 점이다.

이 서비스는 대출을 받는 방법(온라인 대출인지, 오프라인 대출인지)에 대해서는 알지 못한다.
서비스는 단순히 대출을 받는 행위만을 수행할 뿐이다.

  • 유스케이스는 어플리케이션에 특화된 업무 규칙을 구현하는 하나 이상의 함수를 제공한다.
  • 입력DTO, 출력DTO, 상호작용하는 엔티티에 대한 참조등 데이터 요소를 포함한다.

엔티티는 유스케이스에 대해 아무런 의존성이 없다. 오직 유스케이스가 엔티티를 가지고 행위를 명시한다.

✒︎ Q1. 왜 엔티티가 고수준개념이고 유스케이스가 저수준개념일까?

먼저 고수준 개념과 저수준 개념에 대해 설명해보자.

  1. 고수준 개념 : 비즈니스 규칙과 관련된 중요한 정책을 나타냅니다. 이는 특정한 프레임워크, UI, 데이터베이스와 같은 외부 세계의 영향을 받지 않는 순수한 도메인 로직을 의미합니다.
  2. 저수준 개념 : 시스템의 세부 사항과 관련된 부분입니다. 예를 들면, 데이터를 어떻게 저장하고 검색할지, UI를 어떻게 구현할지 등의 세부 사항들이 여기에 해당합니다.

클린 아키텍처에서는 항상 의존성이 항상 저수준 개념에서 고수준 개념으로 향해야 한다.고 주장한다.

“의존성이 항상 저수준 개념에서 고수준 개념으로 향해야 한다”는 원칙의 핵심은,
핵심 비즈니스 로직이나 중요한 정책(고수준 개념)이 구체적인 구현이나 세부 사항(저수준 개념)에 의존해서는 안 된다는 것입니다.
반대로, 구체적인 구현이나 세부 사항은 중요한 정책이나 비즈니스 로직을 참조하고 의존해야 합니다.
즉 의존성은 ‘좀 더 추상적인’ 부분으로 향해야 합니다. 다시 말해서, 저수준의 구체적인 구현은 고수준의 추상적인 개념에 의존하게 됩니다.

이제 엔티티와 유스케이스에 대해 정의해보자.

  1. 엔티티 : 이것은 시스템의 핵심 비즈니스 로직을 포함하고 있습니다. 예를 들면, 계좌에서 돈을 인출할 때의 규칙이나 조건 같은 것입니다. 이러한 로직은 어떤 UI가 사용되든, 어떤 데이터베이스를 사용하든 변경되어서는 안 됩니다.
  2. 유스케이스 : 유스케이스는 특정 사용자의 요구를 충족하기 위해 시스템이 어떻게 행동해야 하는지를 설명합니다. 유스케이스는 엔티티의 핵심 비즈니스 로직을 활용하여 사용자의 요구를 충족시키는 방법을 정의합니다.

예를 들어, "계좌에서 돈을 인출하는 행위" 는 유스케이스일 수 있다.
그러나 돈을 인출할 수 있는지 여부를 결정하는 규칙은 엔티티에 속하게 된다.(원하는 출금액이 amount보다 크면 인출 불가규칙)

따라서 엔티티가 더 고수준의 개념이며, 유스케이스는 더 저수준의 개념으로 간주된다.
왜냐하면 엔티티는 시스템의 핵심 비즈니스 규칙을 나타내기 때문이다. 반면 유스케이스는 그러한 규칙을 사용하여 특정 사용자의 요구를 충족시키는 방법을 정의한다.
각종 입력과 출력에서 멀어질수록, 의존성이 없고 일반화되어있을 수록 고수준이다.

유스케이스에서 주고 받는 DTO와 Domain Entity를 확실히 분리하여야 한다. 시간이 지나면 두 객체는 완전히 다른 이유로 변경될 것이고 서로 다른 속도로 변경될 것이다.


21. 소리치는 아키텍처

엔티티와 유스케이스만 보고 어떤 어플리케이션인지 파악해야 한다!!!!!

  • 아키텍처는 시스템의 모든 세부사항을 설명하는 것이 아니다.
    • 아키텍처는 시스템의 큰 그림을 제공하는 설계 원칙과 가이드라인을 포함한다.
    • 집의 설계도중에서도 구조만 설명되어있는 것과 같다. 화장실의 타일, 색상, 가구의 위치는 설계도에 포함되지 않는다.
  • 아키텍처의 유스케이스와 엔티티만을 보고 해당 시스템이 어떤 일을 하고 있는지 다 알 수 있어야 한다.
    • 우리는 도서관의 설계도와 소방서의 설계도를 보고 무엇을 하는 공간이냐 물어보면 맞출 수 있다. (사서를 위한 카운터, 소방차 주차공간 등)
    • 우리 프로그램의 Entity와 유스케이스도 이와 같다.
    • 엔티티를 보고 핵심 업무를 파악하고 유스케이스를 보고 무슨 기능인지 다 파악할 수 있어야 한다.
  • 아키텍처는 프레임워크와 Db, 라이브러리에 의존하지 않는다.
    • 그래서 시스템의 핵심 비즈니스 로직과 기능은 외부 도구나 라이브러리에 의존적이지 않아야 한다.
    • 이렇게 하면 시스템은 유연하게 유지되며, 외부 의존성의 변화에 대응하기 쉽다.

Web방식 또한 선택사항이기 때문에 Adapter에 Controller가 들어있다. Web방식 또한 결정사항이다.
Controller와 DB 없이도 Usecase와 핵심 Entity만으로 이미 핵심기능에 대한 모든 테스트가 가능해야 한다. 테스트가 더 편해진다.
프레임워크 또한 선택사항이고 도구라는 것을 항상 잊으면 안된다.


22. 클린 아키텍처

드디어 아키텍처를 클린하게 짜는 방식에 대해 나온다.
클린 아키텍처는 이전에 나왔던 Architecture를 사용 가능하게 장점을 병합한 내용이다.
아래는 클린 아키텍처에 병합된 3 가지 아키텍처 스타일이다.

  1. Hexagonal Architecture (Ports and Adapters):
    • Hexagonal Architecture는 Alistair Cockburn에 의해 처음 제안되었으며, Ports and Adapters로도 알려져 있습니다.
    • 이 아키텍처의 주요 아이디어는 핵심 애플리케이션을 외부 요소로부터 격리시키는 것입니다.
      • 중심 (Core): 핵심 비즈니스 로직이 위치합니다.
      • Ports: 애플리케이션과 외부 세계 간의 인터페이스. 입력 포트와 출력 포트로 분류됩니다.
      • Adapters: 특정 기술 또는 프로토콜을 핵심 애플리케이션에 연결하는 구성요소입니다.
  2. Data, Context, and Interaction (DCI):
    • DCI는 객체 지향 프로그래밍에 대한 새로운 관점을 제시하는 아키텍처 스타일입니다.
    • DCI의 목표는 사용자의 사고 방식과 소프트웨어의 구조 간의 간극을 줄이는 것입니다.
      • Data: 시스템의 데이터를 나타내는 객체들입니다.
      • Context: 특정 작업을 실행하는 데 필요한 상황 또는 환경을 나타냅니다.
      • Interaction: 다양한 객체 간의 협력을 나타냅니다. 이는 사용자의 목표를 달성하기 위한 특정 연산을 표현하는 방법입니다.
  3. Boundary-Control-Entity (BCE):
    • BCE는 Ivar Jacobson에 의해 제안된 아키텍처 패턴으로, 주로 Use Case 지향적 설계에서 사용됩니다.
      • Boundary (or Interface): 사용자나 외부 시스템과의 상호작용을 담당하는 객체들입니다.
      • Control: 사용 사례를 나타내며, 주요 처리를 담당합니다.
      • Entity: 비즈니스 도메인의 핵심적인 개념을 나타내는 객체들입니다. 이들은 보통 데이터베이스에 저장되는 영속적인 정보를 담고 있습니다.

궁극의 클린 아키텍처는 이 세 가지 아키텍처 스타일을 결합한 것이다.

  • 세 아키텍처의 공통점
    • 프레임 워크에 독립적이다.
    • 테스트에 편하다. (핵심 로직과 유스케이스가 POJO)
    • UI에 독립적이다.
    • 데이터베이스에 독립적이다.
    • 실제 업무 핵심 규칙은 외부 요소에 독립적이다.
  • 세 아키텍처가 다르게 추구하는 것들
    • 시스템의 핵심 비즈니스 로직을 외부 요소로부터 격리시키고,
    • 사용자의 사고 방식과 소프트웨어의 구조 간의 간극을 줄이며,
    • Use Case 지향적 설계를 적용하는 것을 의미합니다.


김현우_클린아키텍처

클린 아키텍처의 규칙을 설명한다.

  • 밖에서 안으로 들어올수록 고수준이다. (완전 외부 > Adapter > Usecase > Entity(Domain) )
    • 고수준일 수록 핵심 비즈니스 로직(정책), 저수준일 수록 교체 가능해야 한다. (메커니즘)

따라서 소스코드의 의존성은 반드시 안쪽으로, 고수준의 정책을 향해야 한다.

각 원의 계층별로 안의 계층은 밖의 계층의 존재 자체도 모르게 설계되어야 한다.
모른다는 것은 함수, 클래스, 변수, 소프트웨어 엔티티로 명명되는 모든 것을 사용하지 않음을 의미한다.
데이터 형식 자체도 외부에서 생성된 것이라면 사용해선 안된다. 어떤 것도 내부의 원에 외부의 것이 오염되면 안된다.

지금부터 고수준인 Entities부터 하나씩 원에 어떤 것들이 존재하는지 알아보자.

🌟 Entities

핵심 업무 규칙과 필요한 데이터를 캡슐화한다.
그 어떤 외부의 변화에도 영향을 받지 않는다. (대출 부서의 경우 대출금액, 이자율, 대출일자, 상환일자 등)
아예 소스코드가 없더라도 대출 부서의 업무 규칙은 존재한다는 사실이 중요하다.

🌟 Use Cases

유스케이스는 어플리케이션에 특화된 업무 규칙을 포함한다.
사용자가 대출을 받는다거나 사용자 이름을 받아서 대출 가능한지 신용점수를 조회한다거나 하는 것들이 포함된다.
엔티티의 핵심업무규칙을 이용해서 유스케이스의 목표달성에 사용한다.
엔티티로 들어오고 나가는 데이터 흐름과 기능만 명세하고 어떻게는 관여하지 않는다.

🌟 Interface Adapters

  • Controllers
    • 웹 어플리케이션에서 유저의 요청을 처리한다.
    • 예를 들어 여기서 사용자의 요청을 받는 RequestDTO는 UseCase로 반입되면 안된다.
    • UseCase는 RequestDTO를 몰라야 하기 때문에 Entity로 변환해서 넘긴다.

웹 어플리케이션에서 사용자가 어떤 버튼을 클릭하거나 폼을 제출하면, 그 요청은 Controller에 도달한다.
Controller의 주요 역할은 이러한 사용자의 요청을 적절한 서비스나 유스케이스에 전달하는 것이다.

@RestController
public class LoanController {

  @Autowired
  private CreateLoanUseCase createLoanUseCase;
  @Autowired
  private LoanMapper loanMapper;

  @PostMapping("/loans")
  public ResponseEntity<Loan> createLoan(@RequestBody LoanRequest loanRequest) {
    Loan loan = loanMapper.toDomain(loanRequest);
    createLoanUseCase.createLoan(loan);
    return ResponseEntity.ok(loan);
  }
}

  • Gateways
    • 애플리케이션과 외부 세계 사이의 통신 채널을 나타낸다.
    • 주로 DB와의 상호작용, 외부 API와의 통신을 위해 사용된다.
public interface LoanRepository extends JpaRepository<LoanJpaEntity, Long> {
}

@Repository
@RequiredArgsConstructor
public class LoanJpaAdapter implements SaveLoanUseCase {

  private final LoanMapper loanMapper;
  private final LoanRepository repository;

  @Override
  public void save(Loan loan) {
    LoanJpaEntity entity = loanMapper.toEntity(loan);
    repository.save(entity);
  }
}
  • Presenters
    • 유스케이스가 제공하는 출력 데이터를 사용자 인터페이스에 맞게 변환하는 역할을 한다.
    • 출력 데이터는 UI에 직접적으로 적합하지 않을 수 있기 때문이다.
    • 이 때, Presenters는 그 데이터를 UI 또는 클라이언트에 적합한 형태로 변환해준다.

예를 들어 어떤 유스케이스가 대출의 총액과 이자를 계산하여 반환한다고 가정합시다.
이 결과는 LoanResult라는 내부 도메인 모델로 표현될 수 있습니다.
그런데, 웹 페이지에서는 이 결과를 “총 대출액: XXXX 원, 이자: XXX 원”과 같은 형식의 문자열로 표시하려고 합니다.
이럴 때, Presenter는 LoanResult를 받아 해당 문자열로 변환해줍니다.

public class LoanPresenter implements LoanPresentOutPort {
  private LoanViewModel viewModel;

  @Override
  public void present(LoanResult loanResult) {
    viewModel = new LoanViewModel(
            "총 대출액: " + loanResult.getTotalAmount() + " 원",
            "이자: " + loanResult.getInterest() + " 원"
    );
  }

  public LoanViewModel getViewModel() {
    return viewModel;
  }
}

🌟 프레임워크와 드라이버

  • Spring: 자바 플랫폼을 위한 광범위한 프레임워크로, 의존성 주입, AOP, MVC 등 다양한 기능을 제공합니다.
  • Hibernate: ORM(Object-Relational Mapping) 프레임워크로, 자바 객체와 데이터베이스 테이블을 매핑합니다.
  • 각종 Infra : Mysql,MongoDB, Redis, Kafka 등

계층의 최후의 세부사항들이다.

아래의 규칙만 지킨다면 계층은 더 늘어나도 된다.

  • 소스코드 의존성은 항상 안쪽을 향한다.
  • 안쪽으로 이동할수록 추상화와 정책의 수준은 높아진다.
  • 가장 바깥쪽 원은 저수준의 구체적인 세부사항으로 구성된다.

🌟 경계 횡단하기

경계는 위에 나온 클린 아키텍처 사진에서 원에 해당하는 선을 의미한다. ex) Adapter /경계/ useCase

김현우_경계횡단하기

✒︎ Q: 제어 흐름과 의존성 방향이 명백히 반대여야 하는 경우 의존성 역전 원칙을 사용하여 해결한다는게 무슨 말인가?

이 문장을 이해하는데 굉장히 큰 애를 먹었다. 이해하기 위해서는 일단 제어 흐름과 의존성 방향이 무엇인지 알아야 한다.
⚠먼저 나의 잘못된 생각을 예시로 들고 추후에 바로잡도록 하겠다.

  • 제어흐름이란 무엇인가?
    • 단순히 프로그램 실행 순서라고 알면 된다.
    • 위에서 사용자의 요청은 Controller에서 시작되어 UsecaseImpl를 거치고, 마지막에는 Presenter에서 결과를 보여주는 형태로 흘러간다.

      ⚠ 틀렷다. 제어 흐름은 마지막에 제대로 다룬다.

  • 의존성 방향은 무엇인가?
    • 소스코드 상에서 한 객체가 다른 객체의 기능을 사용할 때의 참조 관계이다. (얼마나 다른 것에 의존하여 동작하는지)
    • Controller는 UsecaseImpl의 특정 메서드를 사용해서 호출해 의존한다. Controller -> Usecase
    • Presenter는 UsecaseImpl의 특정 메서드를 사용해서 호출해 의존한다. Presenter -> Usecase
    • 그러나 Usecase는 Controller와 Presenter에 대해 전혀 모른다. Usecase <- Controller, Presenter

      ⚠ 결론부터 말씀드리자면 이렇게 알고 있는건 틀렸었다. 완전히 잘못된 이해로 가는데 큰 원인이었다.
      호출이 아니라 변화에 영향을 가지고 있으면 의존하고 있다고 얘기하고, 의존성 방향을 긋는것이다. 추후에 의존성에 대해 더 자세히, 제대로 알아보자.

  • 의존성 역전 원칙은 무엇인가?
    • 보통 기존의 프로그래밍 흐름은 고수준에서 저수준을 호출하는 것이었다. UsecaseImpl이 아래층인 Presenter구현체를 직접 호출
    • 여기서 Presenter가 UsecaseImpl을 호출하게 하면 제어흐름과 의존성 방향이 명백히 반대가 된다.
    • 그런데 Presenter가 UsecaseImpl을 직접 호출하게 둘 수는 없으니 Usecase의 인터페이스를 만들어서 Presenter가 Usecase를 호출하게 한다.
    • UsecaseImpl은 더이상 Presenter를 의존하지 않고 자신의 기능명세만 가지고 작동시키고, 하위의 구현체는 interface를 호출하게 한다.

      ⚠ 의존성에 대한 잘못된 이해를 가지고 DIP를 설명하고 있었다. 왜 호출이란 단어에 집착하고 있었는지는 모르겠지만,
      결론은 직접 의존하여 강결합을 가지고 있어서 구현체의 변화에 직접적 타격을 받던 상황을 추상체로 바꾼것이다.


지금부터 잘못 알던 내용을 바로잡는다.

예시코드와 함께 보자.
사용자의 이름을 입력받아 환영 메시지를 생성하는 시나리오이다.
제일 먼저 Controller에 입력이 들어온다.

public class WelcomeController {
  private UsecaseInPort usecaseIn;

  public WelcomeController(WelcomeUsecase usecase) {
    this.usecase = usecase;
  }

  public void handleRequest(String userName) {
    usecase.generateWelcomeMessage(userName);
  }
}

  • WelcomeController는 usecaseInPort를 의존한다.
  • 스프링에서 usecaseInPort를 구현한 구현체인 usecaseImpl을 DIP로 넣어준다.

프레젠터를 만들어서 usecaseOutPort를 구현한다.

public class ConsolePresenter implements OutputPort {
  @Override
  public void displayMessage(String message) {
    System.out.println(message); // 예시로 콘솔에 출력하는 방식을 사용합니다.
  }
}

  • ConsolePresenter는 OutputPort를 의존한다. 그리고 구현한다.

의존한다는 의미는 단순히 사용해서 구현한다는 의미가 아니다. 더 넓은 범주의 변화에 예민한 정도를 의미한다.!

김현우_의존성에대한고찰_GPT

만족스러운 아름다운 답변이라 넘어갔다.

다음으로 UseCase를 구현한다.


//Controller에 DIP되어 쓰여지는 In Port
public interface UsecaseInPort {
  void generateWelcomeMessage(String userName);
}


//Presenter에 구현되어 사용되는 Out Port
public interface OutputPort {
  void displayMessage(String message);
}


public class WelcomeUsecaseImpl implements WelcomeUsecase {
  private OutputPort outputPort;

  public WelcomeUsecaseImpl(OutputPort outputPort) {
    this.outputPort = outputPort;
  }

  @Override
  public void generateWelcomeMessage(String userName) {
    String message = "환영합니다, " + userName + "님!";
    outputPort.displayMessage(message);
  }
}
  • Controller (InPort DIP) 에서 InPort 구현체인 WelcomeUsecaseImpl 호출
  • WelcomeUsecaseImpl (OutPort DIP) 에서 OutPort Interface 호출
  • WelcomeUsecaseImpl에서 사용된 OutPort기능시에 구현체인 ConsolePresenter가 DI된다. 호출되어 콘솔에 출력된다.

🌟 내가 이 문장을 이해할 수 없었던 이유 두 가지

  • 의존한다는 의미는 단순히 사용해서 구현한다는 의미가 아니다. 더 넓은 범주의 변화에 예민한 정도를 의미한다는 것을 모르고 사용으로 알고 있었다.
  • 내가 놓치고 있던 부분중에는 Class Diagram에서 선의 종류였다.

김현우_클래스다이어그램

사진을 다시 보고 해석해보자.

  • Controller는 InPort Interface의 변화에 변경된다. 그리고 직접 InPort를 멤버변수 선언하여 사용한다.
  • Presenter는 OutPort Interface의 변화에 변경된다. 그리고 직접 OutPort를 구현한다.
  • UsecaseImpl은 InPort를 Implements해서 구현한다. OutPort는 멤버변수로 선언되어 사용한다.

🌟 DIP 바로잡기

이제 다시 제어흐름과 의존성의 방향이 반대일 경우 DIP를 사용하여 해결한다는 말의 의미를 생각해보자.

  • 제대로된 의존성 역전은 아래 그림과 같다. 변화의 영향을 받는 의존에서 역전시켜 변화에 영향을 받지 않게 한다.
  • ex ) Controller에서 UsecaseInputPort를 사용하여 UsecaseImpl과 의존을 갖지 않는다.
  • ex ) UsecaseImpl에서 UsecaseOutputPort를 사용하여 Presenter와 의존을 갖지 않는다.

김현우_의존성역전원칙

김현우_경계횡단하기_예시

🌟 제어흐름 바로잡기

public class Main {
  public static void main(String[] args) {
    OutputPort presenter = new ConsolePresenter();
    UsecaseInPort usecase = new WelcomeUsecaseImpl(presenter);
    WelcomeController controller = new WelcomeController(usecase);

    controller.handleRequest("채트지");
  }
}

위의 예시코드를 Main에서 실행시키면 위와 같은 모습이 나올것이다.

제어 흐름은 아래와 같다.

  1. Controller의 요청 처리
    • WelcomeController의 handleRequest 메서드를 호출하면서 “채트지”라는 문자열을 인자로 전달합니다.
  2. Usecase 처리
    • WelcomeController의 handleRequest 메서드 안에서는, 전달받은 문자열을 사용해서 usecase의 특정 메서드 (예: welcomeMessage)를 호출하게 됩니다.
    • 이 때, WelcomeUsecaseImpl의 메서드가 실행됩니다. 그 안에서는 비즈니스 로직을 처리한 후 결과를 presenter (즉, ConsolePresenter)에 전달하게 됩니다.
  3. Presenter 처리
    • WelcomeUsecaseImpl에서 호출한 presenter의 메서드는 ConsolePresenter의 displayMessage 메서드를 실행시킵니다.
    • displayMessage 메서드는 전달받은 메시지를 콘솔에 출력합니다.

이와 같이 제어 흐름은 WelcomeController -> WelcomeUsecaseImpl -> ConsolePresenter 순서로 진행된다.
의존성 방향은 Controller 가 InPort, Presenter가 OutPort를 의존하고 있다. ( = 변화에 영향을 받는다.) UsecaseImpl은 usecaseLayer에서 생성한 Port만 의존하고 있다. ( = AdapterLayer의 변화에 영향을 받지 않는다.)

이로써 완전히 UsecaseImpl은 Adapter Layer의 변화에 영향을 받지 않는 상태로 제어 흐름은 유지되었다.


김현우_토비님 이해가 너무 안가서 토비님께 하소연하는 모습. 토비님은 찰리라는 강아지를 키우신다.


🌟 최종 응용편 : 전형적인 웹 시나리오

김현우_전형적인시나리오

자. 이제 마지막단계로 이 다이어그램이 어떻게 동작하는지 알아보자.
앞선 단계를 모두 거친 나는 이제 두렵지가 않다.

김현우_다이어그램최종