왜 자바는 포획된 지역 변수(Captured Local Variable)가 불변(final)이어야 하는 가?

요즘 자바 8의 람다식 때문인지 지역 클래스(Local Class), 무명 클래스(Anonymous Class), 람다식(Lambda Expression)에서 포획된 지역 변수가 final로 명시되거나 “사실상 final(자바 8에서 명시적으로 final 키워드가 붙지는 않았지만 대대입문으로 값을 바꾸지 않아 사실상 값이 바뀌지 않는 변수를 가리키는 용어)”이어야 하는 이유를 두고 온라인이나 오프라인에서 토론하는 일이 많아졌습니다. 저희 팀에서도 지난 주에 이를 가지고 의견을 주고 받았습니다.

이 때 말한 내용을 조금 자세히 정리해 보겠습니다.

내부 클래스 예제

먼저 간단한 예제 코드를 보겠습니다. 좀 쓸모있는 예제를 작성하려 했지만 역시나 그리 유용한 코드는 아닙니다. 예제로 만든 AsyncPrinter 클래스는 비동기로 문자열을 출력합니다.

package com.fupfin.capvar;

import java.io.PrintStream;
import java.util.concurrent.*;

public class  AsyncPrinter {
    private ExecutorService executor;
    private PrintStream out;
    private String prefix;
    private String suffix;

    public AsyncPrinter(ExecutorService executor, PrintStream out, String prefix, String suffix) {
        this.executor = executor;
        this.out = out;
        this.prefix = prefix;
        this.suffix = suffix;
    }

    public void printnln(final String str, final int maxlen) {
        final int len = str.length() > maxlen && maxlen >= 0 ? maxlen : str.length();
        executor.submit(new Runnable() {
            @Override public void run() {
                out.println(prefix + str.substring(0, len) + suffix);
            }
        });
    }
}

대략 보면 생성자 하나와 공개 메서드 하나가 눈에 띕니다.

생성자는 인자를 네 개 받는 데, 첫 번째 매개변수인 executor는 비동기로 작업을 실행할 때 사용할 ExecutorService의 구현체를 받습니다.  두 번째 매개변수 out은 문자열을 출력할 PrintStream입니다. 세 번째와 네 번째는 출력될 문자열의 앞과 뒤에 덧붙여질 문자열로서 출력될 문자열을 꾸밉니다.

유일한 공개 메서드 printnln(…)는 첫 번째 인자로 받은 문자열을 받고 두 번째 인자로 출력한 문자열의 길이를 받습니다. 전달 받은 문자열을 처음부터 길이 만큼 만 잘라서 비동기로 출력합니다.

메서드 내부를 보면, 먼저 출력할 문자열의 정확한 길이를 얻어서 변수 len에 담습니다. 두번째 인자값이 문자열보다 길면 문자열을 자르는 과정에서 예외(IndexOutOfBoundsException)가 발생하기 때문에 적절한 길이 값을 계산하려고 만든 코드입니다.  len 값을 계산하고 나면 생성자로 전달받은 ExecutorService에 비동기로 실행할 작업을 생성해서 제출합니다.

비동기로 실행될 작업 내부에서는 출력한 문자열을 len 만큼 잘라서 앞 뒤에 prefix와 suffix 문자열을 덧붙이고 AsyncPrinter의 생성자로 받은 PrintStream에 출력합니다.

AsyncPrinter를 사용하는 코드는 다음과 같습니다.

AsyncPrinter printer = new AsyncPrinter(ForkJoinPool.commonPool(), System.out,  "<<", ">>");
printer.printnln("Hello, World!", 5);
ForkJoinPool.commonPool().awaitQuiescence(1, TimeUnit.SECONDS);

AsyncPrinter 클래스의 객체를 생성하면서 생성자에 전달하는 처음 두 인자는 ForkJoinPool의 commonPool() 정적 메서드로 얻는, 자바 8에 새로 추가된 공용 ForkJoinPool과 표준 출력(System.out)입니다.  그리고 장식용 문자열로 “<<“와 “>>”를 이어서 전달합니다.

