핵사고날 아키텍처로 파이썬 레거시 DB 격리하기 (with. ArchUnit Test)

@yunhobb· December 26, 2024 · 5 min read

핵사고날 아키텍처(Hexagonal Architecture)를 사용하는 프로젝트에 합류했습니다. 합류 초기에는 이 아키텍처를 도입한 이유를 파악하지 못한 상태였습니다.

포트(Port) 하나, 어댑터(Adapter) 하나, 그 사이를 잇는 매퍼(Mapper)까지. 기능 하나를 추가할 때마다 비슷한 파일이 서너 개씩 늘어났습니다. 이 정도 복잡도에 레이어마다 의존성을 이렇게까지 끊어낼 필요가 있는지 저는 의문이었습니다.


1. 의문의 시작: 너무 많은 보일러플레이트

핵사고날의 핵심은 의존성 역전입니다. 도메인(Domain)은 바깥(DB·웹·외부 API)을 모르고, 바깥이 도메인을 향하도록 의존 방향을 뒤집습니다. 그 방향을 뒤집기 위한 장치가 포트와 어댑터입니다. (잘 정리된 자료가 많은 주제라 간단하게만 언급하고 넘어가겠습니다)

문제는 이 패턴이 기능 단위로 그대로 복제된다는 점이었습니다. 단순히 테이블 하나를 읽어서 내려주는 조회 API에도 관습적으로 같은 구조가 따라붙었습니다.

application/
  port/out/    FooReader (interface)         ← 포트
domain/
  Foo                                         ← 도메인 모델
adapter/out/
  FooJpaEntity                                ← JPA 엔티티
  FooPersistenceAdapter (implements FooReader)← 어댑터
  FooMapper (Foo ↔ FooJpaEntity)              ← 매퍼

도메인 모델 Foo와 JPA 엔티티 FooJpaEntity가 거의 똑같은 필드를 가지고, 그 둘을 변환하는 매퍼가 또 한 벌 필요했습니다. 필드 하나를 추가할 때 다섯 개의 파일을 손대야 하는 구조였습니다.

같은 의문을 가진 팀원이 있어 백엔드 미팅에서 안건을 올렸습니다. 도메인의 복잡도에 비해 핵사고날을 유지하는 비용이 과한 것은 아닌지가 주제였습니다.


2. 핵사고날을 쓴 진짜 이유: 파이썬 레거시 DB 격리

팀과 논의를 거치며 이 아키텍처를 도입한 목적을 확인했습니다.

이 서비스의 DB는 원래 파이썬으로 운영되던 레거시 시스템의 것이었습니다. 시스템을 자바로 전환하면서 기존 테이블들을 그대로 사용해야 했는데, 레거시 스키마를 자바나 JPA로 직접 핸들링하면 DB 구조에 의존적인 클래스가 만들어졌습니다.

  • 테이블의 각 컬럼의 생명주기가 다름
  • 한 테이블에 여러 책임이 섞여 있거나, 반대로 불필요하게 흩어져 있는 구조
  • "이 모델을 도메인 객체로 그대로 쓰면, 도메인이 레거시 DB 구조에 종속된다"는 문제

그래서 선택한 방법이 도메인 엔티티(Domain Entity)와 JPA 엔티티(JPA Entity)를 분리하는 추상화였습니다. 레거시 테이블의 생김새는 JpaEntity가 온전히 떠안고, 도메인은 어댑터 너머에서 자바답게 설계한 모델로만 세상을 바라보게 한 것입니다.

즉, 포트-어댑터(Port-Adapter) 패턴은 레거시 DB를 격리하기 위한 장치였습니다. 처음부터 프로젝트 전체를 핵사고날로 채우려던 것이 아니라, 레거시와 맞닿는 일부 영역에서만 이 패턴을 차용한 구조였습니다.

