Architecture

클린 아키텍처의 모든 것: 이해부터 실전 적용까지

클린 아키텍처(Clean Architecture)

안녕하세요! Devloo입니다. (_ _) 여러분은 기존 코드를 수정하는 데 어려움을 겪거나, 재사용이 어려운 코드를 작성하거나, 새로운 기능을 추가할 때 어디에 추가해야 할지 고민한 적이 있나요? 저도 주니어 개발자 시절부터 이런 고민을 많이 해왔습니다. 이러한 과정을 통해 개발 전에 제대로 된 ‘아키텍처 설계’의 중요성을 크게 깨닫게 되었습니다. 이번 글에서는 여러분이 겪을 수 있는 시행착오를 해결하기 위해 이러한 설계 방법에 대해 알아보려고 합니다. 🙂

Software Architecture
개발 전 설계 단계 (Software Architecture)

소프트웨어 개발에서 아키텍처 설계는 프로젝트의 성공을 좌우하는 중요한 요소입니다. 잘 설계된 아키텍처는 유지보수가 쉽고, 확장성이 뛰어나며, 다양한 변화 요구에 유연하게 대응할 수 있습니다.

클린 아키텍처(Clean Architecture)는 이러한 소프트웨어 아키텍처 설계를 위한 원칙과 가이드라인을 제공합니다. 로버트 C. 마틴(Robert C. Martin)이 제안한 이 개념은 소프트웨어의 독립성을 강조하며, 시스템의 유연성과 유지보수성을 높이는 데 중점을 둡니다.

클린 아키텍처는 의존성 규칙을 통해 비즈니스 로직, UI, 데이터베이스, 외부 에이전시 등의 구성 요소를 명확히 분리하기 때문에 효과적입니다.

이 글에서는 클린 아키텍처의 기본 원리와 실제 적용 방법을 살펴보겠습니다.

클린 아키텍처를 이해하고 적용하면 더 나은 소프트웨어 설계와 개발 과정을 구현할 수 있습니다.

목차

  1. 개요
  2. 클린 아키텍처 개요
  3. 규칙 정의
  4. 엔티티 계층
  5. 유스 케이스 계층
  6. 인터페이스 어댑터
  7. 드라이버와 프레임워크
  8. 메인 클래스
  9. 결론

1. 개요

장기적인 시스템을 개발할 때는 변할 수 있는 환경을 예상해야 합니다.

일반적으로 기능 요구사항, 프레임워크, I/O 장치, 심지어 코드 디자인도 여러 이유로 인해 변경될 수 있습니다. 이를 염두에 두고, 클린 아키텍처는 주변의 모든 불확실성을 고려하여 높은 유지보수성을 가진 코드를 작성하기 위한 지침입니다.

이 글에서는 Robert C. Martin의 클린 아키텍처를 따라 사용자 등록 API 예제를 만들어 보겠습니다. 여기서는 그의 저서 “클린 아키텍처“에 나오는 계층 구성(엔티티, 유스 케이스, 인터페이스 어댑터, 프레임워크/드라이버)을 적용해볼 예정입니다.

2. 클린 아키텍처 (Clean Architecture) 개요

클린 아키텍처는 SOLID, 안정적인 추상화 등 여러 코드 디자인과 원칙을 통합합니다. 핵심 아이디어는 시스템을 비즈니스 가치에 따라 레벨로 나누는 것입니다. 따라서 가장 높은 레벨에는 비즈니스 규칙이 있으며, 그 아래의 각 레벨은 I/O 장치에 점점 더 가까워집니다.

또한, 이 레벨들을 계층으로 번역할 수 있습니다. 이 경우, 반대가 됩니다. 가장 안쪽 계층이 가장 높은 레벨에 해당하며, 그 다음으로 내려갑니다.

클린 아키텍처 레이어
클린 아키텍처의 계층

이를 염두에 두고, 비즈니스가 필요로 하는 만큼 많은 레벨을 가질 수 있습니다. 그러나 항상 의존성 규칙을 고려해야 합니다. 즉, 상위 레벨은 절대 하위 레벨에 의존해서는 안 됩니다.

3. 규칙

사용자 등록 API를 위한 시스템 규칙을 정의해 보겠습니다. 먼저, 비즈니스 규칙입니다:

  • 사용자의 비밀번호는 5자 이상이어야 합니다.

