소스, 바이트 코드, 디버깅

다른 언어: English Español 日本語 Português 中文

Java 프로그램을 디버깅할 때 개발자들은 종종 소스 코드와 직접 상호작용하고 있는 것처럼 인식합니다. 이것은 놀라운 일이 아닙니다 - Java의 툴링은 복잡성을 숨기는 데 있어 뛰어난 작업을 수행하기 때문에 실행시간에 소스 코드가 존재하는 것처럼 느껴집니다.

Java를 처음 시작하는 경우, 컴파일러가 소스 코드를 바이트 코드로 변환하는 방법을 보여주는 다이어그램을 기억할 것입니다. 그럼 그렇다면, 왜 우리는 바이트 코드가 아닌 소스 코드를 검사하고 걸어가는 것일까요? JVM이 우리의 소스에 대해 어떻게 알 수 있는 건가요?

이 글은 디버깅에 관한 이전의 포스트와는 조금 다릅니다. 특정 문제, 예를 들어 응답하지 않는 앱 또는 메모리 누수를 디버그하는 방법에 초점을 맞추는 대신, 이 문서에서는 자바와 디버거가 뒷편에서 어떻게 작동하는지에 대해 살펴봅니다. 지나가는 몇 가지 유용한 팁이 포함되어 있으니 계속 머물러 있어 주세요.

바이트 코드

빠르게 다시 한번 요약해봅시다. Java 책과 가이드에 나온 다이어그램은 실제로 정확합니다 - JVM은 바이트 코드를 실행합니다.

다음과 같은 클래스를 예로 들어봅시다:

package dev.flounder;

public class Calculator {
    int sum(int a, int b) {
        return a + b;
    }
}

이것이 컴파일되면, sum() 메소드는 다음과 같은 바이트 코드로 변환됩니다:

int sum(int, int);
    descriptor: (II)I
    flags: (0x0000)
    Code:
      stack=2, locals=3, args_size=3
         0: iload_1
         1: iload_2
         2: iadd
         3: ireturn
Tip icon

JDK에 포함된 javap -v 명령을 사용하여 클래스의 바이트 코드를 검사할 수 있습니다. IntelliJ IDEA를 사용하고 있다면 이것을 IDE에서도 할 수 있습니다: 프로젝트를 빌드 한 후, 클래스를 선택한 다음 보기 (View) | 바이트코드 표시 (Show Bytecode)를 클릭합니다.

Info icon

클래스 파일은 바이너리 형식이기 때문에, 이들의 원시 내용을 인용하는 것은 도움이 되지 않습니다. 가독성을 위해, 이 글의 예제들은 javap -v 출력의 형식을 따릅니다.

바이트 코드는 일련의 간결한 플랫폼 독립적인 명령으로 구성됩니다. 위의 예에서:

  1. iload_1iload_2는 변수를 오퍼랜드 스택으로 로드하는 것을 담당합니다.
  2. iadd는 오퍼랜드 스택의 내용을 더하고, 이를 결과값으로 남겨놓습니다.
  3. ireturn은 오퍼랜드 스택에 있는 값을 반환합니다.

명령 외에도, 바이트 코드 파일에는 상수, 매개변수의 수, 로컬 변수, 오퍼랜드 스택의 깊이에 대한 정보도 포함합니다. 이것은 JVM 언어로 쓰여진 프로그램을 실행하는 데 JVM이 필요로 하는 모든 정보입니다. 예를 들어 Java, Kotlin, Scala 등입니다.

디버그 정보

바이트 코드가 원본 소스 코드와는 완전히 다른 것처럼 보이기 때문에, 디버깅하는 동안 이를 참조하는 것은 비효율적일 것입니다. 이 때문에 Java 디버거의 인터페이스는 JDK에 포함된 JDB(콘솔 디버거)나 IntelliJ IDEA의 디버거 등등 - 바이트 코드 대신 소스 코드를 표시합니다. 이를 통해 어떤 바이트 코드가 실행되고 있는지 생각하지 않고도 작성한 코드를 디버그할 수 있습니다.

예를 들어, JDB와의 상호작용은 다음과 같이 진행됩니다:

Initializing jdb ...

> stop at dev.flounder.Calculator:5

Deferring breakpoint dev.flounder.Calculator:5.
It will be set after the class is loaded.

> run

run dev/flounder/Main
Set uncaught java.lang.Throwable
Set deferred uncaught java.lang.Throwable
VM Started: Set deferred breakpoint dev.flounder.Calculator:5
Breakpoint hit: "thread=main", dev.flounder.Calculator.sum(), line=5 bci=0

> locals

Method arguments:
a = 1
b = 2

IntelliJ IDEA는 에디터에서 디버그 관련 정보를 표시하고, 디버그 (Debug) 도구 창에서도 표시합니다:

IntelliJ IDEA는 현재 실행 중인 줄을 강조 표시하고 사용 가능한 변수에 대한 정보를 제공합니다 IntelliJ IDEA는 현재 실행 중인 줄을 강조 표시하고 사용 가능한 변수에 대한 정보를 제공합니다

보다시피, 두 디버거 모두 정확한 변수 이름을 사용하고 우리의 위의 코드 스니펫에서 유효한 라인을 참조합니다.

런타임이 소스 파일에 대한 액세스 권한이 없기 때문에, 이 데이터는 다른 곳에서 수집해야 합니다. 이 때 디버그 정보가 등장합니다. 디버그 정보(디버그 심볼이라고도 함)는 바이트 코드를 응용 프로그램의 소스에 연결하는 간결한 데이터입니다. 이것은 컴파일 도중 .class 파일에 포함됩니다.

세 가지 유형의 디버그 정보가 있습니다:

다음 장에서는 각 유형의 디버그 정보와 디버거가 어떻게 이를 사용하는지 간략하게 설명하겠습니다.

라인 번호

라인 번호 정보는 바이트 코드 파일 내의 LineNumberTable 속성에 저장되어 있으며, 다음과 같이 생겼습니다:

LineNumberTable:
line 5: 0
line 6: 2

위의 표는 디버거에게 다음을 알려줍니다:

라인 5는 오프셋 0에 있는 명령어를 포함합니다. 라인 6는 오프셋 2에 있는 명령어를 포함합니다. 이러한 디버그 정보 유형은 디버거나 프로파일러 같은 외부 도구가 소스 코드에서 프로그램이 정확히 어느 라인에서 실행되는지 추적하는 데 도움이 됩니다.

중요하게도, 라인 번호 정보는 예외 스택 추적의 소스 참조에도 사용됩니다. 다음 예에서는, 다른 튜토리얼에서 나온 코드를 라인 번호 정보와 함께 컴파일하고 그렇지 않은 상태로 컴파일했습니다. 아래는 결과 실행 파일이 생성해낸 스택 추적입니다:


Exception in thread "main" java.lang.NumberFormatException: For input string: ""
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:672)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at dev.flounder.Airports.parse(Airports.java:53)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
	at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at dev.flounder.Airports.main(Airports.java:39)
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:672)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at dev.flounder.Airports.parse(Airports.java)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
	at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at dev.flounder.Airports.main(Airports.java)

라인 번호 정보 없이 컴파일된 실행 파일은 프로젝트 코드에 대한 호출을 위해 라인 번호가 없는 스택 트레이스를 생성했습니다. 표준 라이브러리와 의존성에서의 호출은 별도로 컴파일되어서 영향을 받지 않았기 때문에 여전히 라인 번호를 포함합니다.

스택 트레이스 외에도 라인 번호가 관련된 비슷한 상황을 마주칠 수 있습니다. 예를 들면, IntelliJ IDEA의 프레임 (Frames) 탭에서 말이죠:

IntelliJ IDEA 디버거에서 줄 번호가 없는 프레임 IntelliJ IDEA 디버거에서 줄 번호가 없는 프레임

따라서, 실제 라인 번호 대신 -1을 보고 싫다면, 프로그램이 라인 번호 정보와 함께 컴파일되도록 확인하세요.

Crazy icon

IntelliJ IDEA의 프레임 (Frames) 탭에서 바로 바이트 코드 오프셋을 볼 수 있습니다. 이를 위해서는 다음과 같은 레지스트리 키를 추가하세요: debugger.stack.frame.show.code.index=true

IntelliJ IDEA는 'Frames' 탭에서 행 번호 옆에 바이트코드 오프셋을 표시합니다 IntelliJ IDEA는 'Frames' 탭에서 행 번호 옆에 바이트코드 오프셋을 표시합니다

변수 이름

라인 번호 정보처럼, 변수 이름은 클래스 파일에 저장됩니다. 우리의 예제에 대한 변수 테이블은 다음과 같습니다:

LocalVariableTable:
Start  Length  Slot  Name   Signature
    0       4     0  this   Ldev/flounder/Calculator;
    0       4     1     a   I
    0       4     2     b   I

테이블에는 다음 정보가 포함됩니다:

변수가 디버그 정보에서 누락되면, 일부 디버거 기능이 예상대로 작동하지 않을 수 있으며, 실제 변수 이름 대신 slot_1, slot_2 등을 볼 것입니다.

IntelliJ IDEA는 디버거에서 변수 이름 대신 'slot_1', 'slot_2' 등을 표시합니다 IntelliJ IDEA는 디버거에서 변수 이름 대신 'slot_1', 'slot_2' 등을 표시합니다

소스 파일 이름

이 디버그 정보 유형은 클래스를 컴파일하는 데 사용된 소스 파일이 무엇인지를 나타냅니다.

라인 번호 정보처럼, 이것이 클래스 파일에 존재하면 외부 도구 뿐 아니라 프로그램이 생성하는 스택 추적에도 영향을 줍니다.


Exception in thread "main" java.lang.NumberFormatException: For input string: ""
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:672)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at dev.flounder.Airports.parse(Airports.java:53)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
	at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at dev.flounder.Airports.main(Airports.java:39)
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:672)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at dev.flounder.Airports.parse(Unknown Source)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
	at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at dev.flounder.Airports.main(Unknown Source)

소스 파일 이름이 없으면, 해당 스택 추적 호출은 Unknown Source로 표시될 것입니다.

컴파일러 플래그

개발자로서, 디버그 정보를 실행 파일에 포함시킬지 여부와 어떤 유형을 포함시킬지를 결정할 수 있습니다. 이것은 -g 컴파일러 인수를 사용하여 관리할 수 있습니다. 다음과 같이 말이죠:

javac -g:lines,vars,source

문법은 다음과 같습니다:

명령어결과
javac라인 번호와 소스 파일 이름을 포함해서 애플리케이션을 컴파일합니다 (대부분의 컴파일러의 기본값)
javac -g

모든 사용 가능한 디버그 정보를 포함해 애플리케이션을 컴파일합니다: 라인 번호, 변수, 소스 파일 이름

javac -g:lines,source

지정된 유형의 디버그 정보를 포함해서 애플리케이션을 컴파일합니다 - 이 예제에서는 라인 번호와 소스 파일 이름

javac -g:none디버그 정보 없이 애플리케이션을 컴파일합니다
Info icon

기본값은 컴파일러에 따라 다를 수 있습니다. 그 중 일부는 따로 지시하지 않으면 디버그 정보를 완전히 제외합니다.

Maven이나 Gradle과 같은 빌드 시스템을 사용하고 있다면, 컴파일러 인수를 통해 같은 옵션을 전달할 수 있습니다. 예를 들면:


<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.11.0</version>
    <configuration>
        <compilerArgs>
            <arg>-g:vars,lines</arg>
        </compilerArgs>
    </configuration>
</plugin>
tasks.compileJava {
    options.compilerArgs.add("-g:vars,lines")
}

디버그 정보를 삭제하는 이유는?

우리는 이제 막 이해하게 된 바와 같이, 디버그 심볼은 개발 중에 유용한 디버깅 프로세스를 가능하게 합니다. 따라서 디버그 심볼들은 대개 개발용 빌드에 포함됩니다. 프로덕션 빌드에서는 종종 제외되며, 하지만 이는 결국 작업 중인 프로젝트의 유형에 따라 달라집니다.

다음은 고려해볼만한 몇 가지 사항입니다:

보안

디버거가 프로그램을 조작하는 데 사용될 수 있으므로, 디버그 정보를 포함하면 당신의 애플리케이션은 해킹 및 역공학에 약간 더 취약하게 만드는데, 이는 일부 애플리케이션에서는 원치 않는 결과일 수 있습니다.

디버그 심볼이 없는 경우 프로그램을 디버거를 사용하여 방해하는 것을 다소 어렵게 만들 수 있지만, 이것은 프로그램을 완전히 보호하지는 않습니다. 부분적인 또는 누락된 디버그 정보를 가지고도 디버깅이 가능하므로, 이것만으로는 당신의 프로그램의 내부에 접근하는 데 결단력을 가진 개인을 막지 못합니다. 따라서 역공학의 위험에 대해 우려한다면, 소스 코드 난독화와 같은 추가적인 조치를 취해야 합니다.

실행 파일 크기

실행 파일이 포함하는 정보가 많을수록 파일 크기는 커집니다. 정확히 얼마나 커지는지는 여러 가지 요인에 따라 다릅니다. 특정 클래스 파일의 크기는 명령어의 수와 상수 풀의 크기에 의해 지배될 수 있으므로, 범용적인 추정치를 제공하는 것은 실제적이지 않습니다. 그래도 차이가 상당히 클 수 있다는 것을 보여주기 위해, 저는 이전에 스택 트레이스를 비교하던 중 사용한

Airports.java로 실험해 보았습니다. 결과는 디버그 정보 없이 4,460 바이트, 디버그 정보와 함께 5,664 바이트였습니다. 대부분의 경우, 디버그 심볼을 포함해도 상관 없습니다. 하지만 실행파일 크기가 문제가 된다면, 일반적으로 임베디드 시스템에서 그렇게 되는 경우, 바이너리에서 디버그 심볼을 제외하려고 할 수 있습니다.

디버깅을 위한 소스 추가

일반적으로 이루어지는 디버깅에서 필요한 소스들은 프로젝트 내에 있으므로, IDE가 찾는 데 어려움이 없습니다. 그러나 소스 코드가 프로젝트 외부에 있어야 하는 경우, 즉, 코드가 사용하는 라이브러리를 디버깅하려고 하는 경우에는 소스 파일을 수동으로 추가해야 합니다: 이를 sources root로 놓거나 의존 관계로 지정하는 것입니다. 디버깅 중에는 IntelliJ IDEA가 이러한 파일을 자동으로 감지하고, JVM으로 실행되는 클래스와 매치합니다.

프로젝트가 없는 경우

대부분의 경우, 원래의 프로젝트를 사용하여 같은 IDE에서 애플리케이션을 빌드, 실행, 디버깅하게 됩니다. 그러나 소스 파일만 몇 개 있고 프로젝트 자체가 없다면 어떻게 될까요?

다음은 이를 위한 기초적인 디버깅 설정입니다:

빈 자바 프로젝트를 생성합니다.

  1. 소스파일을 sources root 아래에 추가하거나 이를 의존 관계로 지정합니다.
  2. 디버그 에이전트로 대상 애플리케이션을 실행합니다. 자바에서는 일반적으로 VM 옵션을 추가함으로써 이를 수행합니다, 예를 들면:
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
  1. 올바른 연결 세부 사항이 있는 Remote JVM Debug 런 설정을 생성합니다. 이 런 설정을 사용하여 디버거를 대상 애플리케이션에 연결합니다.
  2. 이 설정을 사용하면, 원래의 프로젝트에 접근하지 않고도 프로그램을 디버깅할 수 있습니다.

IntelliJ IDEA는 이용 가능한 소스를 런타임 클래스와 매치시키고, 디버깅 세션에서 이를 사용할 수 있게 합니다. 이런 식으로, 심지어 한 개의 프로젝트 또는 라이브러리 클래스만으로도 디버깅을 시작할 수 있는 진입 점을 얻을 수 있습니다.

소스 불일치

디버깅 중에 당신이 혼란스러운 상황을 접하게 될 수 있는 경우 중 하나는 당신의 애플리케이션이 빈 행에서 일시 중지된 것처럼 보이거나 프레임 (Frames) 탭의 행 번호가 편집기의 행 번호와 일치하지 않는 경우입니다:

IntelliJ IDEA가 빈 줄을 실행된 것처럼 강조 표시합니다 IntelliJ IDEA가 빈 줄을 실행된 것처럼 강조 표시합니다

이런 문제는 디컴파일된 코드를 디버깅하고 있을 때 (우리는 이에 대해 다른 기사에서 논의할 것입니다) 나타나거나, JVM이 실행하는 바이트 코드와 소스 코드가 완전히 일치하지 않을 때 발생합니다.

바이트 코드와 특정 소스 파일 간의 유일한 연결은 파일의 이름과 그것의 클래스이므로, 디버거는 이 정보에 의지해야 하며, 이는 어떤 추론에 의해 보조됩니다. 이는 대부분의 상황에 대해 잘 작동하지만, 디스크 상의 파일 버전은 애플리케이션을 컴파일하는 데 사용된 것과 다를 수 있습니다. 부분적인 일치의 경우, 디버거는 이러한 불일치를 인식하고 이를 해결하려고 시도하며, 빠르게 실패하는 것이 아닙니다. 차이가 얼마나 큰지에 따라 이것이 유용할 수 있습니다, 예를 들면, 가지고 있는 유일한 소스 코드가 가장 가깝게 일치하는 것이 아닌 경우와 같습니다.

면백한 시나리오에서 정확한 버전의 소스가 다른 곳에 있다면, 이 문제를 추가함으로써 해결하고 디버그 세션을 다시 실행할 수 있습니다.

결론

이 글에서는 소스 파일, 바이트 코드, 디버거 사이의 연결에 대해 알아봤습니다. 일상적인 코딩 작업에는 필수적이지 않지만, 밑바닥에서 일어나는 일에 대한 더 명확한 이해는 당신에게 에코 시스템에 대한 더 확고한 이해를 제공하고, 비표준 상황이나 구성 문제에 처했을 때 가끔 도움을 줄 수 있습니다. 이론과 팁이 유용했다면 좋겠습니다!

이 시리즈에서는 아직 다루지 않은 많은 주제들이 남아 있으니, 다음 글을 기대해 주세요. 다루고 싶다 고 생각하는 특정 주제나 아이디어, 피드백이 있다면, 저에게 말해 주셨으면 좋겠습니다!

all posts ->