System.out.println()을 호출하면 왜 NullPointerException이 발생하지 않을까?

민씨·2023년 11월 17일
0
post-custom-banner

coffe

개요

java.lang 패키지의 System 클래스에 PrintStream 타입의 out 필드가 static final 키워드로 선언되어 있습니다.

초기 의문: out 필드가 null인데 왜 예외가 발생하지 않을까?

이 필드의 흥미로운 점은 null 로 초기화 되어있다는 것입니다.

저는 이에 궁금증이 생겼습니다. null 인 객체가 println() 메서드를 호출하면 보통 NullPointerException 이 발생하지 않나요?

System.out.println() 은 예외를 발생시키지 않을까요?

코드 분석

자바의 클래스와 static 키워드를 떠올리며 System.out.println() 메서드를 분석해 보겠습니다.

  • System 클래스의 객체 생성 없이 out 필드를 호출 하였다.
  • 따라서 out 필드에는 static 키워드가 붙어있을 것이다.
  • out 필드를 이용하여 println() 메서드를 호출하였다.

실제로 맞는지 OpenJDK 깃허브 에서 System 클래스 코드를 확인해 볼까요?

// System.java

public final class System {
    public static final PrintStream out = null;
}

제 생각이 맞았습니다. 실제로 static 키워드와 out 필드가 존재합니다.

추가로 PrintStream 클래스가 새롭게 등장하였으니 같이 살펴봅시다.

// PrintStream.java

public class PrintStream extends FilterOutputStream implements Appendable, Closeable {
    public void println() {
        newLine();
}

아하! println() 메서드는 PrintStream 클래스에서 구현한 것이였군요.

실제 코드를 살펴보며 얻은 정보는 다음과 같습니다.

  • out 필드는 static 키워드 외에 final 키워드도 같이 선언되어 있어 상수이다.
  • PrintStream 클래스에 println() 메서드가 정의되어 있다.

예외를 발생시켜보자

다음과 같이 MySystem 클래스와 MyPrintStream 클래스를 생성하였습니다.

// MySystem.java

public class MySystem {
    public static final MyPrintStream out = null;
}
// MyPrintStream.java

public class MyPrintStream {
    public void println() {
        // 구현
    }

}

MySystem 클래스는 System 클래스와 유사한 형태를 갖추고 있으며, 호출 방식 또한 System.out.println() 처럼 사용할 수 있습니다.

Client 클래스를 생성하여 테스트 해볼까요?

// Client.java

public class Client {
    public static void main(String[] args) {
        MySystem.out.println();
    }
}

이 코드를 실행하면 NullPointerException 이 발생합니다.

Exception in thread "main" java.lang.NullPointerException

기대한 대로, null 객체가 메서드를 호출했기 때문에 당연한 결과입니다.

하지만 System 클래스의 out 필드는 null 로 초기화 되었음에도, 왜 System.out.println() 메서드 호출 시 NullPointerException이 발생하지 않을까요?

할당된 final 변수를 재할당 하면 컴파일 에러가 난다

out 필드의 초기화를 static 블록에서 진행하도록 MyPrintStream 클래스를 다음과 같이 수정해보겠습니다.

// MyStream.java

public class MySystem {
    
    static {
        out = new MyPrintStream();
    }
    
    public static final MyPrintStream out;
}

메인 메서드를 실행해보면 정상적으로 실행됩니다.

// Client.java

public class Client {
    public static void main(String[] args) {
        MySystem.out.println();
    }
}

하지만 System 클래스를 다시 살펴보면 out 은 분명히 null 로 초기화 되어있습니다.

// System.java

public final class System {
    public static final PrintStream out = null;
}

그렇다면 MyPrintStreamout 또한 null 로 변경해보겠습니다.

// MySystem.java

public class MySystem {

    static {
        out = new MyPrintStream();
    }

    public static final MyPrintStream out = null;
}

이 코드는 컴파일 에러를 일으킵니다.

java: cannot assign a value to final variable out

final 키워드를 이용하여 상수값을 선언하였기에, 새로운 값의 할당이 불가능 하므로 당연한 결과입니다.

System 클래스의 out 필드는 null 로 초기화 되었고 final 키워드로 static 영역에서 값을 재할당 받지도 못하는데, 어떻게 println() 메서드를 호출할 수 있을까요?

static 블록을 확인해볼까요?

static 필드 초기화

static 필드를 초기화 하는 방법은 두 가지 방법 밖에 없습니다.

  • = 을 이용한 직접 초기화

     public class MyClass {
        public static final int MY_CONSTANT = 42;
    }
  • static 블록을 이용한 초기화

    public class MyClass {
        public static final int MY_CONSTANT;
    
        static {
            MY_CONSTANT = 42;
        }
    }

out 필드의 static 블록

System 클래스의 out 필드는 이미 = 를 이용하여 null 로 초기화되어 있습니다.

// System.java

public final class System {
    public static final PrintStream out = null;
}

그렇다면, static 블록에는 어떠한 코드가 있는지 확인해봅시다.

// System.java

public final class System {

    private static native void registerNatives();
    
