$백엔드 개발자 Rueun의 기술 블로그|Java · Spring · 클린 아키텍처🌱
#Development

멀티 모듈 프로젝트, 왜 클린 아키텍처로 시작했나

@2025-09-03·17 min read·📖 series: 클린 아키텍처로 실제 서비스 만들기

멀티 모듈 프로젝트, 왜 클린 아키텍처로 시작했나

새 프로젝트를 시작할 때 아키텍처를 어떻게 잡을지 고민하는 시간은 항상 설렘과 두려움이 공존한다. "이번엔 제대로 해보자"는 다짐과 함께, 과거에 유지보수하기 힘들었던 코드들이 머릿속을 스쳐 지나간다. 이 글은 문서 분석 시스템을 Spring Boot 3.5.5 + Java 21로 새로 설계하면서 왜 클린 아키텍처와 Gradle 멀티 모듈 구조를 선택했는지, 그리고 그 과정에서 어떤 의사결정을 내렸는지를 정리한 것이다.

프로젝트 소개

이 시스템은 사용자가 문서를 업로드하면, 해당 문서 안에 포함된 이미지들을 추출하고 표절 여부를 분석해주는 서비스다. 단순히 파일을 받아서 저장하는 것이 아니라, 파이프라인을 통해 여러 단계의 분석 작업이 비동기로 진행된다.

핵심 흐름만 간단히 요약하면 다음과 같다.

  1. 사용자가 문서(PDF, 워드 등)를 업로드한다.
  2. 시스템이 문서에서 텍스트와 이미지를 추출한다.
  3. 추출된 이미지를 대상으로 유사 이미지 후보를 탐색한다.
  4. 최종적으로 표절 여부를 판정하고 결과를 반환한다.

이 파이프라인은 각 단계가 수 초에서 수십 초까지 걸릴 수 있기 때문에, 동기 처리로는 사용자 경험이 나빠진다. 또한 각 단계가 서로 다른 외부 시스템(파일 스토리지, 분석 엔진, 캐시)과 통신한다는 특성이 있다.

처음에 생각했던 구조, 그리고 왜 바꿨나

솔직히 처음에는 단순한 레이어드 아키텍처(Layered Architecture)로 시작하려 했다. Controller → Service → Repository 구조는 Spring 생태계에서 가장 익숙하고, 빠르게 개발을 시작할 수 있다. 대부분의 튜토리얼과 예제도 이 방식을 따른다.

그런데 이번 프로젝트는 몇 가지 특성이 기존과 달랐다.

첫째, 외부 의존성이 많다. 파일 스토리지, 외부 분석 API, Redis, MariaDB 등 여러 인프라와 통신해야 한다. 레이어드 아키텍처에서는 Service가 Repository를 직접 의존하고, Repository가 JPA를 직접 사용하게 된다. 이 경우 외부 스토리지나 메시지 브로커 구현체가 바뀌면 Service 코드까지 영향을 받는다.

둘째, 비즈니스 로직이 점점 복잡해질 것이 예상된다. 표절 판정 로직, 상태 관리, 소유권 검증 등 도메인 규칙이 적지 않다. 이런 규칙들이 Service 계층에 흩어지면 테스트하기도 어렵고, 어디에 로직이 있는지 파악하기도 힘들어진다.

셋째, 애플리케이션이 두 개다. 사용자 요청을 처리하는 메인 서비스(visual)와 비동기 분석을 담당하는 analysis-processor가 별도로 존재한다. 두 애플리케이션이 같은 도메인 로직을 사용해야 하는데, 이를 어떻게 공유할지가 문제였다.

이 세 가지 문제를 고민하다 보니 자연스럽게 클린 아키텍처로 방향이 기울었다.

클린 아키텍처의 핵심: Dependency Rule

클린 아키텍처의 가장 중요한 규칙은 딱 하나다.

의존성은 항상 안쪽(도메인)을 향해야 한다.