이 객체의 printnln(…) 메서드를 호출하면서 “Hello, World!”란 문자열과 숫자 5를 인자로 전달합니다.

마지막에 비동기 작업이 실행되지 않고 프로그램이 끝나지 않도록 awaitQuiescence(…) 메서드를 호출해 1초간 기다립니다.

이 코드의 실행 결과는 다음과 같습니다.

<<Hello>>

생성자에 전달한 prefix와 suffix가 “Hello, World!”의 처음 다섯자 앞뒤에 덧붙여져 출력된 것으로 봐서는 정상적으로 동작했습니다.

외곽 클래스(Outer Class) 멤버 필드 접근

위 코드에서 ExecuterService에 제출되는 Runnable 인터페이스의 구현체는 외곽 클래스인 AsyncPrinter의 필드(out, prefix, suffix)에 마치 동일 클래스 내 멤버 필드나 로컬 변수인 것 처럼 직접 접근합니다.

우리가 만든 Runnable의 구현체는 무명 클래스인데, 자바에서는 무명 클래스 뿐 아니라 로컬 클래스, 인스턴스 클래스 등 모든 내부 클래스(Inner Class)가 외곽 클래스(Outer Class)의 멤버 필드에 접근할 수 있습니다.

만약 run(…) 메서드 내부에 외곽 클래스의 멤버와 동일한 이름의 변수가 있어서 그림자 효과(Shadowing)가 일어난다고 해도 AsyncPrinter.this.out, AsyncPrinter.this.prefix, AsyncPrinter.this.suffix와 같이 명시적으로 이름 공간을 적어주면 접근하는데 아무런 문제도 생기지 않습니다. 결국 예제의 무명 클래스 안 로직은 다음과 동일합니다.

AsyncPrinter.this.out.println(
        AsyncPrinter.this.prefix + 
        str.substring(0, len) + 
        AsyncPrinter.this.suffix);

그럼 내부 클래스는 어떻게 자신의 멤버가 아닌 외곽 클래스의 멤버에 접근할 수 있는 걸까요?

그건 자바가 자동으로 모든 내부 클래스(Inner Class)의 객체에 멤버 필드에 외곽 클래스의 참조를 넣어 두기 때문입니다. 자바가 외곽 클래스에 접근하는 장치를 제공하지 않는다고 가정한다면 우리는 아마도 예제의 코드를 이렇게, 명시적으로 생성자에 외곽 클래스의 참조를 넘기도록 바꾸어야 할 겁니다.

public void printnln(final String str, final int maxlen) {
    final int len = str.length() < maxlen ? str.length() : maxlen;

    class Task implements Runnable {

        private final AsyncPrinter outer;

        Task(AsyncPrinter outer) {
            this.outer = outer;
        }

        @Override public void run() {
            outer.out.println(outer.prefix + str.substring(0, len) + outer.suffix);
        }
    };
    executor.submit(new Task(this));
}

먼저 생성자가 필요한데 무명 클래스는 생성자를 만들 수 없으니 지역 클래스(Local Class)로 바꾸었습니다. 그리고 외곽 클래스의 참조를 생성자 인자로 넘기고 이를 지역 클래스의 멤버로 저장했습니다. 이제 지역 클래스에서는 생성자로 받은 외곽 클래스의 참조를 이용해서 그 멤버에 접근 합니다.

물론 자바가 진짜로 이런 생성자를 진짜로 만드는 건 아닙니다. 하지만 내부 클래스의 객체가 생성될 때 마다 외곽 클래스의 참조를 내부 클래스의 멤버 필드에 저장하기 때문에 내부 클래스는 마치 자기 내부의 멤버에 접근하듯 외곽 클래스의 멤버에 접근할 수 있게 됩니다.

자바는 이렇게 내부 클래스와 외곽 클래스의 결합도를 높여서 불필요한 간접 처리를 생략할 수 있게 해줍니다.

지역 변수 포획

다시 예제에서 비동기로 실행되는 코드를 보겠습니다.

out.println(prefix + str.substring(0, len) + suffix);

