What's new in Lua 5.4

Ben Hoyt

Lua 5.4의 새로운 기능

원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기

Lua 5.4 버전이 6월 말에 출시됐다. 1993년 탄생한 이 경량 스크립트 언어의 열다섯 번째 메이저 버전이다. 5.4의 새로운 기능으로는 가비지 컬렉터를 위한 제너레이셔널 모드가 있는데, 수명이 짧은 할당을 많이 하는 프로그램에서 더 뛰어난 성능을 보인다. 이제 이 언어는 지역 변수에 대한 “어트리뷰트”를 지원해 개발자가 변수를 상수(const)로 표시하거나 자원을 닫을 수 있는(close) 자원으로 표시할 수 있다. 이와 함께 5.3 대비 상당한 성능 개선과 수많은 사소한 변경이 이뤄졌다.

Lua는 다른 애플리케이션에 임베드되도록 최적화된 프로그래밍 언어로, Redis와 Adobe Lightroom 등이 대표적인 사용자다. 월드 오브 워크래프트와 앵그리버드를 비롯한 수많은 컴퓨터 게임의 스크립트 언어로 사용되어 왔으며, 2009년 게임 업계 설문조사에서는 가장 많이 사용된 스크립트 언어로 꼽혔다. Lua가 임베딩에 적합한 이유 중 하나는 크기가 작다는 점이다. 가장 단순한 애플리케이션조차 수 메가바이트를 다운로드해야 하는 요즘, Lua 5.4 전체 배포판(소스와 문서 포함)은 349KB짜리 압축 파일 하나에 불과하다. 기본 설정으로 Lua 인터프리터를 빌드하려면 개발자는 make를 입력하고 약 5초간 컴파일을 기다리면 되는데, 그 결과물은 200~300KB 크기의 독립 실행형 바이너리다.

메이저 버전은 특정 출시 주기 없이 수년마다 한 번씩 출시된다. 이전 메이저 버전인 5.3은 5년여 전인 2015년 1월에 출시됐으며, 별도의 정수 타입(이전까지 Lua는 부동소수점 숫자만 사용했다), 비트 연산자, 기본 UTF-8 라이브러리 등 수많은 사소한 기능이 추가됐다.

언어 변경 사항

Lua 5.4의 흥미로운 새 기능 중 하나는 지역 변수 “어트리뷰트”가 추가된 것이다. 지역(블록 스코프) 변수를 선언할 때 개발자는 변수 이름 뒤에 <const><close>를 붙여 해당 어트리뷰트를 부여할 수 있다. const 어트리뷰트는 단순하다. C의 const와 유사하게, 선언 시 초기화한 뒤에는 해당 변수에 다시 값을 할당할 수 없다는 의미다. const 어트리뷰트가 자료구조를 불변으로 만드는 것은 아니다. const 변수에 저장된 테이블의 항목을 변경하는 것은 막지 않지만, 변수 이름 자체에 다시 할당할 수는 없다. const 어트리뷰트는 약간의 컴파일 타임 안전성을 제공한다. 상수에 실수로 재할당하면 컴파일러가 오류를 내기 때문이다:

    do
        local x <const> = 42
        x = x+1
    end
    -- ERROR: attempt to assign to const variable 'x'

아마 더 유용한 것은(문법은 마찬가지로 다소 생소하지만) close 어트리뷰트다. 이는 변수가 스코프를 벗어날 때 Lua가 해당 객체의 __close()메타메서드”를 호출하도록 한다. C++의 RAII나 파이썬의 with과 비슷하게, 메모리 해제, 파일 닫기, 기타 자원의 정리를 결정적인 시점에 보장하는 방법이다. 예를 들어 내장 함수인 io.open()이 반환하는 파일 객체는 <close>와 함께 사용할 수 있다:

    do
        local f <close> = io.open("/etc/fstab", "r")
        -- read from file 'f'
    end
    -- file is automatically closed here

<close> 어트리뷰트는 사용자 정의 객체에도 사용할 수 있다:

    function new_thing()
        local thing = {}
        setmetatable(thing, {
            __close = function()
                          print("thing closed")
                      end
        })
        return thing
    end

    do
        local x <close> = new_thing()
        print("use thing")
    end
    -- "thing closed" is printed here after "use thing"

이전에는 개발자들이 이런 용도로 __gc() 메타메서드를 사용해야 했지만, 이는 블록이 끝날 때 결정적으로 호출되는 것이 아니라 한참 뒤에 객체가 가비지 컬렉션될 때 호출된다.

제너레이셔널 GC