이 분리가 주는 실질적인 효과는 리팩터링 시점에 드러납니다. 도메인은 JPA 엔티티가 아니라 포트(FooReader 같은 인터페이스)에만 의존합니다. 덕분에 훗날 레거시 테이블을 걷어내고 새 스키마로 옮기더라도, 비즈니스 로직은 한 줄도 건드리지 않은 채 DB 레이어(어댑터)만 교체하면 됩니다. 도메인은 JpaEntity의 구조를 모르기 때문에, 교체의 영향이 어댑터 안쪽에서 멈춥니다.

구분 레거시 영역 (이상적인) 신규 영역
DB 스키마 파이썬 시절 그대로, 변경 권한 제약 자바 도메인에 맞춰 새로 설계 가능
모델 도메인 ↔ JPA 분리 필요 굳이 분리할 이유 없음
패턴 포트-어댑터로 격리 격리할 대상 자체가 없음
비용 보일러플레이트를 감수할 명분 있음 같은 비용을 치를 명분 없음

합류 초기의 저는 비용만 보고, 이 선택에 이르기까지의 배경은 파악하지 못한 상태였습니다.


3. 그렇다면 신규 기능은? — 레이어드로 충분하다

여기서 자연스럽게 따라오는 판단은, 격리할 레거시가 없는 신규 기능까지 같은 비용을 낼 이유는 없다는 점입니다.

신규 API는 출발점부터 다릅니다. DB 모델부터 직접 설계하기 때문입니다. 도메인에 맞는 스키마를 직접 설계할 수 있으니, 도메인 엔티티와 JPA 엔티티가 어긋날 일도 거의 없습니다. 둘을 분리하고 매퍼로 잇는 비용이 그것으로 막을 수 있는 위험보다 큽니다.

그래서 앞으로의 방향을 다음과 같이 정했습니다.

  • 레거시와 엮인 기존 기능: 기존처럼 핵사고날(포트-어댑터) 구조를 유지해 레거시를 계속 격리한다.
  • 신규 기능: Controller → Service → Repository로 이어지는 레이어드 아키텍처(Layered Architecture)로 단순하게 접근한다.

문제는 한 코드베이스 안에 두 아키텍처가 공존하게 되었다는 점입니다. 아키텍처 규칙은 문서에만 적어두면 시간이 지날수록 지켜지지 않습니다. 레이어드로 정한 신규 모듈에서 컨트롤러가 레포지토리를 직접 호출하거나, 리팩터링 도중 서비스가 컨트롤러를 역참조하는 일이 코드 리뷰에서 누락될 수 있습니다.

이러한 균열을 사람의 코드 리뷰에만 의존해서 막는 데는 한계가 있습니다. 그래서 이 경계를 자동화된 테스트로 검증하기로 했습니다. 이때 도입한 도구가 ArchUnit입니다.


4. 경계를 테스트로 박는다: ArchUnit

ArchUnit은 아키텍처 규칙을 일반 테스트 코드처럼 작성하여 CI에서 검증할 수 있게 도와주는 라이브러리입니다. "패키지 A는 패키지 B를 참조해서는 안 된다"와 같은 규칙을 JUnit 테스트로 표현할 수 있으며, 규칙을 위반하면 테스트가 실패합니다.

4-1. 신규 모듈의 레이어 방향 강제

신규 서비스에 한해, 레이어가 단방향으로만 흐르도록 규칙을 세웠습니다. (패키지명은 예시입니다)

@AnalyzeClasses(packages = "net.mgrv.apis.newfeature")
class LayeredArchitectureTest {

    @ArchTest
    static final ArchRule 레이어_의존성은_한_방향으로만_흐른다 =
        layeredArchitecture()
            .consideringOnlyDependenciesInLayers()
            .layer("Controller").definedBy("..controller..")
            .layer("Service").definedBy("..service..")
            .layer("Repository").definedBy("..repository..")

            .whereLayer("Controller").mayNotBeAccessedByAnyLayer()           // 아무도 컨트롤러를 의존하지 않는다
            .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")   // 서비스는 컨트롤러만 부른다
            .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service");  // 레포지토리는 서비스만 부른다
    }

이제 누군가 컨트롤러에서 레포지토리를 직접 호출하면, 코드 리뷰어들이 놓치더라도 CI에서 인지할 수 있습니다.

