Clean Architecture 코드로 구현하며 이해하기
개요
클린 아키텍처, 헥사고날 아키텍처 등 많은 책들이 존재하고 코드들이 존재한다.
각자의 방식으로 클린 아키텍처를 구현하고 네이밍도 다양하게 구성할 수 있다.
난 Github 을 돌아다니며 어떤 구성 및 분리가 가장 좋은 아키텍처일까? 라고 찾던 중 좋은 코드 예제를 발견했다.
그것을 참조하여 간단한 Workflow 실행하는 APP 을 만들어봤다.
만들면서 느낀 부분과 고민에 대해서 정리하고 기록한다.
일단 클린 아키텍처, DDD 등의 개념은 외부 세계로부터의 도메인(서비스) 코드가 영향을 받지않아야한다.
즉 "써드 파티 서비스의 변경이 도메인 코드 변경을 만들어서는 안된다"라는 말이다.
가능할까?
직접 코드를 구성해보면서 이야기하자.
순서 1: 모듈 분리하기
내가 읽은 책들의 예제들은 Mono Repo - Mono Module 구조이고 패키지 단위로 나누고 있었다.
물론 그런 구조도 좋지만 어떤 클래스가 import 돼서 사용되고 있는지 한 눈에 알아보기 어려웠고 실수하기 쉬웠다.
그래서 난 Mono Repo - Multi Module 구조를 채택하였다.
build.gradle 에 필요한 모듈을 import 하지 않으면 사용하지 못하도록 제한을 두었다.
위 구조를 가져가다보니 확실하게 경계를 구분지어 줄 수 있었다.
Adapter 부터 살펴보면 in, out 모듈까지는 너무 depth 를 만드는 것 같아 사용하지 않았다.
현재 in 은 controller 이고 out 은 in-memory, mysql 이다.
그리고 실제 서비스 로직 구현은 usecase, domain 이 담당한다.
application 은 java program, spring app 등이 될 수 있다.
spring-boot-app 을 제외하고는 spring 관련 의존성을 전혀 사용하지 않았다.
spring-boot-app 에선 @Configuration + @ConditionalOnProperty 를 사용하여 외부 시스템 교체가 쉽도록 지정하게 만들었다.
순서 2: In-Adapter 구현하기
In-Adapter 인 controller 모듈은 내부적으로 request + response + controller 클래스로 구성된다.
역할은 "요청 검증(비지니스 검증 제외) 및 응답 변환" 이다.
아래 코드를 보면 Usecase 는 Domain 을 반환한다.
여기서 궁금할 수 있다.
Usecase 에서 Response 를 만들어서 주면 Adapter 모듈이 domain 모듈을 import 하지 않아도 되는거아냐?
그러나 문제가 있다고 생각한다.
만약 In-adapter 가 하나 더 생기면 Adapter 응답 형식에 맞춰 Usecase 에서 작업해줘야하는게 맞는 방향일까?
각자의 Adapter 가 동일한 Domain 을 받아 각자의 Response 에 맞게 변환하는게 맞다고 본다.
public class GlobalPropertyController {
private final FindGlobalProperty findGlobalProperty;
public GlobalPropertyResponse getProperty(long id) {
GlobalProperty globalProperty = findGlobalProperty.getGlobalProperty(id);
return GlobalPropertyResponse.from(globalProperty);
}
}
순서 3: Usecase 구현하기
Usecase 모듈은 실제 비지니스 로직이 실행되는 부분이다.
domain 모듈과 직접적으로 연결된다.
domain 모듈에 있는 클래스들의 상태를 직접 변경하면서 비지니스 로직을 실행한다.
또한 다양한 포트를 통하여 out-adapter 와 연계 작업을 진행한다.
아래 코드 예시이다.
public class DeleteWorkflow {
private final JobPropertyRepository jobPropertyRepository;
private final WorkflowRepository workflowRepository;
public DeleteWorkflow(JobPropertyRepository jobPropertyRepository, WorkflowRepository workflowRepository) {
this.jobPropertyRepository = jobPropertyRepository;
this.workflowRepository = workflowRepository;
}
public void deleteWorkflow(long workflowId) {
jobPropertyRepository.deleteByWorkflowId(workflowId);
workflowRepository.deleteById(workflowId);
}
}
또한 여기서 빈약한 도메인 vs 풍부한 도메인 결정을 해서 구현해야한다.
* 빈약한 도메인: 보일러 플레이트 코드(Getter, Tostring..) 등을 가진 클래스
* 풍부한 도메인: 클래스의 상태를 변경할 수 있는 메서드를 가지고 있고 검증도 할 수 있는 클래스(updateName..)
난 풍부한 도메인을 선택했다.
이유는 재 사용성이 좋고 가장 일반적은 getter 메서드를 숨길 수 있다는 장점이 있다.
예외는 어디서 관리해야할까?
이것도 적절하게 선택해야하는데 난 풍부한 도메인을 사용했지만 예외는 usecase 모듈에서 관리하는게 좋다고 생각했다.
순서 4: Out-Adapter 구현하기
클린 아키텍처의 핵심이다.
"영속성 계층의 의존성을 역전시켜 내부로 향하게 하는 작업"이다.
아래의 코드와 같이 Usecase 모듈에 인터페이스 기반으로 필요한 메서드를 Port(Outgoing Port 라고 불림)를 구현한다.
public interface WorkflowRepository {
Optional<Workflow> findById(long workflowId);
List<Workflow> findAllByProjectId(long projectId);
long save(Workflow workflow);
long update(Workflow workflow);
void deleteById(long workflowId);
}
그리고 포트를 구현 할 구현체를 만들어야한다.
이것은 다시 adpater 모듈로 돌아가 구현해야한다.
난 in-memory, mysql 2가지를 모두 사용할 수 있도록 2개의 모듈을 만들었다.
public class InMemoryGlobalPropertyRepository implements GlobalPropertyRepository {
private final Map<Long, GlobalProperty> inMemoryDb = new ConcurrentHashMap<>();
private final AtomicLong idGenerator = new AtomicLong(0);
@Override
public Optional<GlobalProperty> findById(long id) {
return Optional.ofNullable(inMemoryDb.get(id));
}
@Override
public List<GlobalProperty> findAll() {
return inMemoryDb.values()
.stream()
.toList();
}
@Override
public void save(GlobalProperty globalProperty) {
long id = idGenerator.incrementAndGet();
globalProperty.setId(id);
inMemoryDb.put(id, globalProperty);
}
@Override
public void update(GlobalProperty globalProperty) {
GlobalProperty dbGlobalProperty = inMemoryDb.get(globalProperty.getId());
if (dbGlobalProperty == null) {
throw new NoSuchElementException();
}
inMemoryDb.put(globalProperty.getId(), globalProperty);
}
@Override
public void deleteById(long id) {
inMemoryDb.remove(id);
}
}
순서 5: 조립하기
Spring boot application 으로 구동하기 위해서 아래의 모듈을 만들었다.
이제 Application 구동 시 어떤 영속성 포트를 사용할지 어떤 컨트롤러를 사용할지 선택해주는 작업이 필요하다.
이렇게 하고 application.properties 속성 변경으로 DB 를 선택할 수 있도록 한다.
@Configuration
@ConditionalOnProperty(value = "adapter.out.repository.type", havingValue = "IN_MEMORY")
public class InMemoryConfiguration {
private final GlobalPropertyRepository globalPropertyRepository = new InMemoryGlobalPropertyRepository();
private final ProjectRepository projectRepository = new InMemoryProjectRepository();
private final JobPropertyRepository jobPropertyRepository = new InMemoryJobPropertyRepository();
private final WorkflowRepository workflowRepository = new InMemoryWorkflowRepository();
/**
* Controller Bean
*/
@Bean
GlobalPropertyController globalPropertyController() {
return new GlobalPropertyController(findGlobalProperty(), createGlobalProperty(), updateGlobalProperty(), deleteGlobalProperty());
}
......
}
순서 6: 결과
결론은 아래의 그림으로 나타낼 수 있다.
그림에서 보듯 모든 모듈은 domain 을 알고있다.
그래서 만약 domain 의 변경이 발생하면 usecase, adapter 는 변경이 필요하다.
그러나 domain 은 외부 세계를 알지 못한다.
그럼 adapter, usecase 들이 변경이 된다하더라도 domain 의 변경은 필요없게된다.
틀릴 수도 있지만 처음 말했던 "써드 파티 서비스의 변경이 도메인 코드 변경을 만들어서는 안된다" 라는 목표는 달성한 것 같다.
Mapper 를 사용하는 방법도 있고 Query + Command 조합으로 설계하는 코드도 있을 거라 생각한다.
항상 정답은 없으니깐 ㅎㅎ
코드: https://github.com/ghkdwlgns612/clean-architecture-conductor