final에 대해서 내가 이해하고 있는 정도는 불변함이라는 특징밖에 없는 것 같다. 왜 사용하고 언제 사용하는지에 대한 이해도가 없다고 생각하고 무지성으로 남발하지 않기 위해서 정리해본다.
final이란 '최종', '마지막'이라는 뜻을 가지고 있다. 이를 프로그래밍에 적용하면 어떤 것이 마지막, 최종본이라는 의미로 해석했다. Java에서는 final를 불변에 관해서 주로 사용한다. 그렇다면 불변이라는 것은 무엇일까? 불변은 말 그대로 변하지 않는다는 것을 의미한다. 상태를 변하지 않게 하는 것이 불변의 핵심이다. 이 때 사용하는 것이 final이다. final은 Java에서 변수, 메서드, 클래스에서 사용한다. final을 이용해서 변수, 메서드, 클래스를 불변하게 만들어 준다.
변수에 final을 붙인다면 어떻게 될까? 다음 코드를 보자.
final int num = 10;
num = 20; // compile error!
이렇게 final이 붙은 기본 타입을 다시 초기화하게 된다면 컴파일 과정에서 오류가 발생한다. 따라서 변수는 초기화 후에 값을 재할당할 수 없게 된다는 것이다.
final 키워드를 사용한 변수에 초기값을 설정하는 방법은 크게 두 가지가 존재하는데 첫번째는 클래스의 필드에 값을 할당할 때이고 다른 하나는 생성자에서 초기화할 때이다.
class Stair{
private static final int STEP = 1;
public int getStep() {
return STEP;
}
public void setStep(int step) {
STEP = step; //compile error!
}
}
다음과 같이 Stair라는 클래스가 있을 때 상수를 지정해주고 싶을 때 클래스의 필드로 final을 붙여 초기값을 설정해준다. 이렇게 되면 상수로 초기화해준 값은 수정하게 되면 compile error가 발생하게 된다.
클래스의 필드에 final과 함께 static을 이용한 이유는 static을 사용하게 되면 컴파일 시점에 메모리에 값을 할당을 한 번만 하기 때문에 효율성 측면에서 사용하는 것이 좋다.
class Stair{
private final int height;
public Stair(final int height){
height += 1; // compile error
this.height = height;
}
}
이렇게 final이 사용된 변수를 생성자에서 초기화하는 경우 생성자에서 추가적으로 로직이 실행되어야 하는 경우와 같이 복잡한 로직이 생성자에 추가되게 될 경우 매개변수에 final을 붙여 메서드 내부에서 인자를 변경할 수 없도록 만든다.
그렇다면 객체 생성시에 final을 붙이면 객체의 상태를 불변하게 만드는 것일까?
class Stair{
private final List<Integer> steps;
public Stair() {
steps = new ArrayList<>(List.of(1,2,3,4));
}
public List<Integer> getSteps() {
return steps;
}
}
이러한 클래스가 있고 final을 붙인 필드가 있고 객체 생성시에 이를 할당해준다. 그렇다면 다음과 같이 객체를 생성하고 final을 붙이게 된다면 해당 객체는 변경되어서는 안될 것이다.
final Stair stair = new Stair();
// steps = {1,2,3,4}
stair.getSteps().add(5);
// steps = {1,2,3,4,5}
그러나 final 객체의 final 필드를 반환해서 add 작업을 해주니 해당 클래스의 final 필드가 변경되었다. 이는 클래스의 상태를 변경하는 결과를 초래했다. 왜 그런것일까?
이유는 final 키워드는 항상 불변을 보장하는 키워드가 아니기 때문이다. final 키워드는 변수를 사용했더라도 해당 필드를 참조하여 변경할 수 있다면 final이 선언되어 있더라고 변경이 가능해진다. 그렇기 때문에 Java에서는 참조에 의해 값이 변경될 수 있다는 점을 유의해야 한다.
메서드에도 final 키워드를 붙일 수 있다. 다음 코드를 보자
class Parent{
final void finalMethod(){
// final method
}
}
class Child extends Parent{
//compile error !
void finalMethod(){
// final method
}
}
부모 클래스에 선언된 final 메서드를 자식 클래스에서 오버라이드하게 된다면 컴파일 오류가 발생한다. 즉, 메서드에 final 키워드를 사용하는 경우 그 메서드를 변경시킬 수 없다.
메서드에 final을 붙이는 경우는 절대 불변해서는 안되는 중요한 로직이 담긴 메서드에 final을 사용하여 변경을 원치않는 다는 것을 명시할 때 사용하면 될 것 이다.
클래스에도 final을 붙일 수 있다. 다음 코드를 보자.
final class Parent{
}
class Child extends Parent{ // compile error!
}
클래스에 final을 붙이게 된다면 상속할 수 없는 클래스가 된다. 이는 부모 클래스가 될 수 없음을 의미한다. 그러나 이것이 클래스가 불변한다는 것은 아니다. 왜냐하면 해당 클래스의 필드 값과 같은 클래스의 상태는 생성자나 메서드를 통해 변경될 가능성이 있기 때문이다.
따라서 final을 클래스에 붙이면 상속을 하지 못하기 때문에 불변성에 기여할 수는 있지만 불변을 보장할 수는 없다. 대표적인 final이 붙은 클래스는 String이 있다.
그렇다면 변경 가능성이 있는 클래스를 불변하게 만들기 위한 방법은 없을까? 방법은 있다. 불변 클래스를 만들기 위해서는 다음과 같은 규칙을 지켜야 한다.
final class ImmutablePerson{
private final String name;
private final int age;
private final List<String> friends;
private ImmutablePerson(final String name, final int age) {
this.name = name;
this.age = age;
this.friends = new ArrayList<>();
}
public static ImmutablePerson(final String name, final int age){
return new ImmutablePerson(name, age);
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
public List<String> getFriends() {
return Collections.unmodifiableList(friends);
}
}
위의 코드는 불변 클래스를 만들기 위한 조건을 충족한 클래스이다. 하나씩 살펴보면 final 키워드를 class에 붙여 하위 클래스로부터의 변경가능성을 봉쇄하였다. 모든 클래스의 필드를 private, final을 붙여 외부로부터의 변경가능성을 막았다.
그리고 정적 팩토리 메서드를 통해 내부 생성자를 호출하여 외부에서 생성자를 호출하지 못하도록 막았다. 만약 생성자를 내부 생성자가 아닌 JVM이 만들어주는 기본 생성자를 통해 객체를 생성할 경우 외부에서 해당 객체를 자유롭게 호출이 가능해지기 때문에 객체 생성 후 다른 객체를 참조하게 될 가능성이 생긴다.
final class ImmutablePerson {
private final String name;
private final int age;
public ImmutablePerson(final String name, final int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
public class Main {
public static void main(String[] args) {
ImmutablePerson person = new ImmutablePerson("Alice", 30)
person = new ImmutablePerson("Bob", 25); // Immutability is broken
}
}
마지막으로 수정 가능성이 있는 List를 변경하지 못하도록 방어적 복사를 통해 변경하지 못하도록 하였다.
여기서 방어적 복사란?
방어적 복사란 데이터가 불변으로 유지되거나 의도하지 않은 수정으로부터 보호되도록 하는 데 사용되는 프로그래밍 방식이다. 컬렉션을 처리할 때 방어 복사를 통해 외부 코드가 원본 컬렉션을 수정하는 것을 방지하기 위해 컬렉션 복사본을 만드는 작업을 포함한다.
만약 필드를 final로 선언하지 못하는 경우가 있을 수 있다. 그 경우에는 최대한 setter를 최소화하고 변경 가능성이 있는 메서드를 최소화하자.
final을 붙이면 불변하게 만드다는 것은 잘못됬다. final을 붙이는 것은 값을 재할당하는 것을 막아주는 것이지 불변성을 보장한다는 것은 아니다. 위의 코드 예제에서 봤듯이 참조를 통해 값을 변경시켜 객체의 내부 상태를 변경하는 경우도 있다. 그렇다면 왜 불변이 필요하고 왜 불변성을 지켜야만 할까?
불변 객체를 사용해야 하는 이유는 다양한 이유가 존재한다. 대표적인 이유를 알아보자.
불변 객체는 일단 생성이되면 상태를 변경할 수 없기 때문에 본질적으로 스레드로부터 안전하다. 따라서 다중 스레드 환경에서 동기화가 필요하지 않아 병렬 프로그래밍에 유용하다.
불변 객체는 생성 후에 상태가 변경될 수 없기 때문에 안전하게 캐시할 수 있다. 이를 통해 불필요한 객체 생성을 막고 메모리 사용량을 줄여 성능을 향상 시킬 수 있다.
사이드 이펙트 즉, 부작용은 변수의 값의 상태 변경으로 인해 발생하는 오류를 일컫는다. 불변 객체는 이러한 상태 변경 가능성이 적으며, 객체의 생성과 사용이 상당 부분 제한된다. 따라서 오류가 발생할 가능성이 높은 메서드 호출 부분에서 오류 가능성을 줄여 유지보수성이 높은 코드를 작성할 수 있다.
코드를 나 혼자 작성한다면 상관없겠지만, 코드의 양이 방대해지고 협업을 진행하게 된다면 남이 작성한 코드를 믿고 코드를 작성하게 된다. 만약 불변을 보장한 클래스를 사용하게 된다면 해당 객체를 사용할 때 위험 부담성이 적고 코드를 편히 작성할 수 있게된다.
불변성을 통해 가비지 컬렉션의 성능을 향상 시킬 수 있다. 불변 객체의 상태를 변경할 수 없기 때문에 상태를 변경이 필요한 경우 객체를 새로 생성해야 한다는 것이 성능상 좋지 않을 것이라 생각하지만 Oracle은 이는 불변 객체를 이용한 효율로 커버가 가능하다고 설명한다.
GC는 새롭게 생성된 객체는 금방 사라진다는 가설에 맞추어 설계가 되어있다고 한다. 따라서 불변 객체를 새로 생성한다고 해서 GC 입장에선는 생명주기가 짧은 객체를 처리하는 것이 부담이 되지 않는다. 또한 불변 객체를 사용하게 되면 GC의 스캔빈도와 범위가 줄게 되어 GC의 성능에 도움이 된다고 한다. 또한 가변 객체는 상태 변경 가능성이 있기 때문에 생명주기가 길다고 보고 GC의 스캔범위와 스캔빈도가 불변객체보다는 성능상 좋지 못하다고 한다.
final을 사용하게 된다면 단점도 존재한다. final을 사용하여 변경 가능성을 줄여 안정성을 향상시키지만 무분별하게 사용하게 된다면 코드의 유연성을 감소시키고 확장성을 저해할 수 있다.또한 final로 선언된다면 단위 테스트가 어려울 수 있고, final 때문에 코드의 결합성이 강해져 유지보수성이 떨어질 수도 있다.
예를 들어 메서드에 final이 붙은 메서드를 다른 클래스에서 사용하고 있다면 만약 추후에 final이 붙은 메서드를 재정의하거나 수정시에 이 메서드를 사용하고 있는 다른 클래스의 코드도 수정이 필요할 수도 있다. 따라서 final이 코드의 결합을 강화시켜 유지보수성을 떨어뜨릴수도 있다.
// 추후 calculateAreaOfCircle 메서드를 수정해야 할 때 CalculationService를 수정해야 하기 때문에 이를 의존하고 있는 클래스들을 모두 수정해야 할 수도 있다.
public class CalculationService {
public final double calculateAreaOfCircle(double radius) {
return Math.PI * radius * radius;
}
}
public class CircleClient {
private CalculationService calculationService;
public CircleClient(CalculationService calculationService) {
this.calculationService = calculationService;
}
public void displayCircleInformation(double radius) {
double area = calculationService.calculateAreaOfCircle(radius);
System.out.println("Area of the circle with radius " + radius + " is: " + area);
}
}
final을 사용하여 객체의 변경 가능성을 줄인다면 해당 객체를 마음 놓고 사용하여도 부작용이 덜하고 성능적으로도 향상될 수 있다. 따라서 최대한 불변으로 작성하고 불변이 불가능하다면 변경 가능성을 최소화하자. 그렇다고 무지성으로 final을 사용하고 남발하게 된다면 오히려 안좋을 수도 있다. 따라서 잘 고민하고 사용하자.
또한 final을 사용한다고 불변성을 보장하는 것이 아니라 재할당을 막아주는 것이다. 내부 상태가 변하지 않았음을 보장하는 것이 아니다.