티스토리 뷰

헥사고날 아키텍처란?

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

 

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

 


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

 

 

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

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

기존 프로젝트의 문제

프로젝트 규모가 커짐

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

 

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

@Getter
@NoArgsConstructor
@Entity
public class Application extends BaseTimeEntity {

    @Id
    @GeneratedValue
    private Long id;

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

	@Enumerated(EnumType.STRING)
	@Column(nullable=false)
    private ApplicationStatus applicationStatus;
    
    @OneToMany(mappedBy = "application")
    private List<ApplicationAttachment> applicationAttachments = new ArrayList<>();

    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 Exception();
        }

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

 

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

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

 

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

 

 

DTO와 관련된 문제

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

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

 

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

 

그래서 프레젠테이션 계층에서 사용되는 DTO와 비즈니스 계층에서 사용되는 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 ApplicationStatus applicationStatus;

    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));
    }
}

 

유스케이스는 단일 책임 원칙을 적용해 세분화하였습니다. 유스케이스의 변경할 이유를 한 가지로 만들어 관리하기 좋은 코드로 만들고, 서비스 로직이 커지는 것을 방지하였습니다. 또한 유스케이스는 구현 기술을 추상화한 포트에 의존하기 때문에 외부 인프라에 의존하지 않아 외부 의존성 없이 테스트할 수 있습니다.

 

마무리

헥사고날 아키텍처를 도입하기 위해 관련 지식을 학습하고, 프로젝트에 적용하는 등 큰 노력이 필요했습니다.

또한, 이 과정에서 팀원들과 의견을 맞추는 것도 중요한 부분이었습니다.

 

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

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

 

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

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/03   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
글 보관함