5.4 버전에는 객체 수명이 대체로 짧은 특정 종류의 프로그램에서 더 나은 성능을 내는 새로운 제너레이셔널 가비지 컬렉션(GC) 모드도 도입됐다. 제너레이셔널 GC는 “대부분의 객체는 일찍 죽는다”는 관찰에 기반해, “젊은” 객체는 자주 스캔해 참조되지 않으면 해제하고, 오래된 객체(한 번 이상의 GC 패스 이후에도 여전히 참조되는 객체)는 덜 자주 스캔한다. 흥미롭게도 Lua의 창시자 중 한 명인 Roberto Ierusalimschy는 2017년에 언급한 바에 따르면 Lua에 이전에도 제너레이셔널 GC가 있었다고 한다:

Lua는 5.1부터 증분 가비지 컬렉터를 갖추고 있다. 5.2에서 실험적으로 도입됐다가 5.3에서 제거된 것은 제너레이셔널 컬렉터였다. 5.4에서 다시 돌아올 텐데, 이번에는 아마 그대로 유지될 것이다.

Ierusalimschy는 2019년에 증분 GC가 어떻게 동작하는지, 왜 5.2의 제너레이셔널 GC가 그리 좋은 성능을 내지 못했는지, 그리고 Lua 팀이 그것을 무엇으로 대체했는지에 대해 더 자세히 다루는 강연을 했다(PDF 슬라이드YouTube 영상). 5.2 버전에서는 객체가 단 한 번의 GC 사이클(컬렉터 패스)만 살아남으면 “오래된” 객체가 됐지만, 5.4에서는 두 번의 GC 사이클을 살아남아야 하며, 이는 실제 Lua 프로그램에 더 정확한 모델이다. 두 사이클 방식은 구현이 더 복잡하지만 많은 프로그램에서 더 나은 GC 성능을 제공한다. 다만 전부는 아니다. Ierusalimschy는 큰 자료구조를 구축하는 프로그램은 혜택을 보지 못한다고 언급한다. 아마 그 때문에 Lua 팀은 기본값을 바꾸지 않았다. 5.4에서도 기본값은 여전히 증분 컬렉터를 사용하는 것이며, 개발자는 제너레이셔널 GC를 켜려면 프로그램에 “collectgarbage("generational")”을 추가해야 한다.

lua-l 메일링 리스트에서 Gé Weijers는 다음과 같이 설명했다. 제너레이셔널 GC가 그 “마이너 컬렉션”(젊은 객체를 수거하기 위한 빈번한 GC 패스)과 함께 새로운 <close> 기능(이전에는 “toclose”라고 불렸다)과 어떻게 연관되는지에 대해서다:

5.4의 가비지 컬렉터는 제너레이셔널 모드를 구현한다. 객체가 마이너 컬렉션을 살아남으면 접근 불가능해진 뒤 __gc 메타메서드가 호출되기까지 아주아주 오랜 시간이 걸릴 수 있는데, 특히 프로그램이 대부분 수명이 짧은 객체를 생성하는 경우 더욱 그렇다. 이는 __gc를 가난한 사람 버전의 RAII 대체재로 쓰기 어렵게 만든다.

새로운 “toclose” 기능은 자원을 해제하고 잠금을 제때 푸는 데 훨씬 더 유용하다.

더 빨라진 성능

5.4에서 잘 알려지지 않았지만 간과된 기능 중 하나는 상당히 빨라진 인터프리터인데, 릴리스 노트에서는 이를 빠뜨렸다. Gabriel de Quadros Ligneul의 Lua Benchmarks 모음집을 이용해 필자의 64비트 macOS 머신에서 테스트한 결과, 모음집에 포함된 11개 벤치마크에서 5.4 버전이 5.3 버전보다 평균 40% 빠른 것으로 나타났다:

[Lua 벤치마크]

비슷한 향상이 Elmar Klausmeier의 성능 비교에서도 나타난다. 물론 이 두 결과 모두 다소 인위적인 벤치마크다. 게임 엔진 같은 곳에서 Lua를 사용할 때 그래픽이나 행렬 곱셈 같은 성능에 민감한 코드는 분명 C로 작성될 것이다. 그럼에도 수치 연산 위주 코드(이 벤치마크 대부분이 그렇다)에서 이 정도 규모의 개선은 무시할 수준이 아니다. Dibyendu Majumdar는 2018년 lua-l 메일링 리스트에서 이러한 개선의 몇 가지 이유를 설명했다. 5.4에서는 Lua 파서가 관련된 타입이 숫자임을 추론할 수 있을 때 사용할 수 있는, 수치 연산을 위한 새롭고 최적화된 바이트코드 명령어가 추가됐다. 예를 들어 GETISETI는 인덱스가 상수 정수일 때 테이블 조회에 사용되는 두 개의 새로운 명령어다.