다음으로 애플리케이션 규칙입니다. 유스 케이스(Use Case)나 스토리 형식으로 있을 수 있습니다. 여기서는 스토리텔링 문구를 사용해 보겠습니다:

  • 시스템은 사용자 이름과 비밀번호를 받고, 사용자가 존재하지 않는지 확인한 후, 새로운 사용자를 생성 시간과 함께 저장합니다.

데이터베이스나 UI 등에 대한 언급이 없는 점을 주목하세요! 우리의 비즈니스는 이러한 세부 사항에 관심이 없으며, 우리의 코드도 그래야 합니다.

4. 엔티티 계층 (Entity Layer)

클린 아키텍처의 내용에 따라 비즈니스 규칙부터 시작해봅시다:

interface User {
    boolean passwordIsValid();
    String getName();
    String getPassword();
}

그리고, UserFactory:

interface UserFactory {
    User create(String name, String password);
}

사용자 팩토리 메서드를 만든 이유는 두 가지입니다. 안정적인 추상화 원칙을 따르기 위함이며, 사용자 생성을 분리하기 위함입니다.

다음으로, 두 가지를 구현해보겠습니다:

class CommonUser implements User {
    String name;
    String password;

    @Override
    public boolean passwordIsValid() {
        return password != null && password.length() > 5;
    }

    // 생성자와 getter
}
class CommonUserFactory implements UserFactory {
    @Override
    public User create(String name, String password) {
        return new CommonUser(name, password);
    }
}

만약, 비즈니스가 복잡하다면 도메인 코드를 가능한 한 명확하게 작성해야 합니다. 따라서 이 계층은 디자인 패턴을 적용하기 좋은 곳입니다. 특히, 도메인 주도 설계(DDD)를 고려해야 합니다.

4.1 유닛 테스트

이제 CommonUser를 테스트해보겠습니다:

@Test
void given123Password_whenPasswordIsNotValid_thenIsFalse() {
    User user = new CommonUser("Baeldung", "123");
    assertThat(user.passwordIsValid()).isFalse();
}

보시다시피, 유닛 테스트는 매우 명확합니다. 무엇보다도, 이 계층에서 목(mock)이 없다는 것은 좋은 신호입니다.

일반적으로, 이 단계에서 목을 사용해야 한다고 생각하기 시작하면, 아마도 우리는 엔티티와 유스 케이스를 혼합하고 있을 가능성이 있습니다.

5. 유스 케이스 계층 (Use Case Layer)

유스 케이스는 시스템의 자동화와 관련된 규칙입니다. 클린 아키텍처에서는 이를 인터랙터(Interactors)라고 부릅니다.

5.1 사용자 등록 인터랙터 (UserRegisterInteractor)

먼저, 우리가 나아갈 방향을 확인하기 위해 UserRegisterInteractor를 구축할 것입니다. 그런 다음, 사용된 모든 부분을 생성하고 논의하겠습니다:

class UserRegisterInteractor implements UserInputBoundary {
    final UserRegisterDsGateway userDsGateway;
    final UserPresenter userPresenter;
    final UserFactory userFactory;

    // 생성자

    @Override
    public UserResponseModel create(UserRequestModel requestModel) {
        if (userDsGateway.existsByName(requestModel.getName())) {
            return userPresenter.prepareFailView("User already exists.");
        }
        User user = userFactory.create(requestModel.getName(), requestModel.getPassword());
        if (!user.passwordIsValid()) {
            return userPresenter.prepareFailView("User password must have more than 5 characters.");
        }
        LocalDateTime now = LocalDateTime.now();
        UserDsRequestModel userDsModel = new UserDsRequestModel(user.getName(), user.getPassword(), now);
        userDsGateway.save(userDsModel);
        UserResponseModel accountResponseModel = new UserResponseModel(user.getName(), now.toString());
        return userPresenter.prepareSuccessView(accountResponseModel);
    }
}

보시다시피, 우리는 모든 유스 케이스 단계를 수행하고 있습니다. 또한, 이 계층은 엔티티의 동작을 제어하는 역할을 합니다. 그러나 UI나 데이터베이스가 어떻게 작동하는지에 대해서는 가정하지 않고 있습니다. 그런데도 우리는 UserDsGateway와 UserPresenter를 사용하고 있습니다. 그렇다면 어떻게 그들에 대해 모를 수 있을까요? 그것은 UserInputBoundary와 함께 이들이 우리의 입력 및 출력 경계이기 때문입니다.

5.2 입력과 출력 경계 (Input and Output Boundaries)

