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