개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

기초개념 › Java

라이브러리와 의존성

라이브러리는 무엇이고, 없으면 어떻게 되는가

라이브러리는 남이 만들어 둔 클래스들입니다. 이 실습에서는 lib.Greeter 클래스 하나를 jar 파일(클래스 파일을 묶어 둔 파일) greet.jar에 담아 두었습니다. 내 코드 App.java는 import lib.Greeter;로 그 클래스를 가져다 쓰는데, Greeter는 App.java 안에 없습니다. 그래서 컴파일할 때도, 실행할 때도 컴파일러(javac)와 JVM(java)은 클래스패스(클래스 파일을 찾아볼 곳의 목록)에서 Greeter를 찾아야 하고, 명령에서는 -cp 뒤에 그 목록을 적습니다. 내 프로젝트가 쓰는 라이브러리를 의존성이라고 부르며, 빌드 도구 Gradle에서는 implementation으로 적은 의존성이 컴파일할 때와 실행할 때의 클래스패스에 모두 들어갑니다. 이 실습은 javac 명령과 java 명령에서 greet.jar를 하나씩 빼 가며 실제로 컴파일하고 실행해, 어느 단계에서 어떤 오류가 나는지를 봅니다. (Gradle은 이 실습에서 실행하지 않습니다.)

실행으로 대조 · JDK 21에서 네 가지 조합을 greet.jar를 만들어 javac와 java 명령 그대로 실행해 대조

이 실습에 나오는 개념

