클린 아키텍처(Clean Architecture)

이홍준·2023년 7월 31일
2

DDD책을 정독해보고 아키텍처 패턴중에 하나인 포트와 어댑터 패턴을 잠깐 공부해본 적이있다. 흔히 말하는 클린아키텍처와 거의 같은말로 알려져있는데, 구현하는 방법에 대해 자세히 나와있는 만들면서 배우는 클린 아키텍처-톰 홈버그 지음 를 읽게 되었다. MSA를 도입하기 전에 이 방식을 통해 아키텍처를 구현해 보고 들어가보고자 한다.

Clean Architecture란?

Clean_Architecture

"클린 아키텍처(Clean Architecture)"는 소프트웨어 개발에 사용되는 디자인 패턴 중 하나로, 로버트 C. 마틴(Robert C. Martin)이 제안한 아키텍처 원칙이다. 헥사고날 아키텍처라 부르기도하고 포트와 어댑터 패턴이라고 부르기도 한다.

구성요소

  • Adapter(Infrastructure)
    • 외부 세계(Web, DB, UI)와 애플리케이션 내부의 도메인 및 로직 사이를 연결하는 역할.
    • 클린 아키텍처의 경계를 형성하며, 시스템의 독립성을 보장
    • in: Controller, Ui
    • out: JpaRepository
  • Application(Use Case)
    • 비즈니스 로직의 진입점을 제공
    • 시스템의 usecase를 정의하고 구현하는 서비스가 위치
    • domain과 adapter사이에 상호작용을 조정하고 흐름을 제어
  • Domain
    • 시스템의 핵심 비즈니스 로직을 구현
    • 비즈니스 객체, 도메인 서비스 , 도메인 이벤트 등이 포함
    • 외부 요소에 의존하지 않으며 순수성을 보장

계층으로 구성

web(Controller) + domain + persistence (repositoryImpl)으로 이루어진 가장 기본적이면서 간단한 구조이다.

하지만 3가지 정도 단점을 꼽을 수 있다.

  • 애플리케이션의 기능 조각이나 특성을 구분 짓는 패키지 경계가 없다. → 기능을 추가나 변경할 때 모두 수정해주어야 한다.
  • 애플리케이션이 어떤 유스케이스를 제공하는지 파악할 수 없다. 복잡한 도메인으로 이루어진 프로젝트일수록 클래스명만 보고 판단하기 힘들 것이다.
  • 패키지 구조를 통해서 우리가 목표로 하는 아키텍처를 파악할 수 없다.

기능으로 구성

  • User, Post 등등 각 도메인 별로 패키지들로 묶는 방식
  • 효과
    • 서로의 패키지에 대해 package-private 접근 수준을 이용해서 경계를 강화할 수 있다.
    • 유스케이스 목록들을 같은 패키지를 통해 찾을 수 있게 되었다.
  • 단점
    • 아키텍처의 가시성을 더 떨어 뜨린다.
    • 어댑터를 나타내는 패키지명이 없다.
    • 인커밍 포트, 아웃고잉포트를 활용할 수 없다.