경계(boundaries)는 컴포넌트 간의 상호작용 방식을 정의하는 계약입니다. 입력 경계(input boundary)는 유스 케이스를 외부 계층에 노출합니다:

interface UserInputBoundary {
    UserResponseModel create(UserRequestModel requestModel);
}

다음으로, 외부 계층을 활용하기 위한 출력 경계(output boundaries)를 정의합니다. 먼저, 데이터 소스 게이트웨이를 정의하겠습니다:

interface UserRegisterDsGateway {
    boolean existsByName(String name);

    void save(UserDsRequestModel requestModel);
}

그리고 뷰 프레젠터 (View Presenter):

interface UserPresenter {
    UserResponseModel prepareSuccessView(UserResponseModel user);
    UserResponseModel prepareFailView(String error);
}

우리가 데이터베이스나 UI와 같은 세부 사항에서 비즈니스를 자유롭게 하기 위해 의존성 역전 원칙을 사용하고 있다는 점에 주목하세요

5.3 디커플링 모드 (Decoupling Mode)

진행하기 전에, 경계(boundaries)가 시스템의 자연스러운 구분을 정의하는 계약임을 주목하세요. 그러나 우리는 애플리케이션을 어떻게 제공할 것인지도 결정해야 합니다:

  • 모놀리식(Monolithic) – 패키지 구조를 사용하여 조직화될 가능성이 있음
  • 모듈 사용
  • 서비스/마이크로서비스 사용

이를 염두에 두고, 우리는 어떤 디커플링 모드(decoupling mode)에서도 클린 아키텍처 목표를 달성할 수 있습니다. 따라서 현재와 미래의 비즈니스 요구사항에 따라 이러한 전략들 사이를 변경할 준비를 해야 합니다. 디커플링 모드를 선택한 후에는 경계를 기준으로 코드 구분이 이루어져야 합니다.

5.4 요청 및 응답 모델

지금까지 우리는 인터페이스를 사용하여 계층 간의 작업을 생성했습니다. 이제 이러한 경계를 통해 데이터를 전송하는 방법을 살펴보겠습니다.

모든 경계가 오직 String 또는 Model 객체만을 다루고 있다는 점에 주목하십시오:

class UserRequestModel {

    String login;
    String password;

    // 게터, 세터, 생성자
}

기본적으로, 단순한 데이터 구조만이 경계를 넘나들 수 있습니다. 또한, 모든 모델은 필드와 접근자만 가지고 있습니다. 그리고 데이터 객체는 내부 측에 속합니다. 따라서 우리는 의존성 규칙을 유지할 수 있습니다.

하지만 왜 이토록 많은 유사한 객체가 존재할까요? 코드의 반복이 발생할 때, 그것은 두 가지 유형으로 나눌 수 있습니다:

  • 우연한 중복 (Accidental duplication) – 코드의 유사성이 우연에 불과하며, 각 객체는 변화해야 하는 다른 이유를 가지고 있습니다. 이를 제거하려고 하면 단일 책임 원칙을 위반할 위험이 있습니다.
  • 진정한 중복 (True Duplication) – 코드가 같은 이유로 변경됩니다. 따라서 이를 제거해야 합니다.

각 모델이 다른 책임을 가지고 있기 때문에 이 모든 객체가 존재하게 된 것입니다.

5.5 사용자 등록 인터랙터 테스트

이제 유닛 테스트를 작성해보겠습니다:

@Test
void givenBaeldungUserAnd123456Password_whenCreate_thenSaveItAndPrepareSuccessView() {
    User user = new CommonUser("baeldung", "123456");
    UserRequestModel userRequestModel = new UserRequestModel(user.getName(), user.getPassword());
    when(userFactory.create(anyString(), anyString()))
        .thenReturn(new CommonUser(user.getName(), user.getPassword()));

    interactor.create(userRequestModel);

    verify(userDsGateway, times(1)).save(any(UserDsRequestModel.class));
    verify(userPresenter, times(1)).prepareSuccessView(any(UserResponseModel.class));
}

보시다시피, 대부분의 유스 케이스 테스트는 엔티티와 경계 요청을 제어하는 것입니다. 우리의 인터페이스는 세부 사항을 쉽게 목으로 만들 수 있도록 해줍니다.

6. 인터페이스 어댑터 (Interface Adapter)

이제 비즈니스 로직을 모두 마쳤으니, 세부 사항을 연결해 봅시다.

