포스트

우리 서버에 헥사고날이 필요했을까

우리 서버에 헥사고날이 필요했을까

분석 서버 스택을 코프링으로 정하고 나니, 다음 고민은 내부 아키텍처였다.

MVP로 세운 단일 서버가 얼마나 오래 사용될지 몰랐기에, 한번에 잘 만들어두자는 욕심이 생겼다.

기본적으로는 도메인 패키징으로 디렉토리를 나눴고 그 하위를 구성하는 아키텍처를 정해야 했는데, 지금까지의 프로젝트에서 써온 레이어드 아키텍처가 당연하게도 먼저 떠올랐다.

다만 이번 프로젝트의 MVP는 (이때까지만 해도) 예상 구성이 이랬다.

  • 인바운드: 대시보드와 연동된 REST API, SQS Consumer, 분석 집계 워커
  • 아웃바운드: PostgreSQL, Key/정책 캐싱용 Valkey, S3, MailSender 등

이렇게 외부 의존성이 많다면, 헥사고날 아키텍처로 도메인 로직과 외부 의존성을 분리하면서도 Testability를 높일 수 있지 않을까?

라는 무지몽매한 생각으로 헥사고날 아키텍처를 채택해 개발을 시작했다..


헥사고날에 대하여

먼저 왜 헥사고날을 사용할까?

  1. 명확한 관심사의 분리: 문제가 생겼을 때 어디를 봐야 하는지가 구조적으로 정해져 있다는 것

    • 외부와의 연결에 문제가 생기면? 어댑터
    • 인터페이스는? 포트
    • 처리 중간에 EventBridge로 이벤트를 보내거나 트레이스 로그를 심고 싶으면? 서비스
    • 비즈니스 모델이 제대로 동작하지 않으면? 도메인 모델
  2. 쉬운 테스트

    • 각 모듈을 자기 역할만 Port 기반 모킹으로 테스트할 수 있다.
    • 특히 비즈니스 로직은 의존성이 없기 때문에 모킹이 거의 없다.

어떤 프로젝트에 적합할까?

카카오페이 팀에서 헥사고날을 도입했다가 제거했는데, 그 회고에서 적합한 프로젝트의 기준 세 가지를 정리해뒀다.

1. 도메인 모델을 확실하게 정의할 수 있는 서비스

도메인 모델이 명확하지 않은 경우, Hexagonal Architecture의 핵심 요소인 Port와 Adapter의 경계를 설정하는 데에서 어려움이 발생할 수 있습니다.

특정한 도메인의 규칙을 DB와 API 언급 없이 열거했을 때, 도메인 내부에서 문장으로 서술되는 규칙이나 불변식이 남으면 도메인 모델이 있는 것이고, CRUD만 보이면 도메인 모델이 없는 것이라고 한다.

2. 외부 의존성이 많지 않은 서비스

의존성이 많지 않다는 것은 깊이에 대한 부분이 아니라 넓이에 대한 부분입니다. 로직의 대부분이 연동 API에 의해 동작하는 서비스일 경우 결론적으로 Port와 Adapter에 로직이 과중되는 경우가 많아질 확률이 높다고 생각합니다.

외부에 대한 의존성보다 코어 로직이 풍부하게 존재할 때 의미를 가진다는 것인데, 사실 1번과 거의 같은 말이라고 생각된다.

3. 코어 모듈을 사용하는 모듈이 2개 이상인 서비스

코어를 쓰는 쪽이 하나뿐이라면, out Port에 해당하는 부분만 추상화하는 것이 더 나은 방향일 수 있다.

근데 이러면 사실상 레이어드에서 아웃바운드 쪽만 추상화해서 쓰는 거랑 뭐가 다른 거지라는 생각이 든다.

도메인 로직이란?

위에서 도메인 모델을 확실하게 정의한다고 서술했는데, 그걸 다르게 나타내면 도메인 로직이 풍부하다는 뜻이기에, 도메인 로직이란 것은 정확히 무엇인가?에 대해 찾아보았다.

  • 이 코드가 현실 세상의 문제에 대해 의사 결정을 하고 있는가?
    • 은행 앱이라면 이자율, 잔액, 출금, 계좌 개설 같은 고객의 금융 업무 처리
    • 숏폼 SNS 서비스라면 영상 촬영, 편집, 제목 수정, 공유 등
  • 도메인 로직을 제외해도 구동을 위한 수많은 로직이 남는데, 이들을 애플리케이션 서비스 로직이라고 부른다. 외부와 네트워킹을 하거나, DB를 건드리거나, UI를 건드리는 것들

