Skip to main content

Command Palette

Search for a command to run...

Java에서 final이 붙으면 불변일까?

Refrence Type에서의 final과 Primitive Type에서의 final

Updated
•5 min read•View as Markdown
Java에서 final이 붙으면 불변일까?

💡정말 final 변수는 불변할까?

Once a final variable has been assigned, it always contains the same value.

JLS11을 참고해 보면 final 변수는 값이 한번 할당되면, 항상 같은 값을 유지한다고 합니다. 그런데 제가 알기로 리플렉션을 이용하면 final을 사용하더라도 "재할당"이 가능하다고 알고 있었는데, 틀렸던 걸까요? 다음 코드를 한번 살펴봅시다.

public class Main {

    private static final Integer value = 10;

    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
        System.out.println(value); // [1]
        setFinalStatic(Main.class.getDeclaredField("value"), 20);
        System.out.println(value); // [2]
    }

    private static void setFinalStatic(Field field, Object newValue) throws NoSuchFieldException, IllegalAccessException {
        field.setAccessible(true);

        // Field 클래스의 인스턴스 필드 modifiers를 가져옵니다.
        Field modifiersField = Field.class.getDeclaredField("modifiers");
        modifiersField.setAccessible(true);
        // field.getModifiers()에서 FINAL 비트를 해제합니다.
        modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);

        field.set(null, newValue);
    }
}

인라인으로 final 키워드를 붙여준 Integer형 value는 10으로 초기화 되어있습니다. final 키워드를 붙여서 값이 항상 불변하다고 한다면, 리플렉션을 사용하더라도 변하지 않아야겠죠.

그렇다면 main() 메서드의 [1], [2]에서 출력하는 value는 각각 무엇일까요?

Java11에서는 금지된 리플렉션 접근이라는 경고를 띄우긴 하지만, 런타임에 10, 20을 출력해주네요. 값이 바뀐 것을 확인할 수 있습니다. 내부에서는 다음과 같이 동작합니다.

  1. 원시 타입 10, 20은 Integer.valueOf()를 통해 박싱됩니다. -128~127 범위는 IntegerCache에 미리 만들어둔 인스턴스를 그대로 꺼내 씁니다.

  2. [1]에서 value는 new Integer(10)을 참조하고 있으므로 10을 출력합니다.

  3. 리플렉션이 Integer 타입인 value가 new Integer(20)을 참조하도록 변경합니다.

  4. 결과적으로 [2]에서 value는 new Integer(20)을 참조하므로 20을 출력합니다.

실제로 주소를 비교를 해보면, 참조 값이 변경된 것을 알 수 있습니다.

Object o = value;
System.out.println(System.identityHashCode(o)); // 985922955
System.out.println(System.identityHashCode(value)); // 985922955
System.out.println(o == value); // true
setFinalStatic(Main.class.getDeclaredField("value"), 20);
System.out.println(System.identityHashCode(o)); // 985922955
System.out.println(System.identityHashCode(value)); // 873415566
System.out.println(o == value); // false

fyi. Java12부터는 아래 예외가 발생하기 때문에, 조금 다른 방식으로 작성해야 합니다. (이 문서는 JLS11 기준으로 작성되었습니다.)

💡근데 constant variable은 다르지 않나?

If a final variable holds a reference to an object, then the state of the object may be changed by operations on the object, but the variable will always refer to the same object.

JLS의 이 문장은 정상적인 자바 프로그램의 의미론을 기술한 겁니다. 리플렉션은 그 규약을 지키는 게 아니라 우회하는 쪽이라, 반례라기보다 규약 바깥의 이야기로 보는 게 맞습니다.

그리고 정확히는 런타임에 final 키워드가 사라진 게 아닙니다. 클래스 파일의 ACC_FINAL 플래그는 그대로예요. 리플렉션 계층이 쓰기 가능한 FieldAccessor를 고르도록 속인 것뿐입니다.

그렇다면 과연 처음부터 원시 타입으로 초기화한 경우는 어떨까요?

💡놀랍게도 원시 타입 또한 중간에 변경이 됩니다.

Even then, there are a number of complications. If a final field is initialized to a constant expression in the field declaration, changes to the final field may not be observed, since uses of that final field are replaced at compile time with the value of the constant expression.

필드는 바뀌지만, 그 필드를 읽는 코드가 컴파일 시점에 이미 리터럴로 치환되어 있어서 변경이 드러나지 않습니다. 코드로 살펴봅시다.

