소프트웨어 개발의 여정에서 우리는 종종 예상치 못한 암초를 만납니다. 특히 Java와 Spring Framework, 그리고 Maven을 함께 사용하는 복잡한 생태계에서는 더욱 그렇습니다. 개발자의 심장을 철렁하게 만드는 수많은 예외와 에러 메시지 중에서도 java.lang.NoClassDefFoundError
는 단연 으뜸가는 '배신자'로 꼽힙니다. 아무런 코드 변경 없이, 어제까지 멀쩡하게 실행되던 애플리케이션이 오늘 갑자기 이 에러를 뿜어내며 멈춰 설 때의 당혹감은 겪어보지 않은 사람은 이해하기 어렵습니다.
이 에러의 가장 악랄한 점은 컴파일 시점에는 아무런 문제를 일으키지 않는다는 것입니다. 우리의 똑똑한 IDE(IntelliJ, Eclipse 등)와 Maven 컴파일러는 모든 것이 정상이라고 말합니다. "BUILD SUCCESS"라는 청신호를 보고 안심하며 애플리케이션을 실행하는 순간, 우리는 차갑게 거절당합니다. 이 글은 바로 이 미스터리한 에러, `NoClassDefFoundError`의 본질을 파헤치고, 특히 Maven 프로젝트에서 이 문제가 발생하는 근본적인 원인과 체계적인 해결책, 그리고 나아가 이를 예방하기 위한 고급 전략까지 깊이 있게 다루고자 합니다.
NoClassDefFoundError: 이름 뒤에 숨겨진 진실
문제 해결의 첫걸음은 문제의 정확한 이해입니다. 많은 개발자들이 `NoClassDefFoundError`를 `ClassNotFoundException`과 혼동하곤 하지만, 이 둘은 발생 원인과 시점이 명확히 다른, 전혀 별개의 문제입니다.
결정적 차이점: ClassNotFoundException vs. NoClassDefFoundError
-
ClassNotFoundException
(체크 예외, Checked Exception):이 예외는 이름 그대로 '클래스를 찾지 못했을 때' 발생합니다. 주로 동적으로 클래스를 로드하려 시도할 때 발생하며, 대표적인 예시로는
Class.forName("com.example.MyClass")
,ClassLoader.loadClass()
호출 등이 있습니다. JDBC 드라이버를 로드하거나 리플렉션을 사용할 때 자주 마주칩니다. 이는 런타임에 특정 이름의 클래스 파일(`.class`)을 클래스패스(Classpath) 상에서 능동적으로 찾으려 했으나 실패했음을 의미합니다. 개발자는 이 예외를 `try-catch` 블록으로 반드시 처리해야 하는 체크 예외(Checked Exception)입니다. -
NoClassDefFoundError
(에러, Unchecked Error):이것이 오늘 우리의 주인공입니다. 이 에러는 상황이 좀 더 복잡합니다. Java Virtual Machine(JVM)이 특정 클래스를 로드하려고 시도했는데, 컴파일 시점에는 분명히 해당 클래스의 존재를 확인했지만, 정작 런타임에 해당 클래스의 정의를 찾을 수 없을 때 발생합니다. 즉, JVM은 "이 클래스가 필요하다는 건 알고 있는데, 막상 사용하려고 보니 실체가 사라졌네?"라고 말하는 것입니다. 이는 일반적으로 클래스패스가 꼬였거나, 의존성 라이브러리가 누락되었거나, 버전이 충돌할 때 발생하며, 개발자가 직접 처리하기보다는 환경 설정이나 빌드 스크립트의 문제를 해결해야 하는 경우가 대부분입니다.
핵심은 이것입니다: ClassNotFoundException
은 '찾으려 했으나 없었다'이고, `NoClassDefFoundError`는 '분명히 있었는데 없어졌다'는 뉘앙스의 차이입니다. 이 차이를 인지하는 것만으로도 문제의 원인에 훨씬 더 가깝게 다가갈 수 있습니다.
Maven의 그림자: 편리함 뒤에 숨은 '전이 의존성'의 함정
그렇다면 왜 이 '있었는데 없어진' 현상은 Maven 프로젝트에서 유독 빈번하게 발생할까요? 해답은 Maven의 가장 강력한 기능이자 동시에 가장 큰 골칫거리인 전이 의존성(Transitive Dependencies)에 있습니다.
전이 의존성이란 무엇인가?
Maven은 `pom.xml` 파일에 우리가 필요로 하는 라이브러리(의존성)를 선언하면, 해당 라이브러리가 필요로 하는 다른 라이브러리들까지 알아서 연쇄적으로 다운로드하여 클래스패스에 포함해 줍니다. 예를 들어, 우리가 'Spring-Web' 라이브러리를 `pom.xml`에 추가했다고 가정해 봅시다.
org.springframework.boot
spring-boot-starter-web
이 `spring-boot-starter-web`은 내부적으로 `spring-webmvc`, `spring-web`, `tomcat-embed-core`, `jackson-databind` 등 수많은 다른 라이브러리들을 필요로 합니다. 우리는 단지 `spring-boot-starter-web` 하나만 선언했을 뿐인데, Maven은 이 모든 하위 라이브러리들을 자동으로 클래스패스에 추가해 줍니다. 이것이 바로 전이 의존성입니다. 이 기능 덕분에 개발자는 복잡한 의존성 관계를 일일이 신경 쓰지 않아도 되어 매우 편리합니다.
함정의 시작: 보이지 않는 연결고리
편리함에는 대가가 따르는 법입니다. `NoClassDefFoundError`는 이 보이지 않는 연결고리가 끊어질 때 발생합니다.
다음과 같은 시나리오를 상상해 봅시다.
- 내 프로젝트(`MyProject`)는 외부 라이브러리 `A` 버전 1.0에 의존합니다.
- 라이브러리 `A` 버전 1.0은 내부적으로 `org.apache.httpcomponents:httpclient` 라이브러리 버전 4.5.2에 의존합니다.
- 이 의존성 관계 (`MyProject` → `A:1.0` → `httpclient:4.5.2`) 덕분에, 내 프로젝트에서는 `httpclient` 라이브러리의 클래스(예: `org.apache.http.impl.client.HttpClients`)를 자유롭게 사용할 수 있었습니다. 컴파일도 잘 되고, 실행도 잘 됩니다.
- 어느 날, 라이브러리 `A`의 버그 수정 및 기능 개선이 이루어진 버전 1.1이 릴리스되었습니다. 우리는 `pom.xml`에서 `A`의 버전을 1.0에서 1.1로 업데이트했습니다.
- 문제 발생! 라이브러리 `A`의 개발자들은 버전 1.1을 만들면서 더 이상 `httpclient`를 사용하지 않거나, 다른 HTTP 클라이언트 라이브러리(예: `OkHttp`)로 교체했습니다.
- 이제 의존성 관계는 `MyProject` → `A:1.1` → (더 이상 `httpclient` 없음) 으로 변경되었습니다.
이 상황에서 어떤 일이 벌어질까요?
- 컴파일 시점: 대부분의 IDE는 Maven 의존성을 캐싱하고 있거나, 다른 라이브러리를 통해 여전히 `httpclient`가 클래스패스에 남아있을 수 있습니다. 혹은 이미 컴파일된 `.class` 파일들이 남아있어 IDE는 문제를 인지하지 못할 수 있습니다. 그래서 컴파일은 여전히 성공합니다.
- 런타임 시점: Maven이 빌드한 최종 결과물(JAR 또는 WAR)에는 더 이상 `httpclient` 라이브러리가 포함되지 않습니다. 애플리케이션이 실행되다가 `httpclient`의 클래스를 사용하려는 코드에 도달하는 순간, JVM은 해당 클래스의 정의를 찾을 수 없다는 `NoClassDefFoundError`를 발생시키고 즉시 종료됩니다.
이것이 바로 "어제는 됐는데 오늘은 안 되는" 현상의 가장 흔한 원인입니다. 우리는 코드를 한 줄도 바꾸지 않았습니다. 단지 의존성 라이브러리의 버전을 올렸을 뿐인데, 그 라이브러리의 내부 사정이 바뀌면서 내 프로젝트가 간접적으로 의존하던 다른 라이브러리가 사라져 버린 것입니다.
체계적인 문제 해결 가이드: 원인 분석부터 해결까지
이제 `NoClassDefFoundError`의 원흉이 '사라진 전이 의존성'이라는 강력한 심증을 갖게 되었습니다. 이제 탐정처럼 증거를 수집하고 범인을 확정해야 합니다. 다음은 이 문제를 해결하기 위한 체계적인 접근 방법입니다.
1단계: 범인 식별 - 어떤 클래스가 사라졌는가?
가장 먼저 할 일은 에러 로그를 정확히 읽는 것입니다. `NoClassDefFoundError` 메시지에는 사라진 클래스의 전체 패키지 경로(Fully Qualified Class Name)가 명시되어 있습니다.
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/http/conn/SchemePortResolver
at com.example.myproject.service.MyHttpService.execute(MyHttpService.java:42)
...
Caused by: java.lang.ClassNotFoundException: org.apache.http.conn.SchemePortResolver
...
위 로그에서 우리는 `org.apache.http.conn.SchemePortResolver` 라는 클래스가 문제의 원인임을 명확히 알 수 있습니다. 이 클래스 이름이 우리의 첫 번째 단서입니다.
2단계: 용의선상 좁히기 - Maven 의존성 트리 분석
이제 사라진 클래스가 어떤 라이브러리에 속해 있는지, 그리고 그 라이브러리가 왜 클래스패스에서 제외되었는지 추적해야 합니다. 이때 가장 강력한 도구는 Maven의 `dependency:tree` 명령어입니다. 이 명령어는 현재 프로젝트의 모든 의존성 관계를 나무 구조로 시각화하여 보여줍니다.
터미널이나 명령 프롬프트에서 프로젝트의 루트 디렉토리로 이동한 후, 다음 명령어를 실행해 보세요.
mvn dependency:tree
결과가 너무 길다면, 특정 라이브러리를 필터링해서 볼 수 있습니다. 1단계에서 찾은 클래스 이름으로 어떤 라이브러리에 속하는지 유추하거나 검색(예: "SchemePortResolver maven dependency")하여 라이브러리의 `groupId` 또는 `artifactId`를 알아냅니다. `org.apache.http...`로 시작했으니, `httpcomponents`나 `httpclient`와 관련 있을 가능성이 높습니다.
# httpclient 라이브러리가 포함된 경로를 모두 보여줘!
mvn dependency:tree -Dincludes=org.apache.httpcomponents:httpclient
이 명령의 결과는 두 가지 중 하나일 것입니다.
- 아무것도 출력되지 않거나, 원하는 버전이 없는 경우: 이는 `httpclient` 라이브러리가 현재 프로젝트의 의존성 그래프에 아예 존재하지 않는다는 의미입니다. 전이 의존성이 사라졌을 가능성이 100%에 가깝습니다.
- 원치 않는 버전이 표시되거나 `(omitted for conflict)`가 표시된 경우: 이는 여러 라이브러리가 각기 다른 버전의 `httpclient`를 요구하고 있어서 Maven의 의존성 해결(Dependency Mediation) 규칙에 따라 특정 버전이 선택되고 나머지는 제외되었음을 의미합니다. Maven은 기본적으로 '가장 가까운 정의(Nearest Definition)' 규칙을 따릅니다. 즉, 의존성 트리에서 더 상위에(더 가깝게) 선언된 버전이 선택됩니다. 이로 인해 내가 필요로 하는 클래스가 없는 구버전이 선택되었을 수 있습니다.
이 분석을 통해 우리는 "왜" 클래스가 사라졌는지에 대한 구체적인 증거를 확보할 수 있습니다.
3단계: 범인 검거 및 사건 종결 - 해결책 적용하기
원인 분석이 끝났다면 해결은 비교적 간단합니다. 몇 가지 효과적인 해결책이 있습니다.
해결책 1: 명시적 의존성 선언 (가장 확실하고 직접적인 방법)
전이 의존성의 가장 큰 문제는 '암시적'이라는 점입니다. 이 관계를 '명시적'으로 바꿔주면 문제가 해결됩니다. 즉, 내 프로젝트가 직접 사용하는 라이브러리는 전이 의존성에 기대지 말고, `pom.xml`에 직접, 명시적으로 선언하는 것입니다.
앞선 예시에서 `org.apache.httpcomponents:httpclient`가 문제였다면, `pom.xml`의 `
<dependencies>
...
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
</dependency>
...
</dependencies>
이렇게 하면, 다른 라이브러리가 `httpclient`를 포함하든 안 하든, 내 프로젝트는 항상 명시된 4.5.13 버전을 직접 의존하게 됩니다. 이것은 마치 "다른 사람이 뭘 가져오든 상관없이, 나는 내 가방에 직접 `httpclient` 4.5.13 버전을 챙기겠다"고 선언하는 것과 같습니다. 이는 가장 간단하면서도 확실하게 `NoClassDefFoundError`를 해결하는 방법입니다.
해결책 2: 의존성 범위(Scope) 확인
때로는 라이브러리가 클래스패스에 포함되긴 하지만, 잘못된 `scope`로 선언되어 런타임에 사용할 수 없는 경우가 있습니다. Maven 의존성에는 다음과 같은 주요 `scope`가 있습니다.
- `compile`: 기본값. 컴파일, 테스트, 런타임 등 모든 클래스패스에 포함됩니다.
- `provided`: JDK나 WAS(웹 애플리케이션 서버) 같은 컨테이너가 런타임에 제공할 것으로 기대하는 의존성입니다. 컴파일과 테스트 시점에는 사용되지만, 최종 빌드 결과물에는 포함되지 않습니다. (예: `servlet-api`)
- `runtime`: 컴파일 시점에는 필요 없지만, 실행 시점에 필요한 의존성입니다. (예: JDBC 드라이버 구현체)
- `test`: 테스트 컴파일 및 실행 시에만 필요한 의존성입니다. (예: `JUnit`, `Mockito`)
만약 `httpclient`가 어떤 이유로든 `<scope>test</scope>`나 `<scope>provided</scope>`로 선언되어 있다면, 실제 애플리케이션을 실행할 때는 클래스패스에서 제외되어 `NoClassDefFoundError`를 유발할 수 있습니다. `mvn dependency:tree` 결과에서 각 의존성의 scope도 함께 확인하는 습관이 중요합니다.
해결책 3: 버전 충돌 해결을 위한 `<dependencyManagement>`
대규모 프로젝트나 마이크로서비스 아키텍처에서는 여러 모듈/서비스에서 동일한 라이브러리의 다른 버전을 사용하려다 충돌이 발생하는 경우가 비일비재합니다. 이때는 `<dependencyManagement>` 태그를 사용하는 것이 매우 효과적인 예방책이자 해결책입니다.
프로젝트의 최상위 `pom.xml` (부모 POM)에 `<dependencyManagement>` 섹션을 만들고, 사용할 라이브러리와 그 버전을 중앙에서 관리할 수 있습니다.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.13.3</version>
</dependency>
...
</dependencies>
</dependencyManagement>
이렇게 중앙에서 버전을 선언해두면, 각 하위 모듈의 `pom.xml`에서는 버전 번호 없이 의존성을 선언할 수 있습니다.
<dependencies>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
</dependencies>
`<dependencyManagement>`는 일종의 '권장 버전 목록'과 같습니다. 실제로 의존성을 추가하지는 않지만, 만약 어떤 모듈이 해당 의존성을 추가한다면 여기에 명시된 버전을 사용하도록 강제하거나 기본값으로 제공합니다. 이를 통해 프로젝트 전체에 걸쳐 의존성 버전의 일관성을 유지하고, 예기치 않은 버전 충돌로 인한 `NoClassDefFoundError`를 사전에 방지할 수 있습니다.
예방을 위한 고급 전략과 좋은 습관
문제가 터진 뒤에 해결하는 것도 중요하지만, 더 현명한 개발자는 문제가 발생할 소지를 미리 줄입니다.
1. Maven Enforcer 플러그인 활용
`maven-enforcer-plugin`은 빌드 과정에서 특정 규칙을 강제하여 잠재적인 문제를 조기에 발견하도록 도와주는 강력한 도구입니다. 예를 들어, 다음과 같은 규칙을 설정할 수 있습니다.
- `requireUpperBoundDeps`: 의존성 버전 충돌이 발생했을 때, 항상 더 높은 버전을 사용하도록 강제하여 하위 호환성 문제를 줄입니다.
- `bannedDependencies`: 보안 취약점이 있거나 사용하지 말아야 할 특정 라이브러리의 사용을 금지합니다.
- `dependencyConvergence`: 프로젝트 내 모든 모듈이 동일한 버전의 의존성을 사용하도록 강제합니다. 버전 충돌을 근본적으로 막아줍니다.
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>enforce-versions</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<dependencyConvergence />
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
이 플러그인을 설정해두면 `mvn clean install` 같은 빌드 명령어 실행 시 규칙 위반이 발견되면 빌드를 즉시 실패시켜 문제를 강제로 인지하게 만듭니다. "런타임에 죽느니 빌드타임에 죽는 게 낫다"는 격언을 실천하는 셈입니다.
2. 로컬 저장소 정리 및 IDE 재동기화
때로는 문제의 원인이 코드나 `pom.xml`이 아니라, 오염된 로컬 Maven 저장소(`.m2/repository`)나 IDE의 캐시 문제일 수 있습니다. 다음과 같은 "리프레시" 작업들을 시도해볼 수 있습니다.
- IDE의 Maven 프로젝트 새로고침: IntelliJ에서는 Maven 패널의 'Reload All Maven Projects' 버튼을, Eclipse에서는 프로젝트 우클릭 후 'Maven' → 'Update Project'를 실행합니다.
- 로컬 저장소의 특정 라이브러리 삭제: 문제가 되는 라이브러리(예: `.m2/repository/org/apache/httpcomponents/httpclient`) 폴더를 직접 삭제한 후, 다시 빌드하여 Maven이 깨끗한 상태로 다시 다운로드하게 합니다.
- 궁극의 방법, 전체 로컬 저장소 삭제: 최후의 수단으로 `.m2/repository` 폴더 전체를 삭제하고 프로젝트를 처음부터 다시 빌드할 수 있습니다. (주의: 인터넷 연결이 필요하며 모든 의존성을 다시 다운로드하므로 시간이 오래 걸릴 수 있습니다.)
결론: Maven과의 건강한 관계 맺기
java.lang.NoClassDefFoundError
는 단순한 코딩 실수가 아니라, 우리가 구축하고 있는 소프트웨어의 구조와 환경에 대한 깊은 이해를 요구하는 신호입니다. 특히 Maven과 같은 의존성 관리 도구의 편리함에 익숙해져 그 내부 동작 원리를 간과했을 때 이 에러는 어김없이 찾아옵니다.
이 글에서 우리는 `NoClassDefFoundError`가 `ClassNotFoundException`과 어떻게 다른지, Maven의 전이 의존성이라는 편리한 기능이 어떻게 예기치 않은 함정이 될 수 있는지를 살펴보았습니다. 그리고 `mvn dependency:tree`를 통한 체계적인 원인 분석, 명시적 의존성 선언과 `<dependencyManagement>`를 통한 문제 해결 및 예방 전략까지 구체적으로 다루었습니다.
이제 `NoClassDefFoundError`를 마주했을 때 더 이상 당황하지 마십시오. 그것은 우리를 좌절시키기 위한 에러가 아니라, 우리 프로젝트의 의존성 구조를 더 건강하고 견고하게 만들 기회를 주는 스승과도 같습니다. 의존성을 명시적으로 관리하고, 그 관계를 투명하게 들여다보는 습관을 통해 우리는 이 예측 불가능한 '배신자'를 가장 신뢰할 수 있는 '동료'로 만들 수 있을 것입니다.
감사합니다! 아무 이상없는 프로젝트인데 자꾸 에러가 나서 오타가 있나 싶어 이잡듯 뒤졌는데..
ReplyDelete이거 한방에 정상 작동하네요 ^.T