썽크

컴퓨터 프로그래밍에서 썽크(Thunk)는 기존의 서브루틴에 추가적인 연산을 삽입할 때 사용되는 서브루틴이다. 썽크는 주로 연산 결과가 필요할 때까지 연산을 지연시키는 용도로 사용되거나, 기존의 다른 서브루틴들의 시작과 끝 부분에 연산을 추가시키는 용도로 사용되는데, 컴파일러 코드 생성시와 모듈화 프로그래밍 방법론 등에서는 좀 더 다양한 형태로 활용되기도 한다.

썽크(Thunk)는 "고려하다"라는 영어 단어인 "Think"의 은어 격 과거분사인 "Thunk"에서 파생된 단어인데, 연산이 철저하게 "고려된 후", 즉 실행이 된 후에야 썽크의 값이 가용해지는 데서 유래된 것이라고 볼 수 있다.

배경 
컴파일러 연구 초창기에는 식(Expression)에 대한 다양한 평가 전략(Evaluation strategy)이 광범위하게 시도되었었는데, 여기서 주안점은 서브루틴의 인자가 상수가 아닌 임의의 식(Expression)으로 주어질 때 어떻게 해당 서브루틴을 컴파일해야 하는 지에 관한 문제였다. 대표적으로 "값에 의한 호출" 방식은 모든 인자들의 값을 호출 전에 미리 계산하여 서브루틴에 결과만 전달하는 방식이었는데, 이 방식과 대비되는 "이름에 의한 호출" 방식에서는 서브루틴에 인수의 식 자체를 전달하고, 서브루틴이 나중에 자체적으로 그 식들을 평가(Evaluation)하는 방식이었다.

직관적으로 "이름에 의한 호출" 방식은 서브루틴 내에서 인수가 사용되는 모든 경우를 식으로 치환시켜 구현할 수 있는데, 이러한 방식은 인수로 주어지는 식이 다양함에 따라 서브루틴 코드를 중복으로 생성해야 한다는 문제가 있었다. 이에 대한 보완책으로 썽크라는 보조 서브루틴을 이용하는 방식이 제안되었는데, 인수 대신에 썽크의 주소와 컨텍스트 메모리를 전달하고, 후에 호출된 서브루틴에서 이 썽크를 실행하여 인수의 값을 구해내는 방식이었다. 썽크는 최초로 피터 잉겔만(Peter Ingerman)에 의해 "이름에 의한 호출" 방식을 지원하는 프로그래밍 언어인 ALGOL 60을 바탕으로 제안되었다.

응용

여러 지점에서 평가가 필요한 수치 계산 
적분과 같은 수치 계산 루틴은 (피적분 함수와 같은) 식을 여러 지점에서 계산해야 하는데, 클로저나 프로시저 매개변수를 지원하지 않는 프로그래밍 언어에서 이름에 의한 호출이 이러한 용도로 사용되었다.

함수형 프로그래밍 
비록 현재는 소프트웨어 산업 전반에서 값에 의한 호출이나 참조에 의한 호출 방식을 표준처럼 사용하고 있기는 하지만, 이름에 의한 호출 역시 여전히 함수형 프로그래밍 커뮤니티 내에서는 연구가 꾸준히 지속되고 있다. 이들 연구에서 파생된 게으른 평가 방식의 프로그래밍 언어들은 이름에 의한 호출 방식과 흡사한 호출 방식을 사용하는데, 이에 대한 컴파일러 수준의 구현이 썽크에 크게 의존하고 있다. 최근에는 같은 결과를 다시 계산하는 오버헤드를 줄이기 위해 초기 계산 결과를 따로 저장해두는 메모이제이션이라는 최적화 기술을 추가로 적용하고 있다.

함수형 프로그래밍 언어에서는 프로그래머가 명시적으로 썽크를 생성할 수도 있는데, 함수를 호출할 때 인수로 전달할 식 전체를 익명 함수로 감싸서 전달해주면 된다. 익명 함수로 감싸진 식은 이를 전달받는 함수가 해당 익명 함수를 호출하기 전까지는 평가(Evaluation)되지 않는데, 이는 이름에 의한 호출에서 값이 사용될 때에 인자로 주어진 식이 평가되는 것과 같은 효과라고 볼 수 있다. 최근 익명 함수 기능이 여러 프로그래밍 언어에서 도입되면서 이러한 기법이 광범위하게 활용될 수 있게 되었다.

