OpenResty에서 10배 성능 향상을 위한 팁: `Table` 데이터 구조

API7.ai

December 9, 2022

OpenResty (NGINX + Lua)

OpenResty에서 string 연산과 함께 빈번한 성능 문제를 일으키는 것은 table 연산입니다. 이전 글들에서 table 관련 함수들을 간간히 다루었지만, 성능 개선 측면에서는 구체적으로 다루지 않았습니다. 오늘은 table 연산이 성능에 미치는 영향을 살펴보겠습니다.

개발자들이 string 연산보다 table 관련 성능 최적화에 대해 잘 알지 못하는 데는 두 가지 주요 이유가 있습니다.

  1. OpenResty에서 사용하는 Lua는 표준 LuaJIT이나 표준 Lua가 아닌 자체적으로 유지보수하는 LuaJIT 브랜치입니다. 대부분의 개발자들은 이 차이를 모르고 표준 Lua table 라이브러리를 사용하여 OpenResty 코드를 작성하는 경향이 있습니다.
  2. 표준 LuaJIT이나 OpenResty 자체에서 유지보수하는 LuaJIT 브랜치 모두에서 table 연산 관련 문서는 깊숙이 숨겨져 있어 개발자들이 찾기 어렵습니다. 또한 문서에는 샘플 코드가 없어 개발자들은 오픈소스 프로젝트에서 예제를 찾아야 합니다.

이 두 가지 점은 OpenResty 학습에 상대적으로 높은 인지적 장벽을 만들며, 결과적으로 양극화를 초래합니다. 베테랑 OpenResty 개발자는 매우 고성능의 코드를 작성할 수 있지만, 초보자는 OpenResty의 고성능이 실질적인지 의문을 품게 됩니다. 하지만 이번 강의를 배우고 나면 인지적 장벽을 쉽게 넘어 10배의 성능 향상을 달성할 수 있습니다.

table 최적화에 대해 자세히 설명하기 전에, table 관련 최적화의 간단한 원칙을 강조하고 싶습니다.

테이블을 재사용하고 불필요한 테이블 생성을 피하세요.

테이블 생성, 요소 삽입, 비우기, 루프 사용 측면에서 최적화를 소개하겠습니다.

사전 생성된 배열

첫 번째 단계는 배열을 생성하는 것입니다. Lua에서 배열을 생성하는 방법은 간단합니다.

local t = {}

위 코드는 빈 배열을 생성합니다. 생성 시 초기화된 데이터를 추가할 수도 있습니다.

local color = {first = "red", "blue", third = "green", "yellow"}

그러나 두 번째 방법은 성능 측면에서 더 많은 비용이 듭니다. 배열 요소를 추가하고 삭제할 때마다 공간 할당, resize, rehash가 발생하기 때문입니다.

그렇다면 어떻게 최적화해야 할까요? 시간을 위해 공간을 희생하는 것은 일반적인 최적화 아이디어입니다. 여기서 성능 병목은 배열 공간의 동적 할당이므로, 지정된 크기의 배열을 사전에 생성할 수 있습니다. 이는 메모리 공간을 약간 낭비할 수 있지만, 여러 번의 공간 할당, resize, rehash를 하나로 합칠 수 있어 훨씬 효율적입니다.

LuaJIT에 table.new(narray, nhash) 함수가 추가되었습니다.

이 함수는 요소를 삽입할 때 스스로 성장하는 대신 지정된 배열 및 해시 공간 크기를 사전에 할당합니다. 이것이 두 매개변수 narraynhash의 의미입니다.

다음은 이를 사용하는 간단한 예제입니다. 이 함수는 LuaJIT 확장이므로 사용하기 전에 다음과 같이 require해야 합니다.

local new_tab = require "table.new"
 local t = new_tab(100, 0)
 for i = 1, 100 do
   t[i] = i
 end

또한, 이전 OpenResty는 LuaJIT를 완전히 바인딩하지 않고 여전히 표준 Lua를 지원하므로, 일부 오래된 코드는 호환성을 위해 다음과 같이 처리합니다. table.new 함수를 찾지 못하면 빈 함수를 시뮬레이션하여 호출자의 일관성을 보장합니다.

local ok, new_tab = pcall(require, "table.new")
  if not ok then
    new_tab = function (narr, nrec) return {} end
  end

