계층형 아키텍처에서 헥사고날 아키텍처 도입하기

alsdl0629·2023년 8월 11일
1

기술 적용

목록 보기
1/6
post-thumbnail

이번 글에서는 현재 진행 중인 프로젝트에 헥사고날 아키텍처를 도입한 경험을 정리해 보려고 합니다.


헥사고날 아키텍처란?

헥사고날 아키텍처는 클린 아키텍처를 구현한 모델 중 하나로
육각형 안에는 도메인 모델과 유스케이스가 있고(외부를 전혀 알지 못함)
육각형 바깥에는 인프라 기술이 있는 구조
입니다.

추상화된 포트를 사용해 핵심 비즈니스 규칙과 인프라를 분리하고
중요 로직은 인프라로 향하는 의존성을 가질 수 없어
유연한 설계를 할 수 있게 해주는 아키텍처입니다.


그래서 port & adapter 아키텍처라고도 불립니다.
port & adapter 로 모듈을 느슨하게 연결해 코드의 재사용성을 높여주고,
코드를 수정할 때도 다른 모듈에 영향을 끼치지 않게 해줌으로써
유지보수하기 좋게 설계할 수 있습니다.


헥사고날 아키텍처 도입 배경

기존 프로젝트의 문제점

이 글을 보고 계신 분들 중 계층형 아키텍처를 사용하시는 분들은 자세히 봐주시면 좋을 것 같습니다!

프로젝트 규모가 커짐

개발해야 할 도메인은 늘어나 복잡해지고, 코드가 결국에는 인프라에 종속되는 문제
프로젝트를 점점 유지보수하기 어렵다고 판단했습니다.


도메인 모델이 영속성 계층을 의존

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Application extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "student_id", nullable = false)
    private Student student;

    @NotNull
    @Enumerated(EnumType.STRING)
    private ApplicationStatus applicationStatus;
    
    public void checkApplicationStatus(ApplicationStatus status1, ApplicationStatus status2) {
        if (status1 != status2) {
            throw ApplicationStatusCannotChangeException.EXCEPTION;
        }
    }

    public void checkIsDeletable(Student student) {
        if (!this.studentId.equals(student.getId())) {
            throw InvalidStudentException.EXCEPTION;
        }

        if (this.applicationStatus != ApplicationStatus.REQUESTED) {
            throw ApplicationCannotDeleteException.EXCEPTION;
        }
    }
}

도메인 객체가 데이터베이스(JPA)와 강한 의존관계를 갖는 게 문제였습니다.

추후 데이터베이스 종류를 바꾸거나 하는 일이 있을 때 문제가 발생할 거라 예상했고,
코드를 작성하면서 영속성 계층을 의존해 프로그램 전체가 좋지 않은 설계로 흘러가는 것을 알게 되었습니다.

결국 엔티티는 데이터베이스와 대응하기 위한 객체이고, 도메인 로직이 인프라에 종속되지 않도록 분리하고 싶었습니다.


DTO와 관련된 문제

API를 호출할 때 서버 쪽에서 데이터를 받아 처리하는 흐름입니다.

계층 간 데이터를 전달할 때 사용하는 DTO를
프레젠테이션 계층과 비즈니스 계층에서 공유하고 있어
비즈니스 계층이 프레젠테이션 계층을 의존하고 있었습니다.

DTO를 공유하게 되면 프레젠테이션 계층이 변경됐을 때 비즈니스 계층도 영향을 받을 수 있습니다. 계층형 아키텍처에서 하위 계층이 상위 계층을 의존하는 구조는 좋지 않은 구조라고 생각했습니다.


그래서 프레젠테이션 계층에서 사용되는 DTO와 비즈니스 계층에서 사용되는 DTO를 분리하는 게 의존성을 없애고, 책임 분리(유효성 검증 따로) 측면에서 좋기 때문에 리팩토링하면서 같이 분리하는 작업을 했습니다.

여기서 말하는 DTO는 클라이언트로부터 요청받는 Request DTO를 뜻하고,
클라이언트에게 응답하는 Response DTO는 의존 관계 방향이 프레젠테이션 -> 비즈니스 이기 때문에 문제가 되지 않습니다.


헥사고날 아키텍처를 도입한 이유

그래서 저희는 어떻게 하면 핵심 도메인 로직을 인프라와 분리하고,여러 인프라에 의존하는 프로젝트를 관리할 수 있을지 고민을 했습니다.

다른 프로젝트에서 헥사고날 아키텍처를 적용해 개발했었을 때 ,멀티 모듈로 계층별로 모듈을 나누어 코드 응집도가 올라가 코드를 확인하기 쉽고, DIP를 사용해 인프라의 의존을 끊어 핵심 로직에만 집중할 수 있었습니다.

이렇듯이 헥사고날 아키텍처에 대한 좋은 기억이 있어서 도입하게 되었습니다.

코드를 작성하는 시간은 전보다 많이 들겠지만, 장기적으로 봤을 때 더 좋은 서비스를 만들 수 있다고 생각했습니다.

개인적인 생각으로 시스템이 커지고, 모듈이 많아지고, 프로젝트 구조가 커질 것 같으면 처음부터 헥사고날 아키텍처를 적용하는 게 좋을 것 같습니다.


어떻게 적용했는지


멀티 모듈로 계층별 필요한 의존성만 관리하고, 구현 범위를 확실하게 분리했습니다.

application 모듈

  • 핵심 비즈니스 규칙이 모여있습니다.
  • 도메인 모델, 유스케이스, 포트
  • 순수 자바 객체