다음은 JavaScript ES6에서 썽크를 이용하는 간단한 예제이다. // hypot: 인자 2개를 받아 유클리드 거리를 계산해 반환.
const hypot = (x, y) => Math.sqrt(x * x + y * y);

// thunk: 인자를 받지 않지 않는 함수. 내부적으로 hypot(3, 4)를 호출한다.
const thunk = () => hypot(3, 4);

// 아래 예시는 함수 thunk를 썽크처럼 활용하는 방법을 보여준다.
// 썽크는 아래와 같이 다른 함수에 전달되는 시점에는 평가(실행)되지 않고,
doSomethingWithThunk(thunk);

// 아래처럼 호출이 되는 시점에 평가(실행)되어 값을 반환한다.
thunk(); // === 5

객체 지향 프로그래밍 
다중 상속을 지원하는 객체지향 프로그래밍 언어에서는 하나의 메소드가 여러 개의 인터페이스를 통해 호출될 수 있다. 다음 예제는 이와 같은 상황이 발생하는 C++ 코드이다. class A {
 public:
  virtual int Access() const { return value_; }

 private:
  int value_;
};

class B {
 public:
  virtual int Access() const { return value_; }

 private:
  int value_;
};

class C : public A, public B {
 public:
  int Access() const override { return this->x; }

 private:
  int x;
};

int Use(B *b) { return b->Access(); }

int main() {
  // ...
  B some_b;
  Use(&some_b);
  C some_c;
  Use(&some_c);
} 이 예제에서, 클래스 A, B, C는 포인터(레퍼런스)를 통해 간접적으로 메소드 Access를 호출할 때 각자 자신 버전의 Access가 호출될 수 있도록 디스패치 테이블을 구성해야 한다. 이 때 클래스 C의 디스패치 테이블은 특수하게 구성이 되는데, 이 클래스는 클래스 A와 B를 모두 상속하므로, 두 클래스의 멤버에 모두 접근할 수 있도록 먼저 클래스 A와 B의 디스패치 테이블을 이어붙이고, 마지막에 자체적으로 가지고 있는 (22번째 줄의 int x와 같은) 멤버를 위해 추가로 테이블에 요소를 덧붙인다.

이 때 25번째 줄의 b->Access()는 b가 가리키는 객체의 타입이 클래스 B인지 C인지에 따라 다른 버전의 Access를 호출하는데, 만약 가리키는 객체의 타입이 클래스 C라면 19번째 줄의 this->x가 올바른 값을 반환할 수 있도록 this 포인터가 클래스 C의 디스패치 테이블 꼭대기을 가리키도록 해야 한다.

여기서 문제는 b가 클래스 B의 포인터(B *)이기 때문에, 클래스 C 타입의 객체를 가리킬 때에 클래스 C의 디스패치 테이블에서 클래스 B에 해당하는 중간 부분을 가리키고 있다는 점이다. 한편 b가 클래스 B 타입의 객체를 가리키고 있을 때는 정상적으로 해당 객체의 디스패치 테이블 꼭대기를 가리키고 있으므로, 결론적으로 포인터 b를 이용해 this를 결정하기 위해선 Access가 호출되는 시점에 (즉, 25번째 줄에서) 포인터 b가 무슨 타입의 객체를 가리키고 있느냐를 고려해야 한다. 엄밀히 말하면, b가 클래스 C 타입의 객체를 가리키고 있는 경우에 한하여 Access를 호출하기 전에 포인터 b의 값을 조정해주어야 한다.

이렇게 포인터를 조정하는 문제에 대한 직접적인 해결책은 컴파일러가 디스패치 테이블을 구성할 때에 모든 디스패치 테이블 요소에 "해당 메소드를 호출하기 전에 포인터를 얼마만큼 조정해야 하는지", 즉 오프셋을 남겨두는 것이다. 이렇게 되면 컴파일러는 디스패치 테이블을 이용해 메소드를 부를 때마다 포인터 값이 오프셋만큼 조정되도록 코드를 삽입해야 한다.

