전자기기로 이해하는 헥사고날 아키텍처

2026. 4. 30. 12:41·내배캠

예전에 DDD와 헥사고날을 비교하는 글을 썼다가 따끔한 피드백을 받았습니다.
아키텍처(헥사고날)와 방법론(DDD)을 같은 선상에 두었기 때문이죠.

서로 다른 추상화 레벨(다루는 범위와 깊이)이기에 같은 종류의 문제를 다루지 않아 적절하지 않다는 뜻이었습니다.

추상화 레벨 예시
시스템 분산 방식 모놀리식, 마이크로서비스
시스템 내부 구조 (아키텍처) 레이어드, 헥사고날, 클린 아키텍처
설계 방법론 DDD, TDD
코드 수준 패턴 (디자인 패턴) 싱글톤, 팩토리, 옵저버

표를 보면 알 수 있듯이, 이번엔 같은 문제를 다루는 레이어드와 헥사고날을 비교하는 글을 작성했습니다.

 

이 글에선 헥사고날을 '컴퓨터 본체와 USB 포트, 그리고 그에 꽂히는 키보드/마우스'에 비유했고
두 아키텍처의 트레이드오프를 비교하고, 헥사고날의 패키지 구조와 사용 시기까지 다뤄봤습니다.

Layered Architecture

레이어드 아키텍처란?

먼저 레이어드 아키텍처는 물리층을 나타내는 티어와 티어 내부의 계층으로 설명됩니다.
그래서 서버 티어가 3 계층으로 구성되는 구조였습니다.

  • 프레젠테이션(표현) : 화면 표현 및 전환 처리
  • 비즈니스 로직 : 비즈니스 개념, 규칙, 흐름제어
  • 데이터 액세스 : 데이터 처리

그리고 레이어드 아키텍처에서는 계층 간 응집도를 높이고 의존성을 낮추기 위해 몇 개의 규칙을 적용했습니다.

  • 상위 계층이 하위 계층을 호출하는 단방향성 유지
  • 상위 계층은 자신의 바로 아래 계층만 활용
  • 상위 계층이 하위 계층의 변경에 영향을 받지 않도록 구성
  • 하위 계층을 자신을 활용하는 상위 계층을 알지 못하도록 구성
  • 계층 간 호출은 인터페이스를 사용

하지만 위 규칙을 적용하여 DIP는 지킬 수 있지만

전통적인 레이어드 구조에서 인터페이스 소유권을 데이터 계층에 두면 OCP를 지키기 어렵습니다.

DIP(Dependency Inversion Principle : 의존성 역전 원칙) : 상위 모듈이 하위 모듈의 구현이 아닌 추상화에 의존해야 한다는 원칙. → 비즈니스 로직이 데이터 기술(JPA, Redis 등)에 직접 묶이지 않게 해 줍니다.
OCP(Open-Closed Principle : 개방 폐쇄의 원칙) :  확장에는 열려 있고, 수정에는 닫혀 있어야 한다는 원칙.
→ 데이터 기술이 바뀌어도 비즈니스 로직 코드를 수정하지 않아도 되게 해 줍니다.

왜냐하면 각 계층이 자신이 제공하는 기능에 대한 인터페이스를 직접 정의하고 소유하기 때문입니다.

레이어드 아키텍처의 문제점과 해결법

그래서 위 사진처럼 비즈니스 로직 계층에 인터페이스를 두고
데이터 액세스 계층은 구현체만 가지게 해서 DIP와 OCP를 해결할 수 있습니다.

 

아래 토글에서 DIP, OCP를 어떻게 해결했는지에 대해 설명했습니다.

더보기

예시 문제 상황

  1. 데이터 액세스 계층에서 UserRepository 인터페이스 정의, 구현체 정의
  2. 비즈니스 로직 계층에서 이 UserRepository 인터페이스를 import 해서 사용
  3. UserRepository의 로직 변경
  4. 데이터 액세스 계층의 코드도 수정해야함.

위 상황을 아래에서 코드를 통해 자세히 설명하겠습니다.

초기 상황: JPA를 사용하는 데이터 액세스 계층

데이터 액세스 계층(Repository)이 인터페이스와 구현체를 모두 소유하고 있습니다.

이때 인터페이스는 은연중에 JPA라는 기술에 종속된 명세를 내놓게 됩니다.

// [Data Access Layer]
// 인터페이스가 데이터 계층에 있으므로, JPA 엔티티인 UserEntity를 직접 반환합니다.
public interface UserRepository {
    UserEntity findById(Long id); 
    List<UserEntity> findAllByActive(boolean active);
}

