LuaJIT와 표준 Lua의 차이점은 무엇인가요?
API7.ai
September 23, 2022
OpenResty의 또 다른 초석인 LuaJIT에 대해 알아보겠습니다. 오늘 글의 중심 부분은 Lua와 LuaJIT의 필수적이면서도 잘 알려지지 않은 몇 가지 측면에 대해 다룰 것입니다.
Lua의 기본 사항은 검색 엔진이나 Lua 책을 통해 더 많이 배울 수 있으며, Lua 저자의 책인 _Programming in Lua_를 추천합니다.
물론 OpenResty에서 올바른 LuaJIT 코드를 작성하는 문턱은 높지 않지만, 효율적인 LuaJIT 코드를 작성하는 것은 쉽지 않습니다. 이에 대한 주요 요소는 나중에 OpenResty 성능 최적화 섹션에서 자세히 다룰 예정입니다.
먼저 LuaJIT가 전체 OpenResty 아키텍처에서 어디에 위치하는지 살펴보겠습니다.
앞서 언급했듯이, OpenResty Worker
프로세스는 Master
프로세스를 포크하여 얻어집니다. Master
프로세스의 LuaJIT 가상 머신도 포크되며, 동일한 Worker
내의 모든 Worker
프로세스는 이 LuaJIT 가상 머신을 공유하고, Lua 코드 실행은 이 가상 머신에서 이루어집니다.
이것이 OpenResty가 작동하는 기본 방식이며, 이후 글에서 더 자세히 논의할 예정입니다. 오늘은 Lua와 LuaJIT의 관계를 정리하는 것부터 시작하겠습니다.
표준 Lua와 LuaJIT의 관계
먼저 중요한 사항부터 정리하겠습니다.
표준 Lua와 LuaJIT는 두 가지 다른 것입니다. LuaJIT는 Lua 5.1 구문만 호환됩니다.
표준 Lua의 최신 버전은 현재 5.4.4이며, LuaJIT의 최신 버전은 2.1.0-beta3입니다. 몇 년 전의 이전 OpenResty 버전에서는 컴파일 시 표준 Lua VM 또는 LuaJIT VM 중 하나를 실행 환경으로 선택할 수 있었지만, 이제는 표준 Lua 지원이 제거되고 LuaJIT만 지원됩니다.
LuaJIT 구문은 Lua 5.1과 호환되며, 선택적으로 Lua 5.2 및 5.3을 지원합니다. 따라서 우리는 먼저 Lua 5.1 구문을 배우고 이를 기반으로 LuaJIT 기능을 배워야 합니다. 이전 글에서 Lua의 기본 구문을 다루었으므로, 오늘은 Lua의 몇 가지 독특한 기능만 언급하겠습니다.
한 가지 주목할 점은 OpenResty가 공식 LuaJIT 버전 2.1.0-beta3을 직접 사용하지 않고, 이를 확장한 자체 포크인 openresty-luajit2를 사용한다는 것입니다.
이러한 독특한 API는 OpenResty의 실제 개발 과정에서 성능상의 이유로 추가되었습니다. 따라서 이후에 언급하는 LuaJIT는 OpenResty가 자체적으로 유지 관리하는 LuaJIT 브랜치를 의미합니다.
왜 LuaJIT인가?
LuaJIT와 Lua의 관계에 대해 이렇게까지 이야기했으니, 왜 Lua를 직접 사용하지 않고 LuaJIT를 사용하는지 궁금할 수 있습니다. 사실, 주요 이유는 LuaJIT의 성능 이점 때문입니다.
Lua 코드는 직접 해석되지 않고 Lua 컴파일러에 의해 Byte Code
로 컴파일된 후 Lua 가상 머신에서 실행됩니다.
LuaJIT 런타임 환경은 Lua 인터프리터의 어셈블리 구현 외에도 JIT 컴파일러를 포함하고 있어 기계어 코드를 직접 생성할 수 있습니다. 처음에 LuaJIT는 표준 Lua처럼 시작하여 Lua 코드가 바이트 코드로 컴파일되고, 이 바이트 코드는 LuaJIT 인터프리터에 의해 해석되어 실행됩니다.
차이점은 LuaJIT 인터프리터가 바이트코드를 실행하는 동안 일부 런타임 통계를 기록한다는 것입니다. 예를 들어, 각 Lua 함수 호출 진입점이 실행된 실제 횟수와 각 Lua 루프가 실행된 실제 횟수 등입니다. 이러한 횟수가 임계값을 초과하면 해당 Lua 함수 진입점 또는 Lua 루프가 충분히 뜨거워졌다고 판단하여 JIT 컴파일러가 작동하기 시작합니다.
JIT 컴파일러는 뜨거운 함수의 진입점 또는 뜨거운 루프의 위치에서 시작하여 해당 Lua 코드 경로를 컴파일하려고 시도합니다. 이 컴파일 과정은 LuaJIT 바이트코드를 LuaJIT 자체 정의의 IR(Intermediate Representation)로 변환한 후 대상 아키텍처에 대한 기계어 코드를 생성합니다.
따라서, 소위 LuaJIT 성능 최적화는 JIT 컴파일러가 가능한 한 많은 Lua 코드를 기계어 코드로 생성할 수 있도록 하는 것이며, Lua 인터프리터의 해석 실행 모드로 되돌아가지 않도록 하는 것입니다. 이를 이해하면 나중에 배울 OpenResty 성능 최적화의 본질을 이해할 수 있습니다.
Lua의 특별한 기능
이전 글에서 설명했듯이, Lua 언어는 비교적 간단합니다. 다른 개발 언어 배경을 가진 엔지니어라면 Lua의 몇 가지 독특한 측면을 눈치채면 코드의 논리를 쉽게 이해할 수 있습니다. 다음으로 Lua 언어의 더 특이한 측면을 살펴보겠습니다.
1. 인덱스가 1부터 시작
Lua는 제가 아는 유일하게 인덱스가 1
부터 시작하는 프로그래밍 언어입니다. 이는 비프로그래머 배경의 사람들에게는 더 이해하기 쉬울 수 있지만, 프로그램 버그를 유발하기 쉽습니다. 다음은 그 예입니다.
$ resty -e 't={100}; ngx.say(t[0])'
프로그램이 100
을 출력하거나 인덱스 0
이 존재하지 않는다는 오류를 보고할 것으로 예상할 수 있습니다. 하지만 놀랍게도 아무것도 출력되지 않고 오류도 보고되지 않습니다. 따라서 type
명령을 추가하여 출력을 확인해 보겠습니다.
$ resty -e 't={100};ngx.say(type(t[0]))'
nil
결과는 nil
값입니다. 사실 OpenResty에서 nil
값의 판단과 처리는 혼란스러운 점이므로, 나중에 OpenResty에 대해 이야기할 때 더 자세히 다루겠습니다.
2. 문자열 연결에 ..
사용
대부분의 언어가 +
를 사용하는 것과 달리, Lua는 두 개의 점을 사용하여 문자열을 연결합니다.
$ resty -e "ngx.say('hello' .. ', world')"
hello, world
실제 프로젝트 개발에서 우리는 일반적으로 여러 개발 언어를 사용하며, Lua의 독특한 설계는 문자열 연결이 조금 헷갈릴 때마다 개발자들이 생각하게 만듭니다.
3. 테이블이 유일한 데이터 구조
Python과 같이 내장 데이터 구조가 풍부한 언어와 달리, Lua는 table
이라는 유일한 데이터 구조를 가지고 있으며, 이는 배열과 해시 테이블을 포함할 수 있습니다.
local color = {first = "red", "blue", third = "green", "yellow"}
print(color["first"]) --> 출력: red
print(color[1]) --> 출력: blue
print(color["third"]) --> 출력: green
print(color[2]) --> 출력: yellow
print(color[3]) --> 출력: nil
명시적으로 키-값 쌍으로 값을 할당하지 않으면, 테이블은 기본적으로 숫자를 인덱스로 사용하며, 1
부터 시작합니다. 따라서 color[1]
은 blue
입니다.
또한 테이블에서 올바른 길이를 얻는 것은 어렵습니다. 다음 예제를 살펴보겠습니다.
local t1 = { 1, 2, 3 }
print("Test1 " .. table.getn(t1))
local t2 = { 1, a = 2, 3 }
print("Test2 " .. table.getn(t2))
local t3 = { 1, nil }
print("Test3 " .. table.getn(t3))
local t4 = { 1, nil, 2 }
print("Test4 " .. table.getn(t4))
결과:
Test1 3
Test2 2
Test3 1
Test4
보시다시피, 첫 번째 테스트 케이스에서 길이 3
을 반환하는 것을 제외하고, 이후 테스트는 모두 우리의 예상과 다릅니다. 사실 Lua에서 테이블 길이를 얻으려면 테이블이 시퀀스일 때만 올바른 값이 반환된다는 점에 유의해야 합니다.
그렇다면 시퀀스란 무엇일까요? 먼저, 시퀀스는 배열의 부분집합입니다. 즉, 테이블의 요소는 양의 정수 인덱스로 접근할 수 있어야 하며 키-값 쌍이 없어야 합니다. 위 코드에서 t2
를 제외한 모든 테이블은 배열입니다.
둘째, 시퀀스는 구멍, 즉 nil
을 포함하지 않습니다. 이 두 가지를 결합하면, 위의 테이블 중 t1
은 시퀀스이고, t3
과 t4
는 배열이지만 시퀀스는 아닙니다.
이 시점에서 여전히 궁금증이 있을 수 있습니다. 왜 t4
의 길이가 1
일까요? 이는 nil
을 만나면 길이를 얻는 로직이 계속 실행되지 않고 바로 반환되기 때문입니다.
이 부분이 정말 복잡하다는 것을 알 수 있습니다. 그렇다면 우리가 원하는 테이블 길이를 얻을 수 있는 방법이 있을까요? 당연히 있습니다. OpenResty는 이를 확장했으며, 나중에 테이블에 대한 전용 챕터에서 다루겠습니다. 여기서는 미스터리로 남겨두겠습니다.
4. 모든 변수는 기본적으로 전역
나는 확실하지 않은 한, 항상 새로운 변수를 local
변수로 선언해야 한다는 점을 강조하고 싶습니다.
local s = 'hello'
이는 Lua에서 변수는 기본적으로 전역이며, _G
라는 이름의 테이블에 배치되기 때문입니다. 지역 변수가 아닌 변수는 전역 테이블에서 조회되며, 이는 비용이 많이 드는 작업입니다. 변수 이름을 잘못 입력하면 식별하고 수정하기 어려운 버그가 발생할 수 있습니다.
따라서 OpenResty에서는 모듈을 요청할 때도 항상 local
을 사용하여 변수를 선언하는 것을 강력히 권장합니다.
-- 권장
local xxx = require('xxx')
-- 피해야 함
require('xxx')
LuaJIT
Lua의 이 네 가지 특별한 기능을 염두에 두고, 이제 LuaJIT로 넘어가겠습니다.
LuaJIT는 Lua 5.1과 호환되고 JIT를 지원하는 것 외에도 FFI(Foreign Function Interface)와 긴밀하게 통합되어 있어 Lua 코드에서 외부 C 함수를 호출하고 C 데이터 구조를 직접 사용할 수 있습니다. 다음은 가장 간단한 예입니다.
local ffi = require("ffi")
ffi.cdef[[
int printf(const char *fmt, ...);
]]
ffi.C.printf("Hello %s!", "world")
단 몇 줄의 코드로 Lua에서 C의 printf
함수를 직접 호출하고 Hello world!
를 출력할 수 있습니다. resty
명령을 사용하여 실행해보고 작동하는지 확인할 수 있습니다.
마찬가지로 FFI를 사용하여 NGINX와 OpenSSL의 C 함수를 호출하여 더 많은 작업을 수행할 수 있습니다. FFI 방식은 전통적인 Lua/C API 방식보다 성능이 우수하며, 이 때문에 lua-resty-core
프로젝트가 존재합니다. 다음 섹션에서 FFI와 lua-resty-core
에 대해 이야기하겠습니다.
또한 성능상의 이유로 LuaJIT는 테이블 함수를 확장했습니다: table.new
와 table.clear
, 이 두 가지 필수적인 성능 최적화 함수는 OpenResty의 lua-resty
라이브러리에서 자주 사용되지만, 문서가 복잡하고 샘플 코드가 없어 개발자들이 잘 알지 못합니다. 이는 성능 최적화 섹션에서 다루겠습니다.
요약
오늘의 내용을 다시 한 번 살펴보겠습니다.
OpenResty는 성능상의 이유로 표준 Lua 대신 LuaJIT를 선택하고 자체 LuaJIT 브랜치를 유지 관리합니다. LuaJIT는 Lua 5.1 구문을 기반으로 하며, 선택적으로 일부 Lua 5.2 및 Lua 5.3 구문을 호환하여 시스템을 구성합니다. Lua 구문은 인덱스, 문자열 연결, 데이터 구조, 변수 등에서 독특한 특징을 가지고 있으므로 코드를 작성할 때 특히 주의해야 합니다.
Lua와 LuaJIT를 배우면서 어떤 함정에 빠진 적이 있나요? 여러분의 의견을 공유해 주세요. 저도 제가 마주한 함정을 공유하는 글을 작성했습니다. 이 글을 동료와 친구들과 공유하여 함께 배우고 성장하길 바랍니다.