한 키워드인데 붙는 자리마다 막는 것이 다르다.
| 자리 | 무엇을 막나 |
|---|---|
| 지역 변수 | 재할당 |
| 필드 | 생성자·초기화 블록 이후의 변경 |
| 메서드 | 하위 클래스의 재정의 |
| 클래스 | 상속 자체 |
| 파라미터 | 메서드 안에서의 재할당 |
가장 흔한 오해
final List<String> tags = new ArrayList<>();
tags.add("new"); // 된다
tags = new ArrayList<>(); // 컴파일 에러
final 이 지키는 것은 변수가 가리키는 주소다. 그 주소에 있는 객체의 내용은 얼마든지 바뀐다. final ≠ 불변이고, 이 구분을 놓치면 "final 로 했는데 값이 바뀐다" 는 혼란이 생긴다.
진짜 불변 객체는 셋을 다 해야 만들어진다 — 필드를 final 로, setter 를 없애고, 가변 내부는 방어적 복사를 한다. final List 를 그대로 노출하면 밖에서 고칠 수 있으므로 꺼낼 때 복사하거나 읽기 전용으로 감싼다.
람다가 요구하는 것 — effectively final
int count = 0;
list.forEach(x -> count++); // 컴파일 에러
람다는 지역 변수를 값으로 복사해 캡처한다. 지역 변수는 스택에 있고 메서드가 끝나면 사라지는데, 람다는 그 뒤에도 살아 있을 수 있기 때문이다. 복사본을 고쳐도 원본에 반영되지 않으니 애초에 못 바꾸게 막는 편이 낫다는 판단이다.
final 을 명시하지 않아도 한 번만 대입되면 컴파일러가 같은 것으로 취급한다. 이것이 effectively final 이다. 값을 누적해야 한다면 배열 한 칸이나 AtomicInteger 처럼 힙에 있는 객체를 쓴다 — 힙에 있으면 복사되는 것은 참조뿐이라 내용 변경이 통한다.
static final 상수는 호출한 쪽에 박힌다
// 라이브러리
public static final int MAX_RETRY = 3;
// 쓰는 쪽
if (count < Config.MAX_RETRY) { ... } // 컴파일하면 3 이 그대로 박힌다
컴파일 시점에 값이 정해지는 원시 타입·문자열 상수는 참조가 아니라 값으로 인라인된다. 그래서 라이브러리에서 5 로 바꿔 배포해도 쓰는 쪽을 다시 컴파일하지 않으면 여전히 3 이다. 상수 인터페이스나 공통 상수 클래스를 라이브러리로 나눠 쓸 때 값이 안 바뀌는 것처럼 보이는 원인이 이것이다.
바꿀 여지가 있는 값이라면 상수 대신 메서드로 내주거나 설정으로 뺀다.
스레드와 관련된 보장
final 필드는 생성자가 끝난 시점의 값을 다른 스레드가 반드시 제대로 본다는 보장을 받는다. 일반 필드는 이 보장이 없어서, 객체 참조를 먼저 보고 필드는 아직 초기화 전 값으로 보는 일이 생긴다. 불변 객체가 별도 동기화 없이 스레드 안전한 근거가 이것이다.
실무에서 걸리는 곳
final클래스·메서드는 프록시를 못 만든다 — 상속으로 프록시를 만드는 방식(CGLIB)이 재정의할 수 없기 때문이다. 코틀린 클래스가 기본final이라 스프링에서 문제가 되는 것도 같은 이유다- 생성자 주입에서
final을 쓰는 이유 — 만든 뒤 바뀌지 않고, 빠뜨렸으면 컴파일이 막는다 - JIT 에 유리하다 — 값이 안 바뀐다는 것이 확실하면 상수로 접거나 인라인하기 쉽다. 다만 이것을 이유로
final을 붙이는 것은 아니고, 설계 의도를 드러내는 것이 먼저다
Java 26 부터는 리플렉션으로 바꾸면 경고가 뜬다
Field f = Config.class.getDeclaredField("MAX_RETRY");
f.setAccessible(true);
f.setInt(null, 5); // Java 26: 경고. 이후 릴리스에서 막힐 예정
JEP 500(Prepare to Make Final Mean Final)이 이 통로를 좁히기 시작했다. 일부 테스트 프레임워크와 직렬화 라이브러리가 이 수법을 써 왔으므로, 낡은 의존성이 있으면 로그에 경고가 뜬다.
방향은 분명하다 — final 이 진짜 안 바뀐다는 보장이 있어야 JIT 이 값을 상수로 접고 재배열할 수 있고, 위에서 본 final 필드의 스레드 안전 보장도 실제로 성립한다. 지금까지는 리플렉션이 그 전제를 언제든 깰 수 있어 최적화가 보수적일 수밖에 없었다.
면접 함정
- ❌ "
final이면 불변이다" → 참조만 고정된다. 내용은 바뀐다. - ❌ "
final을 붙이면 성능이 좋아진다" → 부수 효과일 뿐 목적이 아니다. - ❌ "람다에서 지역 변수를 못 바꾸는 건 문법 제약" → 스택에 있는 값을 복사해 캡처하기 때문에 생기는 필연이다.