    static {
        registerNatives();
    }
}

살펴보니 native 키워드가 붙은 registerNatives() 메서드를 실행하고 있습니다.

자바에서 친절하게 작성해준 주석을 살펴볼까요?

Register the natives via the static initializer.

정적 초기화 프로그램을 통해 네이티브를 등록합니다.

잘은 모르겠지만 주석과 메서드명으로 추측해 보면 registerNatives() 메서드는 네이티브 메서드들을 등록하는 역할을 하는 것 같습니다.

📚 네이티브 메서드(Native Method) ?

  • 자바 언어로 작성된 코드가 아닌, 특정 플랫폼의 네이티브 코드로 작성된 메소드
  • 일반적으로 C, C++ 등의 언어로 작성된 코드를 의미한다.

복잡하니 정리해봅시다.

  • System 클래스의 static 블록에서 registerNatives() 메서드가 호출된다.
  • registerNatives() 메서드는 네이티브 메서드 들을 등록한다.
  • 네이티브 메서드 는 자바로 작성된 코드가 아니고 C, C++ 등으로 작성된 코드다.

자바의 비밀스러운 네이티브 메서드 호출

System.out.println() 메서드와 NullPointerException 에 대한 의문을 해소하기 위해 자바의 내부 동작 원리를 간단하게 알아봅시다.

클래스 로더와 static 블록

자바 가상 머신(JVM)의 내부에는 클래스 로더(Class Loader)라는 것이 있습니다. 클래스가 처음 참조되거나 호출되는 시점에 해당하는 클래스 파일을 찾아 메모리에 로드하는 역할을 수행하는데요.

이 로드 과정은 로딩, 링크, 초기화의 세 단계로 이뤄지며, 특히 초기화 단계에서 static 블록에 있는 코드들이 실행됩니다.

갑자기 뭔가 어려워진 것 같으니, '클래스 로더라는 놈이 클래스를 메모리에 올릴 때 static 블록이 실행 되는구나' 정도로 정리합시다.

자동으로 로드되는 클래스

자바 프로그램이 실행될 때는 몇 가지 핵심 클래스들이 자동으로 로드됩니다. 몇 가지 살펴보면 다음과 같습니다.

  • java.lang.Object
  • java.lang.System
  • java.lang.String
  • .. 그 외 수많은 클래스

java.lang.SystemSystem 클래스는 자바 프로그램이 실행될 때 자동으로 메모리에 로드되고, static 블록이 실행됩니다. 따라서 registerNatives() 메서드가 실행되어 네이티브 메서드가 등록되는 것이죠. (아쉽게도 해당 메서드는 자바로 구현되어 있지 않아 확인은 불가합니다.)

또한 이렇게 등록된 네이티브 메서드는 자바 코드에서 사용자가 직접 호출하지 않고, 내부적으로 JVM이 필요에 따라 호출하게 됩니다.

정리해봅시다

위 내용을 전부 정리해보겠습니다.

  • 자바 프로그램이 실행될 때 System 클래스가 자동으로 로드된다.
  • static 블록의 registerNatives() 메서드가 실행된다.
  • registerNatives() 메서드가 네이티브 메서드들을 등록한다.
  • 등록된 네이티브 메서드는 자바 코드에서 직접 호출되지 않고, 내부적으로 JVM이 필요에 따라 네이티브 메서드를 호출한다.
  • System.out을 호출할 때, 네이티브 메서드가 내부적으로 실행된다.
  • out 필드는 네이티브 메서드를 통해 PrintStream 객체를 할당받는다.

드디어 의문이 풀렸습니다!

out 필드는 null 로 선언되어 있지만 네이티브 메서드를 이용하여 PrintStream 객체를 특별한 방법으로 할당받기 때문에 NullPointerException 이 발생하지 않았던 것입니다.

기타

static final 변수는 완벽한 불변이 아니다

저도 처음에 정말 이해가 가지 않았던 부분입니다. 불변의 객체가 재할당이 된다니, 말이 안되는 소리라고 생각했으니까요.

하지만, static final은 일반적인 자바 코드 수준에서 재할당이 불가능하지만, 네이티브 메서드를 통해 특별한 방식으로는 가능합니다.

다시 말해, 어떤 상황에서든 완벽하게 불변하는 상수가 아니었던 것입니다.

static final PrintStream out = null

굳이 왜 null 로 초기화 하여 사람을 헷갈리게 했을까요?

저는 자바에서 "이 필드는 특수한 방법으로 초기화 할거야!" 라고 나타낸 부분이라고 이해했습니다.

개인적으로는 조금 더 자세한 주석이 있다면 좋겠다고 생각했는데, 언급이 없어서 아쉽습니다.

마무리

처음에는 out 필드가 null 로 초기화 되어 있음에도 불구하고 왜 NullPointerException 이 발생하지 않는지 의아했습니다.

이 궁금증을 해소하기 위한 과정에서 자바의 내부 동작 원리 및 네이티브 메서드 호출이라는 새로운 지식, static final 변수의 초기화 과정과 완벽한 불변의 상수가 아니라는 흥미로운 내용을 알게 됐습니다.

이 글이 저와 같은 궁금증을 가진 다른 이들에게 도움이 되기를 바랍니다.

감사합니다.

profile
進取
post-custom-banner

0개의 댓글