Java의 상수 필드는 꼭 static final로 선언할까?라는 제목

Java의 상수 필드는 꼭 static final로 선언할까?라는 제목

서론

✅ 자바에서 상수 필드를 선언할 때 static final을 세트처럼 자주 사용합니다.

이 조합은 워낙 빈번하게 등장하여 마치 관례처럼 보일 정도입니다.

// 어느 클래스의 static 멤버
public static final String EXAMPLE =
        SomeClass.getPropertyValue("example");

✅ 자바 인류는 어쩌다 이 표기를 습관적으로 쓰게 되었을까요?

라는 주제로 글을 써 보겠습니다.


키워드의 역할

Static

static으로 선언하면, 인스턴스(≈ 객체)를 만들지 않아도 클래스를 통해 바로 접근할 수 있죠.

// 인스턴스를 만들지 않고 클래스를 통해 접근
Example.MAX_VALUE

// 인스턴스를 만들지 않고 클래스를 통해 접근
Example.staticMethod();
  • 자바에서 static을 사용하면 필드의 소유권을 인스턴스 대신 클래스로 옮길 수 있습니다.

  • 그래서 static 변수는 새 객체를 만들 때마다 새로 생기지 않고, 프로그램 생명주기 동안 하나의 정체성으로 존재하게 됩니다.

  • static은 (처음 생성된 이후) 프로그램의 생명 주기 동안 살아 있습니다.

  • static은 클래스에 종속되어 있기는 하지만, 어느 정도 독립적인 핸들링이 가능합니다.

Final

자바에서 final은 해당 요소가 한 번만 할당될 수 있도록 합니다.

// 선언 (주소 할당)
final int num;

// 대입 (값 할당)
num = 10;
  • final + 변수인 기호 상수는 값을 한 번만 대입할 수 있습니다. 이 변수는 자신이 갖고 있는 값이 최종 값임을 보장해요.

  • final + 메서드는 가상 함수가 아니게 되어서 오버라이딩을 예약할 수 없게 됩니다. 오버라이딩을 할 수 없게 된 이 메서드는 자신이 담고 있는 내용이 최종 동작임을 보장해요.

  • final + 클래스는 상속을 할 수 없습니다. 이 클래스는 상속되기 위한 목적이 아니라고 선언하여 자신을 구성하는 설계가 더 이상 하위로 상속되어 변경되지 않고 최종 설계임을 보장해요. 메서드도 모두 final 메서드와 같은 효과를 얻겠죠.

지역변수가 아닌 것들에 final을 붙이면 컴파일러는 안정성도 보장하지만, 종종 최적화에서도 도움이 될 때가 있습니다. (컴파일러는 예측하기 쉬운 것일수록 잘 최적화하는 편이거든요.)


덧붙이는 정보

💡 자바는 기본적으로 final이 아닌 메서드는 모두 가상 메서드입니다.

가상 메서드는 추상 메서드와 다릅니다.
추상 메서드(abstract method)는 선언만 있고 내용이 아직 없는 메서드를 말합니다.
가상 메서드(virtual method)는 오버라이딩이 가능한 메서드를 말합니다.

C++에서는 virtual 키워드를 붙여야 가상 함수가 되지만, 자바에서는 따로 키워드가 없다면 모두 가상 함수입니다.

💡 자바에서는 상속에 열린 클래스를 기본으로 합니다.

따로 키워드를 붙이지 않은 자바 클래스는 언제든 상속이 가능한 상태에 있습니다.

자바에서는 final, sealed 등 키워드를 붙여야 상속에 (조금 또는 완전히) 닫힌 클래스가 되지만, 코틀린에서는 따로 open 키워드를 붙이지 않으면 상속에 완전히 닫힌 클래스가 됩니다.

💡 상수를 사용하면 일부가 컴파일타임에 해석되어 최적화됩니다.

컴파일러는 자신이 예측하기 쉬운 것들은 쉽게 최적화할 수 있습니다. 즉, 우리 코드를 컴파일러가 더 빠른 코드로 살려 줍니다. 이런 최적화 덕분에 우리가 코드 수준에서는 가독성을 우선시할 수 있죠.

상수 전파, 상수 폴딩

상수 폴딩은 값을 미리 계산할 수 있는 것들은 컴파일타임에 미리 계산해 준다는 최적화 개념입니다.

