Lua에서 table과 metatable이란 무엇인가?

API7.ai

October 11, 2022

OpenResty (NGINX + Lua)

오늘은 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) 함수입니다. 이 함수는 요소를 삽입할 때 스스로 크기를 늘리는 대신, 지정된 배열과 해시의 공간 크기를 미리 할당합니다. 이는 두 매개변수 narraynhash가 의미하는 바입니다. 스스로 크기를 늘리는 것은 공간 할당, 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 함수가 실행됩니다.

getmetatablesetmetatable과 짝을 이루는 작업으로, 설정된 메타테이블을 가져옵니다. 예를 들어:

$ 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를 시뮬레이션하기 위해 한 겹의 래핑을 했을까요? 이 질문에 대해 댓글 섹션에서 토론해보시고, 이 글을 동료와 친구들과 공유하여 함께 소통하고 발전해 나가길 바랍니다.