Ruby의 연속(Continuation) 연산자: `callcc`와 `shift/reset`

[JA] Continuation is to be continued / Masayuki Mizuno @fetburner

3줄 요약

  • `callcc`는 Ruby에서 강력한 연속 연산자로, DSL 구현에 활용될 수 있으나, 전역적인 연속 캡처와 성능 문제로 인해 실용성이 저하됩니다.
  • `shift/reset`은 `callcc`의 단점을 보완하는 제한된 연속 연산자로, 필요한 범위의 연속만을 캡처하여 DSL 구현을 단순화하고 성능을 개선합니다.
  • 향후 `shift/reset`을 C 확장 라이브러리로 구현함으로써 Ruby에서 연속 연산자의 효율성과 활용 가능성을 극대화할 수 있습니다.

본 발표는 Ruby의 `callcc` (call with current continuation) 연산자를 중심으로, 이 기능이 제공하는 강력한 가능성과 더불어 내재된 문제점들을 심층적으로 다룹니다. 비록 `callcc`가 Ruby 2.2.2부터 비권장(deprecated)되었고 파이버(Fiber)가 대안으로 제시되었지만, 파이버가 코루틴(coroutine) 기능에 특화되어 `callcc`의 모든 기능을 대체하지 못합니다. 따라서 본 발표에서는 `callcc`가 여전히 유효한 활용 사례가 있음을 DSL(Domain Specific Language) 구현을 통해 보여주고, `callcc`의 한계를 극복할 수 있는 `shift/reset`이라는 제한된 연속(Limited Continuation) 연산자를 소개하며, 이들의 구현 방식과 성능 개선 가능성을 탐구합니다.

연속(Continuation)이란 무엇인가?

연속은 현재 평가 중인 식의 값을 받은 후 수행될 계산에 해당합니다. 즉, 프로그램의 현재 실행 지점 이후에 남은 모든 계산을 의미합니다. Ruby에는 callcc라는 메서드가 구현되어 있어 현재 시점의 연속을 제1급 값(first-class value)으로 다룰 수 있게 합니다. callcc를 호출하면 현재의 연속이 블록 인수로 전달되며, 이는 setjmplongjmp처럼 동작하여 프로그램의 실행 흐름을 자유롭게 제어할 수 있게 합니다.

callcc의 활용: DSL 구현

callcc는 백트래킹(backtracking)을 수행하는 DSL 구현에 강력한 도구가 될 수 있습니다. 예를 들어, Haskell의 리스트 모나드(List Monad)처럼 비결정 계산(non-deterministic computation)을 플랫하게 기술하는 DSL을 Ruby에서 구현할 때 callcc가 유용합니다. 일반적인 Ruby 코드로는 콜백 지옥(callback hell)에 빠지거나 복잡한 메타 프로그래밍(meta-programming)을 통해 구문을 배열로 변환한 후 flat_map을 적용하는 방식이 필요합니다. 그러나 callcc를 사용하면 변수를 Ruby의 것을 그대로 활용하면서도 백트래킹을 자연스럽게 표현할 수 있어, 개발자가 DSL을 훨씬 직관적으로 작성할 수 있게 됩니다. 이는 callcc가 특정 코드 블록을 여러 번 재실행하여 다양한 경우의 수를 탐색하는 데 적합하기 때문입니다.

callcc의 문제점

그럼에도 불구하고 callcc에는 몇 가지 중요한 문제가 있습니다. 첫째, callcc는 현재의 연속을 모두 캡처합니다. 이는 DSL 구현 시 필요한 특정 코드 범위뿐만 아니라 그 이후의 불필요한 코드까지 모두 포함하게 되어 오히려 불편함을 초래합니다. 또한, callcc로 잘라낸 연속은 한 번 호출되면 돌아오지 않으므로, 원래의 실행 흐름으로 돌아오기 위해 명시적으로 callcc 연속을 다시 호출해야 하는 등 구현이 복잡해집니다. 둘째, callcc는 성능이 매우 비효율적입니다. 메타 프로그래밍으로 순식간에 끝나는 SEND+MORE=MONEY 퍼즐 같은 문제도 callcc를 사용하면 몇 분이 지나도 끝나지 않을 수 있습니다. 이는 callcc가 VM(Virtual Machine)의 전체 상태(스택과 현재 실행 위치)를 스냅샷으로 기록하고 복원하는 과정에서 스택 메모리 복사(memcpy)가 발생하기 때문입니다. Ruby의 callcc는 부분 백업(partial backup)과 같은 최적화를 시도하지만, 여전히 비용이 많이 드는 작업입니다.

shift/reset의 등장과 해결책

callcc의 문제를 해결하기 위해 shift/reset이라는 제한된 연속 연산자가 제안되었습니다. shift/resetshift부터 reset까지의 한정된 범위의 연속만을 잘라냅니다. 이는 callcc가 전체 연속을 캡처하는 것과 달리, 프로그래머가 원하는 정확한 범위의 연속만을 얻을 수 있게 하여 DSL 구현을 훨씬 단순화합니다. shift/reset으로 잘라낸 연속은 함수처럼 사용할 수 있으며, reset 지점에 도달하면 원래의 실행 흐름으로 자동으로 돌아옵니다. 이는 flat_map과 같은 고차 함수와 결합하여 더욱 간결하고 강력한 DSL을 구현할 수 있게 합니다.

놀랍게도 callcc를 사용하여 shift/reset을 구현하더라도 성능이 개선됩니다. 이는 shift/reset의 구조가 Ruby의 callcc 내부 최적화(스택의 필요한 부분만 잘라내는)가 더 잘 적용될 수 있는 형태로 프로그램을 정리하기 때문으로 추정됩니다. 실제로 SEND+MORE=MONEY 퍼즐 해결 시간이 메타 프로그래밍 방식보다는 느리지만(0.14초 vs 0.5초), callcc 단독 사용 시 몇 분 이상 걸리던 것에 비하면 훨씬 현실적인 수준으로 단축됩니다.

향후 과제

shift/reset의 잠재력을 최대한 발휘하기 위해서는 C 확장 라이브러리 형태로 VM의 내부 구현에 직접 접근하여 구현하는 것이 필요합니다. shift/resetshift 호출 시 reset 이후에 필요한 스택을 저장할 필요가 없으므로, 이러한 정보를 VM에 전달함으로써 스택 저장 범위를 더욱 제한하고 최적화를 극대화할 수 있습니다. 현재는 C 확장 라이브러리 구현이 진행 중이며, 이를 통해 Ruby에서 연속 연산자의 실용성과 효율성을 더욱 높일 수 있을 것으로 기대됩니다.

결론적으로, Ruby의 `callcc`는 비록 비권장되었지만 DSL 구현과 같은 특정 영역에서 강력한 추상화를 제공하는 잠재력을 지니고 있습니다. 그러나 그 전역적인 특성과 성능 문제는 광범위한 활용을 제약해왔습니다. `shift/reset`은 이러한 `callcc`의 문제점을 해결하는 대안으로, 필요한 범위의 연속만을 다루어 DSL 구현을 단순화하고 성능을 개선하는 데 크게 기여합니다. `shift/reset`의 C 확장 라이브러리 구현은 Ruby에서 연속 연산자의 효율성을 극대화하고, 더욱 강력하고 유연한 DSL 및 프로그래밍 패러다임의 등장을 가능하게 할 것입니다. 이는 Ruby가 단순한 스크립트 언어를 넘어 더욱 풍부한 표현력을 가진 언어로 발전하는 데 중요한 발걸음이 될 것입니다.