자바에서 final은 “한 번 정한 값을 다시 바꾸지 않겠다”는 표시입니다. 처음에는 단순히 값을 못 바꾸게 막는 키워드처럼 보입니다.

하지만 실제로는 지역 변수, 필드, 상수, 참조형에서 조금씩 다르게 느껴집니다. 특히 참조형에 final을 붙였을 때는 “객체 자체가 안 바뀌는 것인지”, “변수가 다른 객체를 못 가리키는 것인지”를 구분해야 합니다.

이번 글에서는 final을 입문자가 헷갈리기 쉬운 지점 중심으로 정리합니다.


final이란?

final은 변수에 값을 한 번만 넣을 수 있게 만드는 키워드입니다.

final int maxTemperature = 35;

위 코드는 maxTemperature에 35를 넣고, 이후에는 다른 값을 넣지 않겠다는 뜻입니다.

final int maxTemperature = 35;

// maxTemperature = 40; // 컴파일 오류

final이 붙은 변수에 다시 값을 넣으려고 하면 컴파일 오류가 발생합니다. 즉, 실행 중에 문제가 생기기 전에 자바가 미리 막아줍니다.

이 특징 때문에 final은 아래 상황에서 자주 사용됩니다.

  • 바뀌면 안 되는 값
  • 처음 정한 뒤 계속 유지해야 하는 값
  • 코드 의미를 더 분명하게 만들고 싶은 값
  • 상수로 관리할 값

지역 변수에 final 사용하기

메서드 안에서 만든 변수를 지역 변수라고 합니다. 지역 변수에도 final을 붙일 수 있습니다.

public class FinalLocalMain {
    public static void main(String[] args) {
        final int dailyLimit = 3;

        System.out.println(dailyLimit);

        // dailyLimit = 5; // 컴파일 오류
    }
}

실행 결과입니다.

3

dailyLimit은 한 번 3으로 정해진 뒤 다시 바꿀 수 없습니다. 그래서 코드 읽는 사람도 “이 값은 중간에 바뀌지 않는구나”라고 바로 이해할 수 있습니다.

값을 나중에 넣는 것도 가능합니다. 다만 한 번만 넣을 수 있습니다.

final int targetCount;

targetCount = 10;

// targetCount = 20; // 컴파일 오류

처음 선언할 때 바로 넣지 않아도 됩니다. 하지만 값을 한 번 넣은 뒤에는 다시 바꿀 수 없습니다.


매개변수에 final 사용하기

메서드 매개변수에도 final을 붙일 수 있습니다.

public class FinalParameterMain {
    public static void main(String[] args) {
        printLimit(5);
    }

    static void printLimit(final int limit) {
        System.out.println(limit);

        // limit = 10; // 컴파일 오류
    }
}

limit은 메서드가 호출될 때 전달받은 값입니다. 여기에 final이 붙으면 메서드 안에서 매개변수 값을 다시 바꿀 수 없습니다.

매개변수를 꼭 final로 써야 하는 것은 아닙니다. 하지만 “전달받은 값은 그대로 두고, 필요한 계산은 다른 변수로 하겠다”는 의도를 표현할 때 도움이 됩니다.

static int addBonus(final int point) {
    int result = point + 10;
    return result;
}

이렇게 작성하면 point는 입력값으로 남겨두고, 계산 결과는 result에 담습니다.


필드에 final 사용하기

클래스 안에 있는 변수를 필드라고 합니다. 필드에도 final을 붙일 수 있습니다.

public class Member {
    private final String id;
    private String name;

    public Member(String id, String name) {
        this.id = id;
        this.name = name;
    }
}

id는 회원을 구분하는 값입니다. 한 번 만들어진 회원의 ID가 중간에 바뀌면 문제가 생길 수 있습니다.

그래서 idfinal을 붙이고, 생성자에서 한 번만 값을 넣습니다.

public class Member {
    private final String id;
    private String name;

    public Member(String id, String name) {
        this.id = id;
        this.name = name;
    }

    public void changeName(String name) {
        this.name = name;
    }

    public void print() {
        System.out.println("id=" + id + ", name=" + name);
    }
}

