프로젝트/wanna-eat

헥사고날 아키텍처 적용기

개발하는 민우 2025. 3. 21. 14:00

https://velog.io/@onyx01/%ED%97%A5%EC%82%AC%EA%B3%A0%EB%82%A0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EB%A1%9C-%EC%BD%94%EB%93%9C-%EA%B5%AC%ED%98%84-%ED%95%B4-%EB%B3%B4%EA%B8%B0

 

헥사고날 아키텍처로 코드 구현 해 보기

헥사고날 아키텍처 (Hexagonal Architecture) 를 구현 해 보면서 이해한 개념을 정리 하고, 소감을 남겨봅니다.

velog.io

 

본 글은 위 링크의 글을 참고하여 작성하였습니다.


레이어드 아키텍처와의 비교

레이어드 아키텍처

- 소프트웨어 시스템을 계층으로 나누어 설계하는 전통적인 방식이다. 일반적으로 표현 계층(Presentation Layer), 비지니스 계층(Business Logic Layer), 데이터 접근 계층(Data Access Layer)으로 구성되는 3계층의 형태가 일반적인 레이어드 아키텍처이다.

 

  • 표현 계층: 사용자 인터페이스(UI)와 상호작용을 담당
  • 비즈니스 로직(Service) 계층: 애플리케이션의 핵심 로직을 처리하는 부분, 비즈니스 규칙과 관련된 연산이나 데이터 처리 로직을 포함
  • 데이터 접근 계층(Infrastructure): 데이터베이스와 상호작용을 담당

레이어드 아키텍처의 구조

challenge
│  ChallengeApplication.java
│  
├─controller
│      ChallengeController.java
│
├─service
│      ChallengeService.java
│
├─repository
│      ChallengeRepository.java

패키징 구조와 개념과의 대응

controller : 표현 계층 (Presentation Layer)

  • 사용자로부터의 입력 을 받아들이고, 응답 을 반환한다
  • 뷰(View)  모델(Model) 사이의 상호작용을 관리

service : 비지니스 계층 (Business Logic Layer)

  • 비즈니스 로직을 처리하고, 도메인 객체를 활용한다. (여러 도메인을 엮어 비지니스 로직을 수행 해야 할 때)
  • 트랜잭션 관리
  • 데이터 접근 계층과 상호작용하여 데이터의 저장 및 조회 등을 수행

repository : 데이터 접근 계층 (Data Access Layer)

  • 데이터베이스나 파일 시스템 등 외부 시스템과의 데이터의 직접 접근을 담당
  • CRUD(생성, 조회, 갱신, 삭제) 작업을 수행

레이어드 아키텍처의 특징과 장점

  •  상위 계층(Controller) 하위 계층(Service) 에 의존하고, 다시 Service 계층 Repository 계층에 의존한다.

의존성의 방향은 내부에서 외부로 흐른다. 비즈니스 로직 계층이 외부의 UI나 DB에 의존하기 때문이다.

 

장점

  • 이해하기 쉽고 명료한 구조
    각 계층이 명확한 책임을 가지고 있기 때문에 시스템의 구조를 쉽게 이해할 수 있다.
  • 유지보수성 향상
    계층별로 명확하게 분리된 책임 덕분에, 특정 계층에서 문제가 발생하면 해당 계층만 수정할 수 있다. 이로 인해 유지보수가 용이하다.
    새로운 기능을 추가할 때도 기존 계층을 수정하지 않고 새로운 계층만 추가하거나 변경하는 방식으로 확장할 수 있다.
  • 테스트 용이성
    각 계층이 독립적으로 모듈화되어 있기 때문에, 계층별로 단위 테스트를 수행하기 쉽다.

문제점

레이어드 아키텍처는 초기에는 이해하기 쉽고 간단하게 시작할 수 있다. 하지만 시스템이 커지면서 다음과 같은 문제가 발생할 수 있다. 장점으로 나타난 부분이 오염되어갈 가능성이 나타난다.

  • 의존성 역전 위반: 상위 계층이 하위 계층의 구현체에 의존하게 되어 변경에 취약해 진다.
  • 유지보수의 어려움: 비즈니스 로직이 여러 계층에 분산되어 유지보수가 어려워진다.
  • 테스트 어려움: 외부 시스템과 강하게 결합되어 단위 테스트가 어려워진다.

 

