모든 글

그래들 의존성 범위로 아키텍처 경계 세우기

Gradle의 implementation, api, runtimeOnly, compileOnly를 의도적으로 사용해 모듈 접근을 제한하고 우발적인 결합을 막는 방법을 설명합니다.

출처 및 AI 안내: 이 글은 제미니의 개발실무 유튜브를 기반으로 작성되었습니다. gpt-5.6-sol 모델을 사용해 생성·편집했습니다.

Gradle 의존성 선언은 정리 작업이 아닙니다. 멀티모듈 프로젝트에서는 다른 모듈이 어떤 클래스를 볼 수 있는지, 어떤 실수를 빌드가 컴파일 단계에서 거절할지를 결정합니다. 그래서 implementation, api, runtimeOnly, compileOnly도 아키텍처의 일부입니다.

제 기본값은 단순합니다. 우선 implementation을 씁니다. 다른 모듈에 어떤 접근을 허용해야 하는지 설명할 수 있을 때만 범위를 바꿉니다.

구현 세부사항은 전파하지 않기

sftp-client 모듈이 내부에서 Spring Integration SFTP를 사용하고, payments-batch 모듈이 sftp-client에 의존한다고 가정해 보겠습니다.

sftp-client가 Spring Integration을 implementation으로 선언하면 payments-batch는 클라이언트 모듈이 공개한 타입을 사용할 수 있지만 Spring Integration 클래스까지 컴파일 클래스패스로 자동 전달받지는 않습니다. 이 제약은 쓸모가 있습니다. 배치 모듈은 클라이언트 모듈에 파일 전송을 요청해야 합니다. 클라이언트 안쪽을 뚫고 들어가 Spring Integration 객체를 직접 생성해서는 안 됩니다.

같은 의존성을 api로 선언하면 소비 모듈에도 해당 클래스가 노출됩니다. 모듈의 공개 API 자체가 그 타입을 요구한다면 올바른 선택일 수 있습니다. 동시에 쉬운 우회로가 생깁니다. 상위 모듈을 개발하던 사람이 전이 의존성에서 편리한 클래스를 발견하고 그대로 씁니다. SFTP 세부사항은 아래 모듈에 두자는 합의가 있었어도 경계에는 구멍이 생깁니다.

코드 리뷰에서 잡을 수도 있습니다. 컴파일 제약은 리뷰 전에 잡고, 처음 설계한 개발자가 떠난 뒤에도 계속 잡습니다. 인수인계에서는 이 차이가 큽니다. 자동완성에 클래스가 보이면 써도 된다고 생각하기 쉽습니다. 의존성 범위를 먼저 바꿔야 한다면 “일부러 막아둔 것 아닐까?“를 한 번 더 묻게 됩니다. 저는 그 한 번을 만들고 싶습니다.

제가 생각하는 모듈의 첫 번째 기능은 격리와 그로 인한 제약입니다. 응집이나 읽기 좋은 아키텍처도 중요하지만 아무것도 제한하지 않는 모듈은 빌드 파일이 하나 더 있는 디렉터리에 그치기 쉽습니다.

런타임 조립 경로는 좁게 열기

애플리케이션이 시작할 때는 필요하지만 조립하는 모듈의 구현 의존성이 되어서는 안 되는 모듈도 있습니다.

코어 API 애플리케이션이 실행 과정에서 어드민 모듈을 함께 로딩한다고 해보겠습니다. 런타임 클래스패스에는 어드민 코드가 필요하지만 코어 소스가 어드민 구현 클래스를 직접 호출할 이유는 없습니다. runtimeOnly 관계는 이 방향을 표현할 수 있습니다. 실행할 때는 모듈을 조립하면서도 컴파일러가 코어 코드의 어드민 import를 막습니다.

이를 implementation으로 바꾸면 제약이 사라집니다. 코어 모듈에서 어드민 클래스를 컴파일 시점에 볼 수 있으므로 직접 호출할 수 있습니다. 애플리케이션은 여전히 실행되겠지만 아키텍처 경계 하나가 없어집니다.

compileOnly는 다른 문제를 풉니다. 어떤 모듈이 컴파일할 때 API를 필요로 하지만 실제 런타임은 다른 애플리케이션이 소유할 수 있습니다. 예제 프로젝트에서 어드민 API는 단독으로 실행되지 않고 코어 API에 포함되어 실행됩니다. 컴파일 전용 의존성은 어드민 코드가 특정 타입을 컴파일에는 필요로 하지만 런타임 제공 책임까지 갖지 않는다는 뜻을 담을 수 있습니다.