table 인덱스 수동 계산

table 객체를 얻은 후 다음 단계는 요소를 추가하는 것입니다. 요소를 삽입하는 가장 직접적인 방법은 table.insert 함수를 호출하는 것입니다.

local new_tab = require "table.new"
 local t = new_tab(100, 0)
 for i = 1, 100 do
   table.insert(t, i)
 end

또는 현재 배열의 길이를 먼저 얻고 인덱스를 사용하여 요소를 삽입할 수도 있습니다.

local new_tab = require "table.new"
 local t = new_tab(100, 0)
 for i = 1, 100 do
   t[#t + 1] = i
 end

그러나 둘 다 먼저 배열의 길이를 계산한 후 새 요소를 추가해야 합니다. 이 작업의 시간 복잡도는 O(n)입니다. 위 코드 예제에서 for 루프는 배열의 길이를 100번 계산하므로 성능이 좋지 않으며, 배열이 클수록 성능이 더 낮아집니다.

공식 lua-resty-redis 라이브러리가 이 문제를 어떻게 해결했는지 살펴보겠습니다.

local function _gen_req(args)
    local nargs = #args

    local req = new_tab(nargs * 5 + 1, 0)
    req[1] = "*" .. nargs .. "\r\n"
    local nbits = 2

    for i = 1, nargs do
        local arg = args[i]
        req[nbits] = "$"
        req[nbits + 1] = #arg
        req[nbits + 2] = "\r\n"
        req[nbits + 3] = arg
        req[nbits + 4] = "\r\n"
        nbits = nbits + 5
    end
    return req
end

이 함수는 배열 req를 사전에 생성하며, 그 크기는 함수의 입력 매개변수에 의해 결정되어 가능한 한 적은 공간이 낭비되도록 합니다.

Lua의 내장 table.insert 함수와 # 연산자를 사용하여 길이를 얻는 대신, nbits 변수를 사용하여 req의 인덱스를 수동으로 유지합니다.

for 루프에서 nbits + 1과 같은 연산은 요소를 직접 인덱스로 삽입하고, 마지막에 nbits = nbits + 5로 인덱스를 올바른 값으로 유지합니다.

이 방법의 장점은 명확합니다. 배열의 크기를 얻는 O(n) 연산을 생략하고 대신 인덱스로 직접 접근하며, 시간 복잡도가 O(1)이 됩니다. 단점도 명확합니다. 코드의 가독성이 떨어지고 오류 발생 확률이 크게 증가하므로 양날의 검입니다.

단일 table 재사용

table 생성 오버헤드가 크기 때문에 자연스럽게 이를 최대한 재사용하고 싶습니다. 그러나 재사용에는 조건이 있습니다. 먼저 table의 원래 데이터를 정리하여 다음 사용자에게 영향을 미치지 않도록 해야 합니다.

이때 table.clear 함수가 유용합니다. 이름에서 알 수 있듯이, 이 함수는 배열의 모든 데이터를 지우지만 배열의 길이는 변하지 않습니다. 즉, table.new(narray, nhash)로 길이 100의 배열을 생성한 경우, 지운 후에도 길이는 여전히 100입니다.

구현을 더 잘 이해하기 위해 표준 Lua와 호환되는 코드 예제를 제공합니다.

local ok, clear_tab = pcall(require, "table.clear")
  if not ok then
    clear_tab = function (tab)
      for k, _ in pairs(tab) do
        tab[k] = nil
      end
    end
  end

보시다시피, clear 함수는 각 요소를 nil로 설정합니다.

일반적으로 이러한 순환 table은 모듈의 최상위 레벨에 배치합니다. 이렇게 하면 모듈 내 함수를 사용할 때 상황에 따라 직접 사용하거나 지운 후 사용할 수 있습니다.

실제 응용 예제를 살펴보겠습니다. 다음 의사 코드는 오픈소스 마이크로서비스 API 게이트웨이 Apache APISIX에서 가져온 것으로, 플러그인 로드 시의 로직입니다.

local local_plugins = core.table.new(32, 0)
local function load(plugin_names, wasm_plugin_names)
    local processed = {}
    for _, name in ipairs(plugin_names) do
        if processed[name] == nil then
            processed[name] = true
        end
    end
    for _, attrs in ipairs(wasm_plugin_names) do
        if processed[attrs.name] == nil then
            processed[attrs.name] = attrs
        end
    end

    core.log.warn("new plugins: ", core.json.delay_encode(processed))

    for name, plugin in pairs(local_plugins_hash) do
        local ty = PLUGIN_TYPE_HTTP
        if plugin.type == "wasm" then
            ty = PLUGIN_TYPE_HTTP_WASM
        end
        unload_plugin(name, ty)
    end

    core.table.clear(local_plugins)
    core.table.clear(local_plugins_hash)

    for name, value in pairs(processed) do
        local ty = PLUGIN_TYPE_HTTP
        if type(value) == "table" then
            ty = PLUGIN_TYPE_HTTP_WASM
            name = value
        end
        load_plugin(name, local_plugins, ty)
    end

    -- sort by plugin's priority
    if #local_plugins > 1 then
        sort_tab(local_plugins, sort_plugin)
    end

    for i, plugin in ipairs(local_plugins) do
        local_plugins_hash[plugin.name] = plugin
        if enable_debug() then
            core.log.warn("loaded plugin and sort by priority:",
                          " ", plugin.priority,
                          " name: ", plugin.name)
        end
    end

    _M.load_times = _M.load_times + 1
    core.log.info("load plugin times: ", _M.load_times)
    return true
end

보시다시피, 배열 local_pluginsplugin 모듈의 최상위 변수입니다. load 함수 시작 시 table을 지우고 현재 상황에 따라 새로운 플러그인 목록을 생성합니다.

tablepool

이제 단일 테이블을 순환하는 최적화를 마스터했습니다. 그렇다면 한 단계 더 나아가 캐시 풀을 사용하여 여러 테이블을 보관하고 언제든지 사용할 수 있도록 할 수 있습니다. 이것이 공식 lua-tablepool의 기능입니다.

다음 코드는 테이블 풀의 기본 사용법을 보여줍니다. 지정된 풀에서 테이블을 가져오고 사용이 끝나면 다시 풀에 반환할 수 있습니다.

local tablepool = require "tablepool"
local tablepool_fetch = tablepool.fetch
local tablepool_release = tablepool.release


local pool_name = "some_tag"
local function do_sth()
     local t = tablepool_fetch(pool_name, 10, 0)
     -- -- using t for some purposes
    tablepool_release(pool_name, t)
end

tablepool은 우리가 소개한 몇 가지 방법을 사용하며, 코드가 100줄 미만이므로 직접 검색하여 공부해보시기를 강력히 권장합니다. 여기서는 주로 두 가지 API를 소개하겠습니다.

첫 번째는 fetch 메서드로, table.new과 동일한 인수를 취하지만 pool_name이 하나 더 있습니다. 풀에 사용 가능한 배열이 없으면 fetchtable.new을 호출하여 새 배열을 생성합니다.

tablepool.fetch(pool_name, narr, nrec)

두 번째는 release로, 테이블을 풀에 반환하는 함수입니다. 인수 중 마지막 no_cleartable.clear를 호출하여 배열을 지울지 여부를 구성하는 데 사용됩니다.

tablepool.release(pool_name, tb, [no_clear])

우리가 소개한 모든 방법이 이제 어떻게 관련되어 있는지 보셨나요? 그러나 이 때문에 tablepool을 남용하지 않도록 주의하세요. 실제 프로젝트에서 tablepool은 많이 사용되지 않습니다. 예를 들어, Kong에서는 사용되지 않으며, APISIX에서는 몇 번 호출됩니다. 대부분의 경우, 이 레이어 tablepool의 캡슐화 없이도 충분합니다.

요약

OpenResty의 어려운 영역인 성능 최적화는 핫스팟입니다. 오늘은 table 관련 성능 최적화 팁을 소개했습니다. 실제 프로젝트에 도움이 되길 바랍니다.

마지막으로, 스스로 성능 테스트를 수행하고 table 관련 최적화 기술 사용 전후의 성능 차이를 비교해볼 수 있을까요? 다시 한 번, 댓글을 남기고 의견을 나누고 싶습니다. 여러분의 실천과 견해를 알고 싶습니다. 이 글을 공유하여 더 많은 사람들이 함께 참여할 수 있도록 해주세요.