본문 바로가기
빌드노트 검색

빌드노트

Maven 빌드는 성공했는데 Java 17에서 실행되지 않는 이유

Maven 빌드 JDK와 산출물의 대상 버전은 별개다. JDK 21·17에서 클래스 major와 실행 결과를 비교하고, source/target=17로는 놓친 Java 21 API 호출이 release=17에서 차단되는 이유를 설명한다.

Maven 빌드는 성공했는데 Java 17에서 UnsupportedClassVersionError가 나면, 실행하려는 클래스가 Java 17보다 높은 버전을 대상으로 만들어졌는지 확인한다. 개발 PC의 JDK가 21이고 배포 환경을 17로 유지해야 한다면 컴파일 도구는 JDK 21을 쓰면서 대상은 maven.compiler.release=17로 지정할 수 있다.

release는 클래스 파일의 버전만 낮추는 설정이 아니다. 소스 문법과 컴파일할 때 참조하는 Java 플랫폼 API도 지정한 버전에 맞춘다. 이 차이가 중요하다. source와 target만 17로 바꾸면 Java 21에만 있는 메서드를 호출하는 클래스가 만들어질 수 있다. Maven Compiler Plugin의 release 설명 (새 탭에서 열림)

오류에 나온 클래스부터 확인한다

다음은 Windows PowerShell에서 확인하는 명령이다. JDK 21과 17이 각각 설치되어 있어야 하고, Maven은 PATH에 있어야 한다. 두 변수에는 자신의 JDK 설치 경로를 넣는다. 프로젝트의 pom.xml이 있는 폴더에서 실행하며, Main과 클래스 파일 경로는 오류에 나온 클래스에 맞게 바꾼다.

$buildJdk = 'C:\JDK\jdk-21'
$runtimeJdk = 'C:\JDK\jdk-17'
& "$buildJdk\bin\java.exe" -version
& "$runtimeJdk\bin\java.exe" -version
mvn.cmd -version
& "$buildJdk\bin\javap.exe" -verbose .\target\classes\Main.class |
    Select-String 'major version'
& "$runtimeJdk\bin\java.exe" -cp .\target\classes Main

javap는 클래스 파일의 정보를 읽는 JDK 도구다. major version: 65는 Java 21 대상이고, Java 17이 읽을 수 있는 상한은 61이다. 이 숫자는 JVM 명세의 클래스 버전 표 (새 탭에서 열림)와 대응한다. 클래스 파일을 찾지 못했다면 버전 판정에 앞서 경로부터 바로잡는다.

mvn.cmd -version에서는 Maven 자체의 버전과 Java version을 함께 본다. 기본 컴파일러는 Maven을 실행한 JDK의 javac지만, toolchain이나 별도 컴파일러 설정이 있으면 달라질 수 있다. java -version 하나만으로 빌드 JDK를 단정하지 않는다. 컴파일 목표의 JDK 선택 조건 (새 탭에서 열림)

서버에서 실패한다면 서버가 실제 실행한 Java와 배포 산출물에 들어 있는 클래스를 확인해야 한다. 위 명령이 검사하는 것은 로컬 target/classes다. 로컬 클래스가 61이어도 서버에 예전 산출물이 남았거나, 오류에 나온 클래스가 의존 JAR 안에 있으면 그 결과로 서버의 호환성을 판정할 수 없다.

JDK 21로 만들었다고 항상 major 65는 아니었다

원인을 비교하기 위해 실제 앱과 분리한 Maven 프로젝트에서 다음 소스만 컴파일했다. src/main/java/Main.java에 둔, 패키지 선언이 없는 클래스다.

public class Main {
    public static void main(String[] args) {
        System.out.println("hello");
    }
}

실행 환경은 Windows 11, OpenJDK 21.0.1과 17.0.2, Maven 3.9.16, Maven Compiler Plugin 3.16.0이었다. 컴파일은 모두 JDK 21에서 했고, 조건마다 새 출력 폴더를 사용했다. 관찰 결과는 다음과 같다.

  • source, target, release 생략: 빌드 성공, major 52, Java 17에서 hello 출력.
  • maven.compiler.source=21, maven.compiler.target=21: 빌드 성공, major 65, Java 21에서 hello 출력. Java 17에서는 실행 실패.
  • maven.compiler.release=17: 빌드 성공, major 61, Java 17에서 hello 출력.

두 번째 조건에서 Java 17은 종료 코드 1과 함께 다음 메시지를 냈다. 별도 실습에서 얻은 오류이며 운영 장애 로그는 아니다.

java.lang.UnsupportedClassVersionError: Main has been compiled by a more recent version of the Java Runtime (class file version 65.0), this version of the Java Runtime only recognizes class file versions up to 61.0

