CodeGym /행동 /JAVA 25 SELF /프로젝트 모듈 분할: 모범 사례

프로젝트 모듈 분할: 모범 사례

JAVA 25 SELF
레벨 60 , 레슨 3
사용 가능

1. 프로젝트를 언제 왜 모듈로 나눌까

모든 것을 하나의 모듈에 넣지 말아야 하는 이유

작은 실습 과제나 "Hello, World!" 정도를 작성한다면 모듈 시스템이 과한 것처럼 느껴질 수 있습니다. 그러나 프로젝트가 커지면서 — 수십, 수백 개의 클래스, 다수의 패키지, 서드파티 라이브러리 — 혼란은 필연적입니다. 선반이 없는 도서관과 같습니다: 책이 적을 때는 그럭저럭 괜찮지만, 많아지면 찾기가 어렵습니다. 모듈은 당신의 선반입니다: 내부의 ‘주방’(구현)을 감추고 바깥에는 ‘진열대’(API)만 남겨 정리를 도와줍니다.

모듈로 나누는 이유

  • 관심사 분리: 각 모듈은 자신의 영역(DB, 비즈니스 로직, UI 등)을 책임집니다.
  • 재사용성: 모듈을 다른 프로젝트에서 그대로 사용할 수 있습니다.
  • 테스트 용이성: 모듈을 독립적으로 테스트할 수 있습니다.
  • 보안과 캡슐화: 외부에는 API만 보이고 구현은 숨겨집니다.
  • 유지보수 용이성: ‘마법 같은’ 숨은 연결을 줄이고 의존 관계가 명확해집니다.
  • 빠른 빌드와 배포: 변경된 모듈만 다시 빌드됩니다.

언제 모듈로 나눌까

  • 프로젝트가 한 명이 다루기 어려울 만큼 커지거나(혹은 IDE가 느려지기 시작하면).
  • 구성 요소가 뚜렷이 구분될 때: core, ui, utils, api, impl.
  • 다른 프로젝트에서 코드 재사용을 계획할 때.
  • 외부 의존성이 프로젝트의 일부에만 필요할 때.
  • 구현 세부(알고리즘, 내부 클래스)를 숨겨야 할 때.

2. 모듈화의 대표적인 패턴

아래에는 학습용과 실전 프로젝트 모두에 적합한 대표적인 분할 패턴을 정리했습니다.

“양파” 아키텍처 (Onion Architecture)

바깥 레이어는 안쪽 레이어에 의존하지만, 그 반대는 아닙니다.

[ app (UI) ]
     ↓
[ core (로직) ]
     ↓
[ utils (유틸리티) ]
  • app — 외부 모듈: 그래픽 인터페이스, 웹 애플리케이션, 진입점(main).
  • core — 비즈니스 로직, 모델, 서비스.
  • utils — 보조 클래스.

규칙: 내부 레이어는 외부 레이어에 의존해서는 안 됩니다. 이렇게 하면 core를 다양한 인터페이스(콘솔, 웹, 데스크톱)에서 재사용할 수 있습니다.

API와 구현을 분리한 모듈

라이브러리의 경우 인터페이스와 그 구현을 분리하는 것이 편리합니다:

[ mylib.api ]  ← 인터페이스만 export
[ mylib.impl ] ← 구현 포함, export하지 않음

테스트용 모듈

테스트 코드는 운영 산출물에 포함되지 않도록 별도 모듈로 분리하는 경우가 많습니다.

[ app ]
[ core ]
[ core.tests ]

학습용 프로젝트 예시 구조

myeditor/
  ├─ app/         ← 진입점, 애플리케이션 실행
  ├─ core/        ← 비즈니스 로직(파일/텍스트 처리)
  └─ utils/       ← 유틸리티(로깅, 파싱)

3. 모듈 간 의존성

Java 모듈에서 의존성은 module-info.java의 키워드 requires로 명시합니다. 이는 가독성을 높일 뿐 아니라 컴파일러/JVM이 exports를 통해 API 가시성을 제어할 수 있게 해줍니다.

의존성 예시

core/module-info.java

module myeditor.core {
    exports myeditor.core.api; // 외부에는 api 패키지만 보이도록 export
    requires myeditor.utils;   // 유틸리티를 사용
}

app/module-info.java

module myeditor.app {
    requires myeditor.core;    // core를 사용
    requires myeditor.utils;   // 유틸리티를 직접 사용할 수도 있음
}

규칙과 모범 사례

  • 순환 의존성을 피하세요. 만약 ArequiresB를 요구하고, BrequiresA를 요구한다면 설계 결함입니다. 일반적으로 공통 common/api로 분리해 해결합니다.
  • 의존성을 최소화하세요. 실제로 필요하지 않다면 모듈을 추가하지 마세요.
  • 사용되는 패키지를 export하세요. 클래스는 exports로 선언된 패키지에 있어야 합니다. 그렇지 않으면 컴파일 오류가 발생합니다.
  • 유틸리티 — 최대한 독립적으로. utils는 비즈니스 로직에 의존해서는 안 됩니다.

4. 실습: 학습용 프로젝트를 3개 모듈로 나누는 예