바깥 계층(Infrastructure, Presentation)이 안쪽 계층(Domain, Service)을 의존하는 것은 허용되지만, 반대 방향은 절대로 안 된다. 도메인은 JPA도 모르고, Spring도 모르고, HTTP도 모른다.

ℹ️Dependency Rule이란?

로버트 마틴(Uncle Bob)의 클린 아키텍처에서 제시한 핵심 규칙. 소스 코드의 의존성은 반드시 외부에서 내부(고수준 정책)를 향해야 한다. 이 규칙을 지키면 내부 계층은 외부의 변화로부터 보호된다.

이 규칙을 지키면 다음과 같은 이점이 생긴다.

  • 도메인 로직을 순수하게 테스트할 수 있다. Spring Context 없이도 도메인 객체의 비즈니스 규칙을 단위 테스트할 수 있다.
  • 기술 스택을 교체할 수 있다. MariaDB를 PostgreSQL로, Ceph를 AWS S3로 바꿔도 도메인 코드는 손대지 않아도 된다.
  • 두 애플리케이션이 같은 도메인을 공유할 수 있다. 도메인이 어떤 프레임워크도 의존하지 않기 때문에 모듈로 분리해서 재사용이 가능하다.

Gradle 멀티 모듈 구조 설계

의존성 규칙을 코드 수준에서 강제하는 방법이 바로 Gradle 멀티 모듈이다. "규칙을 문서화하는 것"으로는 부족하다. 모듈 간 의존성을 build.gradle로 명시적으로 선언하면, 잘못된 의존성은 컴파일 단계에서 바로 에러가 난다.

최종적으로 아래와 같은 모듈 구조로 결정했다.

root/
  application/
    visual/                  # 메인 서비스 (port 8080)
    analysis-processor/      # 비동기 분석 처리기
  core/
    domain/                  # 순수 도메인 (엔티티, 열거형, 레포지토리 인터페이스)
    common/                  # 공통 유틸, 에러코드
    web/                     # REST 응답 래퍼
    infra/
      database/mariadb/      # JPA 엔티티, 레포지토리 구현체
      storage/port/          # FileStorage 인터페이스 (Port)
      storage/adapter-ceph/  # CephFileStorage (Adapter)
      client/web-client/     # AbstractApiClient
      redis/                 # Redis 설정
  support/
    logger/
    trace/
    deadletter/              # Dead Letter Queue 패턴

모듈 분류 기준

application 모듈: Spring Boot 애플리케이션이 직접 실행되는 진입점. 각 애플리케이션별로 독립적인 설정과 Bean 구성을 가진다. coresupport 모듈에 의존하되, 서로는 의존하지 않는다.

core 모듈: 비즈니스 핵심 코드. domain은 어떤 외부 모듈도 의존하지 않는다. infra 하위 모듈은 domain을 구현하는 어댑터들이다. 중요한 점은 domaininfra를 모른다는 것이다.

support 모듈: 도메인과 무관한 기술적 관심사. 로깅, 분산 추적, Dead Letter Queue 등이 여기에 해당한다. 어느 application이든 필요에 따라 가져다 쓸 수 있다.

왜 storage를 port와 adapter-ceph로 분리했나

파일 스토리지 모듈을 storage/portstorage/adapter-ceph로 나눈 데는 이유가 있다.

port 모듈에는 FileStorage 인터페이스만 정의된다. domain 모듈이 이 인터페이스에 의존할 수 있도록, port는 외부 라이브러리(AWS SDK 등)에 의존하지 않는다.

adapter-ceph 모듈은 실제 Ceph S3 호환 스토리지를 구현한 어댑터다. AWS SDK와 구체적인 설정이 이 모듈에만 격리된다.

만약 나중에 AWS S3 어댑터를 추가하고 싶다면 adapter-s3 모듈만 새로 만들면 된다. domain이나 port는 건드리지 않아도 된다.