라이브러리와 jar 파일
라이브러리는 남이 만들어 둔 클래스들로, 내 코드가 import로 가져다 쓴다. 자바에서는 컴파일한 클래스 파일을 jar 파일 하나로 묶어 나누어 주는 일이 흔하다. 예를 들어 Greeter.java를 컴파일해 jar 도구로 묶으면 jar tf greet.jar가 lib/Greeter.class를 비롯한 안의 파일 목록을 보여 준다. 내 코드를 컴파일하거나 실행할 때 jar 안의 클래스가 필요하면, 그 jar를 클래스패스에 적어야 한다. jar가 내 소스와 같은 폴더에 놓여 있다는 것만으로는 찾아보는 곳에 들어가지 않는다.
클래스패스(-cp)
클래스패스는 컴파일러(javac)와 JVM(java)이 사용자 클래스 파일을 찾아볼 곳의 목록이다. 명령에서는 -cp(또는 --class-path, -classpath) 뒤에 적고, 목록의 한 곳은 디렉터리나 JAR 파일이며 Windows에서는 세미콜론(;)으로, 그 밖의 시스템에서는 콜론(:)으로 구분한다. -cp를 적지 않고 CLASSPATH 환경 변수도 없으면 javac와 java 모두 현재 폴더를 쓴다. javac와 java는 각각 자기 명령의 클래스패스를 따로 받으므로, 컴파일할 때 준 -cp가 실행할 때 저절로 이어지지 않는다. -cp greet.jar처럼 jar만 적으면 현재 폴더는 찾아보는 곳이 아니므로, 현재 폴더의 클래스 파일도 쓰려면 greet.jar;.처럼 점(.)을 함께 적어야 한다.
NoClassDefFoundError
NoClassDefFoundError는 JVM이나 클래스 로더가 클래스의 정의를 불러오려는데 그 정의를 찾을 수 없을 때 던져지는 오류이다. API 문서는 찾으려는 클래스의 정의가 지금 실행 중인 클래스를 컴파일할 때는 있었지만 이제는 찾을 수 없는 경우라고 설명한다. 라이브러리 jar를 컴파일할 때는 클래스패스에 넣고 실행할 때는 빼면 이 오류가 난다. 컴파일할 때 jar가 없어서 나는 "package lib does not exist" 같은 컴파일 오류와는 시점이 다르다. 컴파일 오류는 프로그램을 실행하기 전에 컴파일러가 알리고, 이 오류는 프로그램이 실행되는 도중에 그 클래스가 필요해진 곳에서 난다.
빌드 의존성과 implementation
의존성은 내 프로젝트가 쓰는 라이브러리이다. Gradle에서는 dependencies 블록 안에 group:name:version 좌표로 적는다. 그 가운데 implementation은 메인 소스를 컴파일할 때와 실행할 때 모두 필요한 의존성을 적는 자리이다. Gradle 문서에 따르면 implementation으로 적은 의존성은 컴파일에 쓰는 compileClasspath와 실행에 쓰는 runtimeClasspath에 모두 포함된다. 곧 손으로 javac와 java 명령에 -cp를 적던 일을 빌드 도구가 설정 한 줄로 대신한다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. 터미널에 javac -cp greet.jar App.java 명령을 입력하면 컴파일러(javac)가 App.java를 읽습니다. 소스 맨 위의 import lib.Greeter; 줄은 App.java 안에 없는 Greeter 클래스를 가져다 쓰겠다는 뜻입니다. Greeter는 남이 만들어 둔 클래스이고, greet.jar 안에 lib/Greeter.class라는 파일로 들어 있습니다.
  2. 컴파일러는 Greeter가 어떤 클래스인지 알아야 컴파일할 수 있으므로, 클래스패스에서 lib/Greeter.class를 찾습니다. 클래스패스는 클래스 파일을 찾아볼 곳의 목록이고, 명령의 -cp 뒤에 적습니다. 이 명령은 -cp greet.jar라고 적었으므로 찾아볼 곳은 greet.jar입니다.
  3. greet.jar 안에서 lib/Greeter.class를 찾았습니다. 컴파일러가 Greeter 클래스를 알게 되었으므로 Greeter.hello("kim") 줄도 통과합니다.
  4. 컴파일에 성공해 App.class가 만들어집니다. 폴더에 새로 생긴 클래스 파일은 App.class 하나뿐이고, Greeter.class는 복사되지 않았습니다. greet.jar의 내용이 App.class 안으로 들어온 것이 아닙니다.
  5. java -cp greet.jar;. App 명령을 입력하면 JVM(java)이 클래스패스에서 App 클래스를 찾아 main을 시작합니다. 클래스패스는 greet.jar;.입니다. 세미콜론(;)은 Windows에서 여러 곳을 구분하는 글자이고(macOS와 Linux는 :), 맨 뒤의 점(.)은 현재 폴더, 곧 App.class가 있는 곳입니다. 점을 빼고 -cp greet.jar만 적으면 현재 폴더를 찾아보지 않아 App을 찾지 못합니다.
  6. main 안의 System.out.println(Greeter.hello("kim")) 줄에서 Greeter.hello를 부르려면 Greeter 클래스가 필요합니다. JVM이 클래스패스에서 lib/Greeter.class를 찾습니다.
  7. greet.jar 안에서 lib/Greeter.class를 찾아 불러옵니다. hello("kim")이 hello, kim을 돌려주고, println이 그 글을 출력합니다.

실행 결과

코드

# App.java
// greet.jar 안에 있는, 남이 만든 클래스를 가져온다
import lib.Greeter;

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

# greet.jar (Greeter.java를 컴파일해 jar로 묶은 것)
package lib;

public class Greeter {
    public static String hello(String name) {
        return "hello, " + name;
    }
}

# 터미널
javac -cp greet.jar App.java
java -cp greet.jar;. App

이 모형의 전제

컴파일러와 실행 환경은 JDK 21.0.12.1(javac 21, java 21)입니다. 네 가지 조합을 모두 대조했습니다. 먼저 lib.Greeter 클래스(hello 메서드 하나)를 javac와 jar 도구로 greet.jar에 묶었고(외부 jar는 받지 않았습니다), 조합마다 새 폴더에 greet.jar와 App.java를 놓고 화면에 적힌 터미널 명령 javac와 java를 그대로 실행했습니다. 화면의 코드는 Probe가 실제 파일 내용과 실제 실행한 명령으로 만들어 남긴 글과 같습니다. 대조한 것은 컴파일 성공 여부, 오류의 개수와 진단 코드(compiler.err.doesnt.exist와 compiler.err.cant.resolve.location를 화면의 두 가지 오류 설명으로 옮겼습니다)와 오류가 난 줄, App.class가 만들어졌는지, java 명령의 종료 코드와 출력, 실행 오류의 종류(NoClassDefFoundError)와 그 위치(App.java:6)입니다. 컴파일에 실패한 조합에서도 java 명령을 실제로 실행해, App 클래스를 찾을 수 없다는 오류가 나는 것을 확인했습니다. 단계 문장이 기대는 사실도 따로 실행해 확인했습니다. greet.jar에는 lib/Greeter.class가 들어 있다는 것(jar tf), 컴파일에 성공한 폴더에 생긴 클래스 파일은 App.class 하나뿐이라는 것, java 명령에서 -cp greet.jar만 적고 점(.)을 빼면 현재 폴더의 App.class를 찾지 못한다는 것, 컴파일 전 폴더에는 App.java와 greet.jar만 있었다는 것입니다. 명령은 Windows 11에서 실행했고, 클래스패스의 구분자 세미콜론(;)은 Windows 기준입니다(macOS와 Linux는 콜론). 화면에는 한 가지로만 적었습니다. CLASSPATH 환경 변수는 실행하는 프로세스에서 지웠고, 이 변수가 설정된 환경에서는 -cp를 적지 않은 명령의 결과가 달라질 수 있습니다. java 실행기의 오류 문장은 이 PC의 언어(한국어)로 나왔고, 다른 언어 설정에서는 문장이 다릅니다. 문서는 직접 열어 확인했습니다. Oracle의 javac 도구 문서(https://docs.oracle.com/en/java/javase/21/docs/specs/man/javac.html)에서 --class-path(-cp)가 사용자 클래스 파일을 찾을 곳을 정하고, -cp도 CLASSPATH 환경 변수도 없으면 현재 폴더가 쓰이며, 구분자가 Windows에서 세미콜론이라는 것을, java 도구 문서(https://docs.oracle.com/en/java/javase/21/docs/specs/man/java.html, 앞부분 10만 자만 읽었고 -cp 설명은 그 안에 있었습니다)에서 -cp가 클래스 파일을 찾을 디렉터리, JAR 파일, ZIP 파일의 목록이고 지정하지 않으면 현재 폴더(.)라는 것을, NoClassDefFoundError API 문서(https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/NoClassDefFoundError.html)에서 클래스를 불러오려는데 정의를 찾을 수 없을 때 던져지며 컴파일할 때는 있었던 정의를 지금은 찾을 수 없는 경우라는 것을, Gradle 문서의 Declaring dependencies(https://docs.gradle.org/current/userguide/declaring_dependencies_basics.html)에서 모듈 의존성을 group:name:version 좌표로 적는다는 것과 dependencies 안에 implementation으로 적는 예를, Java Plugin 문서(https://docs.gradle.org/current/userguide/java_plugin.html)의 설정 표에서 implementation이 컴파일할 때와 실행할 때 모두 필요한 의존성이고 compileClasspath와 runtimeClasspath에 포함된다는 것을 따랐습니다. Gradle은 실행하지 않았고, 저장소에서 jar를 내려받는 과정은 문서로도 확인하지 못해 적지 않았습니다. 오류의 개수와 종류, 오류 문장은 명세가 정한 것이 아니라 이 JDK에서 관찰한 것입니다. 이 밖에 라이브러리가 다시 다른 라이브러리를 끌어오는 것(전이 의존성), 버전, 클래스패스 여러 곳에 같은 클래스가 있을 때의 순서, 모듈 시스템은 다루지 않았고, 의존성과 공급망은 "세부 동작 실습: 의존성과 공급망"에서, 의존성을 보고 설정을 자동으로 만들어 주는 스프링 부트는 "세부 동작 실습: 스프링 부트 자동 설정"에서 다룹니다.

앞 실습: 파일 입출력 · 다음 실습: 빈

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요