카테고리 없음

Clean Architecture 코드로 구현하며 이해하기

HOONY_612 2024. 4. 7. 20:11
반응형

개요

클린 아키텍처, 헥사고날 아키텍처 등 많은 책들이 존재하고 코드들이 존재한다.

각자의 방식으로 클린 아키텍처를 구현하고 네이밍도 다양하게 구성할 수 있다.

난 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

반응형