
헥사고날 아키텍처와 C++, 어울릴까?
🚀 서론: C++ 개발자의 아키텍처 고민, 함께 나누고 해법을 찾다
C++ 개발자로서 우리는 늘 성능과 시스템 제어에 대한 고민을 즐기지만, 동시에 대규모 프로젝트에서 마주하는 복잡성, 테스트의 어려움, 그리고 변화에 대한 취약성이라는 아키텍처적 고민을 안고 살아갑니다. 특히 UI, 데이터베이스, 외부 API 등 다양한 외부 기술과의 의존성이 핵심 비즈니스 로직과 얽히기 시작하면, 코드는 점점 더 경직되고 유지보수와 테스트는 고통스러운 작업이 되곤 합니다.
저희 CAM 연구소는 이러한 사내 C++ 개발자들의 고민을 함께 나누고, 헥사고날 아키텍처가 그 해법 중 하나가 될 수 있음을 보여드리고자 합니다. 이를 위해 경험 있는 개발자들이 아키텍처의 개념증명(PoC)을 위한 샘플 프로젝트를 짬을 내어 개발하였습니다. 이 포스트에서는 tui_rog_game(터미널 로그라이크 게임) 샘플 프로젝트를 통해 헥사고날 아키텍처가 C++ 환경에서 어떻게 적용될 수 있는지, 그리고 이를 통해 어떤 이점을 얻을 수 있는지 살펴보겠습니다.
💡 왜 아키텍처가 필요할까?
전통적인 계층형 아키텍처는 종종 비즈니스 로직이 특정 데이터베이스 구현체나 UI 프레임워크에 직접 의존하는 구조를 만듭니다. 이는 다음과 같은 문제들을 야기하며, 소프트웨어의 장기적인 건전성을 위협합니다.
- 높은 결합도:
- 핵심 로직이 외부 기술에 강하게 묶여, 기술 스택 변경 시 광범위한 코드 수정이 불가피해집니다.
- 낮은 테스트 용이성:
- 비즈니스 로직을 테스트하기 위해 실제 데이터베이스나 UI 환경을 구축해야 하므로, 단위 테스트가 어려워지고 통합 테스트 비용이 증가합니다.
- 도메인 로직의 오염:
- 비즈니스 규칙이 기술적 세부사항과 뒤섞여 도메인의 순수성이 훼손되고 이해하기 어려워집니다.
헥사고날 아키텍처는 의존성 역전 원칙을 통해 이러한 문제에 대한 견고한 해법을 제시합니다. 핵심 비즈니스 로직은 외부 기술에 직접 의존하지 않고, 오직 인터페이스(Ports)에만 의존합니다. 그리고 외부 기술들은 이 인터페이스를 구현하는 어댑터(Adapters)를 통해 애플리케이션 코어와 소통하며, 이는 시스템의 유연성과 테스트 용이성을 극대화합니다(참고: 헥사고날 아키텍처 상세 설명).
🛠️ 샘플을 통해 확인하는 C++ 헥사고날 아키텍처 구현 전략
tui_rog_game 프로젝트는 C++17 기반의 터미널 로그라이크 게임으로, 헥사고날 아키텍처의 원칙을 충실히 따릅니다. 각 계층은 명확한 책임과 의존성 규칙을 가집니다.
tui_rog_game
├───adapter/ <- 외부 세계와의 통신 (UI, DB, 외부 API)
│ ├───in/
│ │ └───tui/ <- TUI 입력 어댑터 (사용자 입력 처리)
│ └───out/
│ ├───description/ <- 설명 생성 출력 어댑터 (하드코딩/LLM)
│ └───persistence/ <- 영속성 출력 어댑터 (인메모리/LevelDB)
├───application/ <- 핵심 비즈니스 로직 (육각형 안쪽)
│ ├───domain/
│ │ ├───event/ <- 도메인 이벤트
│ │ ├───model/ <- 도메인 모델 (플레이어, 맵, 몬스터 등)
│ │ └───service/ <- 도메인 서비스 (GameEngine)
│ └───port/
│ ├───in/ <- 입력 포트 (IGetPlayerActionUseCase)
│ └───out/ <- 출력 포트 (IRenderPort, ISaveGameStatePort 등)
├───assembly/ <- 애플리케이션 조립 (ApplicationBuilder)
├───common/ <- 공통 유틸리티
└───main.cc <- 애플리케이션 진입점
도메인 중심 설계와 풍부한 모델
헥사고날 아키텍처의 핵심은 도메인(Domain)입니다. tui_rog_game에서는 Player, Map, Enemy와 같은 도메인 엔티티가 비즈니스 규칙과 상태를 캡슐화하는 풍부한(Rich) 도메인 모델로 설계되었습니다. 이는 도메인 로직의 순수성을 보장하고 코드의 가독성을 높이는 데 기여합니다.
예를 들어, Player 클래스는 플레이어의 레벨업, 아이템 사용, 피해 입기 등 핵심 비즈니스 행위를 직접 수행합니다. 이는 도메인 로직이 외부 서비스에 분산되지 않고, 도메인 객체 스스로 책임과 역할을 가지도록 합니다.
// application/domain/model/src/Player.cc
bool Player::gainXp(int amount) {
xp_ += amount;
bool leveled_up = false;
while (xp_ >= 100 * level_) { // <- 레벨업 비즈니스 규칙
xp_ -= 100 * level_;
level_++;
// 스탯 증가 로직 등
leveled_up = true;
}
return leveled_up;
}
bool Player::useItem(const std::string &item_name) {
for (auto it = inventory_.begin(); it != inventory_.end(); ++it) {
if ((*it)->getName() == item_name) {
if ((*it)->getType() == Item::ItemType::HealthPotion) {
hp_ = std::min(hp_ + 20, getMaxHp()); // <- 아이템 사용 비즈니스 규칙
}
inventory_.erase(it);
return true;
}
}
return false;
}
C++에서의 포트와 어댑터 구현
C++에서는 추상 클래스(Abstract Class)를 인터페이스(Port)로 활용하여 의존성을 역전시킵니다. GameEngine과 같은 도메인 서비스는 오직 이 인터페이스에만 의존합니다(즉, 어댑터에 직접 의존하지 않습니다).
- 입력 포트 (In-Port):
IGetPlayerActionUseCase.h- 외부(UI)에서 애플리케이션 코어(GameEngine)를 구동하기 위한 계약입니다.
TuiAdapter는 사용자 입력을 이 포트의 메서드 호출로 변환하여,IGetPlayerActionUseCase.h포트의 구현체인GameEngine에 전달합니다.
- 외부(UI)에서 애플리케이션 코어(GameEngine)를 구동하기 위한 계약입니다.
// application/port/in/include/IGetPlayerActionUseCase.h
class IGetPlayerActionUseCase {
public:
...
virtual void handlePlayerAction(const PlayerActionCommand &command) = 0;
virtual void toggleDescriptionPort() = 0; // 상황 묘사 모델(하드코딩/LLM) 전환 유스케이스
};
- 출력 포트 (Out-Port):
ISaveGameStatePort.h- 애플리케이션 코어가 외부 서비스(데이터베이스)를 사용하기 위한 계약입니다.
GameEngine은 이 포트를 통해 게임 상태를 저장하며, 실제 저장 방식은 알 필요가 없습니다.
- 애플리케이션 코어가 외부 서비스(데이터베이스)를 사용하기 위한 계약입니다.
// application/port/out/include/ISaveGameStatePort.h
class ISaveGameStatePort {
public:
...
virtual void saveGameState(const GameStateDTO &gameState) = 0;
};
- 유연한 어댑터와 의존성 주입
ApplicationBuilder는 애플리케이션의 모든 구성 요소를 조립하는 역할을 하며, 여기서 의존성 주입(DI)을 활용하여 런타임에 어댑터 구현체를 쉽게 교체할 수 있습니다. 예를 들어, 영속성 어댑터를 LevelDB에서 인메모리 방식으로, 또는 설명 생성 어댑터를 하드코딩 방식에서 LLM 기반 방식으로 쉽게 전환할 수 있습니다.
// assembly/src/ApplicationBuilder.cc
auto persistence_adapter =
std::make_shared<Adapter::Out::Persistence::LevelDbAdapter>(
"./game_data.db");
// auto persistence_adapter = std::make_shared<InMemoryAdapter>(); // 주석 해제 시 인메모리 어댑터 사용
auto hardcoded_desc_adapter = std::make_unique<HardcodedDescAdapter>();
auto chatgpt_desc_adapter = std::make_unique<LlmAdapter>();
auto game_engine = std::make_shared<Domain::Service::GameEngine>(
std::static_pointer_cast<ISaveGameStatePort>(persistence_adapter),
std::static_pointer_cast<ILoadGameStatePort>(persistence_adapter),
std::move(hardcoded_desc_adapter), std::move(chatgpt_desc_adapter));
위 코드에서 주석 처리된 부분을 활성화하는 것만으로 영속성 구현체를 변경할 수 있습니다. 또한, GameEngine은 toggleDescriptionPort() 메서드를 통해 런타임에 설명 생성 어댑터를 동적으로 전환할 수 있습니다.
상황묘사 모델을 런타임에 토글하여, 고정 묘사 출력을 LLM을 통한 묘사 출력으로 전환할 수 있습니다. 상황묘사 모델을 출력 포트로 구현하였기 때문에 쉽게 적용 가능합니다.
C++에서의 테스트 용이성 확보
헥사고날 아키텍처는 핵심 로직을 외부 의존성으로부터 분리하여 테스트 용이성을 극대화합니다. tui_rog_game은 Google Test와 Google Mock을 사용하여 GameEngine과 같은 핵심 도메인 서비스를 외부 의존성 없이 격리하여 테스트합니다.
// application/domain/service/test/GameEngineTest.cc
class MockSaveGameStatePort : public ISaveGameStatePort {
public:
MOCK_METHOD(void, saveGameState, (const GameStateDTO &game_state), (override));
};
TEST_F(GameEngineTest, InitializeNewGame) {
// mock_load_port_의 loadGameState()가 nullptr를 반환할 것으로 예상
EXPECT_CALL(*mock_load_port_, loadGameState()).WillOnce(Return(nullptr));
// mock_render_port_의 render()가 호출될 것으로 예상
EXPECT_CALL(mock_render_port_, render(_, _));
// mock_save_port_의 saveGameState()가 호출될 것으로 예상
EXPECT_CALL(*mock_save_port_, saveGameState(_));
PlayerActionCommand command(PlayerActionCommand::INITIALIZE);
game_engine_->handlePlayerAction(command);
}
GameEngineTest는 Mock 객체를 GameEngine에 주입하여, 실제 데이터베이스나 외부 API에 접근하지 않고도 비즈니스 로직의 정확성을 검증합니다. EXPECT_CALL 매크로를 통해 특정 메서드가 예상대로 호출되는지, 어떤 인자로 호출되는지 등을 검증할 수 있으며, 이는 테스트 코드 작성의 부담을 줄이고 개발 속도를 향상시키는 데 크게 기여합니다.
[ OK ] GameEngineTest.PlayerMoves (0 ms)
[ RUN ] GameEngineTest.ComplexScenario
[2025-11-07 05:31:22.208] [info] GameEngine initialized.
[2025-11-07 05:31:22.208] [info] GameEngine: Handling player action type: 0
[2025-11-07 05:31:22.208] [info] Game loaded. Player: Waldo at (1, 1)
[2025-11-07 05:31:22.208] [info] GameEngine: Entering processEvents. Event count: 2
[2025-11-07 05:31:22.208] [info] GameEngine: Processing event: GameLoadedEvent
[2025-11-07 05:31:22.208] [info] GameEngine: Processing event: Initial description.
[2025-11-07 05:31:22.208] [info] GameEngine: Exiting processEvents.
[2025-11-07 05:31:22.208] [info] Game auto-saved.
[2025-11-07 05:31:22.208] [info] GameEngine: Handling player action type: 2
[2025-11-07 05:31:22.208] [info] GameEngine: Entering processPlayerMove(dx=0, dy=1).
[2025-11-07 05:31:22.208] [info] GameEngine: Player current position (1, 1), new position (1, 2).
[2025-11-07 05:31:22.208] [info] GameEngine: Player moved to new position (1, 2).
[2025-11-07 05:31:22.208] [info] GameEngine: PlayerMovedEvent created.
[2025-11-07 05:31:22.208] [info] GameEngine: Entering processEvents. Event count: 2
[2025-11-07 05:31:22.208] [info] GameEngine: Processing event: PlayerMovedEvent: Player moved to (1, 2)
[2025-11-07 05:31:22.208] [info] GameEngine: Processing event: Found a health potion.
[2025-11-07 05:31:22.208] [info] GameEngine: Exiting processEvents.
[2025-11-07 05:31:22.208] [info] Game auto-saved.
[2025-11-07 05:31:22.208] [info] GameEngine: Handling player action type: 2
[2025-11-07 05:31:22.208] [info] GameEngine: Entering processPlayerMove(dx=0, dy=1).
[2025-11-07 05:31:22.208] [info] GameEngine: Player move blocked to (1, 3). Wall detected.
[2025-11-07 05:31:22.208] [info] GameEngine: Entering processEvents. Event count: 0
[2025-11-07 05:31:22.208] [info] GameEngine: No new events to process.
[2025-11-07 05:31:22.208] [info] GameEngine: Exiting processEvents.
[2025-11-07 05:31:22.208] [info] Game auto-saved.
[2025-11-07 05:31:22.208] [info] GameEngine: Handling player action type: 5
[2025-11-07 05:31:22.208] [info] Combat started with adjacent enemy Orc at (1, 3).
[2025-11-07 05:31:22.208] [info] Player attacked Orc for 25 damage. Orc's health: 175
[2025-11-07 05:31:22.208] [info] Orc attacked player for 15 damage. Player's health: 85
[2025-11-07 05:31:22.208] [info] GameEngine: Entering processEvents. Event count: 6
[2025-11-07 05:31:22.208] [info] GameEngine: Processing event: CombatStartedEvent: Combat started with Orc Orc. HP: 200
[2025-11-07 05:31:22.208] [info] GameEngine: Processing event: Combat description.
[2025-11-07 05:31:22.208] [info] GameEngine: Processing event: Player attacked Orc for 25 damage. Orc's health: 175.
[2025-11-07 05:31:22.208] [info] GameEngine: Processing event: Combat description.
[2025-11-07 05:31:22.208] [info] GameEngine: Processing event: Orc attacked player for 15 damage. Player's health: 85.
[2025-11-07 05:31:22.208] [info] GameEngine: Processing event: Combat description.
[2025-11-07 05:31:22.208] [info] GameEngine: Exiting processEvents.
TUI를 실제로 실행시키지 않고도 Mock 객체를 통해 동작 테스트를 수행할 수 있습니다.
아키텍처 무결성 자동 검증 시스템
아키텍처는 시간이 지나면서 의존성 규칙이 무너지는 경우가 많습니다. tui_rog_game 프로젝트는 이를 방지하기 위해 빌드 시점에 아키텍처 경계를 강제하는 독특한 메커니즘을 도입했습니다.
scripts/extract_deps.py 스크립트가 CMake 파일을 분석하여 현재 프로젝트의 의존성 그래프를 .dot 파일로 추출하고, scripts/check_architecture.cmake 스크립트가 이 추출된 그래프를 미리 정의된 allowed_architecture.dot 파일과 비교합니다. 만약 두 파일의 내용이 다르면 빌드를 실패시켜 아키텍처 변경을 강제적으로 검토하도록 합니다.
# scripts/check_architecture.cmake
# Compare the contents
if(NOT CURRENT_CONTENT STREQUAL ALLOWED_CONTENT)
message(FATAL_ERROR "Architecture change detected!\n\n current_architecture.dot differs from allowed_architecture.dot.\n Please review the changes in ${CURRENT_ARCH_FILE} and update ${ALLOWED_ARCH_FILE} if approved.\n You can use 'diff ${CURRENT_ARCH_FILE} ${ALLOWED_ARCH_FILE}' to see the differences.")
else()
message(STATUS "Architecture check passed: current_architecture.dot matches allowed_architecture.dot.")
endif()
이러한 자동화된 검증 시스템은 C++ 프로젝트에서 아키텍처의 무결성을 지속적으로 유지하기 위한 매우 강력하고 실용적인 방법입니다. 개발자가 의도치 않게 아키텍처 규칙을 위반했을 때 즉각적인 피드백을 제공하여, 아키텍처 부채가 쌓이는 것을 방지합니다.
[ 46%] Verifying architecture against allowed_architecture.dot
[ 47%] Built target benchmark_main
[ 48%] Built target port_in
[ 68%] Built target dom
CMake Error at /home/jwlee/workspace/CppPlayground/tui_rog_game/scripts/check_architecture.cmake:21 (message):
Architecture change detected!
current_architecture.dot differs from allowed_architecture.dot.
Please review the changes in /home/jwlee/workspace/CppPlayground/build/current_architecture.dot and update /home/jwlee/workspace/CppPlayground/tui_rog_game/allowed_architecture.dot if approved.
You can use 'diff /home/jwlee/workspace/CppPlayground/build/current_architecture.dot /home/jwlee/workspace/CppPlayground/tui_rog_game/allowed_architecture.dot' to see the differences.
make[2]: *** [CMakeFiles/check_architecture.dir/build.make:71: CMakeFiles/check_architecture] Error 1
make[1]: *** [CMakeFiles/Makefile2:691: CMakeFiles/check_architecture.dir/all] Error 2
make[1]: *** Waiting for unfinished jobs....
🌟 C++ 에서도 헥사고날 아키텍처가 유효할까?