@Repository
public class UserRepositoryImpl implements UserRepository {
    // 실제 JPA 구현 로직...
}

// [Business Logic Layer]
@Service
public class UserService {
    private final UserRepository userRepository; // 데이터 계층의 인터페이스에 의존

    public void deactivateUser(Long id) {
        // 문제점: 비즈니스 계층인데 JPA 엔티티(UserEntity)를 직접 핸들링함
        UserEntity user = userRepository.findById(id);
        user.setActive(false); 
    }
}

수정 발생: 성능 최적화를 위해 NoSQL이나 Redis 도입

사용자가 급증하여 특정 조회 로직을 JPA가 아닌 Redis나 직접적인 외부 API 호출로 변경해야 하는 상황이 왔다고 가정해 봅시다. 이제 더 이상 UserEntity(JPA용)를 반환할 수 없고, 새로운 DTO나 다른 형태의 객체를 반환해야 합니다.

데이터 액세스 계층의 수정 (수정의 시작)

인터페이스의 소유권이 데이터 계층에 있으므로, 여기서 인터페이스를 수정합니다.

// [Data Access Layer] 수정
public interface UserRepository {
    // 이제 JPA 엔티티가 아닌, 공통 응답 객체나 Redis용 객체를 반환해야 함
    UserResponseDto findById(Long id); 
}

비즈니스 로직 계층의 강제 수정 (OCP 위배)

데이터 계층의 인터페이스가 바뀌자마자, 이를 사용하던 모든 서비스 코드가 컴파일 에러를 뿜어냅니다.

// [Business Logic Layer] - 강제 수정 발생!
@Service
public class UserService {
    public void deactivateUser(Long id) {
        // 기존 코드: UserEntity user = userRepository.findById(id); 
        // 수정 코드: 반환 타입이 바뀌었으므로 서비스 로직 전체를 다 뜯어고쳐야 함
        UserResponseDto userDto = userRepository.findById(id);

        // 만약 기존에 UserEntity의 더티 체킹에 의존했다면? 
        // 이제 save()를 명시적으로 호출하는 로직까지 서비스에 추가해야 함.
        userRepository.updateStatus(id, false); 
    }
}

해결 버전: DIP를 통한 OCP 준수 설계

이 구조에서는 비즈니스 로직 계층이 주도권을 가집니다. 외부 기술(Redis, JPA 등)이 바뀌어도 서비스 코드는 단 한 줄도 수정되지 않습니다.

1. 비즈니스 로직 계층 (도메인 중심)

인터페이스와 비즈니스 객체(Domain)를 이곳에 둡니다. 이 계층은 외부 라이브러리나 DB 기술에 의존하지 않는 순수 Java 코드여야 합니다.

// [Business Logic Layer] - 도메인 모델 (순수 자바 객체)
public class User {
    private Long id;
    private String name;
    private boolean active;

    // 비즈니스 로직은 도메인 객체 내부에서 수행 (캡슐화)
    public void deactivate() {
        this.active = false;
    }

    // Getter 및 생성자...
}

// [Business Logic Layer] - 인터페이스 (소유권이 비즈니스 계층에 있음)
public interface UserRepository {
    // 반환 타입이 기술 종속적인 Entity가 아닌, 순수 도메인 객체(User)임
    User findById(Long id);
    void save(User user);
}

// [Business Logic Layer] - 서비스 (수정될 일이 없음)
@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository; // 자신이 소유한 인터페이스에 의존

    public void deactivateUser(Long id) {
        // 1. 기술(DB)이 무엇인지 몰라도 규격에 맞춰 데이터를 가져옴
        User user = userRepository.findById(id);

        // 2. 비즈니스 로직 수행 (순수 자바 객체의 메서드 호출)
        user.deactivate();

        // 3. 상태 변경 저장 (인터페이스 규격에 맞춤)
        userRepository.save(user);
    }
}

2. 데이터 액세스 계층 (인프라 구현 상세)

이제 기술적인 구현(Redis, JPA 등)은 비즈니스 계층 아래에서 인터페이스를 구현하는 역할만 수행합니다.

// [Data Access Layer] - JPA 또는 Redis 등 구체적인 기술 구현
@Repository
@RequiredArgsConstructor
public class UserInfrastructureRepository implements UserRepository {

    private final JpaUserRepository jpaRepo; // 실제 JPA 레포지토리
    // private final RedisTemplate redisTemplate; // Redis 도입 시 추가 가능

