[Java] 자바의 로컬 클래스? 이거 완전 클로저같은데 왜 final 변수가 되었을까?
🖐️ 서론
최근 개발자를 희망하는 지인을 도우며 자바 문법을 접하게 되었습니다. 프론트엔드 개발자로서 새로운 언어를 접할 때면 자연스럽게 자바스크립트와 비교하게 됩니다. 신입 시절, 백엔드팀과의 협업 경험을 좋게 하기 위해 스프링을 가볍게 접한 적은 있었지만 자바 문법을 깊게 들여다본 건 처음이었습니다. 가장 흥미로웠던 점 중 하나는 ‘로컬 클래스가 외부 변수를 final로만 캡처한다’는 제약이었습니다. 자바스크립트의 클로저에 익숙한 입장에서는, “이거 거의 클로저 같은데 왜 이렇게 제한하지?”라는 의문이 자연스럽게 들었습니다.
이 글에서는 자바의 로컬 클래스와 자바스크립트의 클로저를 비교하며, 두 언어가 메모리 모델과 변경 가능성에 대해 어떤 선택을 했는지를 정리해보려 합니다.
클로저
클로저는 함수의 생명주기가 끝난 후 해당 함수의 스코프를 가리키는 무언가를 지칭합니다.
From. MDN. A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment).
= 클로저는 함수와 주변 렉시컬 환경의 조합이다.
렉시컬 환경이란 자바의 스택 프레임과 유사하게, 각 함수가 가지는 스코프 범위의 구현 실체라고 볼 수 있습니다. 이를 조금 풀어서 해석하면, 클로저는 함수와 그 함수가 선언된 스코프의 조합이라고 볼 수 있습니다.
클로저를 자바스크립트 코드로 만들어보면 이런 예시를 만들 수 있습니다.
const outer = () => {
const closureScopeVariable = ['이 변수는 outer 함수 내부 스코프에 존재합니다.'];
const log = () => {
console.log(closureScopeVariable.join('\n'));
};
const add = (value) => {
closureScopeVariable.push(value);
};
return {
log,
add,
};
};
const { log, add } = outer();
// 이 변수는 outer 함수 내부 스코프에 존재합니다.
log();
add('outer 함수는 이미 반환되어서 종료되었습니다.');
// 이 변수는 outer 함수 내부 스코프에 존재합니다.
// outer 함수는 이미 반환되어서 종료되었습니다.
log();const { log, add } = outer();부분에서 outer 함수의 실행 컨텍스트는 log와 add를 반환한 이후 콜 스택에서 제거됩니다.- 그럼에도 log와 add 함수는 outer 함수의 렉시컬 환경에 대한 참조를 유지하고 있습니다. 이런 기능(혹은 구조)을 클로저라고 합니다. 원리에 대한 설명은 글의 주제에서 벗어나 생략합니다.
- 저는 서론에서 자바의 로컬 클래스가 ‘클로저와 유사하다’라고 표현했습니다. 그 이유는 로컬 클래스 역시 동일한 사용법을 적용할 수 있기 때문입니다.
interface Logger {
void log();
}
class Outer {
public Logger fnc() {
// 자바에서는 명시적으로 final을 붙이지 않아도, 값을 변경할 수 없으면 effectively final로 취급됩니다.
int v = 10;
class Inner implements Logger {
@Override
public void log() {
System.out.println(v);
}
}
return new Inner();
}
}이런 코드에서 Inner 클래스는 다음과 같이 사용 가능합니다.
public class Main {
public static void main(String[] args) {
Outer outer = new Outer();
Logger logger = outer.fnc();
logger.log(); // 10 출력
}
}
logger.log()가 호출되는 시점에는 outer.fnc()가 생성한 프레임이 스택에서 제거되었습니다. 그럼에도 해당 프레임이 가지는 로컬 변수인 v = 10은 여전히 유효합니다. 결과론적으로 위의 자바스크립트로 설명한 클로저와 유사한 동작입니다. 다만 내부 동작 방식은 다릅니다.
자바스크립트에서 클로저가 가능한 이유
- 자바스크립트에는 스코프 체이닝이란 개념이 있습니다. JS는 렉시컬 스코프 개념을 차용하는데, 이는 함수의 호출 시점이 아닌 선언 시점에 스코프를 결정하는 것을 말합니다.
- 클로저가 된 내부 중첩 함수들은 선언된 위치가 outer 함수 내부이기에, log, add 함수의 렉시컬 환경은 모두 상위 스코프로 outer를 가집니다.
- log, add 함수의 참조가 유지되고 있다면, 그들이 가진 상위 스코프에 대한 참조 역시 유지되고 있다는 의미입니다.
- 즉, 함수의 실행 컨텍스트 자체는 스택에서 제거되었지만 참조가 유지되어 GC 대상에서 제외됩니다.
자바 로컬 클래스는 왜 final 변수만 캡처할 수 있을까?
- 그런데 자바는 다릅니다. 로컬 클래스는 외부 메서드의 스택 프레임을 직접 참조하지 않습니다.
- 컴파일 시점에 로컬 변수의 값이 로컬 클래스의 필드로 복사되어 저장되며, 이 값은 이후 변경될 수 없습니다.
- 서로 다른 생명주기를 가진 메모리 영역(복사된 값과 원본 스택 프레임의 값)을 안전하게 관리하기 위해 모두 상수화(final)가 적용됩니다.
- 따라서 프레임이 스택에서 제거되어도 그 값은 유지된다고 볼 수 있습니다.
- 직접적인 참조를 가지고 있는 건 아니기 때문에 자바스크립트처럼 클로저를 사용해 직접 해당 변수를 수정하는 등의 행위는 할 수 없습니다.
- 즉, 자바의 로컬 클래스는 “변수”를 캡처하는 것이 아니라 “값”을 캡처합니다.
- 만약 변수에 담긴 값이 참조라면 참조 값이 캡처되기에 객체 내부는 변경 가능합니다. (이는 클로저에서 이야기하는 외부 변수를 변경하는게 아닌 외부 변수가 가리키는 값을 바꾸는 것이기에 클로저 개념과는 무관합니다)
결론
개념적으로 자바의 로컬 클래스는 클로저의 특성을 일부 갖고 있지만, 자바스크립트와 같은 ‘완전한 클로저’라고 보기는 어렵습니다. 자바는 변수가 아닌 값을 캡처하도록 제한해 서로 다른 생명주기의 메모리 영역을 명확히 분리하고 사이드 이펙트를 최소화하는 선택을 했습니다. 이는 표현력을 중시하는 자바스크립트와, 변경을 제한해 안정성을 확보하려는 자바의 언어 철학 차이라고 볼 수 있습니다.