폴더 구조

myeditor/
  ├─ app/
  │    ├─ src/
  │    │    └─ myeditor/app/Main.java
  │    └─ module-info.java
  ├─ core/
  │    ├─ src/
  │    │    ├─ myeditor/core/api/TextService.java
  │    │    └─ myeditor/core/impl/TextServiceImpl.java
  │    └─ module-info.java
  └─ utils/
       ├─ src/
       │    └─ myeditor/utils/Logger.java
       └─ module-info.java

module-info.java 예시

core/module-info.java

module myeditor.core {
    exports myeditor.core.api;
    requires myeditor.utils;
}

app/module-info.java

module myeditor.app {
    requires myeditor.core;
    requires myeditor.utils;
}

utils/module-info.java

module myeditor.utils {
    exports myeditor.utils;
}

코드 예시 (TextService)

myeditor/core/api/TextService.java

package myeditor.core.api;

public interface TextService {
    String toUpperCase(String text);
}

myeditor/core/impl/TextServiceImpl.java

package myeditor.core.impl;

import myeditor.core.api.TextService;

public class TextServiceImpl implements TextService {
    @Override
    public String toUpperCase(String text) {
        return text.toUpperCase();
    }
}

myeditor/app/Main.java

package myeditor.app;

import myeditor.core.api.TextService;
import myeditor.core.impl.TextServiceImpl;

public class Main {
    public static void main(String[] args) {
        TextService service = new TextServiceImpl();
        System.out.println(service.toUpperCase("hello, modules!"));
    }
}

IntelliJ IDEA에서의 모습

  • 각 디렉터리는 프로젝트 구조에서 별도의 Module입니다.
  • 각 모듈은 src 루트에 자신의 module-info.java를 가집니다.
  • appmain을 실행할 때 IDE가 module-path를 자동으로 설정합니다.
  • export되지 않은 패키지의 클래스를 사용하려 하면 컴파일 오류가 발생합니다.

5. 빌드에 미치는 영향: Maven/Gradle과 모듈

Maven

멀티모듈 프로젝트는 ‘부모’ 프로젝트(parent)와 여러 ‘자식’ 모듈로 구성됩니다.

myeditor/
  ├─ pom.xml        ← parent
  ├─ app/
  │    └─ pom.xml
  ├─ core/
  │    └─ pom.xml
  └─ utils/
       └─ pom.xml

parent pom.xml 예시

<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>myeditor</groupId>
  <artifactId>myeditor-parent</artifactId>
  <version>1.0-SNAPSHOT</version>
  <packaging>pom</packaging>

  <modules>
    <module>app</module>
    <module>core</module>
    <module>utils</module>
  </modules>
</project>

특징:

  • Maven은 컴파일 시 module-info.java를 고려합니다.
  • 실행 시 --classpath 대신 --module-path를 사용합니다.
  • exportsrequires를 빼먹으면 컴파일 오류가 발생합니다.

Gradle

멀티모듈 구성은 settings.gradle과 모듈별 build.gradle에서 설정합니다.

settings.gradle

rootProject.name = 'myeditor'
include 'app', 'core', 'utils'

모듈용 build.gradle

plugins {
    id 'java'
}

java {
    modularity.inferModulePath = true
}

IntelliJ IDEA

  • IDEA는 Java 모듈 생성 시 module-info.java를 만들 수 있습니다.
  • Maven/Gradle과 함께라면 모듈 구조가 자동으로 인식됩니다.
  • appmain을 실행하면 IDE가 module-path를 설정합니다.
  • 가져오기/내보내기 대화상자는 패키지와 모듈의 가시성을 안내합니다.

모듈 분할 시 흔한 실수

오류 №1: 모듈 간 순환 의존성. 두 모듈이 서로 requires를 선언하면 컴파일러가 오류를 발생시킵니다. 보통 이는 아키텍처가 ‘흐트러진’ 신호입니다. 해결책 — 공통 api 모듈을 분리하거나 경계를 재검토하세요.

오류 №2: export되지 않은 패키지의 클래스를 사용. 클래스가 public이라도 해당 패키지가 module-info.javaexports에 명시되지 않았다면 다른 모듈에서 보이지 않습니다. 결과 — 컴파일 오류.

오류 №3: 사용하는 모듈에 대한 requires를 추가하지 않음. 다른 모듈에서 import하더라도 해당 의존성이 module-info.java에 없으면 컴파일되지 않습니다. 항상 의존성을 명시적으로 선언하세요.

오류 №4: 모듈 이름 중복. 모듈 이름은 빌드 범위에서 고유해야 합니다(특히 Maven/Gradle 사용 시). 중복은 빌드를 망가뜨립니다.

오류 №5: 잘못된 디렉터리 구조. 파일 module-info.java는 해당 모듈의 src 루트에 있어야 합니다. 그렇지 않으면 컴파일러가 모듈을 찾지 못합니다.

오류 №6: 잘못된 module-path로 실행. 수동 실행 시 --classpath 대신 --module-path를 지정해야 합니다. 그렇지 않으면 “module not found”.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION