최고의 팁: Lua에서 독창적인 개념과 함정 식별하기
API7.ai
October 12, 2022
이전 글에서는 LuaJIT의 테이블 관련 라이브러리 함수에 대해 배웠습니다. 이 외에도 오늘은 OpenResty에서 사용되는 Lua의 독특하거나 덜 알려진 개념들과 일반적인 Lua 함정에 대해 소개하겠습니다.
약한 테이블(Weak Table)
먼저, Lua의 독특한 개념인 _약한 테이블_에 대해 알아보겠습니다. 이는 가비지 컬렉션과 관련이 있습니다. 다른 고급 언어와 마찬가지로 Lua는 자동으로 가비지 컬렉션을 수행하므로, 구현에 대해 신경 쓸 필요 없이 명시적으로 GC를 호출할 필요가 없습니다. 가비지 컬렉터는 참조되지 않는 공간을 자동으로 회수합니다.
하지만 단순한 참조 카운팅만으로는 충분하지 않을 때가 있으며, 때로는 더 유연한 메커니즘이 필요합니다. 예를 들어, Lua 객체 Foo
(table 또는 함수)를 테이블 tb
에 삽입하면, 이 객체 Foo
에 대한 참조가 생성됩니다. Foo
에 대한 다른 참조가 없더라도, tb
내의 참조는 항상 존재하므로 GC가 Foo
가 차지하는 메모리를 회수할 수 없습니다. 이때 우리에게는 두 가지 선택지가 있습니다.
- 하나는
Foo
를 수동으로 해제하는 것입니다. - 두 번째는 메모리에 상주하도록 하는 것입니다.
예를 들어, 다음 코드를 보겠습니다.
$ resty -e 'local tb = {}
tb[1] = {red}
tb[2] = function() print("func") end
print(#tb) -- 2
collectgarbage()
print(#tb) -- 2
table.remove(tb, 1)
print(#tb) -- 1
하지만 사용하지 않는 객체가 메모리를 차지하도록 두고 싶지는 않을 것입니다. 특히 LuaJIT은 2G 메모리 제한이 있기 때문입니다. 수동 해제 시점을 정하는 것은 쉽지 않으며, 코드의 복잡성을 증가시킵니다.
이때 _약한 테이블_이 등장합니다. 이름에서 알 수 있듯이, _약한 테이블_은 먼저 테이블이며, 이 테이블의 모든 요소는 약한 참조입니다. 개념은 항상 추상적이므로, 약간 수정된 코드를 먼저 살펴보겠습니다.
$ resty -e 'local tb = {}
tb[1] = {red}
tb[2] = function() print("func") end
setmetatable(tb, {__mode = "v"})
print(#tb) -- 2
collectgarbage()
print(#tb) -- 0
'
보시다시피, 사용되지 않는 객체가 해제되었습니다. 이 중 가장 중요한 것은 다음 코드입니다.
setmetatable(tb, {__mode = "v"})
익숙하지 않나요? 메타 테이블의 연산이 아닌가요? 맞습니다. 테이블의 메타 테이블에 __mode
필드가 있으면 그 테이블은 weak table
입니다.
__mode
의 값이k
이면, 테이블의 키가 약한 참조입니다.__mode
의 값이v
이면, 테이블의 값이 약한 참조입니다.- 물론,
kv
로 설정할 수도 있으며, 이는 이 테이블의 키와 값 모두가 약한 참조임을 나타냅니다.
이 세 가지 약한 테이블 중 어느 것이든, 키나 값이 회수되면 전체 키-값 객체가 회수됩니다.
위의 코드 예제에서 __mode
의 값은 v
이며, tb
는 배열이고, 배열의 값은 테이블과 함수 객체이므로 자동으로 회수될 수 있습니다. 그러나 __mode
의 값을 k
로 변경하면 해제되지 않습니다. 예를 들어, 다음 코드를 보겠습니다.
$ resty -e 'local tb = {}
tb[1] = {red}
tb[2] = function() print("func") end
setmetatable(tb, {__mode = "k"})
print(#tb) -- 2
collectgarbage()
print(#tb) -- 2
'
우리는 값이 약한 참조인 약한 테이블, 즉 배열 타입의 _약한 테이블_만을 시연했습니다. 자연스럽게, 객체를 키로 사용하여 해시 테이블 타입의 _약한 테이블_을 구축할 수도 있습니다. 예를 들어, 다음과 같습니다.
$ resty -e 'local tb = {}
tb[{color = red}] = "red"
local fc = function() print("func") end
tb[fc] = "func"
fc = nil
setmetatable(tb, {__mode = "k"})
for k,v in pairs(tb) do
print(v)
end
collectgarbage()
print("----------")
for k,v in pairs(tb) do
print(v)
end
'
collectgarbage()
를 수동으로 호출하여 강제로 GC를 수행한 후, tb
테이블의 모든 요소가 해제됩니다. 물론, 실제 코드에서는 collectgarbage()
를 수동으로 호출할 필요가 없으며, 백그라운드에서 자동으로 실행되므로 걱정할 필요가 없습니다.
하지만 collectgarbage()
함수를 언급했으니, 이에 대해 몇 마디 더 하겠습니다. 이 함수는 여러 가지 옵션을 전달할 수 있으며, 기본값은 collect
로, 전체 GC를 수행합니다. 또 다른 유용한 옵션은 count
로, Lua가 차지하는 메모리 공간의 양을 반환합니다. 이 통계는 메모리 누수가 있는지 확인하고 2G 상한선에 접근하지 않도록 알려주는 데 도움이 됩니다.
약한 테이블과 관련된 코드는 실제로 작성하기가 더 복잡하고 이해하기 어려우며, 이에 따라 더 많은 숨겨진 버그가 발생할 수 있습니다. 서두를 필요는 없습니다. 나중에 약한 테이블로 인한 메모리 누수 문제를 해결하는 오픈 소스 프로젝트를 소개하겠습니다.
클로저와 upvalue
클로저와 upvalue로 넘어가겠습니다. 앞서 강조했듯이, Lua에서는 모든 값이 일급 시민이며, 함수도 포함됩니다. 이는 함수가 변수에 저장되고, 인수로 전달되며, 다른 함수의 값으로 반환될 수 있음을 의미합니다. 예를 들어, 위의 약한 테이블 예제에서 다음과 같은 샘플 코드가 나타납니다.
tb[2] = function() print("func") end
이는 테이블의 값으로 저장된 익명 함수입니다.
Lua에서 다음 코드의 두 함수 정의는 동일합니다. 그러나 후자는 함수를 변수에 할당하는 방법으로, 우리가 자주 사용하는 방법입니다.
local function foo() print("foo") end
local foo = fuction() print("foo") end
또한, Lua는 한 함수 내에 다른 함수를 작성하는 것을 지원합니다. 즉, 중첩 함수를 지원합니다. 예를 들어, 다음 예제 코드를 보겠습니다.
$ resty -e '
local function foo()
local i = 1
local function bar()
i = i + 1
print(i)
end
return bar
end
local fn = foo()
print(fn()) -- 2
'
bar
함수는 foo
함수 내부의 지역 변수 i
를 읽고 그 값을 수정할 수 있음을 알 수 있습니다. 이 변수는 bar
내부에 정의되지 않았음에도 불구하고 말입니다. 이 기능을 렉시컬 스코프라고 합니다.
Lua의 이러한 기능은 클로저의 기초입니다. 클로저는 단순히 다른 함수의 렉시컬 스코프에 있는 변수에 접근하는 함수입니다.
정의에 따르면, Lua의 모든 함수는 실제로 클로저입니다. 중첩하지 않더라도 말입니다. 이는 Lua 컴파일러가 Lua 스크립트 외부를 가져와 주 함수로 감싸기 때문입니다. 예를 들어, 다음 간단한 코드를 보겠습니다.
local foo, bar
local function fn()
foo = 1
bar = 2
end
컴파일 후에는 다음과 같이 됩니다.
function main(...)
local foo, bar
local function fn()
foo = 1
bar = 2
end
end
그리고 함수 fn
은 주 함수의 두 지역 변수를 캡처하므로, 이 또한 클로저입니다.
물론, 클로저 개념은 많은 언어에 존재하며, Lua에만 국한된 것이 아니므로 비교하여 더 잘 이해할 수 있습니다. 클로저를 이해해야만 upvalue
에 대해 이야기할 수 있습니다.
upvalue
는 Lua에만 있는 개념으로, 클로저에서 캡처된 렉시컬 스코프 외부의 변수입니다. 위의 코드를 계속해서 보겠습니다.
local foo, bar
local function fn()
foo = 1
bar = 2
end
함수 fn
은 자신의 렉시컬 스코프에 있지 않은 두 지역 변수 foo
와 bar
를 캡처하며, 이 두 변수는 사실 함수 fn
의 upvalue
입니다.
일반적인 함정
Lua의 몇 가지 개념을 소개한 후, OpenResty 개발에서 마주쳤던 Lua 관련 함정에 대해 이야기하겠습니다.
이전 섹션에서 Lua와 다른 개발 언어 간의 차이점, 예를 들어 인덱스가 1
부터 시작한다는 점, 기본적으로 전역 변수라는 점 등을 언급했습니다. OpenResty의 실제 코드 개발에서는 Lua와 LuaJIT 관련 문제를 더 많이 마주치게 되며, 아래에서 더 일반적인 몇 가지를 이야기하겠습니다.
여기서 한 가지 주의할 점은, 모든 함정을 알고 있더라도 직접 그 함정을 밟아보지 않으면 깊은 인상을 받기 어렵다는 것입니다. 물론, 차이점은 그 함정에서 빠져나와 문제의 핵심을 찾는 방법을 훨씬 더 잘 알게 된다는 것입니다.
인덱스가 0부터 시작하는가, 1부터 시작하는가?
첫 번째 함정은 Lua의 인덱스가 1
부터 시작한다는 것입니다. 이전에 반복해서 언급한 내용입니다.
하지만 이는 전부 사실이 아닙니다. 왜냐하면 LuaJIT에서 ffi.new
로 생성된 배열은 다시 0
부터 인덱싱되기 때문입니다:
local buf = ffi_new("char[?]", 128)
따라서, 위 코드의 buf
cdata
에 접근하려면 인덱스가 0
부터 시작한다는 것을 기억해야 합니다. FFI를 사용하여 C와 상호작용할 때 이 부분을 특히 주의해야 합니다.
정규식 패턴 매칭
두 번째 함정은 정규식 패턴 매칭 문제입니다. OpenResty에는 Lua의 string 라이브러리와 OpenResty의 ngx.re.*
API 두 가지 문자열 매칭 방법이 병렬로 존재합니다.
Lua의 정규식 패턴 매칭은 고유한 형식이며, PCRE와 다르게 작성됩니다. 다음은 간단한 예제입니다.
resty -e 'print(string.match("foo 123 bar", "%d%d%d"))' — 123
이 코드는 문자열에서 숫자 부분을 추출하며, 익숙한 정규 표현식과 완전히 다르다는 것을 알 수 있습니다. Lua의 정규 매칭 라이브러리는 유지 비용이 높고 성능이 낮습니다. JIT가 이를 최적화할 수 없으며, 한 번 컴파일된 패턴은 캐시되지 않습니다.
따라서, Lua의 내장 string 라이브러리를 사용하여 find
, match
등을 수행할 때, 정규식이 필요하다면 주저하지 말고 OpenResty의 ngx.re
를 사용하십시오. 고정 문자열을 찾을 때는 일반 모드를 사용하여 string 라이브러리를 호출하는 것을 고려합니다.
다음은 제안입니다: OpenResty에서는 항상 OpenResty의 API를 우선적으로 사용하고, 그 다음 LuaJIT의 API를 사용하며, Lua 라이브러리는 신중하게 사용하십시오.
JSON 인코딩이 배열과 딕셔너리를 구분하지 않음
세 번째 함정은 JSON 인코딩이 배열과 딕셔너리를 구분하지 않는다는 것입니다. Lua에는 _table_이라는 단일 데이터 구조만 있으므로, JSON이 빈 테이블을 인코딩할 때 배열인지 딕셔너리인지 판단할 방법이 없습니다.
resty -e 'local cjson = require "cjson"
local t = {}
print(cjson.encode(t))
'
예를 들어, 위 코드는 {}
를 출력하며, 이는 OpenResty의 cjson
라이브러리가 빈 테이블을 기본적으로 딕셔너리로 인코딩함을 보여줍니다. 물론, encode_empty_table_as_object
함수를 사용하여 이 전역 기본값을 변경할 수 있습니다.
resty -e 'local cjson = require "cjson"
cjson.encode_empty_table_as_object(false)
local t = {}
print(cjson.encode(t))
'
이번에는 빈 테이블이 배열 []
로 인코딩됩니다.
그러나 이 전역 설정은 큰 영향을 미치므로, 특정 테이블에 대한 인코딩 규칙을 지정할 수 있을까요? 답은 당연히 '예'이며, 두 가지 방법이 있습니다.
첫 번째 방법은 userdata
cjson.empty_array
를 지정된 테이블에 할당하여 JSON으로 인코딩할 때 빈 배열로 처리되도록 하는 것입니다.
$ resty -e 'local cjson = require "cjson"
local t = cjson.empty_array
print(cjson.encode(t))
'
그러나 때로는 지정된 테이블이 항상 비어 있는지 확실하지 않을 수 있습니다. 테이블이 비어 있을 때 배열로 인코딩되기를 원한다면, cjson.empty_array_mt
함수를 사용합니다. 이는 두 번째 방법입니다.
이 함수는 지정된 테이블을 표시하고, 테이블이 비어 있을 때 배열로 인코딩합니다. 이름 cjson.empty_array_mt
에서 알 수 있듯이, 이는 metatable
을 사용하여 설정됩니다. 다음 코드 작업을 보겠습니다.
$ resty -e 'local cjson = require "cjson"
local t = {}
setmetatable(t, cjson.empty_array_mt)
print(cjson.encode(t))
t = {123}
print(cjson.encode(t))
'
변수 수 제한
네 번째 함정은 변수 수 제한입니다. Lua는 함수 내의 지역 변수 수와 upvalue
수에 상한이 있습니다. Lua 소스 코드에서 이를 확인할 수 있습니다.
/*
@@ LUAI_MAXVARS는 함수당 최대 지역 변수 수입니다
@* (250보다 작아야 합니다).
*/
#define LUAI_MAXVARS 200
/*
@@ LUAI_MAXUPVALUES는 함수당 최대 upvalue 수입니다
@* (250보다 작아야 합니다).
*/
#define LUAI_MAXUPVALUES 60
이 두 임계값은 각각 200
과 60
으로 하드코딩되어 있으며, 소스 코드를 수동으로 수정하여 이 값을 조정할 수 있지만 최대 250
까지만 설정할 수 있습니다.
일반적으로 이 임계값을 초과하지는 않지만, OpenResty 코드를 작성할 때 지역 변수와 upvalue
를 과도하게 사용하지 않도록 주의하고, 가능한 한 do ... end
를 사용하여 지역 변수와 upvalue
수를 줄이는 것이 좋습니다.
예를 들어, 다음 의사 코드를 보겠습니다.
local re_find = ngx.re.find
function foo() ... end
function bar() ... end
function fn() ... end
함수 foo
만 re_find
를 사용한다면, 다음과 같이 수정할 수 있습니다:
do
local re_find = ngx.re.find
function foo() ... end
end
function bar() ... end
function fn() ... end
요약
"더 많은 질문하기"의 관점에서, Lua의 250
임계값은 어디에서 왔을까요? 이것이 오늘의 생각할 문제입니다. 여러분의 의견을 남기고 이 글을 동료와 친구들과 공유해 주세요. 함께 소통하며 개선해 나가겠습니다.