4-2. 레거시와 공존하기 — 점진적 적용과 Freeze

여기서 한 가지 분명히 해둘 점이 있습니다. ArchUnit으로 격리하려는 건 레거시와 신규 사이의 의존 방향이 아닙니다. 신규가 레거시에 쌓인 데이터를 읽어야 할 때도 있고, 반대로 레거시가 신규 기능을 불러 써야 할 때도 있습니다. 둘이 서로를 참조하는 것 자체는 막지 않습니다.

대신 격리하는 건 '규칙을 어디에, 얼마나 빠르게 적용하느냐' 입니다. 4-1의 레이어드 규칙을 신규 모듈에만 적용하면 신규는 깨끗하게 출발합니다. 하지만 같은 규칙을 레거시까지 넓히면, 레거시 내부에 이미 쌓여 있던 수백 개의 위반이 한꺼번에 드러납니다. 그렇다고 규칙을 꺼두면 레거시는 계속 방치됩니다.

이 딜레마를 해결하는 도구가 FreezingArchRule입니다.

@ArchTest
static final ArchRule 레이어_규칙을_레거시까지_점진적으로_적용한다 =
    FreezingArchRule.freeze(
        layeredArchitecture()
            .consideringOnlyDependenciesInLayers()
            .layer("Controller").definedBy("..controller..")
            .layer("Service").definedBy("..service..")
            .layer("Repository").definedBy("..repository..")
            .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
            .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
            .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
    );  // 레거시의 기존 위반은 baseline에 기록하고, 새로 생기는 위반만 실패시킨다

freeze()는 현재 존재하는 위반 사항들을 기준선(baseline)으로 기록해 두고, 그 이후에 새롭게 추가되는 위반에 대해서만 테스트를 실패시킵니다. 기존의 기술 부채는 인정하되, 부채가 더 늘어나는 것만 확실히 막는 방식입니다.

제가 적용한 '격리(Isolation)'는 이런 의미였습니다. 레거시를 당장 완벽하게 고치는 게 아니라, 레거시에 이미 쌓인 위반은 baseline에 묶어 더는 늘어나지 않게 동결하고, 신규 코드만큼은 규칙을 지키도록 테스트로 감시하는 것입니다.

규칙 적용 대상 효과
layeredArchitecture() 신규 모듈 레이어 의존 방향(Controller→Service→Repository)을 강제
FreezingArchRule.freeze() 레거시 포함 전체 기존 위반은 baseline으로 동결, 새로 생긴 위반만 실패

5. 마무리

처음에 핵사고날 구조를 보고 복잡도에 비해 과하다고 느꼈던 이유는, 그 아키텍처가 해결하려던 문제를 제가 제대로 이해하지 못했기 때문이었습니다. 핵사고날의 목적은 구조를 깔끔하게 그리는 것이 아니라 파이썬 레거시 DB를 격리하는 것이었고, 그 목적이 존재하는 곳에서는 보일러플레이트도 필요한 요소였습니다.

반대로, 그러한 목적이 없는 신규 기능에까지 같은 패턴을 관습적으로 복제하는 것은 적절하지 않았습니다. 격리해야 할 레거시 자체가 없기 때문입니다.

  • 목적을 모르는 패턴은 비용만 보이고 명분은 보이지 않습니다. 핵사고날이든 레이어드든, 먼저 던져야 할 질문은 "이 아키텍처가 지금 어떤 문제를 풀고 있는가"입니다.
  • 한 레포지토리에 두 아키텍처가 공존할 수 있습니다. 레거시는 핵사고날로 격리하고, 신규 기능은 레이어드로 단순하게 가져갑니다. 각자 해결하려는 문제가 다르기 때문입니다.
  • 단, 그 경계는 사람이 아니라 테스트가 지키게 해야 합니다. 구두 약속과 컨벤션 문서는 쉽게 무너집니다. ArchUnit은 그 경계를 테스트로 검증하게 해 줍니다.
@yunhobb
녹차 주도 개발