[Reach Rich 개발기] 헥사고날 아키텍처 적용하기

Ilhwanee·2023년 4월 28일
0

Reach Rich 개발기

목록 보기
10/10
post-thumbnail

전통적인 계층형 아키텍처의 문제점

전통적인 계층형 아키텍처의 경우 데이터베이스 주도 설계를 유도합니다. 이는 의존성이 영속성 레이어로 향하기 때문인데요,

웹 레이어는 도메인 레이어에, 도메인 레이어는 영속성 레이어에 의존성을 갖습니다.

또한 의존성이 아래로 흐르기 때문에, 도메인 레이어에서 필요로하는 의존성(헬퍼, 유틸리티 등)이 영속성 레이어에 계속하여 추가되기 때문에 거대해질 수 있음


저 역시도 백엔드 개발을 하면서 이러한 구조를 사용했었고, '도메인 로직'이 아닌 '데이터베이스'를 토대로 어플리케이션을 개발했습니다.

도메인 로직을 우선적으로 개발하기 보다는, Spring Data라는 Repository 형태로 제공하는 ORM이나 QueryDSL을 사용한 쿼리 메소드를 먼저 개발했던 것 같습니다.


또한 계층형 구조는 외부 시스템을 변경하기 힘들다는 단점도 있습니다.

추상화를 통해 도메인 레이어와 영속성 레이어의 의존성을 역전하더하도, 도메인 레이어에서 영속성 엔티티를 사용할 뿐더러 결국 Spring Data가 제공하는 Repository의 형태에 의존했습니다.

만약 Spring Data JPA에서 MyBatis로 ORM 프레임워크를 변경한다면, 도메인 레이어의 많은 코드들이 수정될 것입니다.



클린 아키텍처, 헥사고날 아키텍처

클린 아키텍처는 이러한 문제점들을 해결해줍니다.

레이어들은 동심원으로 둘러싸여 있고, 이는 도메인 로직을 담당하는 Use Case와 도메인 엔티티로 향하고 있습니다.

Use Case는 기존 Service보다 좁은, 기능 단위(서비스는 하나의 도메인에 모든 로직이 포함)이기 때문에 넓어지는 Service 문제를 해결할 수 있음

따라서 도메인 코드에서는 어떤 영속성, UI 프레임워크가 사용되는지 알 수 없기 때문에 비즈니스 규칙에 집중할 수 있습니다. (마찬가지로 외부 시스템도 도메인 규칙을 모름)

다른 서드파티 컴포넌트와 포트를 통해 협력하기에, 외부 시스템 변경에도 용이하고요.

클린 아키텍처에서 Port는 Flow에 따라 Input Port, Output Port로 구분됨


따라서, 계층형 구조로 개발된 상태인 Reach Rich 프로젝트에 개발 서적을 통해 공부한 헥사고날 아키텍처를 적용하고자 합니다. (<<만들면서 배우는 클린 아키텍처>> 강력 추천👍)

앞서 클린 아키텍처는 도메인 로직과 외부 시스템을 분리할 수 있다고 했는데, 헥사고날 아키텍처는 포트와 어댑터를 사용하여 모든 의존성이 코어를 향하게 합니다.

헥사고날 아키텍처는 포트와 어댑터 아키텍처로 불리기도 함


지금까지 클린 아키텍처의 장점만 설명했는데, 단점이 없는 것은 아닙니다.

도메인 모델(Entity)을 각 계층마다 따로 구현해야 한다는 점이 번거롭게 느껴질 수 있습니다.

이는 도메인 계층이 영속성 계층을 모르기 때문입니다. 영속성 계층에서 사용한 엔티티 클래스를 도메인 계층에서 사용할 수 없습니다.

JPA 사용 시 영속성 엔티티는 영속성의 관리 대상이기 때문에 프레임워크에 특화되어 있습니다. 따라서 이를 도메인 계층에서 분리하는 것은 곧 도메인과 영속성의 결합을 없애는 것이므로 단점이 아닌 바람직한 일입니다.



헥사고날 아키텍처 패키지 구조