모듈 간 의존성 다이어그램

Module Dependency — 화살표 방향 = 의존 방향 (바깥 → 안쪽)

APPLICATION
visual
port 8080 · 메인 서비스
analysis-processor
비동기 분석 처리
의존
CORE
domain· Spring 무관 · 순수 비즈니스 로직 · 프레임워크 의존 없음
common
공통 유틸 / 에러코드
web
REST 응답 래퍼
INFRA (Port & Adapter)
SUPPORT
✓ domain은 어느 모듈도 의존하지 않는다·✓ application이 모든 어댑터를 조립한다
⚠️의존성 방향을 항상 확인하라

다이어그램에서 화살표 방향을 주목하자. domain을 향하는 화살표만 존재하고, domain에서 바깥을 향하는 화살표는 없다. 이 규칙이 무너지는 순간 클린 아키텍처의 이점이 사라진다.

실제로 적용하면서 느낀 점

좋았던 점

도메인 테스트가 쉬워졌다. domain 모듈은 Spring도 JPA도 없다 보니 순수한 단위 테스트를 작성하기가 매우 편하다. Document.deleteOwnedBy()AnalysisStatus.validateTransition() 같은 비즈니스 규칙을 외부 의존성 없이 테스트할 수 있다.

두 애플리케이션이 도메인을 안전하게 공유한다. visualanalysis-processor가 같은 domain 모듈을 바라보기 때문에 도메인 규칙이 한 곳에 일관되게 유지된다.

기술 교체 결정이 쉬워진다. 파일 스토리지나 캐시 솔루션을 바꾸는 것이 도메인 코드에 영향을 주지 않는다는 확신이 있으면, 기술 선택에 대한 심리적 부담이 줄어든다.

어려웠던 점

초기 설정 비용이 높다. 모듈 구조를 잡고 build.gradle을 올바르게 구성하는 데 생각보다 시간이 걸렸다. 특히 의존성 전이(transitive dependency) 문제로 몇 번 빌드가 깨졌다.

어디에 무엇을 놓을지 판단이 필요하다. "이 코드가 도메인인가, 인프라인가?"를 매번 판단해야 한다. 초반에는 이 경계가 모호해서 팀 내에서 논의가 필요한 경우가 있었다.

과설계의 유혹을 경계해야 한다. 모든 것을 인터페이스로 추상화하고 싶은 충동이 생긴다. 하지만 교체 가능성이 낮은 것까지 추상화하면 코드만 복잡해진다. Port & Adapter는 실제로 교체 가능성이 있거나, 테스트 용이성이 필요한 경우에만 적용했다.

💡과설계를 피하는 기준

인터페이스를 만들기 전에 이 질문을 해보자. "이 구현체를 다른 것으로 교체할 가능성이 있는가? 혹은 테스트에서 Mock으로 대체해야 하는가?" 둘 다 No라면 인터페이스 없이 구현체를 직접 사용해도 된다.

정리

클린 아키텍처는 "좋은 소프트웨어를 만들기 위한 규칙"이 아니라 **"변경에 유연하게 대응하기 위한 설계 전략"**이다. 처음부터 완벽하게 만들 수 없다는 것을 인정하고, 나중에 바꿀 수 있는 구조를 만드는 것이 핵심이다.

멀티 모듈과 클린 아키텍처를 조합하면 이 전략을 코드 수준에서 강제할 수 있다. 잘못된 의존성은 컴파일 에러로, 도메인 규칙 위반은 런타임 예외로 즉시 피드백을 준다.

다음 편에서는 이 프로젝트에서 가장 흥미로웠던 설계 중 하나인 상태 머신을 도메인 객체로 표현하는 방법을 다룬다. AnalysisStatusAnalysisStep이 어떻게 Enum 하나로 상태 전이 규칙을 완전히 캡슐화하는지 살펴볼 것이다.

§ 목차