아키텍처적으로 표현력 있는 패지키 구조

  • 처음에 adapter, application, domain 의 패키지로 분리한다. 그리고 외부 패키지와의 인터페이스를 제외하고 접근 제어자를 private-package로 선언한다.
  1. Domain Layer
    • User: 엔티티 정보이며, 식별자는 불변성을 보장하고 의미적 동등성을 보장하기 위해 VO로 지정한다.
      @AllArgsConstructor(access = AccessLevel.PRIVATE)
      @Getter
      @Builder
      public class User {
          private final UserId id;
          private final String email;
          private final String password;
          private final String nickname;
          private final String name;
      
          @Value
          public static class UserId {
              private final Long value;
          }
      }
  2. Application Layer
    • createUserSerivce의 인터페이스이다.
      public interface CreateUserUseCase {
          CreateUserResponse createUser(CreateUserCommand command);
      }
    • CreateUserPort : User를 생성하기 위한 아웃고잉 포트 인터페이스이며, 어댑터에서 구현한다.
      public interface CreateUserPort {
          User createUser(User user);
      }
    • CreateUserService: User를 생성하는 핵심 비즈니스 로직이다. 각 인터페이스로 정의된 포트를 멤버변수로 받는다.
      @RequiredArgsConstructor
      @UseCase
      @Transactional
      class CreateUserService implements CreateUserUseCase {
          private final CreateUserPort createUserPort;
          private final LoadUserPort loadUserPort;
          private final PasswordEncoderPort encoderPort;
      
          @Override
          public CreateUserResponse createUser(CreateUserCommand command) {
              User user = User.builder()
                      .email(command.getEmail())
                      .nickname(command.getNickname())
                      .name(command.getName())
                      .password(encoderPort.encode(command.getPassword()))
                      .build();
      
              User createdUser = createUserPort.createUser(user);
      
              return CreateUserResponse.builder()
                      .id(createdUser.getId().getValue())
                      .name(createdUser.getNickname())
                      .email(createdUser.getEmail())
                      .name(createdUser.getName())
                      .password(createdUser.getPassword())
                      .nickname(createdUser.getNickname())
                      .build();
          }
      }
  3. Adapter
    • UserPersistenceAdapter : User에 대한 JpaRepository와 비즈니스 로직을 연결하는 역할을 해주고 해당 JpaEntity에 맞게 변환해주는 역할도 한다.
      @RequiredArgsConstructor
      @PersistenceAdapter
      class UserPersistenceAdapter implements CreateUserPort {
          private final SpringDataUserRepository userRepository;
          private final UserMapper userMapper;
          @Override
          public User createUser(User user) {
              checkDuplication(user);
              UserJpaEntity userJpaEntity = userMapper.mapToJpaEntity(user);
              return userMapper.mapToDomainEntity(userRepository.save(userJpaEntity));
          }
      
          private void checkDuplication(User user){
              userRepository.findByEmail(user.getEmail())
                      .ifPresent(o -> {throw new UserAlreadyExistsException();});
              userRepository.findByNickname(user.getNickname())
                      .ifPresent(o -> {throw new UserAlreadyExistsException();});
          }
      }
    • UserJpaEntity : JpaRepository에 직접 연결될 클래스이다.
      @Entity
      @Table(name = "users")
      @Getter
      @Builder
      @NoArgsConstructor
      @AllArgsConstructor
      class UserJpaEntity {
          @Id
          @GeneratedValue
          private Long id;
          @Column(nullable = false)
          private String email;
          @Column(nullable = false)
          private String password;
          @Column(nullable = false)
          private String nickname;
          @Column(nullable = false)
          private String name;
      }
    • UserController : 외부 Web과 직접적으로 연결되는 Controller이며 usecase 인터페이스를 의존하며 해당 DTO를 Command객체로 변환해준다.
      @WebAdapter
      @RestController
      @RequiredArgsConstructor
      @RequestMapping("/api/v1/users")
      class UserController {
          private final CreateUserUseCase createUserUseCase;
          @PostMapping
          public ApiResponse createUser(@RequestBody CreateUserRequest createUserRequest){
              CreateUserCommand userCommand = CreateUserCommand.builder()
                      .email(createUserRequest.getEmail())
                      .name(createUserRequest.getName())
                      .nickname(createUserRequest.getNickname())
                      .password(createUserRequest.getPassword())
                      .build();
              createUserUseCase.createUser(userCommand);
              return SuccessApiResponse.of();
          } 
      }
    • CreateUserRequest : 외부 Web과 직접적으로 연결되는 Controller에 들어갈 DTO 객체이다. 입력에 대한 Validation Check도 수행한다.
      @Value
      @EqualsAndHashCode(callSuper = false)
      public class CreateUserRequest extends SelfValidating<CreateUserRequest> {
          @Email
          private final String email;
          @NotBlank
          private final String password;
          @NotBlank
          private final String nickname;
          @NotBlank
          private final String name;
      
          public CreateUserRequest(String email, String password, String nickname, String name) {
              this.email = email;
              this.password = password;
              this.nickname = nickname;
              this.name = name;
              this.validateSelf();
          }
      }

기대효과

  • 모듈간 결합도가 감소되어 변경에 대해 쉬워진다.
  • 독립적으로 테스트를 하기 쉬워진다.
  • 인프라스트럭처(DB 등등) 계층들을 분리함으로써 특정 기술에 종속되지 않는다.
  • 독립적으로 배포가 가능해진다.
  • 비즈니스 로직에 대해 강조하기 쉬워진다.

단점

  • 클래스 파일이 많아짐으로써 복잡성이 심해진다.
  • 설계에 있어서 어느정도 러닝커브가 존재한다.
  • 간단한 어플리케이션에 대해 오버헤드가 존재할 수도 있다.

결론

계층형 혹은 기능형 방식과 같은 기존 방식들은 구현하기 간단하다. 하지만 DB 처럼 외부 모듈에 의존하게 되면 경계가 모호하게 되기 때문에 공동작업을 하는데 큰 장애물이 될 것이다. 그래서 클린아키텍처로 관심사마다 독립성을 보장해줌으로써 확장성과 배포에 장점을 발휘할 것이라 기대된다.

일반적으로 우리가 주로 실습할 정도의 규모에는 적합하지 않다고 생각한다. 왜냐하면 작은 규모의 프로젝트에 적용하기에는 복잡한 트랜잭션을 요구하는 경우가 거의 없기 때문이다. 규모가 크고 핵심적인 비즈니스로직을 구현하고자 할때 적합하다고 생각한다. 훗날 대용량 트래픽처리와 복잡한 비즈니스 로직을 수행할 상황을 생각해 미리 공부해 둠으로써 개발의 선택지를 늘리고자 했다. 이전에 도메인 주도 설계에 관련된 책을 읽지 않았더라면 이 아키텍처에 대해서 거부감이 들었을 것이라 생각했다. 따라서 어떤 기술에 대해서 선택할때 그 방식을 사용해야 하는 이유에 대해 사전 지식을 먼저 습득을 해야만 자연스럽게 이해하기 쉬워지는 것 같다고 느꼈다.


References

profile
I'm a web developer.

1개의 댓글

comment-user-thumbnail
2023년 7월 31일

정보 감사합니다.

답글 달기