사용 예시입니다.

public class MemberMain {
    public static void main(String[] args) {
        Member member = new Member("user-1", "kim");

        member.print();
        member.changeName("lee");
        member.print();
    }
}

실행 결과입니다.

id=user-1, name=kim
id=user-1, name=lee

이름은 바뀔 수 있지만, ID는 바뀌지 않습니다. 이처럼 final 필드는 객체가 만들어진 뒤 바뀌면 안 되는 값을 표현할 때 유용합니다.


final 필드는 반드시 초기화해야 합니다

final 필드는 한 번만 값을 넣을 수 있습니다. 그래서 객체가 완성되기 전까지는 반드시 값이 정해져 있어야 합니다.

아래처럼 선언만 하고 끝내면 안 됩니다.

public class Turtle {
    private final String name;
}

name에 값을 넣는 코드가 없기 때문에 컴파일 오류가 발생합니다.

해결 방법은 크게 두 가지입니다.

첫 번째는 필드에서 바로 초기화하는 방식입니다.

public class Turtle {
    private final String species = "sulcata";
}

두 번째는 생성자에서 초기화하는 방식입니다.

public class Turtle {
    private final String name;

    public Turtle(String name) {
        this.name = name;
    }
}

생성자에서 초기화하면 객체마다 다른 값을 넣을 수 있습니다. 반면 필드에서 바로 초기화하면 모든 객체가 같은 값을 갖게 됩니다.


static final과 상수

바뀌지 않는 공통 값을 표현할 때는 static final을 자주 사용합니다. 이런 값을 보통 상수라고 부릅니다.

public class CareConstants {
    public static final int MAX_FEED_GRAM = 300;
    public static final int MIN_TEMPERATURE = 20;
    public static final int MAX_TEMPERATURE = 35;
}

상수 이름은 보통 모두 대문자로 쓰고, 단어 사이는 _로 구분합니다.

MAX_FEED_GRAM
MIN_TEMPERATURE
MAX_TEMPERATURE

상수를 사용하면 숫자의 의미가 분명해집니다.

public class FeedingService {
    public boolean isOverLimit(int gram) {
        return gram > CareConstants.MAX_FEED_GRAM;
    }
}

300이라는 숫자만 보면 무엇을 의미하는지 바로 알기 어렵습니다. 하지만 MAX_FEED_GRAM이라고 쓰면 “최대 급여량”이라는 의미가 드러납니다.


매직 넘버를 줄이는 데 도움이 됩니다

코드 곳곳에 의미를 알기 어려운 숫자가 직접 들어가 있으면 나중에 수정하기 어렵습니다. 이런 숫자를 보통 매직 넘버라고 부릅니다.

예를 들어 아래 코드를 보겠습니다.

public class TemperatureChecker {
    public boolean isTooHot(int temperature) {
        return temperature > 35;
    }

    public boolean isTooCold(int temperature) {
        return temperature < 20;
    }
}

3520이 무엇을 의미하는지 지금은 알 수 있습니다. 하지만 코드가 커지면 숫자의 의미를 놓치기 쉽습니다.

상수로 바꾸면 더 읽기 쉬워집니다.

public class TemperatureRule {
    public static final int MIN_TEMPERATURE = 20;
    public static final int MAX_TEMPERATURE = 35;
}
public class TemperatureChecker {
    public boolean isTooHot(int temperature) {
        return temperature > TemperatureRule.MAX_TEMPERATURE;
    }

    public boolean isTooCold(int temperature) {
        return temperature < TemperatureRule.MIN_TEMPERATURE;
    }
}

이제 숫자의 의미가 코드에 드러납니다. 나중에 기준 온도를 바꾸고 싶다면 상수 값만 수정하면 됩니다.


final과 참조형

final을 참조형 변수에 사용할 때는 특히 조심해야 합니다. final이 막는 것은 변수가 다른 객체를 가리키도록 바꾸는 것입니다.

먼저 간단한 클래스를 만들겠습니다.

public class CareRecord {
    public int day;
}

그리고 참조형 변수에 final을 붙여보겠습니다.

