처리중입니다. 잠시만 기다려주세요.
TTJ 코딩클래스
정규반 단과 자료실 테크 뉴스 코딩 퀴즈
테크 뉴스
Hacker News 2026.09.04 42

Java 8에서 가상 스레드 흉내내기: Jactl의 컨티뉴에이션 설계

Hacker News 원문 보기

자바 애플리케이션에 스크립트 기능을 넣으려는 개발자에게 가장 골치 아픈 문제 중 하나는 '블로킹'이다. 스크립트가 데이터베이스 조회나 원격 호출처럼 결과를 기다려야 하는 작업을 만나면, 그 작업이 끝날 때까지 실행 스레드가 묶여 버린다. 특히 Vert.x 같은 이벤트 기반(리액티브) 아키텍처에서는 소수의 이벤트 루프 스레드가 큐에 쌓인 이벤트를 처리하기 때문에, 스레드 하나가 블로킹되면 그만큼 처리량이 무너진다. 임베더블 스크립트 언어 Jactl은 이 문제를 자바 8 환경에서도 풀기 위해 독특한 방식을 택했다.

왜 컨티뉴에이션인가

Jactl은 바이트코드로 컴파일되어 성능을 확보하고, 애플리케이션이 스크립트의 허용 범위를 통제할 수 있는 보안성을 갖추며, 무엇보다 블로킹 연산에서 실행 스레드를 점유하지 않는 것을 목표로 만들어졌다. 개발이 시작된 시점에는 Java 21의 가상 스레드가 존재하지 않았고, 고처리량 자바 애플리케이션은 이벤트 기반으로 작성되던 시절이었다. 게다가 Java 8이나 11에 묶여 있는 애플리케이션에서도 동작해야 했다. 목표는 명확했다. 이벤트 루프 스레드에서 스크립트를 호출하되, 스크립트가 블로킹 연산에 도달하면 자신의 상태를 저장하고 스레드를 반환해 이벤트 루프가 다른 이벤트를 계속 처리하게 하고, 결과가 준비되면 멈췄던 지점에서 다시 이어가는 것이다. 이것이 바로 오늘날 가상 스레드가 제공하는 모델이지만, Java 8의 바이트코드로는 호출 스택을 그대로 보존할 방법이 없었다.

중요한 전제는 저장 대상이 Jactl 코드의 실행 상태에 한정된다는 점이다. 스크립트를 호출한 자바 쪽 상태는 저장하지 않는다. 자바 애플리케이션 자체가 이벤트 기반이므로, 스크립트는 하나의 이벤트로 완료되고 끝나면 완료 콜백이 호출되어 결과를 애플리케이션에 돌려준다. 애플리케이션이 유지해야 할 상태는 그 콜백이 들고 있으면 된다.

예외를 던져 스택을 접는다

Jactl이 택한 방법은 언뜻 반직관적이다. sleep() 같은 장시간 연산이 시작될 때 예외를 던지는 것이다. 이 예외 클래스의 이름은 프로그램 실행 상태를 표현한다는 의미에서 Continuation이다. 예외가 호출 스택을 거슬러 올라가며 풀려나가는 동안, 각 Jactl 스택 프레임에 대해 컴파일러가 생성해 둔 코드가 예외를 붙잡아 자신이 어디까지 실행했는지(대기 중이던 호출의 위치)와 지역 변수 값을 담은 Continuation을 만들고, 방금 잡은 것에 사슬처럼 연결한 새 예외를 다시 던진다. 예외가 스택 밑바닥에 도달할 무렵이면 원래의 호출 스택은 프레임마다 하나씩 대응하는 Continuation 객체들의 사슬로 바뀌어 있고, 이 사슬 전체가 스크립트의 실행 상태를 담게 된다.

성능 튜닝을 해 본 사람이라면 예외를 던진다는 발상에서 곧바로 비용을 떠올린다. 그러나 예외의 비용 대부분은 함께 생성되는 스택 트레이스에 있다. 스택 트레이스를 채우지 않는 예외라면 실제로는 매우 효율적이다. Jactl 컴파일러는 어떤 전역 함수가 장시간 연산을 수행해 Continuation을 던질 수 있는지 알고 있으며, 이런 async 함수를 호출하는 메서드와 함수를 다시 async로 표시하는 식으로 호출 사슬을 따라 전파한다. 덕분에 실제로 예외를 던지는 내장 함수가 여러 단계 아래 깊숙이 묻혀 있어도, 컴파일러는 특정 지점에서 Continuation이 던져질 수 있는지 판단할 수 있다.

상태를 담고 다시 이어가기