out, prefix, suffix 같은 외곽 객체의 멤버에 직접 접근할 수 있는 이유는 이제 밝혀졌는데 str, len는 외곽 객체의 참조를 가지고 있더라도 접근할 방법이 없는, printnln(…) 메서드의 매개변수와 지역 변수입니다. 외곽 객체의 맴버 필드는 힙(Heap)에 보관이 되므로 참조를 알면 접근할 수 있지만, 메서드의 인자와 지연 변수 값은 스택 프레임에 보관되므로 객체 참조를 안다고 접근할 수 있는 값이 아닙니다.

여기서 자바 컴파일러가 한 번 더 마법을 부립니다. 앞에서 내부 클래스의 객체를 생성할 때에 외곽 클래스의 참조를 내부 클래스의 멤버에 복사하는 것처럼, 내부 클래스 중 메서드 안에서 정의되고 생성되는 지역 클래스(Local Class)와 무명 클래스(Anonymous Class)의 경우, 그 객체를 생성할 때에, 지역 클래스와 무명 클래스에서 사용하는 외곽 블럭의 지역 변수와 매개변수를 지역 클래스와 무명 클래스의 멤버 필드로 복사합니다. 이를 변수 포획이라고 부릅니다.

역시 말로 하려니 힘드네요. ㅠㅠ

위에서 처럼 코드로 표현해 보겠습니다. 자바가 변수를 포획하지 않는다면 아마도 우리는 예제의 코드를 이렇게 바꾸어야 했을 겁니다.

public void printnln(final String str, final int maxlen) {
    class Task implements Runnable {

        private final AsyncPrinter outer;
        private String str;
        private int len;

        Task(AsyncPrinter outer, String str, int len) {
            this.outer = outer;
            this.str = str;
            this.len = len;
        }

        @Override public void run() {
            outer.out.println(outer.prefix + str.substring(0, len) + outer.suffix);
        }
    };

    int len = str.length() < maxlen ? str.length() : maxlen;
    executor.submit(new Task(this, str, len));
}

지역 클래스에 멤버 필드와 생성자의 매개변수가 늘어났습니다. 이제 명시적으로 str과 len을 지역 클래스의 생성자로 넘깁니다. 사소한 부분이지만, 이제는 변수 len을 지역 클래스 정의 뒤에 정의할 수 있고 final일 필요도 없습니다.

이렇게 자바가 변수 포획을 자동으로 해주지 않으면 프로그래머가 직접 지역 클래스 내부에서 필요로하는 값을 생성자에서 넘겨 받아 맴버 필드에 보관하고 사용해야 합니다.

정리하면, 원래는 위 코드처럼 명시적으로 메서드 내의 지역 변수와 인자값을 전달해서 사용해야 하지만, 자바가 자동으로 지역 클래스나 무명 클래스가 필요로 하는 로컬 변수나 인자를 인스턴스에 복사해 주기 때문에 굳이 생성자를 통해 전달한다거나 하는 작업 없이 그 변수 값을 얻을 수 있습니다. 이렇게 자동으로 변수를 복사하는 작업을 변수 포획이라고 부릅니다.

내부 클래스 중 메서드 안에 정의할 수 있는 지역 클래스와 무명 클래스는 외곽 클래스의 멤버 뿐 아니라 자신이 정의된 메서드의 지역 변수와 인자에도 접근할 수 있어 해당 메서드의 일부라고 할 수 있을 정도로 결합도가 높습니다.

심지어 자바 8에 새로 추가된 람다는, 사실 무명 클래스와 비슷하게 별도의 객체가 생성됨에도, 마치 코드 블럭인 냥 자신이 정의된 메서드와 동일한 변수 영역(Lexcical Scope)을 갖습니다. 그래서 람다식 안에서 사용되는 this 키워드는 람다식으로 인해 생성되는 객체가 아니 외곽 클래스의 객체를 가리킵니다.

그래 알겠는데 왜 불변(final)이어야 하는데?

SW 개발 기술에 대해서 왜냐고 묻는 사람은 많은 경우 사실 두 가지를 묻는 것입니다. 하나는 그리될 수 밖에 없는 기술적 구조에 대한 질문이고, 다른 하나는 그렇게 설계한 의도입니다.

