OpenResty에서 `string`의 장단점
API7.ai
December 8, 2022
지난 글에서는 OpenResty에서 초보자들이 자주 오용하는 일반적인 블로킹 함수에 대해 알아보았습니다. 이번 글부터는 성능 최적화의 핵심으로 들어가며, OpenResty 코드의 성능을 빠르게 향상시킬 수 있는 다양한 최적화 기법을 다룰 예정이니 가볍게 여기지 마시기 바랍니다.
이 과정에서 우리는 이러한 최적화 기법을 어떻게 사용하고 그 효과를 검증할 수 있는지 체험하기 위해 더 많은 테스트 코드를 작성해야 합니다.
성능 최적화 기법의 배경
최적화 기법은 모두 "실습" 부분에 속하므로, 그 전에 최적화의 "이론"에 대해 이야기해 보겠습니다.
성능 최적화 방법은 LuaJIT와 OpenResty의 반복적인 업데이트에 따라 변화합니다. 일부 방법은 기본 기술에 의해 직접 최적화되어 더 이상 익힐 필요가 없을 수도 있고, 동시에 새로운 최적화 기법이 등장할 수도 있습니다. 따라서 이러한 최적화 기법 뒤에 숨겨진 불변의 개념을 익히는 것이 가장 중요합니다.
OpenResty 프로그래밍에서 성능에 관한 몇 가지 중요한 아이디어를 살펴보겠습니다.
이론 1: 요청 처리는 짧고, 간단하며, 빠르게
OpenResty는 웹 서버이므로 종종 1,000개 이상, 10,000개 이상, 심지어 100,000개 이상의 클라이언트 요청을 동시에 처리합니다. 따라서 전체적인 성능을 최대화하려면 개별 요청이 빠르게 처리되고 메모리와 같은 다양한 리소스가 회수되도록 해야 합니다.
- 여기서 "짧음"은 요청의 생명 주기가 짧아야 한다는 의미로, 오랫동안 리소스를 점유하지 않도록 해야 합니다. 긴 연결의 경우에도 시간 또는 요청 수의 임계값을 설정하여 정기적으로 리소스를 해제해야 합니다.
- 두 번째로 "간단함"은 API에서 한 가지 일만 해야 한다는 의미입니다. 복잡한 비즈니스 로직을 여러 API로 분할하고 코드를 간단하게 유지하세요.
- 마지막으로 "빠름"은 메인 스레드를 차단하지 말고 너무 많은 CPU 연산을 실행하지 말라는 의미입니다. 그래도 해야 한다면, 지난 글에서 소개한 다른 방법과 함께 작업하는 것을 잊지 마세요.
이러한 아키텍처 고려 사항은 OpenResty뿐만 아니라 다른 개발 언어와 플랫폼에도 적합하므로, 여러분이 이를 이해하고 깊이 생각해 보시길 바랍니다.
이론 2: 중간 데이터 생성을 피하라
중간 과정에서 불필요한 데이터를 피하는 것은 OpenResty 프로그래밍에서 가장 지배적인 최적화 이론입니다. 중간 과정에서의 불필요한 데이터를 설명하기 위해 작은 예제를 살펴보겠습니다.
$ resty -e 'local s= "hello"
s = s .. " world"
s = s .. "!"
print(s)
'
이 코드 조각에서 s
변수에 대해 여러 번의 결합 연산을 수행하여 hello world!
라는 결과를 얻었습니다. 그러나 최종적인 hello world!
상태의 s
만 유용합니다. s
의 초기 값과 중간 할당은 모두 중간 데이터로, 가능한 한 적게 생성되어야 합니다.
이유는 이러한 임시 데이터가 초기화 및 GC 성능 손실을 초래하기 때문입니다. 이러한 손실을 과소평가하지 마세요. 이는 루프와 같은 핫 코드에 나타나면 성능이 명확하게 저하됩니다. 나중에 문자열 예제로 이를 설명하겠습니다.
string
은 불변이다
이제 이 글의 주제인 string
으로 돌아가겠습니다. 여기서 강조하고 싶은 점은 Lua에서 string
이 불변(immutable)이라는 사실입니다.
물론, 이는 string
을 결합하거나 수정할 수 없다는 의미는 아닙니다. 하지만 string
을 수정할 때 원래의 string
을 변경하는 것이 아니라 새로운 string
객체를 생성하고 string
에 대한 참조를 변경합니다. 따라서 원래의 string
이 다른 참조를 가지고 있지 않다면 Lua의 GC(가비지 컬렉션)에 의해 회수될 것입니다.
불변 string
의 명백한 이점은 메모리를 절약한다는 것입니다. 이렇게 하면 메모리 내에 동일한 string
의 복사본이 하나만 존재하며, 다른 변수들은 동일한 메모리 주소를 가리키게 됩니다.
이 디자인의 단점은 string
을 추가하고 회수할 때마다 LuaJIT가 lj_str_new
를 호출하여 string
이 이미 존재하는지 확인해야 한다는 것입니다. 존재하지 않는다면 새로운 string
을 생성해야 합니다. 이를 매우 자주 수행한다면 성능에 큰 영향을 미칠 것입니다.
이 예제와 같은 string
결합 작업의 구체적인 예를 살펴보겠습니다. 이는 많은 OpenResty 오픈소스 프로젝트에서 발견됩니다.
$ resty -e 'local begin = ngx.now()
local s = ""
-- `for` 루프, `..`를 사용하여 문자열 결합 수행
for i = 1, 100000 do
s = s .. "a"
end
ngx.update_time()
print(ngx.now() - begin)
'
이 샘플 코드는 s
변수에 대해 100,000번의 string
결합을 수행하고 실행 시간을 출력합니다. 예제가 다소 극단적이지만, 성능 최적화 전후의 차이를 잘 보여줍니다. 최적화 없이 이 코드는 제 노트북에서 0.4초 동안 실행되며, 여전히 상대적으로 느립니다. 그렇다면 어떻게 최적화해야 할까요?
이전 글에서 답을 제시했는데, 바로 table
을 사용하여 임시 중간 string
을 모두 제거하고 원본 데이터와 최종 결과만 유지하는 것입니다. 구체적인 코드 구현을 살펴보겠습니다.
$ resty -e 'local begin = ngx.now()
local t = {}
-- for 루프, 배열을 사용하여 문자열을 보관하고, 매번 배열의 길이를 계산
for i = 1, 100000 do
t[#t + 1] = "a"
end
-- 배열의 concat 메서드를 사용하여 문자열 결합
local s = table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'
이 코드는 각 문자열을 table
에 순차적으로 저장하고, 인덱스는 #t + 1
, 즉 table
의 현재 길이에 1
을 더한 값으로 결정됩니다. 마지막으로 table.concat
함수를 사용하여 각 배열 요소를 결합합니다. 이렇게 하면 자연스럽게 모든 임시 문자열을 건너뛰고 100,000번의 lj_str_new
와 GC를 피할 수 있습니다.
코드 분석은 이렇게 했는데, 최적화는 어떻게 되었을까요? 최적화된 코드는 단 0.007초만 걸리며, 이는 50배 이상의 성능 향상을 의미합니다. 실제 프로젝트에서는 성능 향상이 더 두드러질 수 있는데, 이 예제에서는 한 번에 하나의 문자 a
만 추가했기 때문입니다.
새로운 string
이 10배 길이의 a
라면 성능 차이는 어떻게 될까요?
0.007초의 코드가 우리의 최적화 작업에 충분할까요? 아닙니다. 여전히 최적화할 수 있습니다. 한 줄의 코드를 더 수정하고 결과를 확인해 보겠습니다.
$ resty -e 'local begin = ngx.now()
local t = {}
-- for 루프, 배열을 사용하여 문자열을 보관하고, 배열의 길이를 직접 유지
for i = 1, 100000 do
t[i] = "a"
end
local s = table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'
이번에는 t[#t + 1] = "a"
를 t[i] = "a"
로 변경했고, 단 한 줄의 코드로 배열 길이를 얻기 위한 100,000번의 함수 호출을 피할 수 있었습니다. 이전에 table
섹션에서 언급한 배열 길이를 얻는 작업을 기억하시나요? 이는 O(n)
의 시간 복잡도를 가지는 비교적 비용이 큰 작업입니다. 따라서 여기서는 단순히 배열 인덱스를 직접 유지하여 배열 길이를 얻는 작업을 우회했습니다. 속담처럼, 감당할 수 없다면 피하는 것이 최선입니다.
물론, 이는 더 간단한 작성 방법입니다. 다음 코드는 배열의 인덱스를 직접 유지하는 방법을 더 명확하게 보여줍니다.
$ resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
t[index] = "a"
index = index + 1
end
local s = table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'
다른 임시 string
줄이기
방금 이야기한 string
결합으로 인한 임시 string
오류는 명확합니다. 위의 샘플 코드를 몇 번 상기시키면, 우리는 비슷한 실수를 다시는 하지 않을 것입니다. 그러나 OpenResty에는 더 숨겨진 임시 string
이 생성되는 경우가 있으며, 이는 훨씬 더 발견하기 어렵습니다. 예를 들어, 아래에서 논의할 string
처리 함수는 자주 사용됩니다. 이 함수도 임시 string
을 생성한다는 것을 상상할 수 있나요?
우리가 알고 있듯이, string.sub
함수는 string
의 지정된 부분을 추출합니다. 앞서 언급했듯이, Lua의 string
은 불변이므로 새로운 문자열을 추출하려면 lj_str_new
와 이후의 GC 작업이 필요합니다.
resty -e 'print(string.sub("abcd", 1, 1))'
위 코드의 기능은 string
의 첫 번째 문자를 추출하여 출력하는 것입니다. 자연스럽게 임시 string
이 생성될 수밖에 없습니다. 동일한 효과를 얻기 위한 더 나은 방법이 있을까요?
resty -e 'print(string.char(string.byte("abcd")))'
당연히 있습니다. 이 코드를 보면, 먼저 string.byte
를 사용하여 첫 번째 문자의 숫자 코드를 얻고, 그 다음 string.char
를 사용하여 숫자를 해당 문자로 변환합니다. 이 과정에서는 어떤 임시 string
도 생성되지 않습니다. 따라서 string
관련 스캐닝 및 분석을 수행할 때 string.byte
를 사용하는 것이 가장 효율적입니다.
table
타입에 대한 SDK 지원 활용
임시 string
을 줄이는 방법을 배운 후, 이를 시도해 보고 싶으신가요? 그렇다면 위의 샘플 코드 결과를 클라이언트에 응답 본문의 내용으로 출력할 수 있습니다. 이 시점에서 잠시 멈추고 이 코드를 직접 작성해 보세요.
$ resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
t[index] = "a"
index = index + 1
end
local response = table.concat(t, "")
ngx.say(response)
'
이 코드를 작성할 수 있다면, 이미 대부분의 OpenResty 개발자보다 앞서 있습니다. OpenResty의 Lua API는 string
결합을 위해 table
을 사용하는 것을 고려하고 있으므로, ngx.say
, ngx.print
, ngx.log
, cosocket:send
등 많은 string
을 받을 수 있는 API에서는 string
뿐만 아니라 table
도 매개변수로 받습니다.
resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
t[index] = "a"
index = index + 1
end
ngx.say(t)
'
이 마지막 코드 조각에서는 local response = table.concat(t, "")
라는 string
결합 단계를 생략하고 table
을 직접 ngx.say
에 전달합니다. 이렇게 하면 string
결합 작업을 Lua 레벨에서 C 레벨로 옮기게 되어, 또 다른 string
조회, 생성, GC를 피할 수 있습니다. 긴 string
의 경우, 이는 또 다른 상당한 성능 향상을 가져옵니다.
요약
이 글을 읽고 나면, OpenResty의 성능 최적화가 다양한 세부 사항을 다루고 있다는 것을 알 수 있습니다. 따라서 최적의 성능을 달성하려면 LuaJIT와 OpenResty의 Lua API를 잘 알아야 합니다. 이는 또한 우리가 이전 내용을 잊었다면 반드시 복습하고 통합해야 한다는 것을 상기시킵니다.
마지막으로, 한 가지 문제를 생각해 보세요: hello
, world
, !
라는 문자열을 오류 로그에 작성하세요. string
결합 없이 샘플 코드를 작성할 수 있을까요?
또한, 글 속의 다른 질문도 잊지 마세요. 새로운 string
이 10배 길이의 a
라면 다음 코드의 성능 차이는 어떻게 될까요?
$ resty -e 'local begin = ngx.now()
local t = {}
for i = 1, 100000 do
t[#t + 1] = "a"
end
local s = table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'
이 글을 친구들과 공유하여 함께 배우고 교류하는 것도 환영합니다.