이 선택은 테스트에 영향을 줍니다. 메인 컴파일에만 있는 의존성은 테스트 컴파일이나 테스트 런타임에서 보이지 않을 수 있습니다. 그러면 테스트가 실제로 필요한 의존성만 테스트 설정에 추가해야 합니다. 선언이 중복되어 보이더라도 그 중복에는 정보가 있습니다. 운영 코드 컴파일, 런타임 조립과 테스트 실행의 클래스패스가 같지 않다는 정보입니다.

IntelliJ에서 이 클래스패스를 확인할 수 있습니다. 테스트가 JPA나 웹 클래스를 찾지 못한다면 무작정 넓은 의존성을 추가하기 전에 컴파일 및 런타임 클래스패스를 보십시오. 실패는 명시하지 않은 요구사항을 빌드가 정직하게 알려주는 것일 수 있습니다.

순환 의존성은 설계 경고로 보기

의존성 범위는 좋은 경계를 지킬 수 있지만 잘못 나눈 모듈을 구해주지는 못합니다.

도메인 모듈이 서로 양방향으로 의존한다면 저는 강한 경고로 봅니다. 의도적으로 감수한 트레이드오프일 수는 있지만 대개 개념을 잘못 나눈 경우가 많습니다. 전부 컴파일될 때까지 implementationapi로 바꾸면 신호만 가려집니다. 소유권과 의존성 방향을 다시 살펴야 합니다.

compileOnly에도 같은 절제가 필요합니다. 클래스패스를 정밀하게 만들 수 있지만 정밀함에는 비용이 듭니다. 작은 프로젝트에서 숙련된 팀이 implementation 중심으로 충분하다고 합의했다면 그것도 좋은 전략일 수 있습니다. 빌드 복잡성도 쓸 이유를 증명해야 합니다. Gradle 설정을 얼마나 많이 보여주는지보다 팀이 이해하고 유지할 경계를 선언이 전달하는지가 중요합니다.

빌드를 이어받을 사람을 기준으로 설계하기

회사 소프트웨어는 다른 사람이 운영할 자산입니다. 만든 사람이 퇴사하자마자 기존 설계를 이어가는 것보다 새로 만드는 비용이 낮아서 전면 재작성하는 사례를 여러 번 봤습니다. 빌드 설정 하나가 원인은 아니지만 작은 제약은 설명이 사라진 뒤에도 의도를 남깁니다.

제약의 수준은 팀에 맞아야 합니다. 항아리 중간이 깨져 있으면 그 위로 물을 부어도 차지 않습니다. 한 명의 전문가만 이해하는 설계는 그 사람이 없을 때 빠져나갑니다. 팀 전체가 사용할 수 있는 기준을 두고, 경험이 적은 구성원도 조금씩 성장할 만큼만 높이는 편이 낫습니다. 아무도 건드리지 못할 만큼 영리한 설정은 인수인계에 실패한 것입니다.

실습할 때는 의존성 범위를 좁혀보고 무엇이 깨지는지 살펴볼 수 있습니다. implementation에서 시작합니다. 소비자가 컴파일 시점에 알 필요가 없는 의존성은 runtimeOnly로 옮겨봅니다. 컴파일에는 필요하지만 런타임을 제공하지 않는다면 compileOnly를 쓰고 테스트 의존성을 명시합니다. 더 깊이 보고 싶다면 브랜치에서 의존성을 임시로 compileOnly 쪽으로 좁힌 뒤 컴파일 실패를 하나씩 확인할 수도 있습니다. 검토 없는 운영 변경으로 해서는 안 됩니다.

api는 아껴 써야 합니다. “나를 의존하면 이 의존성도 알아도 되거나 알아야 한다”는 뜻입니다. 공용 테스트 모듈이 소비자에게 필요한 테스트 타입을 노출할 수 있고, 라이브러리의 공개 시그니처가 특정 API 타입을 요구할 수도 있습니다. 이는 기본값이 아니라 판단의 결과입니다.

좋은 Gradle 파일은 구현 코드를 열기 전에 아키텍처 질문에 답합니다. 이 기술은 어느 모듈이 소유하는가, 소비자는 어떤 클래스를 써도 되는가, 런타임은 누가 제공하는가, 테스트는 무엇을 직접 조립해야 하는가. 의존성 범위가 이 질문에 답할 때 빌드는 라이브러리 다운로드를 넘어 설계를 지키는 역할을 합니다.