기술적 구조에 대해서는 이미 어느 정도 답은 나온 것 같습니다.

내부 클래스과 외곽 클래스의 멤버 필드와 지역 변수에 접근할 수 있도록 자바가 내부에서 처리하는 방식를 보면, 지역 클래스나 무명 클래스의 객체가 생성되는 시점에 지역 변수나 인자값이 생성되는 객체 내부에 복사됩니다. 결국, 새로 생성되는 객체 내부에서 사용하는 변수와  그 객체를 생성한 메서드에서 사용하는 변수는 이름만 같을 뿐 보관되는 위치가 별개인, 전혀 다른 변수입니다. 한쪽에서 값을 바꾼다고 다른 쪽 값이 덩달아 바뀌거나 하는 일은 일어나지 않습니다(일어 날 수 없습니다). 반면에 모든 내부 클래스의 인스턴스 내부에 저장되는 외곽 객체의 참조는 힙 상에 저장된 객체에 대한 참조값이므로 외곽 객체의 자기 참조(this 키워드)와 동일한 값입니다. 따라서 내부 클래스에서 변경하면 외곽 클래스에서 변경된 값을 확인할 수 있습니다.

비록 내부적으로는 다른 공간에 저장되는 다른 변수이지만, 자바를 설계하신 분은 프로그래머에게 같은 변수를 사용한다는 의미를 제공하고 싶었던 것 같습니다. 결국 final로 제약을 주어 계속 동일한 값을 갖도록 해서 같은 변수라고 생각하도록 한 것이지요. 불변으로 값을 고정하지 않았다면 한 쪽에서 변경한 값이 다른 쪽에 반영되지 않아 혼란에 빠지는 일이 생겼을 겁니다.

그럼 이리 설계한 의도는 뭘까요?

솔직히 전 모릅니다(뭐냐! 이제와서!). 이와 관련해서 직접 자바 창조자의 얘기를 들어 보지도 못했습니다. 자바 언어 규약집도 final로 값을 고정해야 한다고만 하고 이유는 설명하지 않습니다. 하지만 몇가지 추론은 해 볼 수 있겠습니다.

하나는 의미론으로 봤을 때, 자바가 멀티쓰레드를 장점으로 내세운 이상, 객체의 멤버 필드 값이 다른 어떤 이유로 변하는 게 어색하지 않지만, 지역 변수나 매개변수가 별다른 대입문이 없었는데도 임의로 값이 바뀐다면 무척 생경하게 여길 것입니다. 지역 변수나 매개변수는 쓰레드 마다 독립된 스텍 프레임에 저장되기 때문에 쓰레드에 안전하기 때문입니다. 이런 의미론을 유지하려면 별도 쓰레드에서 동작할 수도 있는 지역 클래스나 무명 클래스가 지역 변수나 매개 변수 값을 수정할 수 없도록 막아야 하지 않았나 싶습니다.

또 다른 하나는, 변수를 포획하는 언어적 장치(클로저)를 함수형 프로그래밍 언어에서 많이 사용하는데, 함수형 언어에서는 기본으로 모든 변수가 불변이므로 final로 값을 고정하는 것이 오히려 자연스러웠을 겁니다. 자바를 설계하면서 변수 포획을 적용하려고 했을 때 함수형 언어의 개념을 그대로 도입할 생각은 없었겠지만 불변으로 만들도록 결정하는데 심리적인 지지 요인은 되지 않았을까 싶습니다.

그럼 정말 못 바꾸는 건가?

그럼 이 모든 상황을 이해하고도, 지역 클래스나 무명 클래스에서 변경한 지역 변수의 값을 사용하고 싶다면 어찌해야 할까요?

이에 대해서는 “Mutable variable capture in anonymous Java classes“를 읽어 보시기 바랍니다. 한마디로 기본 타입의 값과 객체 참조는 불변이더라도 참조하는 객체 안의 멤버 필드는 수정할 수 있으니 변경하고 싶은 값을 담는 간단한 객체를 만들어서 이를 공유하면 된다는 내용입니다.