빌드한 JDBC API가 운영에서 없다고 나왔습니다
Java 8과 Spring Boot 2.7 기반으로 현대화한 다운로드 애플리케이션은 빌드 시 신형 Oracle JDBC 드라이버를 사용했습니다. 하지만 외장 Tomcat 운영 환경에서는 MyBatis의 Java Time API 타입 변환 과정에서 AbstractMethodError가 발생했습니다.
컴파일이 끝났고 애플리케이션에도 신형 드라이버가 포함돼 있었기 때문에 처음에는 MyBatis 변환 처리나 배포 파일 누락을 의심하기 쉬웠습니다. 그러나 AbstractMethodError는 클래스 자체를 찾지 못한 것이 아니라, 실행 시 로딩된 클래스에 컴파일 시 존재하던 메서드 구현이 없다는 신호였습니다.
컴파일 의존성과 실행 의존성을 나눠 확인했습니다
- ClassNotFoundException: 필요한 클래스를 찾지 못한 경우
- NoSuchMethodError: 실행 시 로딩된 클래스에 기대한 메서드 정의가 없는 경우
- AbstractMethodError: 호출 대상 구현체가 런타임이 기대하는 메서드를 구현하지 않은 경우
예외 종류를 기준으로 보면 소스 문법이나 SQL보다 컴파일 시 참조한 JDBC API와 운영 환경에서 실제로 로딩된 드라이버가 다른지를 먼저 확인해야 했습니다.
의존성 트리만으로 설명되지 않은 이유
Maven 의존성 트리와 WAR 내부에서는 신형 드라이버가 확인됐습니다. 하지만 Java 8의 JRE 확장 경로에 있는 라이브러리는 애플리케이션 라이브러리보다 상위 클래스 로더에서 먼저 로딩될 수 있습니다. 따라서 WAR 안의 드라이버 버전만 확인해서는 실제 실행 버전을 확정할 수 없었습니다.
운영 서버에는 JRE 확장 경로의 구형 드라이버가 있었고, 애플리케이션이 포함한 신형 드라이버보다 먼저 선택되고 있었습니다. 빌드는 신형 API를 기준으로 통과했지만 실제 호출은 구형 구현체에 전달된 것입니다.
실제 로딩 경로를 로그로 확인했습니다
Maven 설정과 WAR 목록은 무엇을 넣었는가만 보여 줍니다. 실행 중인 JVM이 실제로 어느 JAR를 골랐는지는 별개의 문제입니다. 그래서 -verbose:class 로그로 Oracle JDBC 클래스의 로딩 경로를 직접 확인했습니다.
-verbose:class
[Loaded JDBC implementation from server-wide extension path]설정 파일에 선언한 버전보다 로그에 찍힌 실제 로딩 경로가 원인을 보여 줬습니다. 이를 통해 MyBatis와 TypeHandler 자체의 결함, WAR 패키징 누락, DB Query 문제를 원인 후보에서 분리할 수 있었습니다.
공용 드라이버를 바로 제거할 수 없었던 이유
JRE 확장 경로의 공용 드라이버를 즉시 제거하면 같은 JVM 구성을 참조하는 다른 애플리케이션에 영향을 줄 수 있었습니다. 그래서 특정 애플리케이션의 의존성만 올리는 것과 서버 공용 라이브러리를 교체하는 것을 별개 변경으로 다뤘습니다.
영향도를 확인하는 과정에서 별도의 레거시 애플리케이션은 JDBC Driver를 자체 포함하지 않고 있었습니다. Maven 의존성 일부가 provided로 설정돼 있어 실행 시 서버 공용 Driver에 의존하는 구간이 있었습니다. 이 상태에서 공용 Driver부터 제거하면 최초 장애를 해결하는 대신 해당 애플리케이션의 기동이나 DB 연결을 깨뜨릴 수 있었습니다.
서버 공용 의존성과 애플리케이션 의존성 분리
공용 JAR부터 지우지 않고, 각 애플리케이션이 실행에 필요한 드라이버를 스스로 포함하도록 만드는 것부터 시작했습니다.
- 다운로드 애플리케이션의 WAR에 신형 Driver가 포함된 상태를 다시 확인했습니다.
- 별도 레거시 애플리케이션의 Maven 설정에서 JDBC Driver를 실행 패키지에 포함하지 않던
provided의존 구간을 확인했습니다. - 해당 애플리케이션도 신형 Driver를 자체 포함하도록 패키징을 보완했습니다.
- 같은 Java 환경을 사용하는 애플리케이션의 기동과 DB 연결 영향 범위를 확인했습니다.
- 애플리케이션 의존성이 독립된 뒤 JRE Extension 경로의 공용 구형 Driver를 정리했습니다.
이 순서의 목적은 모든 애플리케이션이 우연히 서버 전역에 설치된 구형 JAR를 공유하는 방식을 끊는 것이었습니다. 빌드 파일에 선언된 버전과 실제 실행 버전을 애플리케이션 단위로 맞추면 이후 서버 공용 라이브러리 변경의 영향 범위도 줄어듭니다.
조치 후 검증
파일을 교체한 사실만으로 해결됐다고 판단하지 않았습니다. 조치 전과 같은 방식으로 -verbose:class 로그를 다시 수집해 Oracle JDBC 클래스의 실제 출처가 서버 공용 구형 Driver에서 애플리케이션이 포함한 신형 Driver로 바뀌었는지 확인했습니다.
그다음 오류가 발생했던 Java Time API 타입 조회가 정상 처리되는지 검증했습니다. 공용 드라이버에 의존하던 다른 애플리케이션에 회귀가 없는지도 함께 확인했습니다.
- 다운로드 애플리케이션 기동과 DB 연결
- MyBatis의 Java Time API 타입 변환을 사용하는 조회
- 별도 레거시 애플리케이션의 기동과 DB 연결
- 클래스 로딩 로그의 실제 JDBC 드라이버 경로
이 검증으로 특정 화면의 예외가 사라지고 서버 공용 Driver와 각 애플리케이션의 JDBC 의존성이 의도한 대로 분리됐음을 확인했습니다.
이 원인이 설명하는 범위
이 사례의 원인은 Java 8 Extension ClassLoader가 상위 경로의 구형 Driver를 먼저 선택한 것이었습니다. 모든 AbstractMethodError가 JDBC 버전 충돌로 발생하는 것은 아닙니다. 같은 예외라도 인터페이스와 구현체의 컴파일·실행 버전, 애플리케이션 서버의 공용 라이브러리, 중복 JAR의 로딩 순서를 실제 로그로 확인해야 합니다.
또한 공용 라이브러리를 애플리케이션 패키지로 옮기는 결정은 배포 크기만의 문제가 아닙니다. 여러 애플리케이션이 어떤 런타임 의존성을 공유하고 있는지 확인하고, 각 애플리케이션을 독립적으로 기동·연결 검증할 수 있을 때 정리해야 합니다.
정리
의존성 파일의 버전은 컴파일할 때 본 API를 알려 줄 뿐, 운영 JVM이 같은 클래스를 고른다는 보장은 없습니다. -verbose:class로 구형 드라이버의 우선 로딩을 확인한 뒤, 공용 드라이버에 기대던 다른 애플리케이션부터 자체 패키징하도록 보완했습니다. 그 다음에야 서버 전역 의존성을 정리할 수 있었습니다. JAR 하나를 지우는 일보다 컴파일 환경과 실행 환경의 차이를 찾고 변경 순서를 세우는 일이 더 중요했습니다.