비즈니스 로직은 가장 적합한 데이터 형식만을 처리해야 하며, 데이터베이스나 UI와 같은 외부 에이전트도 마찬가지입니다. 그러나 이 형식은 보통 서로 다릅니다. 이러한 이유로 인터페이스 어댑터 계층은 데이터를 변환하는 역할을 맡고 있습니다.

6.1 JPA를 사용하는 UserRegisterDsGateway

먼저 JPA를 사용하여 사용자 테이블을 매핑해봅시다:

@Entity
@Table(name = "user")
class UserDataMapper {
    @Id
    String name;
    String password;
    LocalDateTime creationTime;

    // Getters, setters, and constructors
}

Mapper의 목적은 객체를 데이터베이스 형식으로 매핑하는 것입니다.

다음으로, 우리의 엔티티를 사용하는 JpaRepository를 정의합니다:

@Repository
interface JpaUserRepository extends JpaRepository<UserDataMapper, String> {
}

스프링 부트를 사용할 것이기 때문에, 사용자를 저장하는 데 필요한 것은 이것이 전부입니다.

이제 UserRegisterDsGateway를 구현해봅시다:

class JpaUser implements UserRegisterDsGateway {
    final JpaUserRepository repository;

    // 생성자

    @Override
    public boolean existsByName(String name) {
        return repository.existsById(name);
    }

    @Override
    public void save(UserDsRequestModel requestModel) {
        UserDataMapper accountDataMapper = new UserDataMapper(requestModel.getName(), requestModel.getPassword(), requestModel.getCreationTime());
        repository.save(accountDataMapper);
    }
}

대부분의 경우, 코드는 그 자체로 설명됩니다. 메서드 외에도 UserRegisterDsGateway의 이름에 주목하십시오. 만약 UserDsGateway라는 이름을 선택했다면, 다른 User 유스 케이스들이 인터페이스 분리 원칙을 위반할 가능성이 높아졌을 것입니다.

6.2 사용자 등록 API (UserRegisterController)

이제 HTTP 어댑터를 만들어 보겠습니다:

@RestController
class UserRegisterController {
    final UserInputBoundary userInput;

    // 생성자

    @PostMapping("/user")
    UserResponseModel create(@RequestBody UserRequestModel requestModel) {
        return userInput.create(requestModel);
    }
}

여기서의 유일한 목표는 요청을 받고 응답을 클라이언트에게 보내는 것입니다.

6.3 응답 준비

응답을 보내기 전에 응답을 포맷해야 합니다:

class UserResponseFormatter implements UserPresenter {
    @Override
    public UserResponseModel prepareSuccessView(UserResponseModel response) {
        LocalDateTime responseTime = LocalDateTime.parse(response.getCreationTime());
        response.setCreationTime(responseTime.format(DateTimeFormatter.ofPattern("hh:mm:ss")));
        return response;
    }

    @Override
    public UserResponseModel prepareFailView(String error) {
        throw new ResponseStatusException(HttpStatus.CONFLICT, error);
    }
}

우리의 UserRegisterInteractor는 프레젠터(Presenter)를 만들도록 요구했습니다. 하지만 프레젠테이션 규칙은 어댑터 내에서만 다루어집니다. 또한, 테스트하기 어려운 부분은 테스트 가능한 객체와 단순한 객체로 나누어야 합니다. 그래서 UserResponseFormatter를 사용하면 프레젠테이션 규칙을 쉽게 검증할 수 있습니다:

@Test
void givenDateAnd3HourTime_whenPrepareSuccessView_thenReturnOnly3HourTime() {
    UserResponseModel modelResponse = new UserResponseModel("baeldung", "2020-12-20T03:00:00.000");
    UserResponseModel formattedResponse = userResponseFormatter.prepareSuccessView(modelResponse);

    assertThat(formattedResponse.getCreationTime()).isEqualTo("03:00:00");
}

보시다시피, 우리는 뷰로 보내기 전에 모든 로직을 테스트했습니다. 따라서 단순한 객체만이 덜 테스트 가능한 부분에 남아 있습니다.

7. 드라이버와 프레임워크 (Drivers and Frameworks)

사실, 이 계층에서는 보통 코드를 작성하지 않습니다. 이 계층은 외부 에이전트와의 가장 낮은 수준의 연결을 나타내기 때문입니다. 예를 들어, 데이터베이스에 연결하기 위한 H2 드라이버나 웹 프레임워크가 여기에 해당합니다. 이 경우, 우리는 웹 및 의존성 주입 프레임워크로서 스프링 부트를 사용할 것입니다. 따라서 스프링 부트의 시작 지점을 정의해야 합니다:

@SpringBootApplication
public class CleanArchitectureApplication {
    public static void main(String[] args) {
        SpringApplication.run(CleanArchitectureApplication.class);
    }
}

지금까지 우리는 비즈니스 로직에 어떤 스프링 애노테이션도 사용하지 않았습니다. UserRegisterController와 같은 스프링 특화 어댑터를 제외하고 말입니다. 이는 스프링 부트를 다른 세부 사항과 동일하게 취급해야 하기 때문입니다.

8. 메인 클래스

마침내, 마지막 부분입니다!

지금까지 우리는 안정된 추상화 원칙을 따랐습니다. 또한, 제어의 역전을 통해 내부 계층을 외부 에이전트로부터 보호했습니다. 마지막으로, 모든 객체 생성을 사용과 분리했습니다. 이제 남은 의존성을 생성하고 프로젝트에 주입해 보도록 하겠습니다:

@Bean
BeanFactoryPostProcessor beanFactoryPostProcessor(ApplicationContext beanRegistry) {
    return beanFactory -> {
        genericApplicationContext(
          (BeanDefinitionRegistry) ((AnnotationConfigServletWebServerApplicationContext) beanRegistry)
            .getBeanFactory());
    };
}

void genericApplicationContext(BeanDefinitionRegistry beanRegistry) {
    ClassPathBeanDefinitionScanner beanDefinitionScanner = new ClassPathBeanDefinitionScanner(beanRegistry);
    beanDefinitionScanner.addIncludeFilter(removeModelAndEntitiesFilter());
    beanDefinitionScanner.scan("com.baeldung.pattern.cleanarchitecture");
}

static TypeFilter removeModelAndEntitiesFilter() {
    return (MetadataReader mr, MetadataReaderFactory mrf) -> !mr.getClassMetadata()
      .getClassName()
      .endsWith("Model");
}

우리의 경우, 스프링 부트 의존성 주입을 사용하여 모든 인스턴스를 생성하고 있습니다. @Component를 사용하지 않으므로, 루트 패키지를 스캔하면서 Model 객체만 제외하고 있습니다.

이 전략이 더 복잡해 보일 수 있지만, 이는 우리의 비즈니스를 DI 프레임워크로부터 분리합니다. 반면에, 메인 클래스는 시스템 전체를 제어하게 됩니다. 그래서 클린 아키텍처는 이를 다른 모든 계층을 포괄하는 특별한 계층으로 간주합니다.

9. 결론

이 글에서는 Uncle Bob의 클린 아키텍처가 여러 디자인 패턴과 원칙을 기반으로 어떻게 구축되는지 배웠습니다. 또한, Spring Boot를 사용하여 이를 적용한 유스 케이스를 만들었습니다.

물론 일부 원칙은 다루지 않았지만, 모든 원칙은 같은 방향으로 나아갑니다. 이를 창시자의 말을 인용하여 요약할 수 있습니다: “좋은 아키텍트는 내리지 않은 결정의 수를 최대화해야 한다.” 우리는 경계를 사용하여 비즈니스 코드를 세부 구현 사항으로부터 보호함으로써 이를 달성했습니다.

이번 시간에는 클린 아키텍처에 대해 알아보고, 이러한 원칙들을 Spring Boot 에 적용해보는 시간을 갖았습니다.
혹시 궁금하신 사항이 있으시면 댓글로 남겨주세요 !! ㅎㅎ :))
끝까지 읽어주셔서 감사합니다 (_ _) !!

Written by 개발자서동우
안녕하세요! 저는 기술 분야에서 활동 중인 개발자 서동우입니다. 명품 플랫폼 (주)트렌비의 창업 멤버이자 CTO로 활동했으며, AI 기술회사 (주)헤드리스의 공동 창업자이자 CTO로서 역할을 수행했습니다. 다양한 스타트업에서 일하며 회사의 성장과 더불어 비즈니스 상황에 맞는 기술 선택, 개발팀 구성 및 문화 정착에 깊은 경험을 쌓았습니다. 개발 관련 고민은 언제든지 편하게 연락주세요 :) https://linktr.ee/dannyseo Profile

Leave a Reply

Your email address will not be published. Required fields are marked *