Post

[JAVA] final 키워드 알아보기

final

김영한의 실전 자바 기본편을 보며 정리해보는 final

final은 말 그대로 끝이라는 뜻이다. 변수나 클래스, 메서드에 final이 붙으면 더는 값을 변경할 수 없다.

오늘은 final에 대해 알아보고, 값을 변경할 수 없게 만들면 얻게 되는 이점을 알아보자.

final - 지역 변수

  • final을 지역 변수에 설정할 경우 최초 한번만 할당할 수 있다. 이후에 변수의 값을 변경하려면 컴파일 오류가 발생한다.
  • final을 지역 변수 선언시 바로 초기화 한 경우 이미 값이 할당되었기 때문에 값을 할당할 수 없다.
  • 매개변수에 final이 붙으면 메서드 내부에서 매개변수의 값을 변경할 수 없다. 따라서 메서드 호출 시점에 사용된 값이 끝까지 사용된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class FinalLocalMain {
    public static void main(String[] args) {
        final int data1;
        data1 = 10; // 최초 한 번만 할당 가능
        // data1 = 20;

        // final 지역 변수2
        final int data2 = 10;
        // data2 = 20;
    }

    static void method(final int parameter) {
        // parameter = 20; 컴파일 오류
    }
}

final - 필드(멤버 변수)

  • final을 필드에 사용할 경우 해당 필드는 생성자를 통해서 한번만 초기화 될 수 있다.
1
2
3
4
5
6
7
8
public class ConstructInit {

    final int value;

    public ConstructInit(int value) {
        this.value = value;
    }
}
  • final 필드를 필드에서 초기화하면 이미 값이 설정되었기 때문에 생성자를 통해서도 초기화 할 수 없다.
  • static final 키워드가 붙은 것을 자바에서 상수라고 표현한다.
    • 관례적으로 대문자를 사용하며 언더바로 단어를 구분한다.
1
2
3
4
5
public class FieldInit {

    static final int CONST_VALUE = 10; // 상수
    final int value = 10;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class FinalFieldMain {
    public static void main(String[] args) {
        // final 필드 - 생성자 초기화
        System.out.println("생성자 초기화");
        ConstructInit constructInit1 = new ConstructInit(10);
        ConstructInit constructInit2 = new ConstructInit(20);
        System.out.println(constructInit1.value);
        System.out.println(constructInit2.value);

        // final 필드 - 필드 초기화
        System.out.println("필드 초기화");
        FieldInit fieldInit1 = new FieldInit();
        FieldInit fieldInit2 = new FieldInit();
        FieldInit fieldInit3 = new FieldInit();

        System.out.println(fieldInit1.value);
        System.out.println(fieldInit2.value);
        System.out.println(fieldInit3.value);

        // 상수
        System.out.println("상수");
        System.out.println(FieldInit.CONST_VALUE);

    }
}
1
2
3
4
5
6
7
8
9
생성자 초기화
10
20
필드 초기화
10
10
10
상수
10

위 코드를 실행시켜 보면 constructInit 인스턴스의 value는 생성자를 통해 단 한 번 값을 할당 할 수 있음을 알 수 있고, fieldInit 인스턴스는 이미 값이 설정되어 있어 다른 수를 대입할 수 없음을 알 수 있다. 다른 수를 대입하려 하면 컴파일 에러가 발생한다.

그런데, FieldInit 클래스는 여러 개의 인스턴스를 생성해도 다음과 같은 메모리 구조를 가지게 된다.

alt text

FieldInit 클래스의 인스턴스를 생성하면 인스턴스 변수인 value가 힙 영역에 생성되게 되는데, 이 valuefinal로 선언되어 값을 변경할 수 없다.

즉, 모든 인스턴스 변수가 10이라는 변경할 수 없는 값을 가진다.

그렇다면 굳이 모든 인스턴스가 10을 가지고 있을 필요가 없다. 이는 메모리 낭비와 중복을 초래할 뿐이다.

이럴 때 사용하는 것이 바로 static 영역이다. static에 대해서는 이전 포스팅에서 다뤄보았다.

static final, 상수

변하지 않고 항상 일정한 값을 갖는 수

따라서 모든 클래스 인스턴스에서 똑같이 써야하고 변하지 않는 값이 있다면 static final로 선언해 사용하는 것이 좋다.

다시 한 번 정리하면 static final을 사용해야 하는 경우는 다음과 같다.