public class FinalReferenceMain {
    public static void main(String[] args) {
        final CareRecord record = new CareRecord();

        record.day = 1;
        System.out.println(record.day);

        record.day = 2;
        System.out.println(record.day);

        // record = new CareRecord(); // 컴파일 오류
    }
}

실행 결과입니다.

1
2

record 변수는 다른 CareRecord 객체를 가리킬 수 없습니다. 하지만 record가 가리키는 객체 안의 day 필드는 바뀔 수 있습니다.

즉, 아래 두 문장을 구분해야 합니다.

final 참조형 변수는 다른 객체를 가리킬 수 없습니다.
하지만 그 객체의 내부 값까지 자동으로 불변이 되는 것은 아닙니다.

참조형이 아직 헷갈린다면 자바 기본형과 참조형 정리 - 값과 주소로 이해하기를 먼저 읽으면 좋습니다.


객체 내부까지 바꾸지 못하게 하려면?

참조형 변수에 final을 붙이는 것만으로는 객체 내부 값 변경을 막을 수 없습니다. 객체 내부 값까지 바꾸지 못하게 하려면 필드 자체도 안전하게 설계해야 합니다.

public class CareRecord {
    private final int day;

    public CareRecord(int day) {
        this.day = day;
    }

    public int getDay() {
        return day;
    }
}

이제 day는 객체가 만들어질 때 한 번 정해지고, 이후에는 바뀌지 않습니다.

public class CareRecordMain {
    public static void main(String[] args) {
        final CareRecord record = new CareRecord(1);

        System.out.println(record.getDay());
    }
}

실행 결과입니다.

1

이 구조에서는 참조 변수도 다른 객체로 바꿀 수 없고, 객체 내부의 day도 바꿀 수 없습니다.


final을 사용할 때의 기준

처음에는 아래 기준으로 생각하면 됩니다.

상황사용 방식
메서드 안에서 값이 바뀌면 안 됨지역 변수에 final
객체 생성 후 바뀌면 안 되는 값필드에 final
여러 곳에서 함께 쓰는 고정값static final 상수
다른 객체로 바뀌면 안 되는 참조참조형 변수에 final

모든 변수에 무조건 final을 붙여야 하는 것은 아닙니다. 하지만 값이 바뀌면 코드가 헷갈리거나 위험해지는 곳에는 final이 좋은 신호가 됩니다.

final은 “이 값은 여기서 정해지고, 이후에는 바뀌지 않는다”는 약속입니다.


실수하기 쉬운 부분

첫 번째는 final 변수를 다시 대입하려는 것입니다.

final int limit = 10;

// limit = 20; // 컴파일 오류

두 번째는 final 필드를 초기화하지 않는 것입니다.

public class User {
    private final String id;

    // 생성자에서 id를 초기화하지 않으면 컴파일 오류
}

세 번째는 참조형에서 final의 의미를 잘못 이해하는 것입니다.

final CareRecord record = new CareRecord();

record.day = 2; // 객체 내부 값은 바뀔 수 있음
// record = new CareRecord(); // 참조값 변경은 불가

final은 변수에 다시 값을 넣는 것을 막습니다. 참조형 객체 내부까지 모두 잠그는 문법은 아닙니다.


정리

final은 한 번 정한 값을 다시 바꾸지 못하게 하는 키워드입니다.

지역 변수에 붙이면 메서드 안에서 값이 다시 대입되지 않습니다. 필드에 붙이면 객체가 만들어진 뒤 값이 바뀌지 않습니다. static final로 만들면 여러 곳에서 함께 쓰는 상수로 사용할 수 있습니다.

참조형에 final을 붙이면 다른 객체를 가리키지 못합니다. 하지만 그 객체 내부의 필드까지 자동으로 불변이 되는 것은 아닙니다.

처음에는 아래 문장만 기억해도 좋습니다.

final은 변수에 다시 대입하는 것을 막습니다.
참조형에서는 객체 교체를 막을 뿐, 객체 내부 변경까지 막지는 않습니다.

이 차이를 이해하면 상수, 객체 식별자, 설정값처럼 바뀌면 안 되는 값을 더 안전하게 다룰 수 있습니다.