인텔리J (IntelliJ)를 이용해서 프로젝트를 진행하는데 'Code Inspection'을 수행하다가 'Declaration can have final modifier' 메시지를 보게되었다. Java에서 final 키워드는 어떤 의미를 가지고 있으며 어떻게 사용하는게 좋을지 찾아보았다.
Java의 final 키워드
자바에서 final 키워드에 대한 정의는 다음과 같다. (링크 : 위키피디아)
In he Java programming language, the final keyword is used in several contexts to define an entity that can only be assigned once
final 키워드를 사용할 수 있는 대상에는 '클래스(Class)', '메소드(Method)', '멤버변수(Field)'가 있다. final 키워드를 지정하면, 클래스, 메소드, 멤버변수에 대한 의도하지 않은 사용을 컴파일 타임에 알 수 있다.
3개의 대상에 대한 final 키워드의 의미는 약간씩 다르다.
1. final class (final 클래스)
class의 선언부에 final 키워드를 사용하면, '이 클래스는 하위 클래스(subclass)를 만들 수 없다'라는 의미를 갖게 된다. 즉, final 클래스를 상속하여 새로운 클래스로 확장(Extend) 할 수 없다.
간단한 클래스를 하나 생각해보자. 과일의 무게를 담고 있는 Fruit 클래스를 정의해본다.
1 2 3 4 5 | public class Fruit { public int weight = 10; } | cs |
Fruit 클래스를 상속하여 Apple 클래스를 만들어보자,
1 2 3 4 5 | public class Apple extends Fruit { private String color = "Red"; } | cs |
잘 된다.
만약 Fruit 클래스에 final 키워드를 사용한다면?
1 2 3 4 5 | public final class Fruit { public int weight = 10; } | cs |
Fruit 클래스를 상속하여 만든 Apple 클래스쪽에서 에러가 발생하게 된다. "cannot inherit from final Fruit" 에러가 발생한다. final 클래스인 Fruit은 "extend" 키워드를 통해 상속되어 질 수 없기 때문에 컴파일 타임에 에러가 발생하게 된다.
자신이 정의한 클래스가 잘 못된 방식으로 상속, 확장되는 것을 막고자 할 때 final 키워드를 사용할 수 있다.
2. final method (final 메소드)
final 키워드를 메소드 정의에 붙이면, "이 메소드는 오버라이드(Override) 할 수 없다"는 의미를 갖게된다.
급여를 설정하고 확인할 수 있는 간단한 Employer 클래스를 작성해보자.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | public class Employer { protected int salary; public void setSalary(int newSalary) { this.salary = newSalary; } public int getSalary() { return this.salary; } } | cs |
급여에 해당하는 salary 멤버변수는 상속이후에도 사용되어야하므로 protected 접근 제어자를 사용했다.
Employer를 상속하여 FraudEmployer 클래스를 만들어보면
1 2 3 4 5 6 7 8 | public class FraudEmployer extends Employer { public void setSalary(int newSalary) { this.salary = newSalary * 10; } } | cs |
급여를 설정하는 메소드를 오버라이드(Override) 해서 10의 급여가 설정되도록 바꿨다. setSalary() 메소드를 호출하는 쪽에서는 newSalary로 넘겨준 값이 설정되기를 원하지만 FraudEmployer 클래스에서는 설정한 값의 10배가 설정되도록 메소드가 변경되었다. 상속을 하면서 의도되지 않메소드가 새로 만들어지는 예제이다.
이를 막기위해서 Employer 클래스의 setSalary 메소드에 final 키워드를 붙여보면
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | public class Employer { protected int salary; public final void setSalary(int newSalary) { this.salary = newSalary; } public int getSalary() { return this.salary; } } | cs |
FraudEmployer 클래스의 setSalary 부분에서 에러가 발생한다.
Error:(3, 17) java: setSalary(int) in FraudEmployer cannot override setSalary(int) in Employer
overridden method is final
클래스에 있는 메소드가 오버라이드(Override)되어 의도하지 않게 동작하는 것을 막기 위해서 final 메소드를 정의할 수 있다.
3. final field (final 멤버변수)
마지막으로 final 키워드를 클래스 멤버 변수에 붙이면 생성자(Constructor) 호출시 한번만 초기화 할 수 있다는 의미로 사용된다. 초기화 된 이후에는 읽기만 허용되며 값을 다시 설정 할 수 없다.
1 2 3 4 5 6 7 8 9 10 | public class Employer { protected final int salary; Employer() { this.salary = 2400; } } | cs |
salary 멤버 변수에 final 키워드를 명시하면 생성자(Constructor)에서 한 번만 초기화 할 수 있고, 이 후 다른 메소드에서는 읽기만 할 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | public class Employer { protected final int salary; Employer() { this.salary = 2400; } public int setSalary(int newSalary) { this.salary = newSalary; } } | cs |
final 멤버 변수를 설정하는 메소드를 정의하려고 하면 에러가 발생한다.
Employ 클래스의 final 멤버 변수인 salary 변수를 setSalary() 메소드로 수정하려고 하면 에러가 발생한다.
Error:(12, 13) java: cannot assign a value to final variable salary
인스턴스 생성시에 설정되고 바뀌지 않을 값에 final 키워드를 붙여주면 의도하지 않은 멤버 변수 변경을 막을 수 있다.
또 한, 변하지 않은 멤버 변수를 final로 선언해두면, 자바 컴파일러가 해당 멤버 변수를 Read-Only로 인식하여 레지스터 같은 곳에 값을 캐싱 할 수도 있어 성능향상을 노려볼 수도 있다.
final 멤버를 사용할 때 주의해야 할 점은 final 멤버가 Primitive 타입이 아닌 경우다. 다른 객체에 대한 참조(Reference)를 저장하는 경우 해당 객체에 대한 참조는 final로 변하지 않지만 참조가 가리키는 객체 자체는 변할 수 있다.
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 | // Not immutable -- the states array could be modified by a malicious caller public class DangerousStates { private final String[] states = new String[] { "Alabama", "Alaska", ... }; public String[] getStates() { return states; } } // Immutable -- returns an unmodifiable List instead public class SafeStates { private final String[] states = new String[] { "Alabama", "Alaska", ... }; private final List statesAsList = new AbstractList() { public Object get(int n) { return states[n]; } public int size() { return states.length; } }; public List getStates() { return statesAsList; } } | cs |
DangerousStates 클래스를 보자. 이 클래스는 멤버 변수로 String 타입의 배열을 저장할 수 있는 states 를 갖는다. DangerousStates 타입의 인스턴스가 만들어질 때, states 멤버 변수는 "Alabama", "Alaska" 같은 행정구역 이름을 Element(요소)로 갖는 배열로 초기화 된다. states 멤버 변수는 final 변수이기 때문에 새로운 String 배열을 만들어서 states 멤버에 할당하는 작업은 금지된다.
하지만 this.states 가 참조하는 객체 자체는 수정가능하다. 즉, this.states.add("GangWon"); 이런 식의 동작이 가능하다는 말이다. final 자체는 states 라는 멤버 변수가 String 배열 객체를 가리키는 참조에만 해당되며, 참조가 가리키는 객체에는 적용되지 않는 것이다.
final 변수가 가리키는 객체 자체도 불변(Immutable)으로 만들고 싶으면, SafeStates 처럼 코드를 작성해야 한다. 변경가능한 String 배열은 private으로 막아놓고, 외부에서 배열에 접근하고자 할 때에는 AbstractList()를 통해 값을 접근 하도록 해야한다.
결론
final 키워드를 클래스와 메소드에 붙이는 경우와 멤버 변수에 붙이는 경우는 접근 방식이 다르다.
클래스와 메소드에 final 키워드 사용을 남용하게 되면 객체지향적으로 좋은 코드가 나오기 힘들다. 이는 객체지향의 특징인 상속의 장점을 이용하기 힘들며 메소드 재정의(Override)를 적절하게 사용할 수 없으니 리팩토링이 되지 않고 뚱뚱한 코드가 여기저기 생겨나게 된다.
IBM 개발자 문서에 의하면 성능상 이득을 위해서 final 키워드를 메소드와 클래스에 붙이는 행위를 피하라고 가이드한다. 이를 통해 얻을 수 있는 성능상 이득보다 가독성과 재사용을 훼손하여 발생할 수 있는 악영향이 더 크다는 것이다. cycle-counting 최적화 같은 기계 레벨의 상세한 최적화는 자바 컴파일러와 JVM에게 맡기고 좀 더 효율적일 알고리즘으로 개선하거나 중복 계산을 최소화하는 쪽으로 개선을 하는게 바람직하다.
또 한, final 클래스와 메소드를 사용하는 경우에는 반드시 문서로 final 선언을 한 이유를 명시해야한다. 그래야 유지보수하는 다음 사람이 final의 명확한 의도를 알 수 있고, 삽질을 덜 하게 되기 때문이다.
멤버 변수에 final 을 붙이는 경우는 적극 권장한다. 멤버 변수에 붙이는 final은 멤버 변수 사용을 좀 더 명확하게 지정할 수 있어 클래스를 구현하거나 상속 받을 때 실수할 가능성을 줄여준다. 게다가 final 키워드는 과거 C언어의 register 키워드처럼 컴파일러, JVM 등에 성능 최적화 힌트로 작용할 수도 있다.
댓글