    @Override
    public User findById(Long id) {
        // 1. DB에서 엔티티 조회 (상세 구현)
        UserEntity entity = jpaRepo.findById(id).orElseThrow();

        // 2. 엔티티를 비즈니스 계층이 이해할 수 있는 도메인 모델로 변환 (Mapping)
        // 이 과정 덕분에 상위 계층은 JPA의 존재를 모를 수 있음
        return entity.toModel(); 
    }

    @Override
    public void save(User user) {
        // 도메인 모델을 다시 엔티티로 변환하여 DB에 영속화
        UserEntity entity = UserEntity.from(user);
        jpaRepo.save(entity);
    }
}

레이어드의 문제점

레이어드 아키텍처는 개발자가 선택적으로 DIP를 적용합니다.

다시 말해, 개발자가 DIP를 구현하도록 강제할 수 없습니다.

  • 마감이 급해서 신입 개발자가 Service 클래스에서 RestTemplate을 직접 import 하고 PR을 올림.
  • 코드 리뷰에서 놓치면 그대로 머지되고, 도메인 순수성이 조용히 깨진다.

또한 레이어드는 프레젠테이션 계층과 데이터 액세스 계층 중심으로 설계되어, 
다양한 입출력 채널을 동등하게 다루기엔 구조가 비대칭적입니다.
하지만 현대에는 프레젠테이션 계층과 데이터 액세스 계층 말고도 다양한 인터페이스를 필요로 합니다.

  • 입력 채널: 웹, 메시지 큐, CLI(배치 처리) 등
  • 출력 채널: 데이터 저장, 이벤트 발행, 외부 API 호출 등

하지만 헥사고날은 레이어드의 2가지 문제를 많이 해결합니다.

이제 헥사고날의 개념부터 DIP의 강제성이 높아지는 이유, 여러 외부 기술에 대처하는 방식까지 알아봅시다.

Hexagonal Architecture

헥사고날 아키텍처란?

먼저 헥사고날 아키텍처에서 헥사(Hexa)는 그리스어로 숫자 6을 의미하고

헥사고날(Hexagonal)은 육각형을 뜻합니다.

2개의 육각형으로 구성된 헥사고날 아키텍처

위 그림에선 헥사고날을 2개의 육각형으로 설명했는데
안쪽 육각형으론 비즈니스 요구사항에 대한 명세가 적힌 Core 기술(도메인 모델, 애플리케이션 서비스)을

바깥쪽 육각형으론 비즈니스 요구사항을 처리하기 위한 여러 외부 기술들을 표현했습니다.

 

이렇게 표현한 이유는 안팎의 경계를 그어 안쪽 코어 모듈의 독립성을 보여주기 위함입니다.

모듈의 독립성이란?
- 코어 모듈이 외부 기술에 의존하지 않는다는 의미이다.
- 만약 외부 기술의 변경이 있을 때, 코어 모듈을 수정하지 않아도 된다면 코어 모듈의 독립성이 잘 지켜진 겁니다.

USB 포트와 마우스로 이해하는 포트와 어댑터

레이어드와 헥사고날의 차이점은 안과 밖을 연결해 주는 포트(Port)와 어댑터(Adapter)뿐입니다.

그래서 포트와 어댑터만 잘 이해하면 헥사고날은 다 이해했다고 봐도 과언이 아닙니다.

먼저 두 단어의 사전적 의미부터 살펴봅시다.

  • Port: 외부와 연결되는 접속 지점, 또는 그 규격
  • Adapter: 서로 다른 두 규격을 맞춰주는 장치 ( adapt는 영어로 '맞추다'라는 뜻이다 )

사실 헥사고날 아키텍처의 정식 이름이 "Ports and Adapters"패턴인 것도 이 일상 용어에서 그대로 따온 것입니다.

아래는 전자기기에서 볼 수 있는 포트의 예시입니다.

  • USB 포트: USB 규격으로 장치가 연결되는 지점
  • HDMI 포트: HDMI 규격으로 영상/음성 신호가 연결되는 지점
  • 이더넷 포트(랜 포트): 네트워크 케이블이 연결되는 지점

그리고 아래는 어댑터의 예시입니다.

  • USB 마우스: USB 포트의 규격에 맞춰 컴퓨터에 연결되는 장치
  • 110V/220V 변환 어댑터: 미국 콘센트와 한국 플러그의 다른 규격을 맞춰주는 장치
  • USB-C to HDMI 어댑터: USB-C 포트와 HDMI 포트의 다른 규격을 연결해 주는 장치

정리하면 다음 그림과 같습니다.

포트와 어댑터 실생활 예시

포트는 "이런 규격으로 연결하라"는 약속이고,
어댑터는 "그 약속에 맞춰 양쪽을 연결해 주는 장치"입니다.