async 호출을 생성할 때 컴파일러는 그 호출을 try/catch로 감싸 Continuation을 잡는다. catch 블록은 현재 함수를 가리키는 MethodHandle과, 함수 내에서 async 호출이 일어난 논리적 위치를 Continuation에 저장한다. 여기에 더해 그 시점에 스코프에 있던 지역 변수와 스택 값도 함께 담는데, 프리미티브는 long[] 배열에, 그 밖의 타입은 Object[] 배열에 나눠 저장한다. 모든 async 함수는 첫 인자로 Continuation을 암묵적으로 전달받는다. 처음 실행될 때는 이 값이 null이지만, 중단 후 재개될 때는 자신이 던졌던 Continuation과 함께 다시 호출되어 그 안의 위치 정보로 어디서부터 이어갈지 계산한다.

장시간 연산이 끝나면 사슬의 첫 Continuation의 continueExecution(result)이 호출되어 MethodHandle을 꺼내 실행한다. 호출 스택이 원래와 다르므로 함수가 반환될 때는 원래 부모 함수가 아니라 continueExecution()으로 돌아오고, 이 메서드가 사슬의 다음 Continuation을 꺼내 그 MethodHandle을 부른다. 사슬이 소진되면 애플리케이션이 등록한 완료 콜백이 최종 결과와 함께 호출된다. 재개 도중 또 다른 async 함수가 새로운 Continuation을 던지면, 새 사슬 끝에 남은 기존 사슬을 이어 붙여 새 연산이 끝난 뒤 새 사슬을 먼저 재개하고 남은 옛 사슬로 넘어간다.

이 구조에서 파생된 부가 기능이 체크포인트다. Continuation 사슬을 바이트 배열로 직렬화하면 스크립트 상태를 특정 지점에 저장할 수 있다. Jactl은 checkpoint() 함수를 제공해 처리 중요 단계에서 상태를 남기게 하고, 이렇게 저장한 상태는 디스크나 데이터베이스에 보존하거나 다른 인스턴스로 복제할 수 있다. 원래 호스트가 죽어도 다른 곳에서 그 지점부터 재개할 수 있다는 뜻이다. 모든 내장 타입과 사용자 정의 클래스에 대해 바이트 배열로 저장하는 코드가 생성되며, 애플리케이션은 제공된 훅으로 이를 영속화하거나 복제해 이중화 solution에 활용할 수 있다.

실무적 의미와 한계

성능은 JMH 기반 SuspendResumeBenchmark로 측정했다. Vert.x가 이벤트 스케줄링을 맡고, 스크립트는 200건 단위의 주문 배치를 처리한다. 주문마다 processOrder()가 호출되고 품목마다 항상 true를 반환하는 checkInventory()가 실행되는데, 이 함수는 처음 n번 sleep(0)을 호출한다. sleep(0)은 Continuation을 던져 스크립트를 중단시킨 뒤 시간이 0이므로 곧바로 재개 이벤트를 예약한다. sleep(0) 호출을 0, 1, 2, 5, 10회로 늘려 가며 측정한 결과, 중단·재개 한 번당 부담은 상당히 작았다. 다만 이 수치에는 Vert.x 스케줄러가 스크립트와 재개 이벤트를 예약·실행하는 오버헤드도 포함되어 있고, 실제 영향은 스크립트가 하는 작업량, 스택의 중첩 깊이, 각 단계의 지역 변수 개수에 따라 달라진다는 점을 기억해야 한다.

실무 관점에서 이 방식의 매력은 스크립트 작성자가 async/await나 Future, Promise 같은 비동기 장치로 코드를 오염시키지 않고 블로킹 연산을 자연스럽게 인라인으로 쓸 수 있다는 데 있다. 이벤트 루프 스레드가 스크립트 때문에 막히는 걱정 없이 애플리케이션에 스크립트 기반 커스터마이징을 열어 줄 수 있고, 특히 여전히 구버전 자바에 묶인 환경에서 가치가 크다. 반대로 최신 자바로 올라간 팀이라면 JactlContext.async(false)로 이 내장 메커니즘을 끄고 가상 스레드에 맡기는 선택지도 열려 있다. 결국 Jactl이 보여 주는 것은, 언어 런타임이 컨티뉴에이션을 직접 다룰 수 있다면 플랫폼이 가상 스레드를 제공하기 훨씬 전부터 같은 프로그래밍 모델을 구현할 수 있다는 사실이다.

이 뉴스가 유용했나요?

이 기술을 직접 배워보세요

AI 도구, 직접 활용해보세요

AI 시대, 코딩으로 수익을 만드는 방법을 배울 수 있습니다.

AI 활용 강의 보기

"비전공 직장인인데 반년 만에 수익 파이프라인을 여러 개 만들었습니다"

실제 수강생 후기
  • 비전공자도 6개월이면 첫 수익
  • 20년 경력 개발자 직강
  • 자동화 프로그램 + 소스코드 제공

매일 AI·개발 뉴스를 받아보세요

주요 테크 뉴스를 매일 아침 이메일로 전해드립니다.

스팸 없이, 언제든 구독 취소 가능합니다.