Lua에서 table과 metatable이란 무엇인가?
API7.ai
October 11, 2022
오늘은 LuaJIT의 유일한 데이터 구조인 table
에 대해 배워보겠습니다.
다른 스크립트 언어들은 다양한 데이터 구조를 가지고 있지만, LuaJIT는 단 하나의 데이터 구조인 table
만을 가지고 있습니다. 이 table
은 배열, 해시, 컬렉션 등으로 구분되지 않고, 다소 혼합된 형태를 띱니다. 이전에 언급했던 예제 중 하나를 다시 살펴보겠습니다.
local color = {first = "red", "blue", third = "green", "yellow"}
print(color["first"]) --> output: red
print(color[1]) --> output: blue
print(color["third"]) --> output: green
print(color[2]) --> output: yellow
print(color[3]) --> output: nil
이 예제에서 color
테이블은 배열과 해시를 모두 포함하고 있으며, 서로 간섭 없이 접근할 수 있습니다. 예를 들어, ipairs
함수를 사용하여 테이블의 배열 부분만 순회할 수 있습니다.
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
for k, v in ipairs(color) do
print(k)
end
'
table
연산은 매우 중요하기 때문에 LuaJIT는 표준 Lua 5.1 테이블 라이브러리를 확장했고, OpenResty는 LuaJIT의 테이블 라이브러리를 더욱 확장했습니다. 이제 각 라이브러리 함수를 살펴보겠습니다.
테이블 라이브러리 함수
먼저 표준 테이블 라이브러리 함수부터 시작하겠습니다. Lua 5.1에는 많은 테이블 라이브러리 함수가 없으므로 간단히 훑어보겠습니다.
table.getn
요소의 개수 가져오기
표준 Lua와 LuaJIT 장에서 언급했듯이, LuaJIT에서 모든 테이블 요소의 정확한 개수를 얻는 것은 큰 문제입니다.
시퀀스의 경우 table.getn
또는 단항 연산자 #
을 사용하여 정확한 요소 개수를 반환할 수 있습니다. 다음 예제는 예상대로 3을 반환합니다.
$ resty -e 'local t = { 1, 2, 3 }
print(table.getn(t))
시퀀스가 아닌 테이블의 경우 정확한 값을 반환할 수 없습니다. 두 번째 예제에서는 1이 반환됩니다.
$ resty -e 'local t = { 1, a = 2 }
print(#t) '
다행히도, 이러한 이해하기 어려운 함수들은 LuaJIT의 확장으로 대체되었으며, 이에 대해서는 나중에 언급하겠습니다. 따라서 OpenResty 컨텍스트에서는 시퀀스 길이를 명시적으로 얻는 경우가 아니라면 table.getn
함수와 단항 연산자 #
을 사용하지 않는 것이 좋습니다.
또한, table.getn
과 단항 연산자 #
은 O(1) 시간 복잡도가 아니라 O(n)이므로 가능한 한 피하는 것이 좋습니다.
table.remove
지정된 요소 제거
두 번째는 table.remove
함수로, 테이블의 배열 부분에 있는 요소만 제거할 수 있습니다. 다시 color
예제를 살펴보겠습니다.
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
table.remove(color, 1)
for k, v in pairs(color) do
print(v)
end'
이 코드는 인덱스 1에 있는 blue
를 제거합니다. 해시 부분을 삭제하려면 어떻게 해야 할까요? 간단히 해당 키의 값을 nil
로 설정하면 됩니다. 따라서 color
예제에서 third
에 해당하는 green
이 삭제됩니다.
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
color.third = nil
for k, v in pairs(color) do
print(v)
end'
table.concat
요소 결합 함수
세 번째는 table.concat
요소 결합 함수입니다. 이 함수는 테이블의 요소를 인덱스에 따라 결합합니다. 이 역시 인덱스를 기반으로 하므로 테이블의 배열 부분에 적용됩니다. 다시 color
예제를 보겠습니다.
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
print(table.concat(color, ", "))'
table.concat
함수를 사용한 후, blue, yellow
가 출력되고 해시 부분은 건너뜁니다.
또한, 이 함수는 시작 위치를 지정하여 결합할 수도 있습니다. 예를 들어 다음과 같이 작성할 수 있습니다.
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow", "orange"}
print(table.concat(color, ", ", 2, 3))'
이번에는 yellow, orange
가 출력되고 blue
는 건너뜁니다.
이 함수는 성능 최적화에서 예상치 못한 효과를 낼 수 있으며, 나중에 성능 최적화 장에서 주요 역할을 할 것입니다.
table.insert
요소 삽입
마지막으로 table.insert
함수를 살펴보겠습니다. 이 함수는 지정된 인덱스에 새 요소를 삽입하며, 테이블의 배열 부분에 영향을 미칩니다. 다시 color
예제를 사용하여 설명하겠습니다.
$ resty -e 'local color = {first = "red", "blue", third = "green", "yellow"}
table.insert(color, 1, "orange")
print(color[1])
'
color
의 첫 번째 요소가 orange
로 바뀌었음을 확인할 수 있습니다. 물론 인덱스를 지정하지 않으면 기본적으로 큐의 끝에 삽입됩니다.
table.insert
는 흔히 사용되는 연산이지만 성능이 좋지 않습니다. 지정된 인덱스에 요소를 삽입하지 않는 경우, 매번 LuaJIT의 lj_tab_len
을 호출하여 배열 길이를 얻어야 합니다. table.getn
과 마찬가지로 테이블 길이를 얻는 시간 복잡도는 O(n)입니다.
따라서 table.insert
연산은 핫 코드에서 사용하지 않도록 해야 합니다. 예를 들어:
local t = {}
for i = 1, 10000 do
table.insert(t, i)
end
LuaJIT의 테이블 확장 함수
다음으로 LuaJIT의 테이블 확장 함수를 살펴보겠습니다. LuaJIT는 표준 Lua를 확장하여 테이블을 생성하고 비우는 데 유용한 두 가지 함수를 추가했습니다.
table.new(narray, nhash)
새 테이블 생성
첫 번째는 table.new(narray, nhash)
함수입니다. 이 함수는 요소를 삽입할 때 스스로 크기를 늘리는 대신, 지정된 배열과 해시의 공간 크기를 미리 할당합니다. 이는 두 매개변수 narray
와 nhash
가 의미하는 바입니다. 스스로 크기를 늘리는 것은 공간 할당, resize
, rehash
와 같은 비용이 많이 드는 작업이므로 가능한 한 피해야 합니다.
여기서 주의할 점은 table.new
의 문서가 LuaJIT 웹사이트에 있지 않고 GitHub 프로젝트의 확장 문서에 깊이 숨겨져 있어 구글링으로 찾기 어렵다는 점입니다. 따라서 많은 엔지니어들이 이 함수를 모르고 있습니다.
간단한 예제를 통해 어떻게 작동하는지 보여드리겠습니다. 먼저 이 함수는 확장 함수이므로 사용하기 전에 require
해야 합니다.
local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 100 do
t[i] = i
end
이 코드는 100개의 배열 요소와 0개의 해시 요소를 가진 새 테이블을 생성합니다. 물론 필요에 따라 100개의 배열 요소와 50개의 해시 요소를 가진 새 테이블을 생성할 수도 있습니다.
local t = new_tab(100, 50)
또는 미리 설정한 공간 크기를 초과하더라도 정상적으로 사용할 수 있지만, 성능이 저하되므로 table.new
를 사용하는 의미가 없어집니다.
다음 예제에서는 미리 설정한 크기가 100이지만 200을 사용하고 있습니다.
local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 200 do
t[i] = i
end
table.new
에서 배열과 해시 공간의 크기를 실제 시나리오에 맞게 미리 설정하여 성능과 메모리 사용 사이의 균형을 찾아야 합니다.
table.clear()
테이블 비우기
두 번째는 table.clear()
함수입니다. 이 함수는 테이블의 모든 데이터를 비우지만 배열과 해시 부분이 차지하는 메모리를 해제하지는 않습니다. 따라서 Lua 테이블을 재활용할 때 테이블을 반복적으로 생성하고 파괴하는 오버헤드를 피할 수 있습니다.
$ resty -e 'local clear_tab =require "table.clear"
local color = {first = "red", "blue", third = "green", "yellow"}
clear_tab(color)
for k, v in pairs(color) do
print(k)
end'
그러나 이 함수를 사용할 수 있는 시나리오는 많지 않으며, 대부분의 경우 이 작업을 LuaJIT GC에 맡기는 것이 좋습니다.
OpenResty의 테이블 확장 함수
처음에 언급했듯이, OpenResty는 자체 LuaJIT 브랜치를 유지하며, 여기서도 테이블을 확장하여 여러 새로운 API를 추가했습니다: table.isempty
, table.isarray
, table.nkeys
, table.clone
.
이 새로운 API를 사용하기 전에 OpenResty 버전을 확인하세요. 대부분의 API는 OpenResty 1.15.8.1 이후 버전에서만 사용할 수 있습니다. 이는 OpenResty가 1.15.8.1 이전 버전에서 약 1년 동안 새로운 릴리즈가 없었고, 이 기간 동안 이러한 API가 추가되었기 때문입니다.
문서 링크를 포함했으므로 table.nkeys
를 예로 들어보겠습니다. 다른 세 API는 이름만으로도 이해하기 쉬우므로 GitHub 문서를 살펴보면 이해할 수 있습니다. OpenResty의 문서는 코드 예제, JIT 가능 여부, 주의 사항 등을 포함하여 매우 높은 품질을 자랑합니다. Lua와 LuaJIT의 문서보다 훨씬 우수합니다.
table.nkeys
함수는 테이블의 길이를 가져오는 함수로, 테이블의 배열과 해시 부분의 요소 개수를 반환합니다. 따라서 table.getn
대신 사용할 수 있습니다. 예를 들어:
local nkeys = require "table.nkeys"
print(nkeys({})) -- 0
print(nkeys({ "a", nil, "b" })) -- 2
print(nkeys({ dog = 3, cat = 4, bird = nil })) -- 2
print(nkeys({ "a", dog = 3, cat = 4 })) -- 3
메타테이블
테이블 함수에 대해 이야기한 후, table
에서 파생된 metatable
에 대해 살펴보겠습니다. 메타테이블은 Lua의 독특한 개념으로, 실제 프로젝트에서 널리 사용됩니다. 거의 모든 lua-resty-*
라이브러리에서 찾아볼 수 있다고 해도 과언이 아닙니다.
메타테이블은 연산자 오버로드처럼 동작합니다. 예를 들어, __add
를 오버로드하여 두 Lua 배열의 결합을 계산하거나 __tostring
을 오버로드하여 문자열로 변환하는 함수를 정의할 수 있습니다.
Lua는 메타테이블을 처리하기 위해 두 가지 함수를 제공합니다.
- 첫 번째는
setmetatable(table, metatable)
로, 테이블에 메타테이블을 설정합니다. - 두 번째는
getmetatable(table)
로, 테이블의 메타테이블을 가져옵니다.
이 모든 것을 설명한 후, 메타테이블이 실제로 무엇을 하는지에 대해 더 관심이 있을 것입니다. 실제 프로젝트에서 사용된 코드를 살펴보겠습니다.
$ resty -e ' local version = {
major = 1,
minor = 1,
patch = 1
}
version = setmetatable(version, {
__tostring = function(t)
return string.format("%d.%d.%d", t.major, t.minor, t.patch)
end
})
print(tostring(version))
'
먼저 version
이라는 테이블을 정의했습니다. 이 코드의 목적은 version
에 있는 버전 번호를 출력하는 것입니다. 그러나 version
을 직접 출력할 수는 없습니다. 직접 출력해보면 테이블의 주소만 출력됩니다.
print(tostring(version))
따라서 이 테이블에 대한 문자열 변환 함수를 사용자 정의해야 하며, 이때 메타테이블이 사용됩니다. setmetatable
을 사용하여 version
테이블의 __tostring
메서드를 재설정하여 버전 번호를 출력합니다: 1.1.1.
__tostring
외에도 실제 프로젝트에서 메타테이블의 다음 두 메타메서드를 자주 오버로드합니다.
첫 번째는 __index입니다. 테이블에서 요소를 찾을 때, 먼저 테이블에서 직접 찾고, 찾지 못하면 메타테이블의 __index
로 이동합니다.
다음 예제에서는 version
테이블에서 patch
를 제거했습니다.
$ resty -e ' local version = {
major = 1,
minor = 1
}
version = setmetatable(version, {
__index = function(t, key)
if key == "patch" then
return 2
end
end,
__tostring = function(t)
return string.format("%d.%d.%d", t.major, t.minor, t.patch)
end
})
print(tostring(version))
'
이 경우 t.patch
는 값을 얻지 못하므로 __index
함수로 이동하여 1.1.2를 출력합니다.
__index
는 함수뿐만 아니라 테이블일 수도 있습니다. 다음 코드를 실행해보면 동일한 결과를 얻을 수 있습니다.
$ resty -e ' local version = {
major = 1,
minor = 1
}
version = setmetatable(version, {
__index = {patch = 2},
__tostring = function(t)
return string.format("%d.%d.%d", t.major, t.minor, t.patch)
end
})
print(tostring(version))
'
다른 메타메서드는 __call입니다. 이는 테이블을 호출할 수 있게 해주는 펑터와 유사합니다.
버전 번호를 출력하는 위의 코드를 기반으로 테이블을 호출하는 방법을 살펴보겠습니다.
$ resty -e '
local version = {
major = 1,
minor = 1,
patch = 1
}
local function print_version(t)
print(string.format("%d.%d.%d", t.major, t.minor, t.patch))
end
version = setmetatable(version,
{__call = print_version})
version()
'
이 코드에서 setmetatable
을 사용하여 version
테이블에 메타테이블을 추가하고, 그 안의 __call
메타메서드는 print_version
함수를 가리킵니다. 따라서 version
을 함수처럼 호출하면 print_version
함수가 실행됩니다.
getmetatable
은 setmetatable
과 짝을 이루는 작업으로, 설정된 메타테이블을 가져옵니다. 예를 들어:
$ resty -e ' local version = {
major = 1,
minor = 1
}
version = setmetatable(version, {
__index = {patch = 2},
__tostring = function(t)
return string.format("%d.%d.%d", t.major, t.minor, t.patch)
end
})
print(getmetatable(version).__index.patch)
'
오늘 이야기한 세 가지 메타메서드 외에도 자주 사용되지 않는 메타메서드들이 있습니다. 이들은 문서를 참조하여 더 알아볼 수 있습니다.
객체 지향
마지막으로 객체 지향에 대해 이야기해보겠습니다. 아시다시피 Lua는 객체 지향 언어가 아니지만, 메타테이블을 사용하여 OO를 구현할 수 있습니다.
실제 예제를 살펴보겠습니다. lua-resty-mysql는 OpenResty의 공식 MySQL 클라이언트로, 메타테이블을 사용하여 클래스와 클래스 메서드를 시뮬레이션합니다. 사용 방법은 다음과 같습니다.
$ resty -e 'local mysql = require "resty.mysql" -- 먼저 lua-resty 라이브러리를 참조
local db, err = mysql:new() -- 클래스의 새 인스턴스 생성
db:set_timeout(1000) -- 클래스 메서드 호출
위 코드를 resty
명령줄로 직접 실행할 수 있습니다. 이 코드는 이해하기 쉽지만, 다음과 같은 점이 문제가 될 수 있습니다.
클래스 메서드를 호출할 때 왜 점 대신 콜론을 사용할까요?
사실 콜론과 점 모두 사용할 수 있으며, db:set_timeout(1000)
와 db.set_timeout(db, 1000)
는 정확히 동일합니다. 콜론은 Lua의 문법적 설탕으로, 함수의 첫 번째 인수 self
를 생략할 수 있게 해줍니다.
소스 코드 앞에는 비밀이 없으므로, 위 코드의 구체적인 구현을 살펴보면 메타테이블을 사용하여 객체 지향을 어떻게 모델링하는지 더 잘 이해할 수 있습니다.
local _M = { _VERSION = '0.21' } -- 테이블을 사용하여 클래스 시뮬레이션
local mt = { __index = _M } -- mt는 메타테이블의 약자, __index는 클래스 자체를 가리킴
-- 클래스의 생성자
function _M.new(self)
local sock, err = tcp()
if not sock then
return nil, err
end
return setmetatable({ sock = sock }, mt) -- 테이블과 메타테이블을 사용하여 클래스 인스턴스 시뮬레이션
end
-- 클래스의 멤버 함수
function _M.set_timeout(self, timeout) -- self 인수를 사용하여 작업할 클래스 인스턴스 가져오기
local sock = self.sock
if not sock then
return nil, "not initialized"
end
return sock:settimeout(timeout)
end
테이블 _M
은 클래스를 시뮬레이션하며, 단일 멤버 변수 _VERSION
으로 초기화되고 이후 _M.set_timeout
과 같은 멤버 함수를 정의합니다. 생성자 _M.new(self)
에서 우리는 메타테이블이 mt
인 테이블을 반환하며, mt
의 __index
메타메서드는 _M
을 가리키므로 반환된 테이블은 클래스 _M
의 인스턴스를 시뮬레이션합니다.
요약
오늘의 주요 내용은 여기까지입니다. 테이블과 메타테이블은 OpenResty의 lua-resty-*
라이브러리와 OpenResty 기반 오픈소스 프로젝트에서 많이 사용됩니다. 이 강의가 소스 코드를 읽고 이해하는 데 도움이 되길 바랍니다.
Lua에는 테이블 외에도 다른 표준 함수들이 있으며, 다음 강의에서 함께 배워보겠습니다.
마지막으로, 생각해볼 문제를 남기겠습니다. 왜 lua-resty-mysql
라이브러리는 OO를 시뮬레이션하기 위해 한 겹의 래핑을 했을까요? 이 질문에 대해 댓글 섹션에서 토론해보시고, 이 글을 동료와 친구들과 공유하여 함께 소통하고 발전해 나가길 바랍니다.