훨씬 더 높은 성능이 필요한 사람들은 Mike Pall의 LuaJIT를 사용할 수 있다. 이는 Lua 5.1용 JIT 컴파일러로, 기본 Lua 인터프리터보다 훨씬 더 빠르다. 하지만 LuaJIT는 5.1 버전(2006년 출시) 이후 Lua의 새로운 기능을 전혀 추가하지 않았다. 5.2의 새로운 스코프 규칙과 5.3의 새로운 정수 타입을 비롯한 수많은 호환성을 깨는 변경 때문에 이를 추가하는 일은 상당한 작업이 될 것이다. 이런 이유로 Pall은 Lua 팀이 만드는 하위 호환성을 깨는 변경에 대해 공개적으로 비판해 왔다.

이는 실제 문제로 보이며, 단지 모호한 엣지 케이스만의 문제는 아니다. Lua Benchmarks 모음집의 벤치마크 중 두 개가 5.4에서 “C stack overflow” 오류와 함께 실패했는데(5.3에서는 정상 작동한다), 그래서 실행 전에 이를 제거해야 했다. ackfixpoint-fact 벤치마크가 실패하는데, 아마 5.4에서 재귀 꼬리 호출을 다르게 처리하기 때문으로 보인다. 5.4의 호환성 문제 대부분은 문서화되어 있지만, 그 목록의 길이만으로도 대규모 Lua 스크립트를 업그레이드하려는 사람들에게 상당한 어려움을 줄 수 있다. Redis나 월드 오브 워크래프트처럼 장기적인 안정성이 필요한 도구들이 특정한 구버전 Lua(두 경우 모두 5.1 버전)에 고정하는 이유가 아마 이 때문일 것이다. 커뮤니티 안에는 JIT 컴파일러가 있고 그 이후 변경이 비교적 사소하다는 이유로 5.1을 고수하는 사람들이 있는 등, 어느 정도 분열이 있는 것으로 보인다.

Lua 버전 간의 호환성 문제는 LWN이 지난 2월에 다룬 바 있는 Lua에 통합 표준 라이브러리가 없다는 문제에도 기여할 수 있다. 새 Lua 버전이 나올 때마다 라이브러리 작성자가 업그레이드에 많은 작업을 해야 한다면, 최신 상태를 유지할 가능성이 줄어든다. 그러면 누군가 새 Lua 버전에서 동작하는 포크를 만들거나 아예 새로운 라이브러리를 작성할 가능성이 더 커진다.

그 밖의 작은 변화들

더 큰 변경 외에도 Lua 5.4에는 많은 작은 기능이 추가됐다. 여기에는 기존 C 라이브러리의 rand() 함수 대신 xoshiro256** 알고리즘을 사용하는 새로운 난수 생성기가 포함된다. 이제 종료자(finalizer)나 __close() 메서드에서 오류가 발생했을 때 사용되는 간단한 경고 시스템도 있다. 또한 “userdata”를 가진 Lua 값이 여러 사용자 값을 가질 수 있는 기능도 추가됐다(userdata는 Lua C API로 생성된 메모리 블록에 대한 포인터이므로, 이 기능을 통해 C 확장으로 생성된 객체가 여러 메모리 블록을 연관시킬 수 있다).

의미론상의 사소한 변경도 있었다. for 루프에서 래핑(wrap-around)이 발생하는 엣지 케이스의 처리가 약간 달라졌고, 정수에 대한 문자열-숫자 강제 변환이 조정됐다(예를 들어 "10"+1은 5.4에서는 정수 11이지만 5.3에서는 부동소수점 숫자 11.0이다).

전반적으로 Lua는 그 영역(더 큰 시스템이나 애플리케이션에 임베딩하는 용도)에서는 좋은 언어로 보인다. 5.4의 출시는 핵심 팀으로부터 지속적인 개선이 이뤄지고 있음을 보여준다. Lua에는 명확한 로드맵이 없다보니, 현시점에서 5.5에 어떤 변경이 계획되어 있는지, 혹은 언제 출시될지 알기 어렵다(Lua 개발자인 Pierre Chapuis는 다음 버전이 버전 번호 6.0을 달고 “매우 큰 영향을 주는 변경”이 될 수도 있다고 추측하기도 했다). 어쨌든 5.4의 새로운 기능은 대부분의 사용자에게는 상당히 사소하게 느껴질 수도 있지만, 성능 개선은 분명 반가운 성과가 될 것이다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글