public class Main {

    // Integer가 int로 바뀜
    private static final int value = 10;

    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
        System.out.println(value);
        setFinalStatic(Main.class.getDeclaredField("value"), 20);
        System.out.println(value);
    }

    private static void setFinalStatic(Field field, Object newValue) throws NoSuchFieldException, IllegalAccessException {
        ...
    }
}

처음 봤던 코드에서 레퍼런스 타입을 원시 타입으로 바꾼 코드입니다. 이 경우의 출력값은 어떨까요?

정답은 10, 10입니다. 안 바뀐 거 맞지 않냐구요? 최종적으로는 값이 같지만, 중간에 사실 20으로 바뀌었습니다. javap -c 커맨드로 클래스 파일을 역어셈블하여 한번 확인해 봅시다. 바이트 코드에 대해 궁금하시다면 D2 블로그를 참고해 주세요.

Compiled from "Main.java"
public class Main {
  public Main();
    Code:
       0: aload_0
       1: invokespecial #1     // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]) throws java.lang.NoSuchFieldException, java.lang.IllegalAccessException;
    Code:
       0: getstatic     #7     // Field java/lang/System.out:Ljava/io/PrintStream;
       3: bipush        10
       5: invokevirtual #15    // Method java/io/PrintStream.println:(I)V
       8: ldc           #13    // class Main
      10: ldc           #21    // String value
      12: invokevirtual #23    // Method java/lang/Class.getDeclaredField:(Ljava/lang/String;)Ljava/lang/reflect/Field;
      15: bipush        20
      17: invokestatic  #29    // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      20: invokestatic  #35    // Method setFinalStatic:(Ljava/lang/reflect/Field;Ljava/lang/Object;)V
      23: getstatic     #7     // Field java/lang/System.out:Ljava/io/PrintStream;
      26: bipush        10
      28: invokevirtual #15    // Method java/io/PrintStream.println:(I)V
      31: return
}

bipush는 OpCode는 1byte 크기의 정수를 스택에 로드한다는 의미입니다. 그래서 3: bipush 10는 1byte 크기의 10이라는 피연산자를 스택에 넣는 명령이 됩니다. invokevirtual는 메서드를 호출하는 명령어의 OpCode인데, 2byte의 피연산자를 필요로 합니다. 12: invokevirtual #23는 getDeclaredField() 메서드를 호출하는 명령이 됩니다.

주목할 곳은 getstatic이 한 번도 등장하지 않는다는 점입니다. 3: bipush 10과 26: bipush 10, 두 println 모두 필드를 읽지 않고 리터럴 10을 그냥 스택에 올려버립니다. value가 constant variable이라서 컴파일러가 쓰는 쪽을 아예 값으로 갈아끼운 거죠.

그럼 정말 안 바뀐 걸까요? 리플렉션으로 직접 읽어보면 바로 알 수 있습니다.

Field f = Main.class.getDeclaredField("value");
setFinalStatic(f, 20);
System.out.println(value); // 10 ← 컴파일 타임에 치환된 리터럴
System.out.println(f.getInt(null)); // 20 ← 실제 필드

필드는 20입니다. println(value)가 이미 println(10)으로 컴파일됐을 뿐입니다.

한 가지 덧붙이면, JLS 17.5.3 기준으로 생성 이후에 final 필드를 바꾸는 건 동작이 보장되지 않는 영역입니다.
JIT가 static final 필드를 런타임에도 상수로 인라인할 수 있어서, 위 Integer 예제조차 실행 조건에 따라 결과가 달라질 수 있어요.
"참조 타입은 바꿀 수 있다"는 언어 차원의 보장이 아니라 특정 JDK와 실행 환경에서의 관찰 결과 정도로 받아들이는 게 안전합니다.


🎯정리

  • Field의 modifiers를 조작하면 리플렉션의 final 검사를 우회할 수 있습니다. 클래스 파일의 ACC_FINAL이 지워지는 건 아닙니다.

  • constant variable가 아닌 필드는 변경이 그대로 관찰됩니다. Integer, String 참조, 런타임 초기화된 원시 타입 전부 해당합니다.

  • constant variable은 사용처가 컴파일 타임에 리터럴로 치환되므로 변경이 관찰되지 않습니다. 필드 자체는 바뀝니다.


🔖참고