infrastructure 모듈

  • 외부 인프라 어뎁터가 모여있습니다.
  • Spring MVC, JPA, QueryDsl, S3, SES ...

도메인 모델

application -> domain -> model 에 위치합니다.

@Getter
@Builder(toBuilder = true)
@Aggregate
public class Application {

    private final Long id;

    private final Long studentId;

    private final Long recruitmentId;

    private final ApplicationStatus applicationStatus;

    private final String rejectionReason;
    
    public Application rejectApplication(String reason) {
        if (applicationStatus != ApplicationStatus.REQUESTED) {
            throw ApplicationStatusCannotChangeException.EXCEPTION;
        }

        return this.toBuilder()
                .applicationStatus(ApplicationStatus.REJECTED)
                .rejectionReason(reason)
                .build();
    }

    public void checkApplicationStatus(ApplicationStatus status1, ApplicationStatus status2) {
        if (status1 != status2) {
            throw ApplicationStatusCannotChangeException.EXCEPTION;
        }
    }
}

도메인 모델에는 해당 도메인 모델 속성과 관련 도메인 로직이 모여있습니다.
응집도 높은 코드를 구현할 수 있고, 특정 프레임워크에 의존하지 않는 POJO 객체입니다.


포트

application -> domain -> spi 에 위치합니다.

public interface CommandReviewPort {

    Review saveReview(Review review);

    void deleteReview(Review review);
}
public interface QueryReviewPort {

    boolean existsByCompanyIdAndStudentName(Long companyId, String studentName);

    Optional<Review> queryReviewById(Long reviewId);
}

유스케이스와 어댑터 간의 통신을 돕습니다.
포트를 구현할 때 인터페이스 분리 원칙을 적용해서 Command 와 Query 책임을 분리했습니다.


어댑터

infrastructure -> domain -> presentation or persistence 에 위치합니다.

@RequiredArgsConstructor
@Repository
public class ReviewPersistenceAdapter implements ReviewPort {

    private final ReviewJpaRepository reviewJpaRepository;
    private final ReviewMapper reviewMapper;
    private final QnAJpaRepository qnAJpaRepository;
    private final QnAMapper qnAMapper;
    private final JPAQueryFactory queryFactory;

    @Override
    public Review saveReview(Review review) {
        return reviewMapper.toDomain(
                reviewJpaRepository.save(
                        reviewMapper.toEntity(review)
                )
        );
    }

    @Override
    public void deleteReview(Review review) {
        reviewJpaRepository.delete(reviewMapper.toEntity(review));
    }

    @Override
    public boolean existsByCompanyIdAndStudentName(Long companyId, String studentName) {
        return reviewJpaRepository.existsByCompanyIdAndStudentName(companyId, studentName);
    }

    @Override
    public Optional<Review> queryReviewById(Long reviewId) {
        return reviewJpaRepository.findById(reviewId)
                .map(reviewMapper::toDomain);
    }
}

의존 역전 원칙을 적용해 유스케이스가 포트에 의존함으로써 다른 구현 기술로 쉽게 변경할 수 구현했습니다.
빠르게 기술이 변화되고 있는 현재 다양한 기술 변화에 대응할 준비를 할 수 있도록 구현했습니다.


유스케이스

application -> domain -> usecase 에 위치합니다.

@RequiredArgsConstructor
@UseCase
public class QueryReviewsUseCase {

    private final QueryCompanyPort queryCompanyPort;
    private final QueryReviewPort queryReviewPort;

    public QueryReviewsResponse execute(Long companyId) {
        if (!queryCompanyPort.existsCompanyById(companyId)) {
            throw CompanyNotFoundException.EXCEPTION;
        }

        return new QueryReviewsResponse(queryReviewPort.queryAllReviewsByCompanyId(companyId));
    }
}

유스케이스는 단일 책임 원칙을 적용해 세분화하였습니다.

유스케이스의 변경할 이유를 한 가지로 만들어 관리하기 좋은 코드로 만들고, 서비스 로직이 커지는 것을 방지하였습니다.

또한 유스케이스는 구현 기술을 추상화한 포트에 의존하기 때문에 외부 인프라에 의존하지 않아 외부 의존성 없이 테스트할 수 있습니다.


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

헥사고날 아키텍처를 도입하기 위해 관련 지식을 학습 하고, 프로젝트에 적용하는 등 큰 노력이 필요했습니다. 또한, 이 과정에서 팀원들과 의견을 맞추는 것도 중요한 부분이었습니다.

프로젝트 상황에 맞게 리팩토링해서 좋은 선택을 한 것 같으면서도, 한 편으로는 다른 좋은 방법은 뭐가 있을지 생각하게 되었습니다.

결과적으로 좋은 설계에 대해 고민해 볼 수 있어서 좋은 경험이었던 것 같습니다.

추가로 테스트 코드가 있었으면 기존에 있던 API가 잘 작동하는지 리팩토링 끝나고 테스트를 쉽게 할 수 있었는데 테스트 코드를 작성하지 않아 이 부분을 수동으로 확인한 게 아쉬웠습니다.

profile
인풋보다 아웃풋

4개의 댓글

comment-user-thumbnail
2023년 8월 11일

유익한 자료 감사합니다.

1개의 답글
comment-user-thumbnail
2024년 7월 1일

일요일에 헥사고날 아키텍쳐 공부하다가 강민님 글 보게됐는데~~~ 내용이 넘 좋아서 트위터에 임베딩했더니 RT를 좀 많이 탔어요~!!! ㅎㅎ 글에서 고민의 흔적이 느껴져서 저도 많이 배워가네요. 더위조심하시고 또 좋은 글 기대하겠습니다!!

1개의 답글