reachrichuser
├── global
│   ├── config
│   └── util
└── user
    ├── adapter
    │   ├── in
    │   │   └── web
    │   │       ├── cookiefactory
    │   │       ├── dto
    │   │       └── handler
    │   └── out
    │       ├── email
    │       └── persistence
    │           ├── entity
    │           └── repository
    ├── application
    │   ├── port
    │   │   ├── in
    │   │   │   └── command
    │   │   └── out
    │   │       ├── email
    │   │       ├── emailauth
    │   │       ├── refreshtoken
    │   │       └── user
    │   └── validator
    │       ├── login
    │       └── refreshtoken
    └── domain
        └── exception

헥사고날 아키텍처 적용 후 프로젝트 패키지 구조

위 패키지 구조에서 application, domain이 도메인 영역에 해당되고, adapter가 외부 시스템에 해당됩니다.

처음 접하면 정말 어지러운 패키지 구조인데... 어댑터가 무엇인지, Input & Output Port가 무엇인지, 이를 사용하여 어떻게 도메인과 외부 의존성을 분리할 수 있는지 살펴보겠습니다.



로그인 예시

프로젝트에서 구현한 로그인을 예시로 REST API(HTTP)와 JPA(Persistence)의 외부 시스템을 사용한 예시를 들어보겠습니다.


1) Spring Web은 LoginController를 통해 클라이언트에게 로그인 요청을 받습니다.

  • 이 과정은 HTTP 통신을 통하여 외부 시스템(Web)에게 데이터가 요청 받는(in) 것입니다.
  • 따라서 adapterin 포트, web 패키지에 관련 객체들을 생성합니다.
  • ExceptionHandler 등, 외부 시스템에 종속되는 모든 것이 포함될 수 있습니다.
  • 이제 요청한 기능을 수행해야 되는데, 외부 시스템은 도메인의 존재를 모르기 때문에 2)의 과정을 수행합니다.

2) 외부 요청을 수행하기 위하여 UseCase(기능 단위)에게 요청합니다.

  • 외부 시스템과 도메인을 분리하기 위함입니다.
  • 따라서 applicationin 포트에 LoginUseCase를 생성합니다.
  • 하나의 Use Case 객체는 하나의 기능만을 담당하면 좋습니다. (의미가 명확해짐, 충돌 방지)
  • 외부 시스템은 단지 기능을 수행하기 위해 Use Case를 호출하므로, 도메인의 존재를 모르게 됩니다.

3) LoginService에서 LoginUseCase의 기능을 구현합니다.

  • 하나의 Service에서 여러 Use Case를 구현할 수 있습니다.

4) LoginService에서 외부 시스템을 필요로 하는 경우, 직접 사용하지 않고 포트를 사용합니다.

  • 로그인 중 영속성을 필요로 한다면 포트를 통해 요청합니다.
  • 따라서 applicationout포트에 ReadUserPort가 생성합니다.
  • 포트는 너무 크지않게 구성하는 것이 좋습니다. (CRUD를 모두 수행하는 포트 등)
  • Out Port 사용으로 의존성이 역전되어, 도메인은 더 이상 외부 시스템에 의존하지 않게 됩니다.

5) 외부 시스템은 UserPersistenceAdapter를 통해 ReadUserPort를 구현합니다.

  • 하나의 Adapter에서 여러 Port를 구현할 수 있습니다.
  • UserPersistenceAdapter는 JPA 영속성 레파지토리(UserRepository)를 구성하여 Out Port가 받은 요청을 처리합니다.

설명만 들으면 헷갈릴 수 있으니, 헥사고날 아키텍처를 적용한 코드를 살펴보겠습니다!


@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
class LoginController {

    private final LoginUseCase loginUseCase;
    private final LogoutUseCase logoutUseCase;

    @PostMapping("/login")
    public ResponseEntity<Void> login(@RequestBody LoginDto dto) {
        LoginCommand command = new LoginCommand(dto.getEmail(), dto.getPassword());
        String refreshTokenValue = loginUseCase.login(command);
        ResponseCookie cookie = FactoryOfJwtCookieFactory.createJwtCookie(REFRESH_TOKEN,
            refreshTokenValue);

        return ResponseEntity.ok().header(SET_COOKIE, cookie.toString()).build();
    }

    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@RequestBody LogoutDto dto) {
        LogoutCommand command = new LogoutCommand(dto.getNickname());
        logoutUseCase.logout(command);
        ResponseCookie cookie =
            FactoryOfJwtCookieFactory.createJwtCookie(EXPIRED_REFRESH_TOKEN, null);

        return ResponseEntity.ok().header(SET_COOKIE, cookie.toString()).build();
    }
}