플레이 영상
이 샘플 프로젝트를 통해 C++ 환경에 헥사고날 아키텍처가 어떻게 적용될 수 있는지를 확인해보았습니다. 이 설계 방법론을 통해 우리는 다음과 같은 이점을 기대할 수 있습니다.
- 높은 테스트 용이성:
- 핵심 비즈니스 로직을 외부 의존성 없이 빠르게 테스트할 수 있어 개발 효율이 증대됩니다.
- 뛰어난 유지보수성:
- 기술 스택 변경에 유연하게 대응하며, 코드 변경의 파급 효과를 최소화하여 장기적인 프로젝트 관리가 용이해집니다.
- 명확한 관심사 분리:
- 도메인 로직의 순수성을 유지하고, 각 모듈의 책임이 명확해져 코드 이해도가 높아지고 협업이 원활해집니다.
- 확장성:
- 새로운 UI, 데이터베이스, 외부 서비스가 추가되어도 핵심 로직은 변경 없이 어댑터만 추가하면 되므로, 시스템 확장이 용이합니다.
C++ 프로젝트의 복잡성 관리와 장기적인 유지보수성을 고민하고 있다면, 헥사고날 아키텍처를 한번 적용해보시면 어떨까요?tui_rog_game 프로젝트의 전체 코드는 하기 링크를 통해 확인하실 수 있습니다.
GitHub 저장소: https://github.com/woojung3/CppPlayground
참고 자료