헥사고날 아키텍처를 나는 왜 적용했는가?(feat. 계층형구조)
계층형구조 Layered Architecture
절차적 프로그래밍으로 인도
레거시 애플리케이션의 경우 대부분 계층형 구조로 작성되어 있었다. 계층형 구조는 주로 절차형 프로그래밍의 산물이라고 생각한다. 절차형 프로그래밍이란? 위키피디아 정의는 아래와 같다.
절차적 프로그래밍(節次的 프로그래밍, 영어: procedural programming)은 절차지향 프로그래밍 혹은 절차지향적 프로그래밍이라고도 불리는 프로그래밍 패러다임의 일종으로서, 때때로 명령형 프로그래밍과 동의어로 쓰이기도 하지만, 프로시저 호출의 개념을 바탕으로 하고 있는 프로그래밍 패러다임을 의미하기도 한다. 프로시저는 루틴, 하위프로그램, 서브루틴, 메서드, 함수(수학적 함수와는 다르고 함수형 프로그래밍에 있는 함수와는 비슷한 의미이다.)라고도 하는데, 간단히 말하여 수행되어야 할 연속적인 계산 과정을 포함하고 있다. 프로그램의 아무 위치에서나 프로시저를 호출할 수 있는데, 다른 프로시저에서도 호출 가능하고 심지어는 자기 자신에서도 호출 가능하다.
여기서 집중해야 할 문장은 "수행되어야 할 연속적인 계산 과정을 포함하고 있다" 이다. 이처럼 service에 많은 책임을 포함해 구현하다 보면, 우리는 자연스럽게 transaction script 방식의 코드를 작성하게 된다. 그렇다면 이것이 왜 문제가 되는지 한 번 생각해볼 필요가 있다.
Usecase, 즉 service는 비즈니스 로직을 담당하는 계층이다. 우리가 정의한 비즈니스는 보호해야 할 대상이며, 비즈니스 로직이 반복적으로 수정되더라도 기존 로직이 깨지지 않았는지를 확인할 수 있는 신뢰할 수 있는 수단(테스트) 이 잘 작성되어 있다면, 우리는 하나의 비즈니스에 대해 충분히 검증하며 변경할 수 있다. 즉 변경에 대한 두려움이 감소한다.
하지만 하나의 비즈니스에 다양한 비즈니스 간 의존성이 함께 얽혀 있고, 서비스와 직접적인 관련이 없는 Infrastructure까지 함께 의존하고 있다면, 이는 구조적으로 테스트하기 어려울 수밖에 없다. 또한 다른 담당자가 해당 코드를 수정해야 하는 상황에서는, 기존에 작성된 비대해진 service 내부의 transaction script를 하나하나 파악해야 하며, 이 과정 자체가 큰 비용으로 작용한다.
(이는 절차 지향 방식 자체가 잘못되었다는 의미는 아니다. 제 생각에는 기능 단위로 책임이 명확하게 작성된 코드는 언제든지 단위 테스트를 작성하기 쉬운 코드라고 본다. 이러한 특성은 Java가 각광받아온 이유 중 하나인 객체 지향(OOP) 과도 맞닿아 있다고 생각한다.)
Java is a high-level, general-purpose, memory-safe, object-oriented programming language. It is intended to let programmers write once, run anywhere (WORA),[17] meaning that compiled Java code can run on all platforms that support Java without the need to recompile. - wikipedia
- 확장에 대해 닫혀있는 구조
presentation Controller
|
application Service
|
infrastructure JpaRepository
위 그림은 우리가 주로 사용하던 계층형 구조이다. 극단적인 예로 Service를 개발하기 위해서는 반드시 Repository가 필요하고, Controller(presentation)을 개발하기 위해서는 Service개발이 완료 되어야 한다.
각 화살표의 흐름처럼 계층간에 강하게 결합하기 때문에 이는 앞서 언급한 테스트에 닫혀있는 구조라고 볼 수 있다. Controller가 변경되면, service, repository도 변경되어야 하는 구조이다.
즉 연쇄적이다.
(repository, mapper가 class type이 interface니까 추상화 된거 아닌가? 생각이 들수도 있으실거 같아요. interface가 순수 계약일때 추상화라고 할 수 있습니다. (jpa, mybatis의 의존적이면 안된다.))
하나의 코드 예시를 보면,
@Transactional // DB 트랜잭션 안에 S3, Redis, PG 호출이 다 섞여 있음
public PaymentResult processPayment(PaymentCommand command) {
// DB 저장
paymentRepository.save(payment); // DB 트랜잭션
pgClient.requestPayment(pgRequest); // 외부 API (트랜잭션 X)
amazonS3.putObject(...); // S3 (트랜잭션 X)
redisTemplate.set(...); // Redis (트랜잭션 X)
// DB 롤백되어도 PG 결제, S3 업로드는 이미 완료됨
}
위 코드를 테스트할 때 우리가 실제로 검증하고 싶은 것은 결제가 정상적으로 생성되는지 여부이다. 그러나 현재 구조에서는 service나 infra 계층에 대한 추상화가 없기 때문에, processPayment를 테스트하려면 외부 API 호출, S3 파일 적재, requestPayment와 같은 인프라 의존 로직까지 모두 함께 실행되어야 한다. 이로 인해 비즈니스 로직의 흐름과 외부 의존성이 하나의 테스트에 강하게 결합된 상태가 된다.
이런 상황에서 테스트를 작성하려고 하면 선택지는 제한적이다. test 환경에서는 강하게 결합된 service들을 stubbing 해야 하고, infra 계층 역시 외부 시스템에 의존하고 있기 때문에 정상적으로 적재되었는지, 케이스별로 올바르게 동작했는지를 검증하기 어렵다. 특히 repository에 직접 의존하고 있는 구조에서는 이 부분 또한 stubbing이나 mocking 없이는 테스트가 불가능하다.
service 로직 자체는 mocking을 활용하면 비교적 테스트하기 쉬운 편이다. 하지만 infra와 같은 기반 기술들은 대부분 외부로 나가기 때문에, 추상화 없이 직접 의존하는 구조에서는 service 로직의 단위 테스트 작성이 매우 어려워진다.
그렇다면 추상화를 통해 얻을 수 있는 테스트 관점의 이점은 무엇일까?
내가 생각하는 주요 이점은 다음 두 가지이다.
- 런타임 시 구현체를 유연하게 교체할 수 있다 → 테스트 환경에서는 fake 구현체를 주입하여 외부 의존성 없이 테스트가 가능해진다.
- 모듈 경계가 명확해진다 → 모듈 간 책임이 분리되면서 테스트에서 불필요한 stubbing이나 mocking이 감소 → fake 객체를 활용한 classic한 단위 테스트를 작성할 수 있게 된다.
이처럼 많은 외부 인프라 기술에 직접적으로 의존하는 service 로직을 작성하게 되면, 신규 기능 개발 자체가 점점 어려워진다. 뿐만 아니라 이미 작성된 코드에 대해서도 의미 있는 단위 테스트를 추가하는 것이 사실상 불가능해진다.
만약 주문 서비스는 결제, 알림, 배송, 재고 등 다양한 의존성을 가지고 있다. 만약 트랜잭션 스크립트(서비스 코드 내)를 이 의존들에 대한 세부 구현 내용이 OrderService 코드에 다 들어 있다면, 현재 강하게 결합된 기반 기술들과 서비스 로직으로 인해 대부분의 테스트는 mocking에 의존할 수밖에 없는 구조일 것이다. 이 경우 우리가 선택할 수 있는 테스트 전략은 결국 UI 테스트나 통합 테스트 정도만 남게 된다.
그렇다면 여기서 한 가지 질문이 생긴다. "UI 테스트만으로 충분할까?"
UI 테스트만으로 테스트 전략을 가져가는 것은 다음과 같은 문제를 야기한다.
- 테스트 실행 비용이 높고 느리다
- 실패 원인을 빠르게 파악하기 어렵다
- 작은 비즈니스 로직 변경에도 테스트가 쉽게 깨진다
- 로직 단위의 안정성을 보장하기 어렵다
결과적으로 이는 변경에 취약한 코드로 이어지고, 개발자는 점점 테스트를 신뢰하지 않게 된다. 이런 악순환을 막기 위해서라도, 외부 인프라에 대한 적절한 추상화와 명확한 모듈 경계는 단순한 설계 취향의 문제가 아니라 테스트 가능성과 유지보수성을 위한 필수 조건이라고 생각한다. 이러한 점이 왜 문제가 될까?
내가 수정한 부분은 단순한 알림 기능인데 같은 작은 기능만 테스트하면 되는데, 전체 flow(UI)를 통해 테스트함으로써 많은 리소스가 들어가게 된다. 그리고 만약 해당 비즈니스 로직을 처음보는 사람은 단순한 추가 기능을 구현해야한다면, 개발보다 기존에 작성된 코드를 이해하기 위해 더 많은 시간을 들여야한다.
이야기가 너무 길어지니 이만 줄이고 다시 강하게 결합된 계층 구조의 문제점를 살펴보자.
내가 궁극적으로 말하고 싶은 것은 한 번 비대해진 service는 다시 쪼개거나 구조를 단순하게 만드는 데 매우 많은 리소스가 든다는 점이다. 만약 처음 설계와 구현을 담당했던 개발자가 계속 해당 서비스를 유지·보수할 수 있다면 비교적 수월하게 개선할 수 있을지도 모른다. 하지만 실제 개발 환경에서는 퇴사, 전배, 휴가 등 다양한 변수가 발생할 수 있고, 항상 동일한 담당자가 코드를 책임진다는 보장은 없다.
이러한 상황에서 테스트 코드 없이 서비스를 운영하게 되면 문제는 더 커진다. 이후 기능이 추가되거나 기존 로직이 변경될 때, 변경 영향도를 빠르게 파악하기 어렵고, 의도하지 않은 사이드 이펙트가 발생할 가능성도 높아진다. 특히 앞서 언급했듯이 단위 테스트가 부재한 상태에서는 검증 수단이 UI 테스트(E2E)에 과도하게 의존하게 된다.
물론 모든 신규 기능이나 변경 사항에 대해 E2E 테스트는 필요하다. 하지만 문제는 작은 수정 하나를 검증하기 위해 매번 E2E 테스트로 디버깅해야 하는 상황이 반복된다는 점이다. 이는 테스트 비용을 크게 증가시키고, 개발자를 지치게 만들며, 결과적으로 테스트 자체를 회피하게 만드는 원인이 된다.
그래서 나는 서비스가 일정 수준 이상으로 커지기 시작할 때,
- 해당 기능을 별도의 domain으로 분리해 관리할 것인지,
- 아니면 domain 내부에서 고정적으로 사용되는 모델과 책임을 명확히 포함시켜 관리할 것인지
이 두 가지를 지속적으로 고민하고 판단하는 습관이 필요하다고 생각한다. 이러한 판단은 단기적인 개발 속도보다는 중·장기적인 유지보수성과 변경 용이성 측면에서 큰 차이를 만든다.
중요한 점은, 항상 무조건 작은 단위로 쪼개고 보일러플레이트를 늘리는 것이 정답은 아니라는 것이다. 과도한 추상화나 분리는 오히려 복잡도를 높이고, 개발 생산성을 떨어뜨릴 수 있다. 결국 우리가 지향해야 할 방향은 over-engineering과 under-engineering 사이에서 균형을 잘 맞추는 것이며, 비즈니스 성장 속도와 팀 상황을 고려해 적절한 시점에, 적절한 수준으로 구조를 진화시키는 것이라고 생각한다.
헥사고날 아키텍처 Hexagonal Architecture
헥사고날 아키텍처까지 필요했을까?
아래 예시 코드는 모두 실제 작성된 코드가 아닌 집에서 혼자 도메인을 대략적으로 설계하고 작성한 코드이며, 하나의 상황을 예시로 보여주는 코드입니다.
"도메인 모델이 명확한 서비스" 위 한 문장으로 헥사고날의 도입을 목표해볼 수 있을까?
헥사고날 아키텍처의 본질적 가치는 다음과 같다.
1. 도메인 로직의 순수성 보호이다.
만약 서비스가 외부 API를 호출하고 결과를 그대로 전달하는 pass-through 성격이라면 controller -> service -> port -> adapter(실제 로직) 이런 구조가 되어버리고 이건 의미없는 레이어 낭비인것은 분명하다. 예를 들어 결제 시스템을 보면 PG사, 은행, 포인트 시스템 등 외부 연동이 많지만, 그 데이터를 조합하고 비즈니스 규칙을 적용하는 도메인 로직이 풍부할 수 있다. 이런 경우엔 외부 의존성이 많아도 헥사고날이 여전히 유효하게 적용될 수 있다고 생각한다. 1번에 대한 주요 쟁점은 결과적으로 Domain 모듈의 imprt 문에 jpa나 외부 의존성을 띄지 않아야 하는데, 이게 보호의 가시적 증거이다.
광고를 예를 들었을때 도메인 로직이 많은 부분이 붙게 된다. 하나의 예시로 타겟광고, 예산 유효성체크, 빈도제어, 스코어링 등 순수 도메인 로직에 많은 usecase가 필요해 보이는데, 이러한 경우에는 헥사고날로써 각각의 비즈니스 로직을 쪼개 usecase에서 port를 조립하여 사용한다면 재사용이 가능하다. 재사용이 어떻게 가능한가? port는 조립하여 사용하는것이 이와 같은 케이스이다. 하지만 이 조각을 너무 많이 쪼갤 경우에는 보일러 플레이트가 증가하기 때문에 개인적인 생각으로는 관심사-도메인(기능) 별로 묶거나 infra에서 기능 단위로 쪼개는게 좋다고 생각했다.
실제 개발을 진행하며 TPS 변경, serving 전략 등이 수시로 바뀌었고, 계층형 구조일때의 경우 아래와 같이 비즈니스 로직이 지속적으로 변경될거 같다. 광고 도메인인의 경우는 실제로 사용자가 소비하는 매체로 광고 송출이 멈춘다면 비용적인 측면에서도 큰 타격이 있을거 같은데, 이를 위해 나는 안정적으로 설계하고 송출해야 한다고 생각을 했고, 이 판단에 대하여 동료, 리더와 신중하게 논의를 했던거 같다. 이렇게 요구사항이 빠르게 변한다면 개발을 하는것이 문제가 아닌 안정성의 측면에서 좀 더 고민을 했던거 같다.
만약 아래와 같은 강하게 결합하고 있는 계층형 코드라면 추가 개발한 기능만을 단위 테스트하기 어려울거 같다.
하나의 예시로 요구 사항은 아래와 같은 흐름으로 변경했다고 가정하겠다.
- persistence에서 직접 조회
- local cache -> redis -> persistence 순서대로 조회
- redis -> persistence 조회
결국 현재 구조에서는 service 코드 내부를 직접 수정하는 방법밖에 없다. 그런데 여기서 의문이 든다. S3 적재, 외부 API 호출 같은 Infrastructure 작업을 과연 비즈니스 로직이라고 볼 수 있을까?
나는 기반 기술은 비즈니스 로직을 수행하기 위한 '도구'이며, 정합성 보장(Guarantee) 같은 요구사항이야말로 비즈니스라고 생각한다. 즉 "무엇을 보장해야 하는가"가 비즈니스 요구이고, 그 요구를 만족시키기 위해 "어떤 인프라(도구)를 선택해 구현할 것인가"를 결정하는 구조가 자연스럽다.
따라서 비즈니스 정책(보장해야 하는 규칙)과 인프라 구현(도구 선택/연동)이 service 내부에 뒤섞이면, 변경 시마다 서비스 코드를 크게 수정하게 되고 테스트 비용도 함께 증가한다.
2. 인프라 교체의 용이성, 인프라없이 테스트하기
트래킹의 경우 사실 pass-through의 성향이 매우 강했지만, 이를 도메인의 순수성을 지키는 측면에서의 이점보다 infra adapter를 교체하기 용이성의 이점이 더 크다고 생각했다. Adapter에 인프라와 상호작용하는 내용이 포함이 되어 있고 이를 교체하기는 분명한 틈이 있었기 때문에 편할 것으로 예상했다. (실제로 그런일은 일어나지 않았고 급변하는 software나 내가 계속 해당 애플리케이션을 유지보수 했다면 가능했을 것 같다.)
개발을 하면서 메시지가 produce, consuming 되는 구간은 전부 fake object를 통해 단위 테스트를 작성했다. 내부 적용한 패턴대로 메시지가 발행되고 소비되는지 확인하기 위해서 였다. 인프라 없이 테스트가 가능했던 구조가 초기 개발을 할때 UI 테스트에 비해 더 빠른 성공과 실패를 확인하는데 많은 도움이 됐다. (이후 통합(연동) 테스트는 localstack을 활용 - 다른 포스트를 통해 한번 더 다룰 예정)
인프라는 자주 교체되지 않는다.
"인프라 교체에 용이하다." 헥사고날만의 장점도 아니며, 애플리케이션 아키텍처를 정하는것은 한사람이 아닌 함께 일하는 동료와 함께 현 상황에서 보다 효과적인 구조를 약속 하는 것이라고 생각한다.
사실 헥사고날 구조를 잘 보면 의존성의 역전이 잘 들어나 있다. port를 통해 순수한 인터페이스를 선언하고 이를 다른 계층에서 구현하여 우린 애플리케이션 내에 포트를 통해 타 계층에 접근하고 제어권이 순수한 port에게 주어지는데,
이를 통해 우리는 infra(외부 기술)에 의존하는게 아니라 서비스에서 처리해야하는 로직은 서비스에 담기고 port를 통해 adapter에서 처리해야할 그리고 정해야할 부분을 분리된 구조로 볼 수 있다.
비즈니스 로직 보호
위처럼 헥사고날은 usecase(sevice) 로직에 나중에 추가할 내용은 타게팅, budget등이 되고 결국 infra에 대한 모든 의존성은 빠진 형태로 비즈니스 로직을 보호할 수 있는 것이다.
헥사고날은 정말 잘 이해하고 사용해야하며 러닝커브가 높은 만큼 비즈니스 로직이 정말 복잡해질때 빛을 발하는 아키텍처이다. 나는 이러한 트레이드 오프를 인지 하여 헥사고날 구조의 일부를 뽑아 아래와 같은 결론에 도달한거 같다.
이미 정해진 API(controller)는 변할일이 거의 없다. Controller에서 의존하는 Service도 비즈니스가 정해지면 거의 바뀔일이 없다고 생각한다.
때문에 추상화를 진행하는 것은 우리 비즈니스 밖(관심사 밖인) 인프라만 진행하는게 나을거 같다고 판단했다.
개발의 단위를 기능으로 모두가 잘 고민한다면 계층형 구조로도 테스트와 유지보수에 용이한 구조를 가져갈 수 있다. 앞서 소개한 강의 중 가장 와닿았던 말이 있는데, (토비의 스프링에도 나오는 말이다.)
지금 테스트를 도입하기 어렵다면 그건 좋은 코드라고 말하기 어렵다. 나도 이제는 이 말에 동의하는 편이다.
헥사고날의 단점
- 많은 클래스파일, interface등으로 보일러플레이트 증가
- 러닝커브가 높다
- 복잡하지 않은 비즈니스로직에는 적합하지 않다. (ex. pass-through)
내 경우에는 앞서 언급한 이유 외에도 도입을 해야하는 이유가 있었지만, 사실 내가 궁극적으로 하고 싶은말은 "추상화, 도메인 객체를 잘 이용하자" 이다. 헥사고날의 경우 구조를 강제하는 방식으로 archUnit을 함께 사용할 경우 애플리케이션 아키텍처가 유지되는지 확인할 수 있다. 위 구조를 강제하여 개발자들이 강제적으로 추상화를 하며 개발하게 만드는 구조이다. 반면 계층형 구조는 자유도가 높다. 개발하면서 끊임없이 고민하고 동료들과 함께 컨벤션을 지키고 유지한다면, 좋은 소프트웨어가 지속될 수 있을것이라고 믿는다.
계층형 구조 + DIP는 어떻게 하는걸까?
presentation Controller
|
application Service -> (domain) <- Repository
|
infrastructure RepositoryImpl <- JpaRepository
간단하게 JPA로 가정하여 설명해보겠다. 이는 결국 DIP를 통해 Repository를 추상화하고 구현체에서 JPA 기술에 의존하게 한것이다. 이 부분을 잘 살펴보면 결국 Hexagonal에서 보여주는 구조와 비슷하다는 생각이 들었는데, 나 또한 이러한 부분이 너무 혼동이 되었고, 다만 차이점이라고 하면 service 계층도 추상화를 통해 controller 계층에서 약하게 결합하는 점만이 다르다는 생각이 들었다.
과도하게 service 계층까지 추상화 하는 것이 좋은 방법일까? 라는 고민이 들기도 했지만, controller는 정해진 비즈니스 규약이며, 비즈니스가 정해지면 controller - service 간의 계약은 변하지 않을 것이라고 생각했다.
만약 변하고 확장하는 부분은 물론 추상화(디자인패턴)으로 해결할 수도 있다. 내가 지금까지 학습하며 느낀점은 모든 애플리케이션 아키텍처에서 궁극적으로 하고 싶은 이야기는 "SOLID를 잘 지키면 유지보수 하기 좋다" 라는 생각이 들었다. (헥사고날, 계층형 + DIP 모두 "추상화"를 이용하고 있는 점이 공통점을 띄고 있다.)
| 계층형 + DIP | 헥사고날 | |
|---|---|---|
| 의존성 역전 | 개발자가 "의도적으로" 적용해야 함 | 구조가 "강제"함 |
| 경계 | 암묵적 (컨벤션 의존) | 명시적 (Port/Adapter) |
| 신규 인원 온보딩 | "여기선 이렇게 해요" 설명 필요 | 폴더 구조만 봐도 어디에 뭘 넣을지 명확 |
이 글은 결코 헥사고날이 답이다. 아니면 계층형 구조가 답이다. 이러한 결론을 내리는 글은 아니다. 완벽한 방법과 정답은 없으며 각 프로젝트의 상황(리소스), 설계에 맞는 보다 나은 선택은 존재하지 않을까 싶다. 아무래도 생각을 정리하는 글이다보니 내가 강조하고 싶은 내용은 반복해서 설명했던거 같다.
결론
- 기술 스택은 선택사항일 뿐이다 (특히, 인프라는 자주 바뀌지 않지만 변경에 예민하지 않은 아키텍처를 선택하고 설계해야한다)
- 계층형, 클린 아키텍처, 헥사고날는 아키텍처 설계론 / tdd, ddd는 방법론일뿐이다. → 우리 상황에 맞다고 생각되는 것만 가져가자
- 우리가 지켜야할 부분 그리고 유연하게 가져가야할 부분(레이어)는 명확하다 → 인프라 레이어( 이 부분이 아이러니 한데 DIP 때문에 그렇습니다. 우리가 지킬건 service(비즈니스 로직)이고 infra는 도구이다)
나는 정해지지 않은 것에 대한 해결책을 얻기 위해 혼자 많은 시간을 투입한거 같다. 그래도 많은 시간을 들였던거 만큼 배운점도 분명히 있었다. 이 글을 읽는 분들 역시 애플리케이션 아키텍처에 대해 많은 고민을 하고 있을 것이라 생각한다. 그 고민 속에서 이 글이 조금이나마 도움이 되었기를 바라며 글을 마무리한다.
참고
만들면서 배우는 클린아키텍처(서적)
엔터프라이즈 애플리케이션 아키텍처 패턴(서적) (아직 읽는중)