하지만 위에 기술된 해결책은 본질적으로 이름에 의한 호출에서 발생했던 문제와 같은 문제가 있는데, 결과적으로 컴파일러가 오프셋을 읽어서 포인터를 조정해주는 일을 하는 동일한 코드를 메소드가 호출되는 모든 곳에 복사/삽입해주어야 하기 때문이다. 따라서 이와 같은 방법 대신에, 클래스 C의 Access(줄 19)가 클래스 B의 포인터로 호출될 때 해당 포인터를 조정하여 올바른 this를 만들어낸 후에 본래의 Access(줄 19)를 호출하는 썽크를 활용할 수 있다. 이러한 썽크를 클래스 C의 디스패치 테이블의 클래스 B에 해당하는 부분에 Access 대신 등록해둠으로써, 정확히 클래스 B의 포인터를 통해 클래스 C의Access를 호출하는 상황에서만 포인터가 조정되게끔 할 수 있다.

상호 운용성 
썽크는 소프트웨어 모듈 사이에서 서로의 루틴(함수)를 직접적으로 호출할 수 없는 상황에서 모듈간 상호 운용성을 보장하기 위해서도 사용된다. 가령 호출 규약이나, 동작하는 CPU 모드, 혹은 주소 공간이 루틴 간에 다르다거나, 한 쪽이 가상 머신 위에서 구동되는 경우에 루틴을 직접 호출할 수 없는 상황이 발생할 수 있는데, 이는 컴파일러와 같은 툴로 하여금 호출을 위한 인수 변환이나 데이터 복사, CPU 모드 스위칭과 같은 작업을 자동으로 수행하는 썽크를 생성하게 하여 해결할 수 있다. 여기서 호출하는 쪽에서 추가로 해야하는 일을 얼마나 많이 대신 해주느냐 여부가 이러한 목적으로 생성된 썽크의 좋고 나쁨을 판가름한다고 볼 수 있다.

상당수의 상호 운용성 썽크에 관한 문헌들이 다양한 윈텔 플랫폼이나 (MS-DOS, OS/2, 윈도우, .NET 프레임워크) 16비트에서 32비트로의 메모리 어드레싱 방식 전환과 관련이 있다. 사용자 입장에서는 플랫폼을 옮겨감에 따라 과거 플랫폼에서 사용하였던 레거시 소프트웨어를 사용할 때에 썽크가 필수적인 역할을 한다.

오버레이 및 동적 링크 
가상 메모리를 구현하는 하드웨어가 없는 시스템의 경우에는 썽크를 이용해 오버레이라는 제한된 형태로 가상 메모리를 구현할 수 있다. 먼저 개발자는 프로그램을 서로 독립적으로 메모리에 올리거나 내릴 수 있는 여러 세그먼트로 나눈 후에, 각 세그먼트로 진입하는 시작점을 미리 구분해둔다. 세그먼트가 다른 세그먼트에 있는 함수를 호출할 때에는 반드시 브랜치 테이블을 통해서 간접적으로 호출해야 하는데, 호출하고자 하는 세그먼트가 메모리에 올라와있는 경우에는 브랜치 테이블에서 해당 세그먼트의 함수로 컨트롤을 넘겨준다. 세그먼트가 메모리에서 내려가있을 때에는 해당 세그먼트의 진입점들이 "세그먼트를 다시 메모리로 불러오는 썽크"로 치환되어 있어 유사시에 바로 메모리로 올려질 수 있도록 되어 있다.

마찬가지로, 동적 라이브러리와 같이 런타임에 동적으로 링킹되는 시스템 역시 썽크를 이용해 모듈을 연결할 수 있다. 각 모듈들은 다른 모듈들에 존재하는 함수들을 썽크 테이블을 통해 호출할 수 있는데, 이 테이블은 링커에 의하여 호출되는 모듈이 불러와질 때 자동으로 완성된다. 이러한 방식은 모듈로 하여금 다른 모듈이 메모리 어느 곳에 위치하고 있는지에 대한 정보가 없이도 해당 모듈을 호출할 수 있게끔 해준다.

참조

썽크 기술 

 DOS 보호 모드 인터페이스 (DPMI)
 DOS 보호 모드 서비스 (DPMS)
 J/Direct
 Microsoft Layer for Unicode
 플랫폼 호출 서비스
 Win32
 Windows on Windows
 WoW64
 libffi

관련 개념 

 익명 함수
 Future 와 Promises
 원격 프로 시저 호출
 Shim (컴퓨팅)
 트램펄린 (컴퓨팅)
 환원성 표현

참고 문헌 

함수형 프로그래밍
컴퓨터 용어