  • final + 필드 초기화를 사용하는 경우
    • 변하지 않는 값을 모든 인스턴스에서 메모리에 할당하기 때문에 메모리 낭비 발생
  • 모든 곳에서 하나의 값을 일관되게 사용하기 위해 상수를 선언해야 하는 경우

이럴 때 static final을 사용하면 메모리 비효율 문제와 중복을 제거할 수 있다.

상수의 특징

자바에서 상수의 특징은 다음과 같다.

  • static final 키워드를 사용한다.
  • 대문자를 사용하고 구분은 _로 한다.
  • 필드에 직접 접근해서 사용한다.
    • 상수는 기능이 아니라 고정된 값 자체를 사용하는 것이 목적이다.
    • 상수는 값을 변경할 수 없다. 따라서 필드에 직접 접근해도 데이터가 변하는 문제가 발생하지 않는다.
  • 보통 애플리케이션 전반에서 사용되기 때문에 public을 자주 사용한다.
  • 중앙에서 값을 하나로 관리할 수 있다는 장점이 있다.
  • 런타임에 변경할 수 없다. 상수를 변경하려면 프로그램을 종료하고 코드를 변경한 후 프로그램을 다시 실행해야 한다.
  • 멀티 스레드 환경에서도 안전하다.

상수를 사용하면 얻을 수 있는 장점

그럼 왜 상수를 사용하면 좋은지 코드를 통해 알아보자.

예시를 들기 위해 다음과 같은 게임을 만들었다.

1
2
3
4
1. 1~10까지의 숫자를 랜덤으로 생성한다.
2. 사용자로부터 숫자를 입력받는다.
3. 사용자가 입력한 숫자와 생성된 숫자가 같으면 "정답!"을 출력하고, 아니면 "오답!"을 출력한다.
4. 정답을 맞추면 프로그램을 종료한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class GameMain {
    public static void main(String[] args) {
        Random random = new Random();
        int answer = random.nextInt(10) + 1;
        System.out.println("게임 시작! 랜덤 숫자가 생성되었습니다.");
        Scanner input = new Scanner(System.in);

        while (true) {
            System.out.println("숫자를 입력해주세요.");
            int userInput = input.nextInt();

            isInRange(userInput);

            if (userInput == answer && isInRange(userInput)) {
                System.out.println("정답!");
                break;
            } else {
                System.out.println("오답!");
            }
        }
    }

    static boolean isInRange(int value) {
        if (value < 1 || value > 10) {
            System.out.println("숫자 범위는 1부터 10까지입니다. 다시 입력해주세요.");
            return false;
        }
        return true;
    }
}

이 코드에는 다음과 같은 문제가 있다.