설정을 생략한 첫 조건이 실패하지 않은 이유도 확인해야 한다. 이 플러그인 버전의 source와 target 기본값은 1.8이다. 빌드에 사용한 JDK 버전과 산출물의 대상 버전은 같은 값으로 정해지지 않는다. 다른 부모 POM이나 플러그인 버전까지 이 기본값으로 추측하지 말고, 실제 설정과 클래스의 major를 확인한다. source·target 기본값 (새 탭에서 열림)

Java 17을 지원해야 한다면 release를 명시한다

기존 pom.xml의 properties와 build/plugins에 다음 설정을 반영한다. 이미 같은 요소가 있으면 그 안에 합치고, 직접 지정한 maven.compiler.source·maven.compiler.target은 제거해 대상 기준을 하나로 정리한다. 아래는 전체 POM이 아니라 바꿀 부분이다.

<properties>
  <maven.compiler.release>17</maven.compiler.release>
</properties>

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <version>3.16.0</version>
    </plugin>
  </plugins>
</build>

17은 컴파일러를 JDK 17로 교체하라는 뜻이 아니다. JDK 21의 컴파일러에 Java 17을 대상으로 만들도록 요청한다. javac의 --release는 지원하는 이전 릴리스에 한해 사용할 수 있다. javac의 release 적용 범위 (새 탭에서 열림)

위의 짧은 Main으로 비교한다면 Maven이 JDK 21을 사용하는 셸에서 다시 컴파일하고, 앞의 javap와 Java 17 실행 명령을 반복한다. clean은 이 프로젝트의 이전 target 출력을 지우므로 별도 실습 프로젝트나 재빌드해도 되는 작업 사본에서 실행한다.

mvn.cmd -B -ntp clean compile

실습에서는 컴파일 종료 코드 0, major version: 61, Java 17의 hello 출력을 함께 확인했다. 컴파일이 실패했다면 남아 있는 클래스를 실행해 수정 성공으로 판단하지 않는다. 이 명령은 컴파일까지만 한다. 실제 배포할 때는 같은 설정으로 배포 산출물도 다시 만들어야 한다.

major가 61인데도 실행이 실패하는 경우

이번에는 같은 Main에 import java.util.List;를 추가하고, 출력하는 한 줄을 다음으로 바꿨다.

System.out.println(List.of("alpha", "beta").getFirst());

List.getFirst()는 Java 21에서 추가된 API다. Java 21 List API의 도입 버전 (새 탭에서 열림)

release 없이 source=17, target=17만 지정한 조건에서는 빌드가 성공했고 major도 61이었다. Java 21에서는 alpha를 출력했지만 Java 17에서는 다음 오류로 종료했다.

Exception in thread "main" java.lang.NoSuchMethodError: 'java.lang.Object java.util.List.getFirst()'

Java 17이 클래스 파일의 형식은 읽었지만, 호출할 메서드를 표준 라이브러리에서 찾지 못한 것이다. source는 문법 수준을, target은 클래스 파일의 대상 버전을 정한다. 이 두 값만으로 컴파일 중 보이는 API가 Java 17의 것으로 제한되지는 않는다.

같은 코드를 release=17로 컴파일하자 이번에는 Maven이 종료 코드 1을 반환했고 cannot find symbol, method getFirst()를 보고했다. 클래스도 생성되지 않았다. 실행 단계의 실패를 컴파일 단계에서 발견한 셈이다. 이 예제는 목록이 비어 있지 않으므로 getFirst()를 get(0)으로 바꿔 다시 확인했다. release=17 빌드와 Java 17 실행이 모두 성공했고 alpha가 나왔다. 일반 코드에서 바꿀 때는 빈 목록 처리도 따로 확인해야 한다.

Java 17을 계속 지원해야 한다면 이런 API 사용을 고치고 release=17을 유지한다. Java 21의 API가 꼭 필요하다면 실행 환경도 Java 21로 올릴 수 있는지 결정해야 한다. 바이트코드 버전만 낮춰서는 없는 API가 생기지 않는다.

여기서 검증한 범위는 외부 의존성이 없는 클래스와 직접 호출한 표준 API다. release=17을 지정해도 이미 만들어진 의존 JAR의 클래스 버전까지 낮아지는 것은 아니다. 외부 의존성과 리플렉션 호출은 이번 실험에 포함하지 않았다. 전체 애플리케이션과 실제 배포 환경의 동작은 확인하지 않았으므로, 최종 산출물을 지원 대상 Java에서 실행하는 확인은 별도로 남는다.

← 전체 글 보기