위와 같이 외부 시스템의 요청을 LoginController가 받아, 로그인 기능을 수행하기 위해서 LoginUseCase에 요청합니다.

같은 패키지안에서만 호출되거나 호출되지 않는 경우(Spring이 관리) package-private 접근자를 사용하여 잘못된 접근을 막아야 함 (Spring 내부에서 리플렉션 사용)


하나의 모듈 안에서는 공개 API만 public으로, 나머지는 package-private

DTO를 그대로 넘기지 않는 이유는, 요청하는 외부 시스템에 따라 객체의 형태가 달라질 수 있기 때문 (Json의 경우 직렬화, 역직렬화를 위해 Getter, Setter 필요하듯이)


만약 Use Case의 파라미터를 Json Request DTO로 받는다면 외부 시스템 변경 시 Use Case 코드도 함께 수정되어야 함


따라서 Command 객체로 변환하여 Use Case에 요청, 입력 값 검증은 Command 객체 생성자에서 진행

@Getter
public class LoginCommand extends SelfValidating<LoginCommand> {
.
    @NotBlank
    private final String email;
    @NotBlank
    private final String password;
.
    public LoginCommand(String email, String password) {
        this.email = email;
        this.password = password;
        validateSelf();
    }
}

public interface LoginUseCase {

    String login(LoginCommand command);
}

앞서 말씀드렸듯, Use Case의 범위는 작으면 작을수록 좋다고 생각합니다. (하나의 Use Case 객체는 하나의 기능만 수행)


@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
class LoginService implements LoginUseCase, LogoutUseCase {

    private final ReadUserPort readUserPort;
    private final CreateRefreshTokenPort createRefreshTokenPort;
    private final DeleteRefreshTokenPort deleteRefreshTokenPort;
    private final PasswordEncoder passwordEncoder;
    private final JwtGenerator jwtGenerator;

    @Override
    public String login(LoginCommand command) {
        Optional<User> maybeUser = readUserPort.readByEmail(command.getEmail());

        LoginObjectToValidate objectToValidate =
            LoginObjectToValidate.of(maybeUser, command.getPassword());
        new LoginValidator(objectToValidate, passwordEncoder).execute();

        User user = maybeUser.get();
        String nickname = user.getNickname();
        String refreshTokenValue = jwtGenerator.generateRefreshToken(nickname);

        createRefreshTokenPort.create(RefreshToken.of(nickname, refreshTokenValue));

        return refreshTokenValue;
    }

    @Override
    public void logout(LogoutCommand command) {
        deleteRefreshTokenPort.deleteByNickname(command.getNickname());
    }
}

도메인 영역에서는 Use Case를 구현하여 기능을 도메인 로직을 통해 구현합니다.

기능을 구현하기 위해서는 외부 시스템(DB, Cache, 파일 시스템 등)이 필요할 수 있으므로, Port를 사용하는 것을 볼 수 있습니다.

만약 Port로 요청하는 메시지에 영속성 엔티티가 포함된다면, 도메인이 영속성의 의존하는 것이라 판단하여 Port로의 메시지에는 도메인 엔티티를 사용합니다.


public interface ReadUserPort {

    Optional<User> readByEmail(String email);

    boolean existsByEmail(String email);
}

Port는 외부 시스템을 도메인이 의존성을 역전하여 사용하기 위한 것입니다.

앞서 말씀드린 것과 동일한 이유로 return type도 도메인 엔티티를 사용하였습니다.


@Component
@RequiredArgsConstructor
class UserPersistenceAdapter implements CreateUserPort, ReadUserPort {

    private final UserRepository userRepository;

    @Override
    public String create(User user) {
        return userRepository.save(UserEntity.ofDomainEntity(user)).getNickname();
    }

    @Override
    public Optional<User> readByEmail(String email) {
        Optional<UserEntity> maybeEntity = userRepository.findByEmail(email);

        if (maybeEntity.isEmpty()) {
            return Optional.empty();
        }

        return Optional.of(maybeEntity.get().toDomainEntity());
    }

    @Override
    public boolean existsByEmail(String email) {
        return userRepository.existsByEmail(email);
    }
}

Adapter는 외부 시스템과 Port를 연결하는 역할을 수행합니다.

도메인 엔티티에서 영속성 엔티티로 변환하는 메소드를 정의하는 경우가 있는데, 이는 도메인 레이어에서 영속성의 의존성을 가지는 것임 (만약 JPA를 더 이상 사용하지 않는다면 도메인 엔티티 수정)



헥사고날 아키텍처를 적용하고 느낀점

프로젝트에 헥사고날 아키텍처를 적용하면서, 도메인의 모든 것이 외부 시스템에서 분리되는 것을 경험했습니다.


이전 Controller -> Service -> Repository 계층형 구조에서는 도메인 레이어(Service)에서 외부 의존성의 흔적들이 존재했습니다.

Controller -> Service의 경우에는 HttpServletRequest, Request DTO 등 Web의 의존성을 그대로 사용하였고,

Service -> Repository는 더욱이 영속성 엔티티와 Spring Data에서 제공하는 Repository 사용으로 도메인과 영속성의 강한 결합이 존재했습니다.

반면, 헥사고날 아키텍처를 적용한 이번 프로젝트는 Port, Adapter와 Domain Entity 사용으로 도메인 레이어에서는 온전히 도메인 로직만 존재했습니다.


또한 계층형 구조에서는 하나의 엔티티 범위로 Controller, Service 등을 작성하여 간혹 Service가 엄청나게 커지는 경우가 많아 가독성도 떨어지고 협업 시 같은 파일을 수정하여 충돌이 발생하는 경우도 잦았는데,

이번 구조에서는 유스케이스 단위로 분리하다 보니 클래스 명과 기능이 명확하게 다가오는 것을 느낄 수 있었습니다.


하지만, 분명한 단점들도 존재했습니다.


우선 각 계층에서 데이터에 대한 객체를 변환하는 과정이 번거롭게 느껴졌습니다.

Input Port의 경우에는 입력 값을 Command로 변환하는 과정이, Output Port의 경우에는 도메인 엔티티를 영속성 엔티티로 변환하는 과정이 그러했습니다.


가장 큰 단점이라 생각이 들었던 것은, 도메인을 외부 시스템과 분리하기 위해 작성하는 모든 파일들(Adapter, Port, 분리된 엔티티 등)이 너무 많이 생겨난 것이었습니다.

'과연 이렇게 까지 클린 아키텍처를 지향할 필요가 있는지? 오버헤드인 것은 아닌지?'라고 생각이 들었습니다. (처음부터 헥사고날 아키텍처를 사용했다면 달랐을 것 같음!)


결론적으로 헥사고날 아키텍처의 적용은 잃는 것도 있었지만 그보다 얻는 것이 많다는 판단이 들었습니다.

특히 기능, 유스케이스 중심으로 어플리케이션 개발이 흘러가는 느낌을 받아 협업시 굉장히 좋을 것 같다는 느낌을 받았습니다. (헥사고날 아키텍처에서의 협업을 경험하진 못했지만...)

서버는 점점 더 많은 외부 시스템을 필요로 할 것이고, 헥사고날 아키텍처가 그에 적합하다는 것또한 헥사고날 아키텍처를 사용해야할 큰 이유라고 생각합니다!👻



profile
블로그 이전 -> https://pppp0722.github.io

1개의 댓글

comment-user-thumbnail
2023년 6월 15일

좋은 글 감사합니다! 글을 읽다보니 문득 궁금한점이 생겼는데요,
제가 헥사고날 아키텍처를 찾아보면서, Web Adapter(컨트롤러)에서 input-port로 넘어간 후, UseCase에서 이를 받아오는 그림을 보았습니다! 허나 실제 구현 코드들의 예시를 보니, UseCase라는 명칭의 인터페이스가 실제 input port의 역할을 하고 있더라구요.

  • 관점 1 ) UseCase는 인커밍 port 역할을 하며 해당 UseCase를 어플리케이션 서비스가 구현한다.
  • 관점 2) 인커밍 port 인터페이스를 구현한 구현체가 UseCase이다
    두 가지 관점을 두고 봤을때 어느 쪽이 더 타당하다고 생각하지는지 궁금합니다!
답글 달기