1. Lua 프로그래밍 핵심 요점
Lua의 데이터 타입과 테이블의 구조적 특징
Lua는 여덟 가지 기본 타입을 제공합니다: nil, boolean, number, string, function, userdata, thread, table. 이 중 table은 유일한 복합 자료구조로, 내부적으로 배열(Array)과 해시맵(Hash Map)이 결합된 형태입니다. 연속적인 정수 키는 배열 부분에 저장되어 빠른 인덱싱을 보장하고, 그 외의 키는 해시 부분에 저장됩니다. number 타입은 기본적으로 배정밀도 부동소수점(double)을 사용하지만, Lua 5.3부터는 64비트 정수(integer) 서브타입이 도입되어 정밀한 수치 연산이 가능해졌습니다.
Userdata의 역할
Userdata는 C/C++ 데이터를 Lua 변수에 담기 위한 타입입니다. Lua GC가 메모리를 관리하는 full userdata와 단순히 포인터만 전달하는 light userdata로 나뉩니다. 주로 게임 엔진의 C++ 객체를 Lua에서 제어하거나, 메타테이블을 연결해 C 함수를 객체지향적으로 호출할 때 사용됩니다.
클로저(Closure)와 Upvalue
클로저는 함수와 그 함수가 참조하는 외부 지역 변수(Upvalue)의 결합체입니다. 함수 내부에서 선언되지 않은 외부 변수를 참조할 때, 해당 변수는 스택을 벗어나더라도 힙 영역에 유지되어 클로저가 상태를 기억할 수 있게 합니다.
function createStepCounter(initialValue)
local count = initialValue
return function(step)
count = count + (step or 1)
return count
end
end
local myCounter = createStepCounter(10)
print(myCounter(5)) -- 15
print(myCounter()) -- 16
코루틴(Coroutine)의 메커니즘과 Python과의 차이
Lua의 코루틴은 협력적 멀티태스킹을 지원하는 'Stackful Coroutine'입니다. OS 스레드와 달리 비선점형(Non-preemptive) 방식으로, 코드가 명시적으로 yield를 호출해야만 제어권이 넘어갑니다. Python의 asyncio가 이벤트 루프 기반의 비대칭적(Symmetric) 구조라면, Lua는 resume과 yield를 통해 호출자와 실행자 간의 제어권을 직접 주고받는 비대칭적 구조를 가집니다.
테이블 순회 중 요소 삭제 시 주의사항
pairs를 이용한 순회 중에 요소를 nil로 설정하여 삭제하는 것은 안전하지만, 새로운 키를 추가하는 행위는 예기치 않은 결과를 초래할 수 있습니다. table.remove를 배열 부분에서 사용할 경우 인덱스가 앞당겨지므로 뒤에서부터 순회하거나 별도의 삭제 리스트를 만들어 처리하는 것이 권장됩니다.
-- 안전한 배열 요소 삭제 (뒤에서부터 순회)
local data = {10, 20, 30, 40, 50}
for i = #data, 1, -1 do
if data[i] > 25 then
table.remove(data, i)
end
end
가비지 컬렉션(GC) 알고리즘
Lua는 기본적으로 'Mark-and-Sweep' 방식의 GC를 사용합니다. 성능 최적화를 위해 점진적(Incremental) GC 모드를 지원하며, 이는 한 번에 모든 메모리를 회수하는 대신 작은 단계로 나누어 실행하여 프로그램 정지 시간(Stop-the-world)을 최소화합니다. 메모리 누수를 방지하기 위해 순환 참조가 발생할 수 있는 캐시 등에는 __mode 필드를 이용한 Weak Table을 활용하는 것이 중요합니다.
2. Skynet 프레임워크 아키텍처
Skynet의 설계 철학
Skynet은 서비스(Service) 중심의 마이크로서비스 아키텍처를 채택하고 있습니다. 핵심 엔진은 C 언어로 구현되어 고성능 메시지 전달을 담당하며, 개별 서비스 로직은 Lua로 작성되어 개발 생산성을 높입니다. 각 서비스는 독립적인 Lua 가상 머신과 메시지 큐를 가지며, 32비트 주소로 식별됩니다.
Actor 모델과 메시지 통신
Skynet은 Actor 모델을 기반으로 합니다. 각 서비스는 상태를 공유하지 않고 메시지를 통해서만 소통하므로 잠금(Lock) 경쟁에서 자유롭습니다.
- skynet.send: 비동기 메시지 전송. 응답을 기다리지 않고 즉시 다음 로직을 수행합니다.
- skynet.call: 동기식 RPC 요청. 내부적으로 코루틴을 yield 시켰다가 응답 메시지가 도착하면 다시 resume 합니다.
스케줄러 동작 원리
Skynet의 스케줄러는 워커 스레드 집합으로 구성됩니다. 워커 스레드는 전역 큐에서 메시지가 쌓인 서비스를 꺼내어 해당 서비스의 메시지 큐에 있는 메시지들을 처리합니다. 이때 코루틴을 활용해 시분할 처리를 수행하며, 특정 서비스가 CPU를 독점하지 않도록 관리합니다.
네트워크 모델과 역압(Backpressure)
Skynet은 단일 스레드 네트워크 IO 모델을 사용하며, 소켓 이벤트 처리를 전담하는 스레드가 별도로 존재합니다. 클라이언트가 데이터를 과도하게 빠르게 보낼 경우, Skynet의 소켓 버퍼가 가득 차게 됩니다. 프레임워크는 경고(Warning)를 발생시키며, 개발자는 이를 감지하여 연결을 끊거나 데이터 수신을 일시 중단(socket.pause)하는 등의 유입량 제어 로직을 구현해야 합니다.
에러 전파 메커니즘
skynet.call 실행 중 대상 서비스에서 에러가 발생하거나 서비스가 종료되면, 호출한 쪽의 코루틴에 에러가 전달됩니다. 이를 방어하기 위해 중요한 RPC 호출은 pcall로 감싸서 예외 처리를 해야 서비스 전체의 가용성을 보장할 수 있습니다.
local ok, response = pcall(skynet.call, target_service, "lua", "query_data", params)
if not ok then
skynet.error("RPC Call failed: " .. tostring(response))
-- 복구 로직 또는 에러 응답 처리
end
3. 실무 최적화 전략
성능 향상을 위한 팁
- 객체 풀링: 잦은 테이블 생성과 삭제는 GC 부담을 줍니다. 전투 시스템 등에서 빈번히 사용되는 객체는 재사용합니다.
- 로컬화: 전역 변수(
_G) 접근은 느립니다. 빈번히 사용하는 함수나 모듈은local변수에 캐싱합니다. - 메시지 최소화: 서비스 간 대량의 데이터를 주고받기보다는 데이터의 식별자(ID)만 전달하거나, 공유 메모리(Shared Data) 모듈을 적절히 활용합니다.
- 모니터링: 서비스별 메시지 큐 길이와 CPU 점유율을 주기적으로 체크하여 병목 지점을 파악합니다.