Closure란?

Closure보다 먼저 알아야할 개념들

Lexical scope vs Dynamic scope

Lexical scope를 사용하는 언어(C, C++, Java, Javascript 등)에서는 소스코드 내에서 변수나 함수가 정의된 위치와 Lexical Context를 기준으로 이름 참조(Name resolution)를 수행한다. 찾아야하는 이름이 내부 Lexical context에 존재하지 않는 경우 외부 Lexical context에서 이를 찾고, 모든 외부 Lexical context에도 해당 이름이 존재하지 않는 경우 오류가 발생한다.

var x = "variable in global";

function inner() {
	console.log(x);
}

function outer() {
	var x = "variable in outer";
  inner();
}

inner();
outer();

위의 Javascript 코드를 실행하면 아래와 같은 결과가 출력된다.

variable in global
variable in global

Javascript는 Lexical scope를 사용하는 언어이기 떄문에 innerx라는 변수의 이름을 찾을 때에는 자신의 Lexical Context(inner 함수의 Body)에서 우선 x를 찾는다. 여기에 x가 없었기 때문에 자신의 전역 Lexical Scope에 위치한 x(“variable in global”)을 참조하게 된다. 이와 같이 Lexical scope를 사용하는 언어는 Compile time에 함수 혹은 변수 이름의 바인딩이 가능하기 때문에 Early Binding이라고도 불린다.

반면에 Dynamic scope를 사용하는 언어(bash, jinja 등)에서는 실행 시점의 컨텍스트를 기준으로 이름 참조를 수행한다. 찾아야하는 이름이 현재 실행 컨텍스트에 존재하지 않는 경우 상위 실행 컨텍스트에서 이를 찾고, 모든 상위 실행 컨텍스트에도 해당 이름이 존재하지 않는 경우 오류가 발생한다.

#!/bin/bash
  
x="variable in global"

function inner() {
  echo $x
}

function outer() {
  x="variable in outer"
  inner
}

inner
outer

위의 Bash 코드를 실행하면 아래와 같은 결과가 출력된다.

variable in global
variable in outer

Bash는 Dynamic scope를 사용하는 언어이기 때문에 innerx라는 변수를 찾을 때는 inner가 실행될 당시의 Call stack을 추적해야 한다.

우선 첫번째 inner 함수가 호출되었을 때, inner 안에는 x가 없기 때문에 자신의 상위 실행 컨텍스트에서 선언된 x를 찾게 된다. 첫번째 inner의 상위 컨텍스트는 전역 Context이기 때문에 “variable in global” 가 출력된다.

두번째 inner 함수 호출은 outer 내에서 수행되기 때문에 inner의 상위 컨텍스트는 outer가 된다. outer 내에는 “variable in outer” 값을 가진 x가 존재하기 때문에 “variable in outer” 가 출력된다.

Dynamic scope를 사용하는 언어는 실제 프로그램이 수행되기 전까지는 함수나 변수에 대한 Binding이 불가능하기 때문에 Late Binding이라고 불린다.

Free variable vs Bound variable

자유 변수(Free variable)는 표현식 내에 해당 변수의 선언이 포함되어 있지 않으며, 다른 영역의 값으로 치환 가능한 변수를 의미한다. 이와 반대로, 종속 변수(Bound variable)은 표현식 내에 해당 변수의 선언이 포함되어 있는 변수를 의미한다.

const f = (bound) => free * bound
var free = 10

f(20)

위 코드의 함수 표현식((bound) => free * bound)은 두가지 변수(freebound)를 가지고 있다. 여기서 bound는 표현식의 인자에 선언된 값이다. 때문에 bound는 종속 변수이다. 그러나 free의 선언은 표현식 외부에 존재하기 때문에 이는 자유 변수이다.

쉽게 생각하면 종속 변수는 지역 변수, 자유 변수는 그 이외의 변수라고 생각하면 쉽다.

Closure

function createLambdaFunc() {
	let counter = 0;
  return () => {
  	counter++;
    return counter;
  };
}
const func = createLambdaFunc();
console.log(func());

//1이 출력된다.

위의 createLambdaFunc 가 반환하는 람다 표현식은 Closure가 없이는 실행될 수 없다.

createLambdaFunc가 반환하는 람다 표현식에서는 자유변수 counter를 사용하고 있고, 이 counter는 Lexical Scope 규칙에 따라 createLambdaFunc에 위치한 counter 주소를 참조하게 된다. 그러나 countercreateLambdaFunc가 종료됨에 따라 Stack Frame에서 사라지게 되고, 람다 표현식이 실행되는 순간의 counter는 비정상적인 메모리 주소를 참조하게 된다.

이러한 문제를 해결하기 위해 Closure라는 기술이 도입되었다. Closure는 자유 변수들을 람다 표현식에 바인딩하는 기술이다. Closure를 사용하면 위의 구문은 아래와 같이 수행된다.

  1. createLambdaFunc가 반환하는 람다 표현식(() => { counter++; return counter; })은 반환될 때 자신이 가진 자유 변수 counter에 대한 참조를 포함하여 반환된다.
  2. func() 가 수행될 때 람다 표현식의 countercreateLambdaFunc 반환 시 포함하였던 counter를 사용하여 수행된다.

분산 처리 프레임워크에서의 Closure

Spark이나 Flink와 같은 분산 처리 프레임워크에서도 람다 표현식을 통한 데이터 변환을 지원한다. 분산 처리 프레임워크는 이러한 람다 표현식을 Serialize해서 각 노드들에 배포한 뒤, 각 노드들은 전달받은 람다 표현식을 Deserialize한 뒤 실행한다.

만일 람다 표현식 내에 자유 변수가 포함되어 있다면 어떻게 될까? 이런 자유 변수 또한 각 노드들로 배포해야 하기 때문에 분산 처리 프레임워크에서는 ClosureCleaner(이름은 다를 수 있다)라는 모듈을 통해 자유 변수를 추적하여 Serialize 한 뒤 배포를 진행한다.

때문에 람다 표현식 내에 Serializable하지 않은 자유 변수가 포함되어 있는 경우에는 java.io.NotSerializableException(spark)와 같은 오류가 발생하는 것이다.

Spark이나 Flink의 각 함수들에서는 아래와 같이 매개변수로 전달받은 람다 표현식에 대해 Closure Cleaning 작업을 수행하게 된다.

Spark의 RDD

def map[U: ClassTag](f: T => U): RDD[U] = withScope {
  val cleanF = sc.clean(f)
  new MapPartitionsRDD[U, T](this, (_, _, iter) => iter.map(cleanF))
}

def flatMap[U: ClassTag](f: T => TraversableOnce[U]): RDD[U] = withScope {
  val cleanF = sc.clean(f)
  new MapPartitionsRDD[U, T](this, (_, _, iter) => iter.flatMap(cleanF))
}

Flink의 Dataset

def map[R: TypeInformation: ClassTag](fun: T => R): DataSet[R] = {
  if (fun == null) {
    throw new NullPointerException("Map function must not be null.")
  }
  val mapper = new MapFunction[T, R] {
    val cleanFun = clean(fun)
    def map(in: T): R = cleanFun(in)
  }
  wrap(new MapOperator[T, R](javaSet,
    implicitly[TypeInformation[R]],
    mapper,
    getCallLocationName())
  )
}

이러한 Cleaner들은 사용하지 않는 자유변수 혹은 자유변수 내부의 Reference를 지우는 작업도 추가적으로 수행하게 된다.

참고 자료