final이라는 단어는 “마지막”, “변경 불가”라는 의미를 담고 있습니다. 그런데 자바에서 자바 final 키워드는 붙는 위치에 따라 완전히 다른 세 가지 의미로 작동합니다. 변수에 붙이면 재할당을 막고, 메서드에 붙이면 오버라이딩을 차단하고, 클래스에 붙이면 상속 자체를 금지합니다. 단순히 “변경 못 하게 하는 키워드”로만 알고 있다면 불변 객체 설계, 람다에서의 effectively final, String이 왜 final 클래스인지 같은 핵심 개념에서 막히게 됩니다. 이 글에서는 final을 쓰는 이유부터 각 적용 위치별 동작 원리, 실전 설계 패턴, 그리고 반드시 알아야 할 주의점까지 한 번에 완벽하게 정리합니다.
목차
- 자바 final 키워드를 쓰는 근본 이유: 불변성과 안전성
- final 변수: 상수 선언과 재할당 금지의 모든 것
- final 메서드: 오버라이딩 방지와 설계 계약
- final 클래스: 상속 금지와 불변 클래스 설계
- effectively final과 람다·익명 클래스에서의 활용
- final 실전 설계 패턴, 주의점, 전문가 관점
1. 자바 final 키워드를 쓰는 근본 이유: 불변성과 안전성
final을 왜 써야 할까요? 단순히 실수로 값을 바꾸지 못하게 막는 것이 전부가 아닙니다. final은 코드를 읽는 사람과 컴파일러 모두에게 “이 값(또는 동작, 계층 구조)은 변하지 않는다” 는 계약(Contract)을 명시적으로 선언하는 행위입니다.
final이 제공하는 세 가지 가치
가치 1: 의도의 명시
final이 붙은 변수를 보는 순간, 개발자는 이 값이 초기화 이후 절대 바뀌지 않는다는 것을 즉시 알 수 있습니다. 코드를 추적하며 “이 변수가 어디선가 바뀌지는 않았나?” 하는 불안에서 해방됩니다.
가치 2: 컴파일러 안전망
final 변수에 재할당을 시도하면 컴파일 시점에 에러가 발생합니다. 런타임 버그가 되기 전에 컴파일러가 먼저 잡아줍니다.
java
final int MAX_SIZE = 100;
MAX_SIZE = 200; // 컴파일 에러: cannot assign a value to final variable
가치 3: 멀티스레드 안전성
final 필드는 JMM(Java Memory Model)에서 특별하게 취급됩니다. 객체 생성이 완료된 이후 어떤 스레드에서 참조하더라도 final 필드의 초기화된 값이 올바르게 보입니다. 별도의 동기화 없이도 안전한 공유가 가능한 이유입니다.
final의 세 가지 적용 위치
final 키워드 적용 위치와 효과:
┌─────────────────────────────────────────────────────┐
│ 적용 위치 │ 효과 │ 핵심 목적 │
├─────────────────────────────────────────────────────┤
│ 변수(필드, │ 재할당 금지 │ 불변 값 보장 │
│ 지역변수, │ (참조 고정, │ 상수 선언 │
│ 매개변수) │ 객체 내용은 별개) │ │
├─────────────────────────────────────────────────────┤
│ 메서드 │ 오버라이딩 금지 │ 핵심 로직 보호 │
│ │ │ 템플릿 메서드 │
├─────────────────────────────────────────────────────┤
│ 클래스 │ 상속(extends) 금지│ 불변 클래스 설계 │
│ │ │ 보안·무결성 보장 │
└─────────────────────────────────────────────────────┘
이 세 가지 사용법은 서로 독립적입니다. 클래스에 final을 붙인다고 해서 필드까지 자동으로 final이 되지 않으며, 반대로 필드만 final이라고 클래스가 상속 불가능해지지 않습니다.
2. final 변수: 상수 선언과 재할당 금지의 모든 것
final이 변수에 붙으면 “이 변수는 한 번만 값을 할당할 수 있고, 이후 재할당은 불가능하다” 는 규칙이 적용됩니다. 변수의 종류(인스턴스 필드, static 필드, 지역 변수, 매개변수)마다 초기화 시점과 규칙이 조금씩 다릅니다.
final 인스턴스 필드: 반드시 초기화해야 한다
java
public class Circle {
// final 인스턴스 필드는 세 가지 방법 중 하나로 반드시 초기화
private final double radius; // 방법 ①: 선언 시 초기화 (아래 참고)
private final String color = "RED"; // 방법 ②: 선언과 동시에 초기화
// 방법 ①: 생성자에서 초기화 (가장 권장되는 방식)
public Circle(double radius) {
this.radius = radius; // 생성자에서 단 한 번만 할당 가능
}
// 이후 어떤 메서드에서도 radius 재할당 불가
public void resize(double newRadius) {
// this.radius = newRadius; // ❌ 컴파일 에러
}
public double getArea() {
return Math.PI * radius * radius; // 읽기는 항상 가능
}
}
final 인스턴스 필드를 생성자에서 초기화하는 패턴은 불변 객체(Immutable Object) 설계의 첫 번째 조건이기도 합니다. 이 방식을 사용하면 객체가 생성된 이후 radius가 절대 바뀌지 않는다는 것을 컴파일러가 보장합니다.
static final: 클래스 전체의 상수 선언
static과 final을 함께 쓰면 **클래스 수준의 상수(Constant)**가 됩니다. 관례상 대문자 스네이크 케이스로 명명합니다.
java
public class MathUtils {
// static final: 클래스 로딩 시 단 한 번 초기화되는 상수
public static final double PI = 3.141592653589793;
public static final int MAX_RETRY = 3;
public static final String APP_NAME = "MyService";
// 잘못된 예: 상수인데 소문자로 쓰는 것은 관례 위반
// public static final int maxSize = 100; // ❌ 관례 위반
// 올바른 예
public static final int MAX_SIZE = 100; // ✅
}
// 사용: 클래스명.상수명 (new 없이 직접 접근)
double circumference = 2 * MathUtils.PI * radius;
final 지역 변수와 매개변수
java
public class FinalLocalDemo {
// ① final 매개변수: 메서드 내에서 재할당 불가
public double calculateArea(final double radius) {
// radius = -1; // ❌ 컴파일 에러
return Math.PI * radius * radius;
}
public void demo() {
// ② final 지역 변수: 선언 후 한 번만 할당 가능
final int MAX = 10;
// MAX = 20; // ❌ 컴파일 에러
// ③ 선언과 초기화를 분리할 수 있음 (단, 사용 전 반드시 초기화)
final String message;
if (MAX > 5) {
message = "크다";
} else {
message = "작다";
}
// 이 시점부터 message 재할당 불가
System.out.println(message); // ✅ 사용 가능
}
}
핵심 주의점: final은 참조를 고정하지, 객체 내용은 고정하지 않는다
final에서 가장 많이 혼동하는 부분이 바로 이것입니다.
java
import java.util.ArrayList;
import java.util.List;
public class FinalReferenceDemo {
public static void main(String[] args) {
final List<String> list = new ArrayList<>();
// ✅ 리스트 내용 변경은 가능 (final은 참조만 고정)
list.add("첫 번째");
list.add("두 번째");
list.remove(0);
System.out.println(list); // [두 번째]
// ❌ 재할당은 불가 (final이 막는 것은 참조 변경)
// list = new ArrayList<>(); // 컴파일 에러
// 같은 원리: final 인스턴스도 내부 상태 변경 가능
final StringBuilder sb = new StringBuilder("Hello");
sb.append(", World"); // ✅ 내용 변경 가능
// sb = new StringBuilder(); // ❌ 재할당 불가
}
}
final의 범위:
final List<String> list = new ArrayList<>();
↑
이 화살표(참조)만 고정
list가 가리키는 ArrayList 객체 자체는 자유롭게 수정 가능
진정한 불변 컬렉션을 원한다면:
List.of(...) // Java 9+, 불변 리스트
Collections.unmodifiableList(list) // 불변 래퍼
List.copyOf(list) // Java 10+, 불변 복사본
이 사실을 모르면 “final을 붙였는데 왜 내용이 바뀌지?”라는 혼란이 생깁니다. final은 참조(reference)의 불변성을 보장하지, 참조가 가리키는 객체의 상태(state) 불변성을 보장하지 않습니다.
3. final 메서드: 오버라이딩 방지와 설계 계약
final이 메서드에 붙으면 해당 메서드를 하위 클래스에서 오버라이딩(Override)할 수 없게 만듭니다. 클래스 자체는 여전히 상속 가능하지만, 그 특정 메서드의 구현은 고정됩니다.
기본 문법과 동작
java
public class Payment {
private String orderId;
private double amount;
public Payment(String orderId, double amount) {
this.orderId = orderId;
this.amount = amount;
}
// final 메서드: 하위 클래스에서 오버라이딩 불가
public final double getAmount() {
return amount;
}
// final 메서드: 결제 검증 로직은 절대 변경 불가
public final boolean validate() {
return orderId != null && !orderId.isBlank() && amount > 0;
}
// 일반 메서드: 하위 클래스에서 오버라이딩 가능
public String getDescription() {
return "주문 " + orderId + ": " + amount + "원";
}
}
public class CardPayment extends Payment {
public CardPayment(String orderId, double amount) {
super(orderId, amount);
}
// ❌ 컴파일 에러: getAmount()는 final이므로 오버라이딩 불가
// @Override
// public double getAmount() { return super.getAmount() * 0.95; }
// ✅ 일반 메서드는 오버라이딩 가능
@Override
public String getDescription() {
return "[카드결제] " + super.getDescription();
}
}
final 메서드가 필요한 세 가지 상황
상황 1: 보안·무결성이 핵심인 로직
결제 금액 계산, 인증 토큰 검증, 암호화 로직처럼 하위 클래스가 임의로 변경하면 심각한 보안 문제가 발생할 수 있는 메서드에 final을 붙여 보호합니다.
상황 2: 템플릿 메서드 패턴(Template Method Pattern)
알고리즘의 골격(전체 흐름)을 final로 정의하고, 세부 단계만 하위 클래스가 구현하도록 강제하는 패턴입니다.
java
public abstract class DataExporter {
// 전체 흐름은 final로 고정 (템플릿 메서드)
public final void export(String destination) {
List<Object> data = fetchData(); // 공통 단계
validate(data); // 공통 단계
String formatted = format(data); // 하위 클래스 구현
write(formatted, destination); // 하위 클래스 구현
log(destination); // 공통 단계
System.out.println("내보내기 완료: " + destination);
}
// 공통 로직은 직접 구현 (final은 아니어도 됨)
protected List<Object> fetchData() {
// DB 조회 로직 (공통)
return new ArrayList<>();
}
protected void validate(List<Object> data) {
if (data == null) throw new IllegalArgumentException("데이터 없음");
}
protected void log(String destination) {
System.out.println("로그: " + destination + " 내보내기");
}
// 하위 클래스가 반드시 구현해야 하는 단계 (추상 메서드)
protected abstract String format(List<Object> data);
protected abstract void write(String content, String destination);
}
// CSV 형식으로 내보내는 구체 클래스
public class CsvExporter extends DataExporter {
@Override
protected String format(List<Object> data) {
return data.stream()
.map(Object::toString)
.collect(Collectors.joining(","));
}
@Override
protected void write(String content, String destination) {
// CSV 파일로 저장 로직
System.out.println("CSV 저장: " + destination);
}
// export() 메서드 자체는 final이므로 오버라이딩 불가
// 전체 흐름(fetchData → validate → format → write → log)은 보장됨
}
상황 3: JDK 내부의 final 메서드 활용
Object.getClass(), Thread.isInterrupted() 등 JDK 핵심 메서드들이 final로 선언되어 있는 이유도 같습니다. 이 동작들이 임의로 변경되면 JVM 전체의 동작이 불안정해지기 때문입니다.
private 메서드와 final의 관계
java
public class Parent {
// private 메서드는 암묵적으로 final
// 하위 클래스에서 '같은 이름'의 메서드를 만들어도
// 오버라이딩이 아닌 '새로운 메서드 정의'임
private void helper() {
System.out.println("부모 helper");
}
public void execute() {
helper(); // 항상 이 메서드 호출
}
}
public class Child extends Parent {
// 이것은 오버라이딩이 아님! 새로운 메서드 정의
private void helper() {
System.out.println("자식 helper");
}
}
// Child c = new Child();
// c.execute(); → "부모 helper" 출력 (자식의 helper가 아님!)
private 메서드는 하위 클래스에서 보이지 않으므로 final을 따로 붙이지 않아도 사실상 동일한 효과를 가집니다. 단, 위 예시처럼 같은 이름을 쓰면 오버라이딩이 아닌 완전히 별개의 메서드가 생성되어 혼란이 발생할 수 있으므로 주의해야 합니다.
4. final 클래스: 상속 금지와 불변 클래스 설계
final이 클래스 선언에 붙으면 그 클래스는 어떤 클래스도 상속(extends)할 수 없습니다. 상속을 통한 확장 자체를 차단하여 클래스의 동작을 완벽하게 고정합니다.
기본 문법과 컴파일 에러
java
// final 클래스 선언
public final class SocialSecurityNumber {
private final String value;
public SocialSecurityNumber(String value) {
validate(value);
this.value = value;
}
private void validate(String value) {
if (value == null || value.length() != 14) {
throw new IllegalArgumentException("유효하지 않은 주민등록번호 형식");
}
}
public String getMasked() {
return value.substring(0, 6) + "-*******";
}
}
// ❌ 컴파일 에러: SocialSecurityNumber는 final 클래스
// public class HackedSSN extends SocialSecurityNumber {
// @Override
// public String getMasked() {
// return value; // 마스킹 우회 시도 → 차단됨
// }
// }
String, Integer가 final 클래스인 이유
자바에서 가장 유명한 final 클래스는 java.lang.String입니다.
java
// JDK 내부 선언 (개념적 표현)
public final class String implements Serializable, Comparable<String>, CharSequence {
private final char[] value; // (Java 9+에서는 byte[])
// ...
}
// 마찬가지로 래퍼 클래스들도 final
public final class Integer extends Number implements Comparable<Integer> { }
public final class Long extends Number implements Comparable<Long> { }
public final class Double extends Number implements Comparable<Double> { }
String이 final인 이유는 크게 세 가지입니다.
이유 1: 불변성 보장
String이 변경 가능하다면, 어떤 메서드에 문자열을 넘겼을 때 그 메서드 내부에서 문자열이 바뀔 수 있습니다. 특히 해시맵의 키, 보안 인증 토큰, 파일 경로 등으로 사용되는 String이 외부에서 변경된다면 예측 불가능한 버그가 발생합니다.
이유 2: String Pool 최적화
JVM의 String Pool은 동일한 문자열 리터럴을 공유합니다. String이 변경 가능하다면 한 곳에서 바꿨을 때 같은 Pool을 공유하는 다른 곳까지 영향을 받게 됩니다. final이기에 안전하게 공유 가능합니다.
이유 3: 상속을 통한 동작 변경 방지
누군가 String을 상속해 equals()나 hashCode()를 바꾸면, HashMap·HashSet이 오작동할 수 있습니다. final로 막아 이 위험을 원천 차단합니다.
완전한 불변 클래스(Immutable Class) 설계 5원칙
final 클래스는 불변 클래스 설계의 핵심 요소입니다. 이펙티브 자바(Item 17)가 제시하는 불변 클래스의 5가지 원칙을 함께 적용해야 진정한 불변 클래스가 됩니다.
java
// 완전한 불변 클래스 설계 예시
public final class Money { // ① 클래스에 final
private final long amount; // ② 모든 필드에 final
private final String currency; // ② 모든 필드에 final
// ③ setter 없음, 상태 변경 메서드 없음
private Money(long amount, String currency) { // ④ 생성자는 private
if (amount < 0) {
throw new IllegalArgumentException("금액은 0 이상: " + amount);
}
this.amount = amount;
this.currency = currency;
}
// ④ static 팩토리 메서드로 생성 (생성자 대신)
public static Money of(long amount, String currency) {
return new Money(amount, currency);
}
public static Money zero(String currency) {
return new Money(0, currency);
}
// 상태를 바꾸는 대신 새 객체를 반환 (함수형 방식)
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("통화 불일치");
}
return new Money(this.amount + other.amount, this.currency); // 새 객체 반환
}
public Money multiply(int factor) {
return new Money(this.amount * factor, this.currency); // 새 객체 반환
}
// getter만 제공 (setter 없음)
public long getAmount() { return amount; }
public String getCurrency() { return currency; }
// ⑤ 가변 객체를 필드로 가질 경우 방어적 복사 필수
// (여기서는 long, String 모두 불변이므로 불필요)
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money)) return false;
Money money = (Money) o;
return amount == money.amount && currency.equals(money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
@Override
public String toString() {
return amount + " " + currency;
}
}
불변 클래스 설계 5원칙 요약:
① final 클래스 선언 (상속 차단)
② 모든 필드를 private final로 선언
③ setter 및 상태 변경 메서드 제거
④ 생성자를 private으로 + static 팩토리 메서드 사용
⑤ 가변 객체 필드가 있다면 방어적 복사 적용
5. effectively final과 람다·익명 클래스에서의 활용
Java 8부터 도입된 effectively final(사실상 final) 개념은 자바 면접에서 자주 등장하는 주제입니다. final 키워드를 명시하지 않았지만, 초기화 이후 값이 변경되지 않은 변수를 JVM이 사실상 final로 취급하는 개념입니다.
람다에서 외부 변수를 사용하는 조건
java
public class EffectivelyFinalDemo {
public static void main(String[] args) {
// ① 명시적 final: final 키워드 선언
final String greeting = "안녕하세요";
// ② effectively final: final 없지만 값이 변경되지 않음
String name = "홍길동"; // 이후 name이 변경되지 않으면 effectively final
// 람다는 외부 변수를 캡처(capture)할 때 반드시 final 또는 effectively final이어야 함
Runnable r = () -> {
System.out.println(greeting + ", " + name); // ✅ 모두 사용 가능
};
r.run(); // 안녕하세요, 홍길동
// ③ effectively final 조건 위반 예시
String city = "서울";
city = "부산"; // 값 변경 → effectively final 아님
Runnable r2 = () -> {
// System.out.println(city); // ❌ 컴파일 에러
// Variable used in lambda expression should be final or effectively final
};
}
}
왜 람다는 final/effectively final 변수만 캡처할 수 있는가?
이 제약의 이유는 자바의 메모리 모델과 람다의 동작 방식에 있습니다.
람다가 외부 변수를 캡처하는 방식:
외부 메서드의 Stack 영역
┌──────────────┐
│ String name │ ← 지역 변수는 Stack에 존재
│ = "홍길동" │ 메서드 종료 시 사라짐
└──────────────┘
│
│ 람다가 캡처 (복사)
↓
람다 객체 (Heap 영역)
┌──────────────────┐
│ captured: "홍길동" │ ← 람다 객체 안에 값 복사본 저장
└──────────────────┘
메서드가 종료되어 Stack이 사라져도
람다 객체는 Heap에 남아 캡처된 값을 사용
만약 name이 변경 가능하다면:
Stack의 name과 람다가 캡처한 복사본이
서로 다른 값을 갖게 되는 불일치 발생
→ 이를 방지하기 위해 final/effectively final만 허용
java
// 실전에서 자주 보이는 effectively final 패턴
public List<String> filterByPrefix(List<String> items, String prefix) {
// prefix는 이 메서드 내에서 변경되지 않음 → effectively final
return items.stream()
.filter(item -> item.startsWith(prefix)) // prefix 캡처 가능
.collect(Collectors.toList());
}
// effectively final 위반으로 인한 우회 패턴 (안티패턴)
public void badExample() {
int[] counter = {0}; // int 대신 배열로 우회 (권장하지 않음)
List.of("a", "b", "c").forEach(item -> {
counter[0]++; // 배열 참조는 final, 내용 변경은 가능
});
// 하지만 이 패턴은 AtomicInteger를 사용하는 것이 올바른 대안
}
// 올바른 대안: AtomicInteger
public void goodExample() {
AtomicInteger counter = new AtomicInteger(0); // effectively final (참조 고정)
List.of("a", "b", "c").forEach(item -> {
counter.incrementAndGet(); // ✅ 원자적 연산으로 값 변경
});
System.out.println(counter.get()); // 3
}
익명 클래스에서의 effectively final
람다와 동일한 규칙이 Java 7 이전의 **익명 클래스(Anonymous Class)**에도 적용됩니다. Java 7까지는 final을 명시해야 했고, Java 8부터 effectively final 개념이 도입되어 명시 없이도 조건만 만족하면 됩니다.
java
// Java 7 방식: 명시적 final 필요
public void java7Style(final String message) {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println(message); // final 명시 필요
}
};
r.run();
}
// Java 8+ 방식: effectively final로 충분
public void java8Style(String message) { // final 생략 가능
Runnable r = () -> System.out.println(message); // effectively final
r.run();
}
6. final 실전 설계 패턴, 주의점, 전문가 관점
final을 이해했다면, 언제 써야 하고 언제 쓰면 안 되는지 설계 관점에서 정리해야 합니다.
이펙티브 자바(Effective Java)의 final 관련 권고
조슈아 블로크는 Effective Java 3판에서 final과 불변성에 대해 다음과 같이 명확하게 권고합니다.
| 아이템 | 핵심 권고 |
|---|---|
| Item 15 | 클래스와 멤버의 접근 가능성을 최소화하라 |
| Item 17 | 변경 가능성을 최소화하라 (불변 클래스를 선호하라) |
| Item 17 | 생성자를 제외하고는 불변 클래스를 변경할 수 없게 하라 |
| Item 19 | 상속을 고려해 설계하고 문서화하라, 그러지 않으면 상속을 금지하라 |
특히 Item 19는 매우 실용적입니다. 상속을 허용하려면 하위 클래스가 어떤 메서드를 어떻게 오버라이딩해야 하는지 철저히 문서화해야 하고, 그 자신이 없다면 차라리 final로 선언해 상속을 막는 것이 더 안전한 설계라는 것입니다.
final 필드와 생성자 주입(DI)의 결합
스프링 프레임워크에서 가장 권장되는 패턴은 생성자 주입 + final 필드 조합입니다.
java
@Service
public class OrderService {
// ① 생성자 주입 + final 조합: 스프링 공식 권장 방식
private final OrderRepository orderRepository;
private final PaymentService paymentService;
private final EventPublisher eventPublisher;
// ② Lombok @RequiredArgsConstructor로 자동 생성 가능
public OrderService(
OrderRepository orderRepository,
PaymentService paymentService,
EventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.paymentService = paymentService;
this.eventPublisher = eventPublisher;
}
public OrderResult placeOrder(OrderRequest request) {
// orderRepository, paymentService, eventPublisher는
// 이 서비스의 생애 전체에서 절대 바뀌지 않음이 보장됨
Order order = orderRepository.save(new Order(request));
paymentService.charge(order);
eventPublisher.publish(new OrderPlacedEvent(order));
return OrderResult.success(order);
}
}
이 패턴의 장점은 세 가지입니다. 첫째, final로 선언한 필드는 반드시 생성자에서 초기화되어야 하므로 의존성 누락을 컴파일 타임에 감지할 수 있습니다. 둘째, 필드 주입(@Autowired)과 달리 의존성이 외부에서 명확히 보여 테스트 시 Mock 주입이 쉽습니다. 셋째, 객체 생성 후 의존성이 절대 바뀌지 않아 스레드 안전성이 자연스럽게 확보됩니다.
반드시 알아야 할 주의점 5가지
주의점 1: final은 참조만 고정, 객체 내용은 아님 (반복 강조)
가장 많이 실수하는 부분입니다. final List는 리스트 자체를 못 바꾸는 것이 아니라 다른 리스트로 재할당만 못 합니다. 내용 불변이 필요하면 List.of(), Collections.unmodifiableList() 등을 사용해야 합니다.
주의점 2: final 클래스의 모든 필드가 자동으로 final이 되지 않음
java
public final class BadExample {
public int count = 0; // ← final 클래스지만 필드는 변경 가능
// 이 객체는 불변이 아님! 상속만 불가능한 것
}
불변 클래스를 만들려면 클래스에 final을 붙이는 것만으로 부족합니다. 모든 필드에도 final을 적용해야 합니다.
주의점 3: final 남용은 유연성을 해친다
모든 메서드와 클래스에 final을 남발하면 테스트 작성이 어려워집니다. Mock 프레임워크(Mockito)는 기본적으로 final 클래스와 메서드를 모킹(Mocking)할 수 없기 때문입니다.
java
// 문제: final 클래스는 기본 Mockito로 Mock 불가
public final class ExternalApiClient { ... }
// 해결책 1: final 제거 + 인터페이스 추출
public interface ApiClient { ... }
public class ExternalApiClient implements ApiClient { ... }
// 해결책 2: Mockito 2.x+ inline mock 설정
// src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
// mock-maker-inline
주의점 4: 기본형 final 변수의 성능 최적화는 JIT가 처리
일부 개발자는 “final 변수는 JIT 컴파일러가 인라이닝해 성능이 좋다”며 성능 목적으로 모든 지역 변수에 final을 붙이는 경우가 있습니다. 현대 JVM에서는 final 없이도 JIT가 충분히 최적화하므로, 성능보다는 설계 의도 표현을 목적으로만 final을 사용하는 것이 권장됩니다.
주의점 5: 직렬화와 final의 함정
java
public final class Config implements Serializable {
private final String value;
public Config(String value) {
this.value = value;
}
// 역직렬화 시 readObject()를 통해 final 필드에 값이 할당됨
// 이 과정에서 리플렉션이 사용되며, 악의적 역직렬화 공격 가능성 있음
// → 직렬화가 필요한 불변 클래스는 readResolve() 구현을 고려
}
final 사용 여부 결정 기준
final 변수: 거의 항상 붙이는 것이 좋음
✅ 필드: 생성자에서 초기화 후 변경 없으면 반드시 final
✅ 상수: static final 조합 항상 사용
✅ 의도 표현: 변경 없는 지역 변수에 붙여 가독성 향상
⚠️ 컬렉션: final만으로는 내용 불변이 안 됨 → 불변 래퍼 병행
final 메서드: 신중하게 사용
✅ 보안·무결성 핵심 로직
✅ 템플릿 메서드 패턴의 골격 메서드
❌ 단순히 "변경하지 말라"는 의도만이면 문서화가 더 나음
final 클래스: 명확한 목적이 있을 때만
✅ 불변 값 객체 (Money, PhoneNumber 등)
✅ 보안·무결성이 핵심인 클래스
❌ 확장 가능성이 필요한 유틸리티·서비스 클래스
❌ 테스트에서 Mock이 필요한 클래스
결론
자바 final 키워드는 붙는 위치에 따라 재할당 금지(변수), 오버라이딩 방지(메서드), 상속 금지(클래스)라는 세 가지 전혀 다른 역할을 수행합니다. 공통된 철학은 하나입니다. “이것은 변하지 않는다”는 계약을 코드에 명시하여 안전성·가독성·스레드 안전성을 동시에 높이는 것입니다. 특히 final이 참조만 고정하며 객체 내용은 고정하지 않는다는 점, effectively final로 람다에서 외부 변수를 캡처하는 조건, 그리고 불변 클래스 설계의 5원칙은 자바 면접과 실전 설계 모두에서 핵심 지식입니다. 오늘부터 새 클래스를 만들 때 “이 필드가 정말로 바뀌어야 하는가?”를 먼저 질문해 보세요.
⚠️ 기술 적용 면책 고지: 본 포스트의 코드 예제는 Java 11–17 LTS 기준으로 작성된 교육용 예제입니다. 스프링 버전·Mockito 설정·직렬화 환경에 따라
final적용 방식이 달라질 수 있으므로, 프로덕션 코드 적용 전 공식 JDK 문서와 팀 컨벤션을 반드시 함께 참고하시기 바랍니다.
답글 남기기