기호 상수를 사용할 때는 상수 전파도 적용될 수 있습니다. 그 기호 상수가 컴파일타임에도 값을 알 수 있는 것일 때 기호 상수를 리터럴 상수로 대치하기도 합니다. 상수 전파를 한 후 상수 폴딩으로 간단한 수식 정도는 컴파일타임에 계산해서 결과를 넣습니다.

함수 인라이닝

함수를 호출하는 비용도 아낄 수 있도록 함수의 동작을 그대로 함수 호출부로 넣는 작업입니다.

메서드가 결국 상수를 반환할 때는 메서드 호출 대신 그 상수를 넣을 수 있습니다.


Static과 Final을 함께 쓰는 이유

static으로 선언하지 않은 상수는 그 클래스의 모든 인스턴스에 해당 값에 대한 메모리를 새로 할당해 메모리 낭비가 발생합니다. 또 인스턴스 생성·정리 비용도 조금은 증가하겠죠.

  • 여러 이유로 상수 사용은 권장되는 방식입니다. (안정성, 최적화, 일부 로직에서 쉬운 핸들링 등)

  • static 사용으로 메모리를 아낄 수 있습니다. (인스턴스마다 중복 생성을 하지 않으니까요.)

  • static 사용으로 인스턴스에 종속되지 않은 상수를 사용할 수 있습니다.

  • static이지만 final이기 때문에 최적화에 용이합니다.

  • 이렇게 작성한 일부 상수는 컴파일타임에 값을 보장하기도 합니다.


💻 자기야, 그건 오해야.

"상수 필드는 꼭 static final로 선언해야 할까요?"라는 오해 풀기

✅ 자바에서 상수 필드를 선언할 때 static final을 세트처럼 자주 사용합니다.

그러다 보니 몇몇 분들이 오해하기를, 자바에서 상수 필드를 선언할 때는 반드시 static final로 해야 바람직하다고 생각하는 것 같습니다.

public class Example {
    public static final int MAX_VALUE = 100;
    public static final int MIN_VALUE = 0;
}

✅ 사실 처음 가르칠 때는 그렇게 설명하는 게 편하고 좋습니다.

값을 바로 기호 상수에 담는 거라면 그게 좋거든요. (위 예시처럼요.)

✅ 객체마다 다른 값을 갖는 상수는 static이 아닙니다.

이러면 보통 생성자에서 초기화합니다. (공통되는 작업은 인스턴스 초기화 블록을 쓸 수도 있습니다만, 유연한 관리를 위해 상수는 반드시 생성자에서 초기화하는 것을 권합니다.)

public class ExampleClass {
    private final String message;

    public ExampleClass(String message) {
        this.message = message;
    }
}

✅ 객체에 종속되지 않고 항상 일정한 값을 static final로 선언하는 겁니다.

어차피 객체마다 만들어도 같은 값을 사용할 거라면, 재생성을 할 필요 없이 static으로 취급합니다.


💡 "static을 붙이면 컴파일타임에 값을 할당한다."라는 오해 풀기

static은 정적으로 관리되는 것은 맞지만, 컴파일타임에 값을 항상 보장하는 것은 아니에요.

컴파일러가 똑똑해서 일부는 컴파일타임에 해석해서 보장해 주는 거예요.

✅ 리터럴 상수를 바로 대입하고 있을 때 → 컴파일타임에 해석이 가능합니다.

✅ static이더라도 값의 결정이 런타임에 될 때 → 컴파일타임에는 값을 정할 수 없습니다.

예를 들어, 런타임에 결과가 정해지는 함수를 사용할 때 그렇습니다.

✅ 이건 static final이더라도 마찬가지예요.

자바의 final은 어떤 식으로든 한 번만 대입이 된다는 게 보장되면 되고, 이건 다음 예시처럼 런타임에 값을 결정해서 대입하는 것을 방지하진 않거든요.

// 어느 클래스의 static 멤버
static final String EXAMPLE =
        SomeClass.getPropertyValue("example");
// 사용된 클래스와 함수
public class SomeClass {
    public static String getPropertyValue(String propertyName) {
        // 이곳에서 어느 파일의 값을 읽어 반환하고 있다고 가정합니다.
        // 파일은 런타임에 읽습니다.
        // (컴파일타임에는 외부 파일의 내용을 알 수 없고, 파일의 내용이 불변임을 보장할 수 없습니다.)
    }
}