만들면서 배우는 클린 아키텍쳐 (2)
만들면서 배우는 클린 아키텍쳐 (톰 홀버그 지음, 위키북스)를 읽고 정리한 내용입니다.
이전 포스팅
포트
핵심 비즈니스 로직과 외부 시스템 간의 상호작용을 정의하는 인터페이스 역할을 하는 컴포넌트
입력 포트 (Input Port)
- 유스케이스의 진입점 역할
- 외부에서 내부로 흐르는 데이터 흐름 (=외부 요청을 내부로 전달)
- Controller와 같은 입력 어댑터에서 유스케이스를 호출할 때 사용
- 애플리케이션의 핵심 비즈니스 로직을 담고 있는 서비스(Service)가 이 인터페이스를 구현
- 비즈니스 로직의 공개 API
예시
public interface SendMoneyUseCase {
boolean sendMoney(SendMoneyCommand command);
}
출력 포트 (Output Port)
- 도메인 엔티티가 외부 시스템(데이터베이스, 메시지 브로커, API)과 통신하도록 돕는 역할
- 내부에서 외부로 흐르는 데이터 흐름 (= 내부 처리 결과를 외부로 전달)
- Persistence Adapter 같은 출력 어댑터에서 이 인터페이스를 구현
- 외부 시스템과의 의존성을 캡슐화하는 역할
예시
public interface LoadAccountPort {
Account loadAccount(AccountId accountId, LocalDateTime baselineDate);
}
public interface UpdateAccountStatePort {
void updateActivities(Account account);
}
어댑터
외부 시스템과 애플리케이션 코어(유스케이스) 사이를 연결하는 역할을 수행한다.
포트 인터페이스를 실제로 구현하여 외부 시스템과 상호 작용한다.
웹 어댑터 (Web Adapter)
웹 인터페이스를 제공하는 어댑터이며, 주도하는/인커밍 어댑터이다.
대표적으로 컨트롤러가 있다.
역할
- HTTP 객체 매핑
- 권한 검사
- 입력 유효성 검증 및 매핑
- 유스케이스 호출
- 출력을 HTTP로 매핑 및 반환
동작 과정
컨트롤러(웹 어댑터) ➡️ 포트(어플리케이션) ➡️ 서비스(어플리케이션)
더하자면, 포트가 생략되어 [컨트롤러 -> 서비스]로 작동 가능하다는 점에서 의존성 역전의 법칙 이 성립한다.
예시 - 컨트롤러
@RestController
@RequiredArgsConstructor
class SendMoneyController {
private final SendMoneyUseCase sendMoneyUseCase; // 유스케이스 호출
@PostMapping(path = "/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}")
void sendMoney(
@PathVariable("sourceAccountId") Long sourceAccountId,
@PathVariable("targetAccountId") Long targetAccountId,
@PathVariable("amount") Long amount) {
SendMoneyCommand command = new SendMoneyCommand(
new AccountId(sourceAccountId),
new AccountId(targetAccountId),
Money.of(amount));
sendMoneyUseCase.sendMoney(command);
}
}
영속성 어댑터 (Persistence Adapter)
주도되는 / 아웃고잉 어댑터
애플리케이션 계층과 외부 영속성 시스템(데이터베이스, 파일 시스템 등) 간의 상호 작용을 처리
데이터베이스 쿼리, 파일 입출력 등 영속성 관련 기술적인 세부 사항을 처리하며, 포트를 통해 서비스와 상호작용
동작 과정
- 서비스로부터 데이터 처리 / 조회 요청 수신
- 요청 데이터를 영속성 시스템에 적합한 형식으로 변환
- 영속성 시스템과 상호 작용하여 데이터를 저장하거나 조회
- 영속성 시스템으로부터 결과를 수신
- 수신된 결과를 애플리케이션 계층에서 사용할 수 있는 형식으로 변환하여 출력 포트를 통해 서비스로 전달
- 출력 코어는 어플리케이션 코어에 위치함을 유의하자
예시
@RequiredArgsConstructor
class AccountPersistenceAdapter implements LoadAccountPort, UpdateAccountStatePort { // 포트 구현
private final SpringDataAccountRepository accountRepository;
private final ActivityRepository activityRepository;
private final AccountMapper accountMapper;
@Override
// Account 모델은 Entity가 아닌 도메인 객체
public Account loadAccount(
AccountId accountId,
LocalDateTime baselineDate) {
...
}
@Override
public void updateActivities(Account account) {
...
}
}
위의 어댑터는 service 계층에서 사용된다.
@Transactional
@RequiredArgsConstructor
public class SendMoneyService implements SendMoneyUseCase {
private final LoadAccountPort loadAccountPort;
...
@Override
public boolean sendMoney(SendMoneyCommand command) {
LocalDateTime baselineDate = LocalDateTime.now().minusDays(10);
Account sourceAccount = loadAccountPort.loadAccount(
command.getSourceAccountId(),
baselineDate);
Account targetAccount = loadAccountPort.loadAccount(
command.getTargetAccountId(),
baselineDate);
...
}
}
유스케이스 (Use Case)
애플리케이션의 핵심 비즈니스 로직을 담당하는 계층
이 계층은 비즈니스 로직을 구체적으로 어떻게 실행할지 정의하며, 캡슐화된 서비스 계층이라고 이해할 수 있다.
동작 과정
입력을 받음 ➡️ 모델의 상태 변경 ➡️ 영속성 어댑터를 통해 포트로 상태 전달 ➡️ 저장 ➡️ 출력값을 출력 객체로 변환
예시
입력 포트(인터페이스)
public interface SendMoneyUseCase {
boolean sendMoney(SendMoneyCommand command);
}
유스케이스(입력 포트를 구현한 서비스)
@Transactional
@RequiredArgsConstructor
public class SendMoneyService implements SendMoneyUseCase {
private final LoadAccountPort loadAccountPort; // persistence-out port
private final AccountLock accountLock;
private final UpdateAccountStatePort updateAccountStatePort; // persistence-out port
@Override
public boolean sendMoney(SendMoneyCommand command) {
...
}
}
정리 및 패키지 구조
클래스가 어디에 위치하는지 확인하며 정리해보자!
크게 application, domain, adapter, config, common 로 나뉘며,
application은 비지니스 로직의 실질적인 구현인 service와, 명세에 해당하는 port가 위치하는 패키지
domain은 비지니스 모델이 상호작용하는 순수 자바 객체가 위치하는 곳
adapter는 외부와 상호작용하는 컴포넌트인 web-adapter들,
DB와 같은 외부 시스템과 소통하는 persistence-adapter로 구성된 패키지이다.