OpenResty에서 10배 성능 향상을 위한 팁: `Table` 데이터 구조
API7.ai
December 9, 2022
OpenResty에서 string
연산과 함께 빈번한 성능 문제를 일으키는 것은 table
연산입니다. 이전 글들에서 table
관련 함수들을 간간히 다루었지만, 성능 개선 측면에서는 구체적으로 다루지 않았습니다. 오늘은 table
연산이 성능에 미치는 영향을 살펴보겠습니다.
개발자들이 string
연산보다 table
관련 성능 최적화에 대해 잘 알지 못하는 데는 두 가지 주요 이유가 있습니다.
- OpenResty에서 사용하는 Lua는 표준 LuaJIT이나 표준 Lua가 아닌 자체적으로 유지보수하는 LuaJIT 브랜치입니다. 대부분의 개발자들은 이 차이를 모르고 표준 Lua
table
라이브러리를 사용하여 OpenResty 코드를 작성하는 경향이 있습니다. - 표준 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)
함수가 추가되었습니다.
이 함수는 요소를 삽입할 때 스스로 성장하는 대신 지정된 배열 및 해시 공간 크기를 사전에 할당합니다. 이것이 두 매개변수 narray
와 nhash
의 의미입니다.
다음은 이를 사용하는 간단한 예제입니다. 이 함수는 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_plugins
는 plugin
모듈의 최상위 변수입니다. 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
이 하나 더 있습니다. 풀에 사용 가능한 배열이 없으면 fetch
는 table.new
을 호출하여 새 배열을 생성합니다.
tablepool.fetch(pool_name, narr, nrec)
두 번째는 release
로, 테이블을 풀에 반환하는 함수입니다. 인수 중 마지막 no_clear
는 table.clear
를 호출하여 배열을 지울지 여부를 구성하는 데 사용됩니다.
tablepool.release(pool_name, tb, [no_clear])
우리가 소개한 모든 방법이 이제 어떻게 관련되어 있는지 보셨나요? 그러나 이 때문에 tablepool
을 남용하지 않도록 주의하세요. 실제 프로젝트에서 tablepool
은 많이 사용되지 않습니다. 예를 들어, Kong에서는 사용되지 않으며, APISIX에서는 몇 번 호출됩니다. 대부분의 경우, 이 레이어 tablepool
의 캡슐화 없이도 충분합니다.
요약
OpenResty의 어려운 영역인 성능 최적화는 핫스팟입니다. 오늘은 table
관련 성능 최적화 팁을 소개했습니다. 실제 프로젝트에 도움이 되길 바랍니다.
마지막으로, 스스로 성능 테스트를 수행하고 table
관련 최적화 기술 사용 전후의 성능 차이를 비교해볼 수 있을까요? 다시 한 번, 댓글을 남기고 의견을 나누고 싶습니다. 여러분의 실천과 견해를 알고 싶습니다. 이 글을 공유하여 더 많은 사람들이 함께 참여할 수 있도록 해주세요.