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; // 유틸리티를 직접 사용할 수도 있음
}
규칙과 모범 사례
- 순환 의존성을 피하세요. 만약 A가 requires로 B를 요구하고, B가 requires로 A를 요구한다면 설계 결함입니다. 일반적으로 공통 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를 가집니다.
- app의 main을 실행할 때 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를 사용합니다.
- exports나 requires를 빼먹으면 컴파일 오류가 발생합니다.
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과 함께라면 모듈 구조가 자동으로 인식됩니다.
- app의 main을 실행하면 IDE가 module-path를 설정합니다.
- 가져오기/내보내기 대화상자는 패키지와 모듈의 가시성을 안내합니다.
모듈 분할 시 흔한 실수
오류 №1: 모듈 간 순환 의존성. 두 모듈이 서로 requires를 선언하면 컴파일러가 오류를 발생시킵니다. 보통 이는 아키텍처가 ‘흐트러진’ 신호입니다. 해결책 — 공통 api 모듈을 분리하거나 경계를 재검토하세요.
오류 №2: export되지 않은 패키지의 클래스를 사용. 클래스가 public이라도 해당 패키지가 module-info.java의 exports에 명시되지 않았다면 다른 모듈에서 보이지 않습니다. 결과 — 컴파일 오류.
오류 №3: 사용하는 모듈에 대한 requires를 추가하지 않음. 다른 모듈에서 import하더라도 해당 의존성이 module-info.java에 없으면 컴파일되지 않습니다. 항상 의존성을 명시적으로 선언하세요.
오류 №4: 모듈 이름 중복. 모듈 이름은 빌드 범위에서 고유해야 합니다(특히 Maven/Gradle 사용 시). 중복은 빌드를 망가뜨립니다.
오류 №5: 잘못된 디렉터리 구조. 파일 module-info.java는 해당 모듈의 src 루트에 있어야 합니다. 그렇지 않으면 컴파일러가 모듈을 찾지 못합니다.
오류 №6: 잘못된 module-path로 실행. 수동 실행 시 --classpath 대신 --module-path를 지정해야 합니다. 그렇지 않으면 “module not found”.
GO TO FULL VERSION