3. 헥사고날 아키텍처란?

도메인 헥사곤 (Domain Hexagon)

  • 핵심 비즈니스 로직과 도메인 모델이 위치하는 영역
  • 외부 시스템이나 기술 스택에 의존하지 않는 순수한 코드로 작성된다
  • 비즈니스 규칙과 상태 관리를 담당하며, 애플리케이션의 가장 핵심적인 부분으로 가장 내부영역이다

주요 구성 요소

  • 도메인(Domain) 객체: 상태와 행동을 포함하는 객체로 핵심 비지니스 로직을 가지고 있다.
  • 값 객체(Value Object): 불변 객체로, 하나의 값을 나타내며 식별자가 없음

애플리케이션 헥사곤 (Application Hexagon)

  • 포트(Port) 를 정의하여 도메인 헥사곤과 외부 세계를 연결
  • 유스케이스(Use Case)를 구현하여 비즈니스 작업의 흐름을 관리하는 영역
  • 트랜잭션 관리, 권한 검사, 로깅 등의 작업을 수행

주요 구성 요소

  • 입력 포트(Input Port): 외부에서 들어오는 요청을 처리하는 인터페이스
  • 출력 포트(Output Port): 외부 시스템과의 통신을 위한 인터페이스
  • 유스케이스 구현체(Use Case Implementation): 입력 포트를 구현하여 비즈니스 로직을 실행

프레임워크 헥사곤 (Framework Hexagon)

  • 어댑터(Adapter) 를 정의하고 외부 시스템과의 통신을 담당
  • 웹 프레임워크, 데이터베이스 접근, 메시징 시스템 등 기술적인 직접 구현이 위치
  • 외부로부터의 입력과 출력을 처리하며, 구체적인 기술 스택에 의존한다

주요 구성 요소

  • 입력 어댑터(Inbound Adapter): 외부 요청을 받아 애플리케이션 헥사곤의 입력 포트를 호출
  • 출력 어댑터(Outbound Adapter): 애플리케이션 헥사곤의 출력 포트를 구현하여 외부 시스템과 상호작용 함

헥사고날 아키텍처의 구조

challenge
│  ChallengeApplication.java
│  
├─application
│  ├─dto
│  │      ChallengeInputDTO.java
│  │      ChallengeOutputDTO.java
│  │
│  ├─port
│  │  ├─inbound
│  │  │      CreateChallengeUseCase.java
│  │  │
│  │  └─outbound
│  │          ChallengeRepository.java
│  │
│  └─service
│          CreateChallengeService.java
│
├─domain
│  ├─model
│  │      Challenge.java
│  │
│  └─vo
│          AdditionalInfo.java
│          ...
│
└─framework
    └─adapter
        ├─inbound
        │  └─web
        │          ChallengeController.java
        │          CreateRequest.java
        │          CreateResponse.java
        │		   ...
        │
        └─outbound
            └─jpa
            	└─entity
                		   ChallengeJPAEntity
                           ...
             ChallengeJpaAdapter.java
             ChallengeJpaRepository.java

 

 

패키징 구조와 개념과의 대응

domain 패키지: 도메인 헥사곤 (Domain Hexagon)

  • 비즈니스 도메인 모델을 표현하는 엔티티(Entity) 값 객체(Value Object) 를 정의
  • 도메인 모델은 비즈니스 규칙과 상태를 관리
  • 여러 도메인이 협력하는 경우 별도의 도메인 서비스로 묶어서 규칙을 정의 할 수도 있음
  • 애플리케이션의 독립적인 부분으로, 외부 시스템이나 프레임워크에 의존하지 않는다

 

application 패키지: 애플리케이션 헥사곤 (Application Hexagon)

  • 포트(Port) 를 정의하여 도메인 헥사곤과 외부를 연결.
    • 입력 포트 (InputPort / Inbound) : 외부의 입력을 처리하기 위한 인터페이스. 코드 내에서는 UseCase라는 이름으로 명명했다.
    • 출력 포트 (OutputPort / Outbound) : 외부 출력을 위한 인터페이스. DB의 저장이나 통신 등을 외부 시스템으로 본다. 코드 내에서는 Repository로 명명했다. 여기에서 Repository는 그 어떤 외부 저장소에 대한 정보를 알지 못한다.
    • 서비스 (service) : 애플리케이션 서비스를 뜻한다. 주로 트랜잭션을 관리하며 입력 포트(UseCase) 의 구현체이다.
    • DTO (Data Transfer Object) : 애플리케이션 헥사곤에서 데이터를 전달하기 위한 객체이다. 외부 입력이나 외부 출력의 경우 실제 도메인이 가진 정보 그대로 모두 전달 하거나 받을 필요가 없고, 필요한 데이터만 관리하기 위한 목적으로 사용한다.

코드 예시

입력 포트

public interface CreateChallengeUseCase {
    ChallengeOutputDTO createChallenge(ChallengeInputDTO challengeInputDTO);
}

InputPort에 해당한다. 외부의 입력을 받고 (이 입력은 DTO를 통해서 받는다) 이것을 처리하기 위한 인터페이스다.

 

출력 포트

public interface ChallengeRepository {
    Challenge save(Challenge challenge);
}

(애플리케이션) service

@Service
@Transactional
@RequiredArgsConstructor
public class CreateChallengeService implements CreateChallengeUseCase {

    private final ChallengeRepository challengeRepository;

    @Override
    public ChallengeOutputDTO createChallenge(ChallengeInputDTO challengeInputDTO) {
        Challenge challenge = Challenge.create(null, challengeInputDTO.getUserId(), challengeInputDTO.getNickName(),
                new Period(challengeInputDTO.getStartDate(), challengeInputDTO.getEndDate()),
                GoalContent.create(challengeInputDTO.getMainContent(), challengeInputDTO.getAdditionalContent(), GoalType.valueOf(challengeInputDTO.getGoalType())),
                challengeInputDTO.getAttachedImagePaths(), challengeInputDTO.getChallengeCertificateImagePath());
        return ChallengeOutputDTO.from(challengeRepository.save(challenge));
    }
}

service에 해당하며, InputPort의 구체적인 구현체이다. 하지만 여기서도 구체적인 외부의 저장소를 알지 못한다. 전달 받은 외부의 DTO 값을 객체로 만들고, 이 생성된 객체를 통해 외부로 저장을 요청한다. 이 결과값으로 받은 값 또한 다시 출력을 위한 DTO로 변환된 값을 받도록 했다.

 

framework 패키지: 프레임워크 헥사곤 (Framework Hexagon)

  • 어댑터(Adapter) 를 정의하며, 애플리케이션 헥사곤과 외부를 연결.
    • 입력 어댑터 (inbound) : 외부의 입력을 처리하며, 구체적인 기술 스택에 의존한다. 코드 내에서는 추가적인 패키징으로 web하위에 controller를 구현했다. HTTP 요청을 통한 구체적인 웹 요청을 처리한다.
    • 출력 어댑터 (outbound) : 외부의 출력을 처리하며, 주로 구체적인 DB나 외부 통신의 구현을 한다. 코드 내에서는 JpaAdapter를 통해 JPA를 구현하기로 했다.

출력 어댑터

@Repository
@RequiredArgsConstructor
public class ChallengeJpaAdapter implements ChallengeRepository {

    private final ChallengeJpaRepository challengeJPARepository;

    @Override
    public Challenge save(Challenge challenge) {
        return challengeJPARepository.save(ChallengeJPAEntity.fromDomain(challenge)).toDomain();
    }
}

public interface ChallengeJpaRepository extends JpaRepository<ChallengeJPAEntity, Long> {}

 

 

헥사고날 아키텍처의 특징과 장단점

특징

헥사고날 아키텍처에서의 의존성은 항상 외부에서 내부로 향하도록 설계되어야 한다. 가장 내부는 도메인 헥사곤 영역이다.

  • 프레임워크 헥사곤은 애플리케이션 헥사곤의 포트에 의존한다.
  • 애플리케이션 헥사곤은 도메인 헥사곤에 의존한다.
  • 도메인 헥사곤은 어떤 외부에도 의존하지 않는다.

헥사고날 아키텍처에서 핵심은 비즈니스 로직과 외부 시스템의 의존성을 분리하는 것에 있기 때문에 레이어드 아키텍처 처럼 수직관계인 상위, 하위 개념으로 설명하지 않고 외부와 내부로 표현하는게 적절하다고 한다.

의존성의 방향은 외부에서 내부로 흐른다.
데이터베이스, 외부 API, UI 같은 외부 의존성은 내부 비즈니스 로직에 의존하지만, 비즈니스 로직은 외부의 세부 사항에 의존하지 않는다. 따라서 내부의 핵심 비즈니스 로직은 외부 시스템(DB, UI, 외부 API 등)에 대해 몰라도 된다. 내부 로직은 기술적인 세부 사항에 의존하지 않고, 외부 시스템이 변경되더라도 비즈니스 로직은 영향을 받지 않는다. 즉 도메인 헥사곤 영역의 코드만으로도 완벽하게 돌아가는 프로그램인 것이다.

 

Web서비스를 가정한 상호작용의 흐름

  1. 클라이언트가 HTTP 요청을 보낸다.
  2. 입력 어댑터(ChallengeController)가 요청을 받아 입력 포트(CreateChallengeUseCase)를 호출한다.
  3. 유스케이스 구현체인 CreateChallengeService가 비즈니스 로직의 흐름을 제어하고, 모든 비즈니스 규칙은 도메인(Challenge)에서 처리된다.
    필요에 따라 출력 포트(ChallengeRepository)를 호출한다.
  4. 출력 어댑터(ChallengeJpaAdapter)가 출력 포트(ChallengeRepository)를 구현하여 데이터베이스에 접근하고, DB에 직접적으로 데이터를 저장하거나, 조회, 삭제 등을 수행한다.
  5. 결과는 역순으로 반환되어 클라이언트에게 응답된다.

장점

  • 유지보수성 향상: 핵심 비즈니스 로직은 외부 시스템과 분리되어 있어, 외부 시스템의 변경이 발생해도 비즈니스 로직은 그대로 유지될 수 있다. 이를 통해 변경 사항에 대한 영향 범위가 줄어들고, 코드의 일관성을 유지할 수 있다
  • 테스트 용이성 증가: 외부 의존성이 분리되어 단위 테스트가 쉬워지며 완전 독립성을 가진 테스트를 만들 수 있다 특히, 외부 시스템에 대한 모킹(Mocking) 을 통해 도메인 로직을 독립적으로 테스트할 수 있으며, 이를 통해 테스트 속도와 신뢰성이 높아진다
  • 확장성 확보: 어댑터를 통해 외부 시스템을 쉽게 교체할 수 있고, 새로운 기능을 추가할 때도 기존 도메인 로직에 영향을 주지 않으므로 확장성이 뛰어나다. 만약 새로운 외부 시스템을 만드는 경우에도 내부의 도메인에는 아무런 영향을 주지 않기 때문에 자연스럽게 OCP 원칙을 지키며 유연한 개발이 가능하다
  • 의존성 관리 용이: 의존성 방향이 명확하여 코드와 데이터의 흐름을 이해하기 쉽다

단점

  • 복잡성 증가: 초기 설계 시 개념을 이해하고 적용하는 데 시간이 필요하다. 계층별로 인터페이스와 어댑터를 분리하다 보니, 규모가 작은 애플리케이션에서는 오버엔지니어링(Over-engineering)이 발생할 수 있다
  • 개발 속도 저하: 레이어드 아키텍처에 비해 초기 개발 속도가 느릴 수 있다. 계층별로 인터페이스와 구현체를 분리하고, DTO 변환 작업 등이 추가된다
  • 과도한 추상화 위험: 필요 이상으로 추상화를 진행하면 오히려 코드 복잡도가 증가하고, 이해하기 어려운 구조가 된다. 추상화가 과도해질 경우, 인터페이스와 구현체의 분리가 너무 세분화되어 전체 코드의 복잡도가 증가할 수 있으며, 각 모듈 간의 연결이 불명확해질 수 있다