우리 프로젝트에 투영해보자.

직관

외부 의존성이 많아서 헥사고날을 쓰려 했더니, 외부 의존성이 많지 않아야 적합한 프로젝트라고 한다.

위 내용처럼 외부 의존성이 넓다는 건 Port와 Adapter가 그만큼 많아진다는 뜻이라 도메인 로직이 적고 연동만 많은 서비스에서는 인터페이스 개수만 늘어난다. 헥사고날은 보호할 도메인 로직이 많을 때 쓰는 구조인데, 나는 정확히 반대 방향의 근거로 채택한 것이다.

그럼 현재 활용되고 있는 형태는 어떤가?

현재 구현된 분석 서버를 기준 세 가지에 대보자.

도메인 규칙을 기술 없이 열거해보면 — 호출 비용 계산 로직(토큰 사용량 * 모델 단가, 캐시 토큰의 별도 단가 적용 등) 정도가 나왔다. 실제로 이 Cost 계산만 사실상 domain 레이어에 순수하게 포함되고, 나머지는 게이트웨이/SDK가 보낸 이벤트를 받아서 저장 후 집계하여 대시보드에 보여주는게 전부였다. 집계 규칙 자체도 도메인 로직이긴 한데, 대량 데이터 계산이라 구현이 SQL과 한 몸이어서 순수 코어로 떼어낼 수 없었다.

외부 의존성은 — PostgreSQL, S3, Valkey, 메일 발송, LLM 호출로 넓다. 그리고 로직의 대부분이 그 연동을 호출하고 결과를 저장 및 전달하는 코드이다. 앞서 말했던 Port와 Adapter에 로직이 과중되는 조건에 정확히 해당했다.

코어를 쓰는 모듈은 — 현재 REST API 하나다. 포트마다 구현은 거의 다 1개씩이고, 어댑터를 갈아끼운 일은 거의 없었다. 최초 MVP 범위로 잡았던 인바운드 중 Consumer는 부하 테스트 결과를 근거로 제외했고, 워커도 성능 문제가 실제로 생겨야 채택할 요소였다.

세 기준으로 나누어 생각해도 모두 미달이기에, 제대로 활용하고 있지 않다고 결론이 났다.

내가 잃은 것은?

포트 인터페이스가 코드에서 실제로 얼마나 차지하는지 세어봤다.

  • 구현이 1개뿐인 포트 인터페이스 43개 -> 합쳐서 약 900줄이었고, 전체 코드의 약 10%가 구현을 하나만 가진 인터페이스로 채워졌다.
  • 호출 흐름이 컨트롤러->서비스->포트 인터페이스->구현체의 4단계라서 코드를 읽으려고 이동할 때마다 구현체가 아닌 인터페이스에 도착해서 구현체 탐색을 한 번씩 더 하게 됐다.

그래서 아직 초반인 지금, 도메인 패키지 내부를 헥사고날에서 레이어드로 되돌리기로 결정했다.

다만 Testability는 반드시 챙겨야 하기에 기존의 아웃바운드 port는 인터페이스 분리를 유지한다.


결론

헥사고날 아키텍처는 실버불렛이 아니다. 보호할 도메인 규칙이 적은 서비스에서는 도메인을 분리한다는 목적은 사라지고 구조를 유지하는 일만 남는다.

최근 뼈저리게 느끼고 있는, 당연한 결론으로 또 도달했다. 처음부터 100% 완벽한 것을 만들려 하기보다 목적과 상황에 맞는 적절한 전략을 채택해야 한다는 거..

다만 다음에 규칙이 계약이나 수식으로 이미 존재해서 코드가 그것을 옮겨 적는 등 도메인 모델이 명확한 서비스를 구축하게 된다면, 그때만큼은 헥사고날의 코어 격리와 Port 기반 테스트를 제대로 활용해서 명확한 관심사의 분리가 보장된 비즈니스 로직에 집중하는 프로젝트를 만들어보고 싶다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.