특히 같은 USB 포트라는 약속에 마우스를 꽂으면 마우스가 되고,
키보드를 꽂으면 키보드가 되고, 외장하드를 꽂으면 저장장치가 됩니다.


컴퓨터 본체는 무엇이 꽂히든 신경 쓰지 않습니다. 그저 USB 규격이라는 약속만 지키면 됩니다.

 

사실 이 구조가 우리가 짜는 코드와 정확히 같습니다.
헥사고날에서 포트는 인터페이스이고, 어댑터는 그 인터페이스를 구현한 클래스입니다.

예를 들어 "알림을 발송한다"는 포트(인터페이스)가 있다면,

  • 이메일로 발송하는 어댑터를 꽂을 수도 있고
  • 카카오톡 알림톡으로 발송하는 어댑터를 꽂을 수도 있고
  • SMS로 발송하는 어댑터를 꽂을 수도 있다.

"이벤트를 발행한다"는 포트도 마찬가지입니다.
Kafka, RabbitMQ, Spring Publisher 중 어떤 어댑터를 꽂아도 도메인 코드는 한 줄도 바뀌지 않습니다.

 

이걸 그림으로 정리하면 다음과 같습니다.

포트와 어댑터 코드 예시

도메인은 어떤 어댑터가 꽂혔는지 모르고 포트만 알고 있습니다.

헥사고날의 데이터 흐름

지금까지는 포트와 어댑터가 무엇인지 정적인 그림을 봤습니다.

이제 데이터가 실제로 어떻게 흐르는지 살펴봅시다.

 

전체 흐름은 다음과 같습니다.

외부 요청 → 인 어댑터 → 인 포트 → 도메인 → 아웃 포트 → 아웃 어댑터 → 외부 기술

이것을 그림으로 표현하면 아래와 같습니다.

헥사고날의 실생활 비유와 적용 예시

컴퓨터-USB 포트-키보드/마우스의 관계와 실제 헥사고날의 처리 흐름을 한눈에 비교했습니다.

 

도메인은 자신의 입구(인 포트)와 출구(아웃 포트)만 알고 있을 뿐, 그 양쪽 끝에 어떤 어댑터가 꽂혔는지 알 필요가 없습니다.

이 구조 덕분에 헥사고날은 다양한 외부 기술에 유연하게 대응할 수 있습니다.
새로운 입력 채널이 필요할 때마다 인 어댑터를 추가하면 됩니다.

  • 웹 요청을 받고 싶다면 WebController (인 어댑터)
  • CLI 명령을 받고 싶다면 CommandHandler (인 어댑터)
  • 메시지 큐 이벤트를 받고 싶다면 MessageListener (인 어댑터)

새로운 외부 기술과 연동해야 할 때마다 아웃 어댑터를 추가하면 됩니다.

  • DB에 저장할 땐 JpaOrderRepository (아웃 어댑터)
  • 이벤트를 발행할 땐 KafkaPublisher (아웃 어댑터)
  • 결제 API를 호출할 땐 TossPaymentAdapter (아웃 어댑터)

도메인 코드는 한 줄도 바뀌지 않습니다. 새 어댑터를 만들어 포트에 꽂기만 하면 됩니다.

앞서 말씀드린 "레이어드는 입출력 채널을 비대칭적으로 다룬다"는 문제가

헥사고날에서 자연스럽게 해결되는 이유입니다.

헥사고날의 패키지 구조

헥사고날의 핵심은 "도메인이 외부 기술에 의존하지 않는다"입니다.

그리고 이건 단순한 다짐이 아니라 패키지 구조로 강제됩니다.

 

주문(Order) 도메인을 예시로 살펴봅시다.

src/main/java/com/example/order
│
├── domain/                              ← 순수 도메인 (비즈니스 규칙)
│   └── model/Order.java                 (순수 도메인 모델)
│
├── application/                         ← 애플리케이션 (유스케이스 + 포트)
│   ├── port/
│   │   ├── in/PlaceOrderUseCase.java    (인 포트)
│   │   └── out/OrderRepository.java     (아웃 포트)
│   └── service/OrderService.java        (UseCase 구현, 비즈니스 로직 조율)
│
└── adapter/                             ← 어댑터 (외부 기술 구현)
    ├── in/web/OrderController.java      (인 어댑터)
    └── out/persistence/
        └── OrderJpaAdapter.java         (아웃 어댑터)

핵심은 포트는 '안쪽 육각형'(domain + application)에, 어댑터는 '바깥쪽 육각형'에 있습니다.

안쪽이 '이런 구멍이 필요해'라고 스스로 선언하고, 어댑터는 그 구멍에 맞춰 바깥에서 꽂힙니다.

