Ruby의 지속성 연산자: callcc와 shift/reset

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

작성자
RubyKaigi
발행일
2025년 05월 27일

핵심 요약

  • 1 Ruby의 `callcc`는 비결정 계산 DSL 구현에 유용하지만, 전체 컨텍스트를 캡처하고 성능이 저하되는 문제가 있습니다.
  • 2 `shift/reset`은 필요한 범위만 캡처하는 제한된 지속성 연산자로, `callcc`의 문제를 해결하고 DSL 구현을 간소화합니다.
  • 3 `shift/reset`은 `callcc`를 통해 구현하더라도 성능 개선을 보이며, 향후 C 확장으로 구현 시 더 큰 최적화 가능성이 있습니다.

도입

본 발표는 온닷토리의 미즈노 씨가 루비 회의에서 'Continuous Continuation'이라는 주제로 루비 프로그래밍 언어의 지속성(Continuation) 연산자에 대해 발표한 내용입니다. 함수형 언어에서 영감을 받은 지속성 연산자의 개념을 소개하고, 루비에 내장된 `callcc`의 기능과 한계점을 분석합니다. 이어서 `callcc`의 단점을 극복할 수 있는 대안인 `shift/reset`의 개념과 이점, 그리고 향후 루비에서의 구현 방향에 대해 다룹니다. 발표는 지속성 연산자의 기본 개념부터 실제 DSL(Domain Specific Language) 구현 사례를 통해 그 유용성과 문제점을 심층적으로 탐구합니다.

지속성(Continuation)은 현재 평가하려는 식의 값을 받은 후 이어지는 모든 계산을 형식적으로 정의한 것으로, 런타임에서의 실행 지점이라고 볼 수 있습니다. 루비는 callcc (call with current continuation)를 통해 이러한 지속성을 일급 객체로 다룰 수 있도록 지원합니다. callccsetjmp/longjmp와 유사하게 동작하며, 한 번 캡처된 지속성을 여러 번 호출할 수 있는 다중 호출(multi-shot) 특성을 가집니다. 비록 Ruby 2.2.2부터 callcc가 비권장되고 코루틴 기능을 위한 Fiber 사용이 권장되지만, Fiber가 단일 호출(one-shot) 특성만을 가지므로 callcc는 다중 호출이 필요한 비결정 계산(non-deterministic computation)을 위한 DSL 구현 등 특정 시나리오에서 여전히 유용합니다.

발표에서는 백트래킹(backtracking)을 수행하는 DSL 구현을 예시로 callcc의 잠재력을 설명합니다. 하스켈의 리스트 모나드처럼 평이하게 백트래킹 코드를 작성할 수 있는 DSL을 루비에서 구현할 때, 단순히 flat_map을 사용하는 방식은 콜백 지옥을 초래합니다. 메타 프로그래밍을 통한 DSL 구현은 구문 추가 시마다 DSL 구현을 수정해야 하고 변수 관리가 복잡해지는 단점이 있습니다. 반면, callcc를 활용하면 루비의 일반적인 변수 할당 구문을 사용하면서도 내부적으로 백트래킹을 통해 변수 값을 변경하며 코드를 반복 실행할 수 있어, DSL의 유연성과 개발 편의성을 크게 향상시킵니다.

그러나 callcc는 몇 가지 심각한 문제점을 안고 있습니다. 첫째, callcc는 현재 실행 중인 모든 지속성을 캡처하므로, DSL 구현 시 실제 필요한 범위보다 과도한 컨텍스트(불필요한 이후 코드까지)를 포함하게 됩니다. 이로 인해 코드의 복잡성이 증가하며, 캡처된 지속성 호출 후 원래 실행 지점으로 명시적으로 돌아오는 처리가 필요해 구현이 까다로워집니다. 둘째, callcc의 성능은 매우 비효율적입니다. ‘SEND+MORE=MONEY’ 퍼즐과 같은 문제 해결 시, 메타 프로그래밍 방식으로는 0.13초 만에 완료되는 반면, callcc 방식으로는 수분 이상 소요되거나 아예 완료되지 못하는 경우가 발생합니다. 이는 callcc가 루비 VM의 스택과 실행 위치를 스냅샷으로 기록하고 복원하는 방식으로 구현되기 때문입니다. 스택의 memcopysetjmp/longjmp를 통한 VM 상태 저장 및 복원 과정은 상당한 오버헤드를 발생시키며, 루비 내부적으로 일부 최적화(차등 백업)가 이루어지더라도 근본적인 성능 저하를 피하기 어렵습니다.

이러한 callcc의 문제점을 해결하기 위한 대안으로 ‘제한된 지속성(delimited continuation)’ 연산자인 shift/reset이 소개됩니다. shift/resetreset 블록 내에서 shift를 통해 필요한 범위의 지속성만을 캡처합니다. 이는 callcc의 과도한 컨텍스트 캡처 문제를 해결하여 DSL 구현을 훨씬 간결하게 만듭니다. shift/reset은 캡처된 지속성을 함수처럼 활용하여 flat_map 등에 전달할 수 있으며, reset 지점에 도달하면 자동으로 원래 흐름으로 복귀하여 callcc와 달리 명시적인 복귀 처리가 필요 없습니다. 놀랍게도 shift/resetcallcc를 사용하여 구현할 수 있으며, 이렇게 구현된 shift/reset조차 callcc를 직접 사용하는 것보다 월등히 나은 성능을 보여줍니다 (예: ‘SEND+MORE=MONEY’ 퍼즐이 0.5초 내에 완료). 이는 shift/reset의 구조가 callcc의 내부 스택 최적화가 더 효율적으로 작동하도록 프로그램을 정리하기 때문으로 추정됩니다.

발표자는 향후 과제로 shift/reset을 루비 VM의 내부를 직접 다루는 C 확장 라이브러리로 구현하여 스택 저장 범위를 더욱 제한하는 최적화를 통해 성능을 극대화할 계획임을 밝혔습니다. 이를 통해 shift/reset은 DSL 개발의 편의성과 실행 효율성을 동시에 만족시키는 강력한 도구가 될 잠재력을 가지고 있습니다.

결론

결론적으로, Ruby의 `callcc`는 비록 현재 비권장되지만, 다중 호출 지속성이 필요한 DSL 구현과 같은 특정 영역에서 여전히 강력한 유용성을 가집니다. 그러나 `callcc`는 과도한 컨텍스트 캡처와 심각한 성능 저하라는 명확한 한계를 가지고 있습니다. 이러한 문제에 대한 효과적인 해결책으로 제시된 `shift/reset`은 필요한 범위의 지속성만을 캡처하고 명시적인 복귀 처리 없이도 작동하여 DSL 개발을 크게 간소화합니다. 또한, `shift/reset`은 `callcc`를 기반으로 구현하더라도 상당한 성능 개선을 보여주었으며, 향후 루비 VM에 직접 접근하는 C 확장 라이브러리로 구현될 경우 훨씬 더 큰 성능 최적화가 가능할 것으로 예상됩니다. 이는 루비의 DSL 개발 환경을 한 단계 발전시킬 중요한 기여가 될 것입니다.

댓글 0

댓글 작성

0/1000
정중하고 건설적인 댓글을 작성해 주세요.

아직 댓글이 없습니다

첫 번째 댓글을 작성해보세요!