  • 만약 숫자 범위를 변경하려면 여러 곳의 변경 포인트가 발생한다.
  • 매직 넘버 문제가 발생한다.

🔮 Magic Number란?

코드에서 제거되는 것이 권장되는 안티 패턴으로, 소스 코드에서 의미를 가진 숫자나 문자를 그대로 표현한 것을 말한다.

예를 들어 isInRange 메서드를 살펴보자.

1
2
3
4
5
6
static boolean isInRange(int value) {
        if (value < 1 || value > 10) {
            return false;
        }
        return true;
    }

해당 코드에서 1과 10이라는 숫자는 무엇을 의미하는지 바로 알기 힘들다.

물론 코드를 읽고 유추할 수는 있지만 맞는 의미인지 확신하기 어렵다.

이런 표현은 코드를 읽기 어렵게 만들고 코드의 흐름을 이해하기 위해 많은 시간을 소모하게 된다.

이런 Magic Number를 없애기 위해 상수가 사용된다.

앞서 만든 게임을 상수를 이용해 리팩토링 해보자.

먼저 게임에 사용하는 상수들을 한 곳에서 관리하기 위해 GameNumbers 클래스를 만들었다.

1
2
3
4
public class GameNumbers {
    public static final int MIN_NUMBER = 1;
    public static final int MAX_NUMBER = 10;
}

앞으로 게임에서 사용되는 숫자 범위에 변동이 생기면 여기에서 바꾸어주면 된다. 다음으로 메인 함수를 상수를 사용하도록 수정해주자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class GameMain {
    public static void main(String[] args) {
        Random random = new Random();
        int answer = random.nextInt(GameNumbers.MAX_NUMBER) + GameNumbers.MIN_NUMBER;
        System.out.println("게임 시작! 랜덤 숫자가 생성되었습니다.");
        Scanner input = new Scanner(System.in);

        ...

    static boolean isInRange(int value) {
        if (value < GameNumbers.MIN_NUMBER || value > GameNumbers.MAX_NUMBER) {
            System.out.println("숫자 범위는 " + GameNumbers.MIN_NUMBER +
                    "부터 " + GameNumbers.MAX_NUMBER + "까지입니다. 다시 입력해주세요.");
            return false;
        }
        return true;
    }
}

이렇게 하면 만약 랜덤 숫자의 범위가 늘어나거나 줄어들게 되더라도 GameNumbers의 상수 값만 바꾸어주면 된다.

앞서 이야기 했던 두 가지 문제점을 모두 해결하고, 가독성과 유연성이 향상되었다. 또, final로 선언해 줌으로써 게임의 룰을 다른 곳에서 변경할 수 없도록 안전성을 보장한다.

final 변수와 참조

final은 변수의 값을 변경하지 못하도록 막는다.

기본형 변수는 값(10, 20)을 보관하고 참조형 변수는 객체의 참조값 (x001, x002)을 보관한다.

즉, 참조형 변수에 final을 사용한다면 해당 변수의 참조값을 변경할 수 없게 되는 것이다. 더 쉽게 이야기하면 이제 다른 객체를 참조할 수 없게 되는 것이다.

1
2
3
public class Data {
    public int value; // 어디서든지 변경할 수 있는 변수
}
1
2
3
4
5
6
7
8
9
10
11
12
public class FinalRefMain {
    public static void main(String[] args) {
        final Data data = new Data(); // 참조값 변경 불가능
        // data = new Data();

        // 참조 대상의 값은 변경 가능
        data.value = 10;
        System.out.println(data.value);
        data.value = 20;
        System.out.println(data.value);
    }
}
1
2
final Data data = new Data();
data = new Data(); // 컴파일 오류

data에는 Data 클래스 인스턴스의 주소값이 들어있다. 여기에 다시 new Data()를 통해 새로운 인스턴스 주소값을 할당하려고 하면 컴파일 오류가 발생한다. 이는 다음과 같은 코드가 작동하지 않는 것과 같다.

1
2
final int value = 10;
value = 20; // 컴파일 오류

final은 어떤 값이든 최초 한 번만 값을 대입할 수 있기 때문이다.

그러나 참조 변수의 경우, 참조 대상의 객체 값은 변경할 수 있다.

1
2
3
4
data.value = 10;
System.out.println(data.value);
data.value = 20;
System.out.println(data.value);
1
2
10
20

기존에 10이었던 data 인스턴스의 value 값이 20으로 바뀌었다.

왜냐하면 final이 붙은 참조 변수는 참조 대상을 바꿀 수 없을 뿐이지, 참조 대상 안에 있는 변수까지 영향을 주는 것은 아니기 때문이다.

data 인스턴스 안에 있는 valuefinal이 아니기 때문에 값을 변경할 수 있다.

This post is licensed under CC BY 4.0 by the author.