의존성 역전(DIP)이 자연스럽게 일어나는 구조죠.

헥사고날의 의존성 역전
- adapter/ 패키지를 통째로 지워도 domain/과 application/은 컴파일됩니다.
- 도메인은 어댑터 패키지의 어떤 클래스도 import 하지 않습니다.

레이어드에서는 "신입 개발자가 Service에 RestTemplate을 직접 import 하는 사고"가 충분히 일어날 수 있습니다. 코드 리뷰에서 놓치면 그대로 머지되고요.

 

하지만 헥사고날에서는 패키지 구조 자체가 그런 사고를 어렵게 만듭니다.

만약 프로젝트를 패키지가 아닌 모듈단위로 분리하여 구성한다면 DIP 강제성이 더욱 강력해집니다.

  • 도메인 모듈
  • 애플리케이션 모듈
  • 어댑터 모듈

헥사고날 트레이드오프

헥사고날은 만능이 아닙니다. 분명한 트레이드오프가 있습니다.

헥사고날의 단점

  • 보일러플레이트 증가 (도메인 모델 ↔ 엔티티 매핑이 매번 발생)
  • 학습 곡선 (팀원 모두가 이해해야 함)
  • 단순 CRUD에서 오히려 가독성 저하
  • 트랜잭션 경계 설정이 까다로워짐 (도메인이 영속성을 모르므로)

적합한 상황

  • 비즈니스 복잡도가 높은 프로젝트
  • 외부 연동이 다양한 시스템 (여러 PG사, 알림 채널, 메시지 브로커 등)
  • 장기 운영되며 기술 스택이 바뀔 가능성이 있는 프로젝트

과한 상황

  • 단순 CRUD 위주의 작은 서비스
  • 단기 프로젝트나 프로토타입
  • DB 하나만 쓰는 단순한 시스템

결국 던져야 할 질문은 이겁니다.

"코드량이 늘어나는 비용을 감수할 만큼, 이 비즈니스 로직을 기술 변화로부터 보호할 가치가 있는가?"

이 질문에 "그렇다"라고 답할 수 있다면 헥사고날은 좋은 선택입니다.

글 요약

이 글의 핵심은 2 가지입니다.

  1. 레이어드는 DIP를 권장하고, 헥사고날은 DIP의 강제성을 높인다.
  2. 레이어드는 입력/출력 계층이 비대칭적이고, 헥사고날은 모든 외부 인터페이스를 포트로 대칭적으로 다룬다.

두 아키텍처는 같은 목표(도메인을 외부 기술로부터 보호하기)를 향하지만,
레이어드는 "개발자의 규율"에 의존하고, 헥사고날은 "패키지/모듈 구조"로 그 규율을 뒷받침합니다.

 

기술이 계속 바뀌어도 비즈니스 로직은 살아남아야 합니다.

헥사고날은 그 비즈니스 로직을 기술 변화로부터 지켜줄 수 있습니다.

'내배캠' 카테고리의 다른 글

TIL - 대회/랭킹 도메인 흐름 정리  (0) 2026.05.06
TIL - Redis 및 Kafka 사용 시 DB와의 분산트랜잭션 문제 해결하기  (0) 2026.05.02
TIL - 공통 모듈의 ErrorCode, 어떻게 관리해야 할까  (0) 2026.04.26
TIL - 생성자에 private과 protected, 언제 어떻게 쓸까  (0) 2026.04.24
TIL - 실시간 랭킹 구현 방법  (0) 2026.04.23
'내배캠' 카테고리의 다른 글
  • TIL - 대회/랭킹 도메인 흐름 정리
  • TIL - Redis 및 Kafka 사용 시 DB와의 분산트랜잭션 문제 해결하기
  • TIL - 공통 모듈의 ErrorCode, 어떻게 관리해야 할까
  • TIL - 생성자에 private과 protected, 언제 어떻게 쓸까
MvA
MvA
백엔드 개발자 김재현입니다. 주로 공부하면서 느낀점을 기록합니다.
  • MvA
    Man vs Ai
    MvA
  • 전체
    오늘
    어제
    • 분류 전체보기 (94)
      • Java (6)
      • Python (8)
        • 딥러닝 (1)
        • 머신러닝 (7)
      • JavaScript (2)
      • 내배캠 (60)
      • 개인 프로젝트 (11)
      • 책 후기 (5)
      • 기타 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    아키텍처
    Riot API
    내일배움캠프
    머신러닝
    딥러닝
    TiL
    배포
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
MvA
전자기기로 이해하는 헥사고날 아키텍처
상단으로

티스토리툴바