루아 레퍼런스 매뉴얼 — 기본 개념
루아 레퍼런스 매뉴얼 — 기본 개념 (Basic Concepts)
루아 5.4 레퍼런스 매뉴얼의 2장을 번역한 자료예요. 본격적으로 문법이나 API를 배우기 전에, 루아라는 언어를 이해하는 데 바탕이 되는 개념들 — 값과 타입, 환경, 에러 처리, 메타테이블, 가비지 컬렉션, 코루틴 — 을 차례로 짚어볼게요. 이 장을 읽고 나면 뒤의 문법 설명이 훨씬 자연스럽게 손에 들어와요.
출처: Lua 5.4 Reference Manual — 2 Basic Concepts (공식 문서, 챕터 2 전체 번역)
본문
2.1 값과 타입 (Values and Types)
루아는 동적 타입 언어예요. 즉 변수에는 타입이 없고, 값에만 타입이 있어요. 언어에 타입 정의 같은 건 없고, 모든 값이 저마다의 타입을 품고 다닌다고 보면 돼요.
그리고 루아의 모든 값은 일급 값(first-class value)이에요. 쉽게 말하면 모든 값을 변수에 저장할 수 있고, 다른 함수의 인자로 넘기고, 결과로 되돌려줄 수도 있다는 뜻이에요.
루아에는 8가지 기본 타입이 있어요. nil, boolean, number, string, function, userdata, thread, table이 바로 그것들이죠.
nil 타입은 값이 하나뿐이에요. 바로 nil인데요, 그 핵심 성질은 "다른 어떤 값과도 다르다"라는 거예요. 그리고 보통 "유용한 값이 없다"는 상황을 나타낼 때 써요. boolean 타입은 false와 true 두 값을 가져요. 여기서 nil과 false는 모두 조건식을 거짓으로 만들기 때문에, 이 둘을 합쳐 **거짓 값(false value)**이라고 불러요. 그 외에 어떤 값이라도 조건식을 참으로 만들어요.
이름과 달리 false는 nil을 대신해서 쓰이는 경우가 아주 많아요. 둘 사이의 핵심 차이는, false는 테이블 안에서 평범한 값처럼 행동하지만, 테이블 안의 nil은 "키가 없다"는 의미로 쓰인다는 거예요.
number 타입은 정수와 실수(부동소수점)를 하나로 아우르는 타입인데, 내부적으로는 integer와 float 두 하위 타입으로 나뉘어요. 표준 루아는 64비트 정수와 배정밀도(64비트) float를 쓰지만, 32비트 정수와/또는 단정밀도(32비트) float를 쓰도록 컴파일할 수도 있어요. 특히 정수와 float를 모두 32비트로 쓰는 옵션은 작은 기기나 내장형 시스템에서 특히 매력적이에요. (luaconf.h 파일의 매크로 LUA_32BITS 참고.)
특별한 언급이 없는 한, 정수를 다룰 때 오버플로가 발생하면 2의 보수 연산 규칙에 따라 값이 돌아서(wrap around) 이어지게 돼요. 다시 말해 실제 결과는 "수학적 결과를 2^n으로 나눈 나머지와 합동인, 표현 가능한 유일한 정수"가 되는데, 여기서 n은 정수 타입의 비트 수예요.
루아는 언제 어떤 하위 타입을 쓸지에 대한 명확한 규칙을 갖고 있지만, 필요할 때는 자동으로 두 타입을 서로 변환하기도 해요 (§3.4.3). 그래서 프로그래머는 정수와 float의 차이를 대부분 무시해도 되고, 반대로 각 숫자의 표현을 완전히 통제하고 싶다면 그렇게 할 수도 있어요.
string 타입은 불변(immutable)인 바이트 시퀀스를 나타내요. 루아는 8비트 클린(8-bit clean)이라서 문자열 안에 포함된 0('\0') 같은 값도 포함해 모든 8비트 값을 담을 수 있어요. 또 인코딩에 대해 중립적이라 문자열 내용에 대해 어떤 가정도 하지 않아요. 한 가지 제약이 있다면, 문자열의 길이는 반드시 루아 정수에 들어갈 수 있어야 한다는 점이에요.
루아는 루아로 작성된 함수와 C로 작성된 함수를 모두 호출(및 조작)할 수 있어요 (참고 §3.4.10). 이 둘은 모두 function 타입으로 표현돼요.
userdata 타입은 임의의 C 데이터를 루아 변수에 저장할 수 있게 해주는 타입이에요. userdata 값은 원시 메모리 블록 하나를 나타내는데, 두 종류가 있어요. 메모리 블록을 루아가 관리하는 객체인 full userdata와, 단순한 C 포인터 값인 light userdata가 바로 그것들이에요. userdata는 할당과 동일성 검사 외에는 루아에서 미리 정의된 동작이 없어요. 하지만 메타테이블을 쓰면 full userdata 값에 대해 연산을 정의할 수 있어요 (§2.4). userdata 값은 루아에서 만들거나 수정할 수 없고, 오직 C API를 통해서만 가능해요. 이렇게 함으로써 호스트 프로그램과 C 라이브러리가 가진 데이터의 무결성을 보장해요.
thread 타입은 독립적인 실행 스레드를 나타내며, 코루틴을 구현하는 데 쓰여요 (참고 §2.6). 여기서 중요한 건, 루아의 스레드는 운영체제의 스레드와는 무관하다는 점이에요. 루아는 네이티브 스레드를 지원하지 않는 시스템에서조차 모든 시스템에서 코루틴을 지원해요.
table 타입은 **연관 배열(associative array)**을 구현해요. 인덱스로 숫자뿐 아니라 nil과 NaN을 제외한 어떤 루아 값이든 쓸 수 있죠. (NaN, 즉 Not a Number는 IEEE 754 표준이 0/0 같은 정의되지 않은 수치 결과를 나타내기 위해 쓰는 특수 부동소수점 값이에요.) 테이블은 이질적(heterogeneous)일 수 있어요. 다시 말해 nil을 제외한 모든 타입의 값을 담을 수 있다는 뜻이에요. nil이 연결된 키는 테이블의 일부로 간주되지 않아요. 반대로 테이블에 없는 키는 연결된 값이 nil이에요.
테이블은 루아에서 유일한 데이터 구조화 수단이에요. 평범한 배열, 리스트, 심볼 테이블, 집합, 레코드, 그래프, 트리 등을 모두 테이블로 표현할 수 있어요. 레코드를 표현할 때는 필드 이름을 인덱스로 써요. 루아는 a.name을 a["name"]의 문법 설탕(syntactic sugar)으로 제공해서 이런 표현을 언어 차원에서 지원해요. 테이블을 만드는 간편한 방법도 여럿 있어요 (참고 §3.4.9).
인덱스와 마찬가지로 테이블 필드의 값도 어떤 타입이든 될 수 있어요. 특히 함수는 일급 값이기 때문에 테이블 필드에 함수를 담을 수 있고, 그래서 테이블이 메서드도 가질 수 있어요 (참고 §3.4.11).
테이블의 인덱싱은 언어의 원시 동등성(raw equality) 정의를 따르게 돼요. a[i]와 a[j]는 i와 j가 원시적으로 동일할 때(즉 메타메서드 없이 같을 때)에만 같은 테이블 요소를 가리켜요. 특히 정수 값을 갖는 float는 해당 정수와 같아요 (예: 1.0 == 1). 모호함을 피하기 위해 정수와 같은 값을 가진 float를 키로 쓰면 그 float는 그 정수로 변환돼요. 예를 들어 a[2.0] = true라고 쓰면 실제로 테이블에 들어가는 키는 정수 2예요.
테이블, 함수, 스레드, (full) userdata 값은 객체예요. 즉 변수가 이 값들을 실제로 담는 게 아니라, 이 값들에 대한 참조를 담는 거예요. 할당, 인자 전달, 함수 반환은 언제나 이런 값에 대한 참조를 다루며, 이 과정에서 복사가 일어나지는 않아요.
라이브러리 함수 type은 주어진 값의 타입을 설명하는 문자열을 돌려줘요 (type 참고).
2.2 환경과 전역 환경 (Environments and the Global Environment)
§3.2와 §3.3.3에서 더 자세히 다루겠지만, 어떤 자유 이름(free name, 즉 어떤 선언에도 묶이지 않은 이름) var에 대한 참조는 문법적으로 _ENV.var로 변환돼요. 게다가 모든 청크는 _ENV라는 이름의 외부 지역 변수 스코프 안에서 컴파일되기 때문에 (§3.3.2), 청크 안에서 _ENV 자체는 결코 자유 이름이 아니에요.
이런 외부 _ENV 변수의 존재와 자유 이름의 변환에도 불구하고, _ENV는 완전히 평범한 이름이에요. 특히 이 이름으로 새 변수나 매개변수를 정의할 수도 있어요. 자유 이름에 대한 각 참조는 그 지점에서 보이는 _ENV를 사용하며, 이는 루아의 보통 가시성 규칙을 따르게 돼요 (§3.5).
_ENV의 값으로 쓰이는 테이블은 무엇이든 **환경(environment)**이라고 불러요.
루아는 **전역 환경(global environment)**이라는 특별한 환경을 하나 유지해요. 이 값은 C 레지스트리의 특별한 인덱스에 보관돼요 (§4.3). 루아에서는 전역 변수 _G가 이 값과 똑같이 초기화돼요. (_G는 내부적으로는 결코 쓰이지 않으므로, 이 값을 바꾸면 여러분 자신의 코드에만 영향이 가요.)
루아가 청크를 로드할 때, 그 청크 _ENV 변수의 기본값은 전역 환경이에요 (load 참고). 그래서 기본적으로 루아 코드의 자유 이름은 전역 환경의 항목을 가리키며, 때문에 이들을 전역 변수라고도 불러요. 게다가 모든 표준 라이브러리는 전역 환경에 로드되고, 그곳의 일부 함수는 바로 그 환경을 대상으로 동작해요. 다른 환경으로 청크를 로드하려면 load(또는 loadfile)를 쓰면 돼요. (C에서는 청크를 로드한 뒤 첫 번째 업밸류의 값을 바꿔야 해요; lua_setupvalue 참고.)
2.3 에러 처리 (Error Handling)
루아의 여러 연산은 에러를 일으킬 수 있어요. 에러는 프로그램의 정상 흐름을 중단시키며, 프로그램은 에러를 잡아서 실행을 계속할 수 있어요.
루아 코드는 error 함수를 호출해 명시적으로 에러를 일으킬 수 있어요. (이 함수는 결코 반환하지 않아요.)
루아에서 에러를 잡으려면 pcall(또는 xpcall)로 **보호 호출(protected call)**을 하면 돼요. pcall 함수는 주어진 함수를 보호 모드에서 호출해요. 함수를 실행하는 동안 에러가 나면 그 함수의 실행은 멈추고, 제어권이 즉시 pcall로 돌아오며, pcall은 상태 코드를 돌려줘요.
루아는 내장형 확장 언어(embedded extension language)이기 때문에, 루아 코드는 호스트 프로그램의 C 코드가 호출하면서 실행을 시작해요. (루아를 독립 실행으로 쓸 때는 lua 응용 프로그램이 호스트 프로그램이에요.) 보통 이 호출은 보호되어 있어요. 그래서 루아 청크의 컴파일이나 실행 중에 보호되지 않은 에러가 발생하면 제어권이 호스트로 돌아가고, 호스트가 에러 메시지 출력 같은 적절한 조치를 취할 수 있어요.
에러가 발생하면 항상 그 에러에 대한 정보를 담은 **에러 객체(error object)**가 전파돼요. 루아 자체는 에러 객체가 문자열인 에러만 만들어 내지만, 프로그램은 어떤 값이든 에러 객체로 삼아 에러를 만들 수 있어요. 이런 에러 객체를 어떻게 처리할지는 루아 프로그램이나 호스트의 몫이에요. 역사적인 이유로 에러 객체는 흔히 에러 메시지라고 불리는데, 실제로는 문자열일 필요가 없어요.
xpcall(C에서는 lua_pcall)을 쓸 때는 에러가 났을 때 호출될 메시지 핸들러를 지정할 수 있어요. 이 함수는 원래 에러 객체를 인자로 받아 새 에러 객체를 반환해요. 에러가 스택을 풀기(unwind) 전에 호출되기 때문에, 스택을 살펴보고 스택 트레이스백을 만드는 식으로 에러에 대한 정보를 더 모을 수 있어요. 이 메시지 핸들러도 보호 호출에 의해 보호되므로, 메시지 핸들러 내부의 에러는 메시지 핸들러를 다시 호출하게 만들어요. 이 루프가 너무 오래 이어지면 루아가 끊고 적절한 메시지를 반환해요. 메시지 핸들러는 일반적인 런타임 에러에 대해서만 호출돼요. 메모리 할당 에러나 finalizer 혹은 다른 메시지 핸들러를 실행하는 동안의 에러에 대해서는 호출되지 않아요.
루아는 또한 경고(warning) 시스템을 제공해요 (warn 참고). 에러와 달리 경고는 프로그램 실행에 전혀 간섭하지 않아요. 보통 사용자에게 메시지를 보여주는 정도인데, 이 동작은 C에서 변경할 수 있어요 (lua_setwarnf 참고).
2.4 메타테이블과 메타메서드 (Metatables and Metamethods)
루아의 모든 값은 **메타테이블(metatable)**을 가질 수 있어요. 메타테이블은 특정 사건(event)에서 원래 값이 어떻게 동작할지를 정의하는 평범한 루아 테이블이에요. 메타테이블의 특정 필드를 설정하면 값의 동작 중 여러 면모를 바꿀 수 있어요. 예를 들어 숫자가 아닌 값이 덧셈의 피연산자가 되면, 루아는 그 값의 메타테이블에서 __add 필드에 함수가 있는지 확인해요. 함수가 있으면 루아는 그 함수를 호출해 덧셈을 수행해요.
메타테이블에서 각 사건의 키는 사건 이름 앞에 밑줄 두 개를 붙인 문자열이에요. 이에 해당하는 값을 **메타값(metavalue)**이라고 불러요. 대부분의 사건에서 메타값은 함수여야 하는데, 그런 함수를 **메타메서드(metamethod)**라고 불러요. 앞의 예에서 키는 문자열 "__add"이고, 메타메서드는 덧셈을 수행하는 함수예요. 특별한 언급이 없는 한 메타메서드는 사실상 호출 가능한(callable) 값, 즉 함수거나 __call 메타메서드를 가진 값이면 무엇이든 될 수 있어요.
getmetatable 함수를 쓰면 어떤 값의 메타테이블이라도 조회할 수 있어요. 루아는 메타테이블에서 메타메서드를 조회할 때 원시 접근(raw access)을 사용해요 (rawget 참고).
테이블의 메타테이블은 setmetatable 함수로 바꿀 수 있어요. 다른 타입의 메타테이블은 디버그 라이브러리(§6.10)를 쓰지 않는 한 루아 코드에서 바꿀 수 없어요.
테이블과 full userdata는 저마다의 메타테이블을 갖지만, 여러 테이블과 userdata가 하나의 메타테이블을 공유할 수도 있어요. 그 외 모든 타입의 값은 타입당 하나의 메타테이블을 공유해요. 즉 모든 숫자가 공유하는 메타테이블 하나, 모든 문자열이 공유하는 메타테이블 하나가 있는 식이에요. 기본적으로 값에는 메타테이블이 없지만, 문자열 라이브러리가 문자열 타입에 메타테이블을 설정해 줘요 (참고 §6.4).
메타테이블이 제어하는 연산의 상세한 목록을 이어서 보여드릴게요. 각 사건은 그에 해당하는 키로 식별돼요. 관례상 루아가 쓰는 모든 메타테이블 키는 밑줄 두 개 뒤에 소문자 라틴 알파벳을 붙인 형태로 구성돼요.
__add: 덧셈(+) 연산. 덧셈의 어느 피연산자라도 숫자가 아니면 루아는 메타메서드를 호출하려 해요. 먼저 첫 번째 피연산자(숫자라 해도)를 확인하고, 그 피연산자가__add에 대한 메타메서드를 정의하지 않았다면 두 번째 피연산자를 확인해요. 메타메서드를 찾으면 두 피연산자를 인자로 호출하고, 그 호출 결과(하나의 값으로 맞춰진)가 연산의 결과가 돼요. 메타메서드를 찾지 못하면 루아는 에러를 일으켜요.__sub: 뺄셈(-) 연산. 덧셈과 비슷하게 동작해요.__mul: 곱셈(*) 연산. 덧셈과 비슷하게 동작해요.__div: 나눗셈(/) 연산. 덧셈과 비슷하게 동작해요.__mod: 나머지(%) 연산. 덧셈과 비슷하게 동작해요.__pow: 거듭제곱(^) 연산. 덧셈과 비슷하게 동작해요.__unm: 부정(단항 -) 연산. 덧셈과 비슷하게 동작해요.__idiv: 버림 나눗셈(//) 연산. 덧셈과 비슷하게 동작해요.__band: 비트 AND(&) 연산. 어느 피연산자라도 정수도 아니고 정수로 강제 변환 가능한 float도 아니라면 메타메서드를 시도한다는 점만 빼고 덧셈과 비슷하게 동작해요 (참고 §3.4.3).__bor: 비트 OR(|) 연산. 비트 AND 연산과 비슷하게 동작해요.__bxor: 비트 배타적 OR(이항 ~) 연산. 비트 AND 연산과 비슷하게 동작해요.__bnot: 비트 NOT(단항 ~) 연산. 비트 AND 연산과 비슷하게 동작해요.__shl: 비트 왼쪽 시프트(<<) 연산. 비트 AND 연산과 비슷하게 동작해요.__shr: 비트 오른쪽 시프트(>>) 연산. 비트 AND 연산과 비슷하게 동작해요.__concat: 연결(..) 연산. 어느 피연산자라도 문자열도 숫자(항상 문자열로 강제 변환 가능)도 아니라면 메타메서드를 시도한다는 점만 빼고 덧셈과 비슷하게 동작해요.__len: 길이(#) 연산. 객체가 문자열이 아니면 루아는 메타메서드를 시도해요. 메타메서드가 있으면 객체를 인자로 호출하고, 그 호출 결과(항상 하나의 값으로 맞춰진)가 연산의 결과예요. 메타메서드가 없는데 객체가 테이블이면 루아는 테이블 길이 연산을 사용해요 (§3.4.7). 그도 아니라면 루아는 에러를 일으켜요.__eq: 같음(==) 연산. 비교하는 두 값이 모두 테이블이거나 모두 full userdata이고 원시적으로 같지 않을 때에만 메타메서드를 시도한다는 점만 빼면 덧셈과 비슷하게 동작해요. 호출 결과는 항상 불리언으로 변환돼요.__lt: 작음(<) 연산. 비교하는 두 값이 모두 숫자도, 모두 문자열도 아닐 때에만 메타메서드를 시도한다는 점만 빼면 덧셈과 비슷하게 동작해요. 게다가 호출 결과는 항상 불리언으로 변환돼요.__le: 작거나 같음(<=) 연산. 작음 연산과 비슷하게 동작해요.__index: 인덱싱 접근 연산table[key]. 이 사건은table이 테이블이 아니거나key가table안에 존재하지 않을 때 발생해요. 메타값은table의 메타테이블에서 조회돼요. 이 사건의 메타값은 함수, 테이블, 또는__index메타값을 가진 어떤 값이든 될 수 있어요. 함수라면table과key를 인자로 호출하고, 그 호출 결과(하나의 값으로 맞춰진)가 연산의 결과예요. 그렇지 않다면 최종 결과는 이 메타값을key로 인덱싱한 결과예요. 이 인덱싱은 원시 접근이 아니라 일반 인덱싱이므로, 또 다른__index메타값을 촉발할 수 있어요.__newindex: 인덱싱 할당 연산table[key] = value. 인덱스 사건과 마찬가지로, 이 사건은table이 테이블이 아니거나key가table안에 존재하지 않을 때 발생해요. 메타값은table의 메타테이블에서 조회돼요. 인덱싱과 마찬가지로 이 사건의 메타값은 함수, 테이블, 또는__newindex메타값을 가진 어떤 값이든 될 수 있어요. 함수라면table,key,value를 인자로 호출해요. 그렇지 않다면 루아는 같은key와value로 이 메타값에 대해 인덱싱 할당을 반복해요. 이 할당은 원시 할당이 아니라 일반 할당이므로, 또 다른__newindex메타값을 촉발할 수 있어요.__newindex메타값이 호출되면 루아는 원시적인 할당을 수행하지 않아요. 필요하다면 메타메서드 자신이rawset을 호출해서 할당을 실행할 수 있어요.__call: 호출 연산func(args). 이 사건은 루아가 함수가 아닌 값(즉func가 함수가 아닌 경우)을 호출하려 할 때 발생해요. 메타메서드는func에서 조회돼요. 있으면func를 첫 번째 인자로 하고 그 뒤에 원래 호출의 인자들(args)을 이어 붙여 호출해요. 이 호출의 모든 결과가 연산의 결과예요. 이건 여러 결과를 허용하는 유일한 메타메서드예요.
위 목록에 더해, 인터프리터는 메타테이블에서 다음 키들도 존중해요. __gc (§2.5.3), __close (§3.3.8), __mode (§2.5.4), 그리고 __name이에요. (__name 항목은 문자열을 담고 있을 때 tostring이나 에러 메시지에서 사용될 수 있어요.)
단항 연산자(부정, 길이, 비트 NOT)의 경우, 메타메서드는 첫 번째 피연산자와 같은 가짜 두 번째 피연산자와 함께 계산되고 호출돼요. 이 추가 피연산자는 루아 내부를 단순하게 만들기 위한 것(이 연산자들이 이항 연산처럼 동작하게 하려는 것)뿐이며, 미래 버전에서 제거될 수 있어요. 대부분의 용도에서 이 추가 피연산자는 무관해요.
메타테이블은 평범한 테이블이기 때문에 위에서 정의한 사건 이름뿐 아니라 임의의 필드를 담을 수 있어요. 표준 라이브러리의 일부 함수(예: tostring)는 자기 목적을 위해 메타테이블의 다른 필드를 사용하기도 해요.
필요한 모든 메타메서드를 어떤 객체의 메타테이블로 설정하기 전에 테이블에 모두 추가해 두는 것이 좋은 습관이에요. 특히 __gc 메타메서드는 이 순서를 지킬 때만 동작해요 (§2.5.3). 또 객체를 만든 직후에 바로 그 메타테이블을 설정하는 것도 좋은 습관이에요.
2.5 가비지 컬렉션 (Garbage Collection)
루아는 자동 메모리 관리를 수행해요. 즉 새 객체를 위해 메모리를 할당하거나, 더 이상 필요 없어진 객체의 메모리를 해제하는 일을 신경 쓸 필요가 없다는 뜻이에요. 루아는 가비지 컬렉터(garbage collector)를 돌려 죽은 객체를 모두 수집하는 방식으로 메모리를 자동 관리해요. 루아가 쓰는 모든 메모리 — 문자열, 테이블, userdata, 함수, 스레드, 내부 구조 등 — 가 자동 관리 대상이에요.
객체는 컬렉터가 "이 객체는 프로그램의 정상 실행에서 다시 접근되지 않는다"고 확신할 수 있게 된 순간 죽은(dead) 것으로 간주돼요. ("정상 실행"에는 죽은 객체를 부활시킬 수 있는 finalizer(§2.5.3)와 디버그 라이브러리를 쓰는 작업은 제외돼요.) 여기서 주의할 점은, 컬렉터가 객체가 죽었다고 확신하는 시점이 프로그래머의 기대와 일치하지 않을 수 있다는 거예요. 보장되는 것은 딱 두 가지예요. 루아는 정상 실행에서 여전히 접근될 수 있는 객체를 수집하지 않는다는 것, 그리고 루아에서 접근 불가능한 객체는 결국 수집한다는 것이에요. (여기서 "루아에서 접근 불가능"은 변수도 다른 살아있는 객체도 그 객체를 참조하지 않는다는 뜻이에요.) 루아는 C 코드에 대해 알지 못하기 때문에, 레지스트리(§4.3)를 통해 접근 가능한 객체 — 여기에는 전역 환경(§2.2)도 포함돼요 — 는 결코 수집하지 않아요.
가비지 컬렉터(GC)는 증분(incremental) 모드와 세대(generational) 모드 두 가지로 동작할 수 있어요.
기본 GC 모드와 기본 매개변수는 대부분의 용도에 충분해요. 다만 시간의 상당 부분을 메모리 할당·해제에 쓰는 프로그램은 다른 설정에서 이득을 볼 수 있어요. 명심할 점은 GC 동작은 플랫폼에 따라서도, 그리고 루아 릴리스에 따라서도 이식 가능하지 않다는 거예요. 그래서 최적 설정 또한 이식 불가능해요.
GC 모드와 매개변수는 C의 lua_gc나 루아의 collectgarbage를 호출해 바꿀 수 있어요. 이 함수들로 컬렉터를 직접 제어(예: 멈추고 다시 시작)할 수도 있어요.
2.5.1 증분 가비지 컬렉션 (Incremental Garbage Collection)
증분 모드에서는 각 GC 사이클이 마크-앤-스윕(mark-and-sweep) 수집을 작은 단계로 나누어 프로그램 실행과 섞어 가며 수행해요. 이 모드에서 컬렉터는 GC 사이클을 제어하기 위해 세 숫자를 사용해요. 바로 가비지컬렉터 일시정지(garbage-collector pause), 가비지컬렉터 단계 배수(garbage-collector step multiplier), **가비지컬렉터 단계 크기(garbage-collector step size)**예요.
일시정지는 컬렉터가 새 사이클을 시작하기 전에 얼마나 기다릴지를 제어해요. 컬렉터는 메모리 사용량이 이전 수집 이후 사용량의 **n%**에 도달하면 새 사이클을 시작해요. 값이 클수록 컬렉터는 덜 공격적으로 동작해요. 100 이하의 값은 컬렉터가 새 사이클을 시작하기 위해 기다리지 않는다는 뜻이에요. 200이라는 값은 컬렉터가 새 사이클을 시작하기 전에 사용 중인 총 메모리가 두 배가 되기를 기다린다는 뜻이에요. 기본값은 200이고, 최댓값은 1000이에요.
단계 배수는 컬렉터의 속도를 메모리 할당에 상대적으로 제어해요. 즉 메모리 1 킬로바이트를 할당할 때마다 요소를 몇 개 마크하거나 스윕할지를 정해요. 값이 클수록 컬렉터는 더 공격적으로 동작하지만 각 증분 단계의 크기도 커져요. 100보다 작은 값은 쓰지 않는 게 좋아요. 컬렉터가 너무 느려지고 결국 사이클을 끝내지 못할 수 있기 때문이에요. 기본값은 100이고, 최댓값은 1000이에요.
단계 크기는 각 증분 단계의 크기를 제어해요. 구체적으로는 인터프리터가 한 단계를 수행하기 전에 몇 바이트를 할당할지를 정하는 값이에요. 이 매개변수는 로그 스케일이에요. 값이 n이면 인터프리터가 단계 사이에 2^n 바이트를 할당하고, 그 단계 동안 그에 상응하는 작업을 수행한다는 뜻이에요. 큰 값(예: 60)은 컬렉터를 stop-the-world(비증분) 컬렉터로 만들어요. 기본값은 13인데, 대략 8Kbyte 단위를 의미해요.
2.5.2 세대 가비지 컬렉션 (Generational Garbage Collection)
세대 모드에서는 컬렉터가 잦은 **축소 수집(minor collection)**을 하는데, 이는 최근에 생성된 객체만 순회해요. 축소 수집 후에도 메모리 사용량이 한계를 넘어서면, 컬렉터는 모든 객체를 순회하는 stop-the-world **확대 수집(major collection)**을 해요. 세대 모드는 두 매개변수를 사용해요. minor 배수와 major 배수가 그것들이에요.
minor 배수는 축소 수집의 빈도를 제어해요. minor 배수가 x라면, 메모리가 지난 확대 수집 이후 사용량보다 x% 더 커졌을 때 새 축소 수집이 수행돼요. 예를 들어 배수가 20이면, 컬렉터는 메모리 사용량이 지난 확대 수집 이후 사용량보다 20% 커지면 축소 수집을 해요. 기본값은 20이고, 최댓값은 200이에요.
major 배수는 확대 수집의 빈도를 제어해요. major 배수가 x라면, 메모리가 지난 확대 수집 이후 사용량보다 x% 더 커졌을 때 새 확대 수집이 수행돼요. 예를 들어 배수가 100이면, 컬렉터는 메모리 사용량이 지난 수집 이후 사용량의 두 배보다 커지면 확대 수집을 해요. 기본값은 100이고, 최댓값은 1000이에요.
2.5.3 가비지 컬렉션 메타메서드 (Garbage-Collection Metamethods)
테이블에 대해, 그리고 C API를 쓰면 full userdata에 대해서도 가비지 컬렉터 메타메서드를 설정할 수 있어요 (참고 §2.4). finalizer라고 불리는 이 메타메서드들은, 가비지 컬렉터가 해당 테이블이나 userdata가 죽었다고 감지했을 때 호출돼요. finalizer를 사용하면 루아의 가비지 컬렉션을 파일 닫기, 네트워크나 데이터베이스 연결 해제, 자신이 할당한 메모리 해제 같은 외부 리소스 관리와 조율할 수 있어요.
수집될 때 finalize되려면 객체(테이블 또는 userdata)를 finalization용으로 표시해야 해요.
객체의 메타테이블을 설정하고 그 메타테이블에 __gc 메타메서드가 있을 때, 그 객체는 finalization용으로 표시돼요. 주의할 점은, __gc 필드 없이 메타테이블을 설정하고 나중에 그 메타테이블에 필드를 만들어도, 그 객체는 finalization용으로 표시되지 않는다는 거예요.
표시된 객체가 죽으면, 가비지 컬렉터가 즉시 수집하지 않아요. 대신 루아는 그 객체를 목록에 넣어요. 수집이 끝난 뒤 루아는 그 목록을 훑어보며 각 객체의 __gc 메타메서드를 확인해요. 있으면 객체를 단일 인자로 해서 호출해요.
각 가비지 컬렉션 사이클이 끝나면, 그 사이클에서 수집된 객체들 중 finalizer는 객체가 finalization용으로 표시된 순서의 역순으로 호출돼요. 즉 가장 먼저 호출되는 finalizer는 프로그램에서 가장 나중에 표시된 객체에 연결된 것이에요. 각 finalizer의 실행은 일반 코드의 실행 중 어느 시점에든 일어날 수 있어요.
수집되는 객체를 finalizer가 여전히 사용해야 하기 때문에, 루아는 그 객체(그리고 그것을 통해서만 접근 가능한 다른 객체들)를 **부활(resurrect)**시켜야 해요. 보통 이런 부활은 일시적이며, 객체의 메모리는 다음 가비지 컬렉션 사이클에서 해제돼요. 하지만 finalizer가 객체를 전역적인 곳(예: 전역 변수)에 저장한다면 그 부활은 영구적이 돼요. 게다가 finalizer가 finalizing 중인 객체를 다시 finalization용으로 표시하면, 그 객체가 죽은 다음 사이클에서 그 finalizer가 다시 호출돼요. 어쨌든 객체의 메모리는 객체가 죽고 finalization용으로 표시되지 않은 GC 사이클에서만 해제돼요.
상태(state)를 닫을 때(lua_close 참고), 루아는 finalization용으로 표시된 모든 객체의 finalizer를 그들이 표시된 순서의 역순으로 호출해요. 이 단계에서 어떤 finalizer가 객체를 수집용으로 표시해도 그 표시는 효력이 없어요.
finalizer는 yield할 수도, 가비지 컬렉터를 실행할 수도 없어요. 예측할 수 없는 시점에 실행되기 때문에, 각 finalizer는 연결된 리소스를 제대로 해제하는 데 필요한 최소한으로만 제한하는 것이 좋은 습관이에요.
finalizer 실행 중 발생하는 어떤 에러든 경고를 생성하며, 그 에러는 전파되지 않아요.
2.5.4 약한 테이블 (Weak Tables)
**약한 테이블(weak table)**은 요소가 약한 참조(weak reference)인 테이블이에요. 약한 참조는 가비지 컬렉터가 무시하는 참조예요. 다시 말해 객체에 대한 참조가 약한 참조뿐이라면, 가비지 컬렉터는 그 객체를 수집해요.
약한 테이블은 약한 키, 약한 값, 또는 둘 다를 가질 수 있어요. 약한 값을 가진 테이블은 값은 수집되게 하지만 키는 수집되지 못하게 해요. 약한 키와 약한 값을 모두 가진 테이블은 키도 값도 수집되게 해요. 어느 경우든 키나 값 중 하나가 수집되면, 그 쌍 전체가 테이블에서 제거돼요. 테이블의 약함(weakness)은 그 메타테이블의 __mode 필드에 의해 제어돼요. 이 메타값은 존재한다면 다음 문자열 중 하나여야 해요. "k"(약한 키), "v"(약한 값), 또는 "kv"(약한 키와 값 모두).
약한 키와 강한 값을 가진 테이블을 ephemeron 테이블이라고도 불러요. ephemeron 테이블에서 값은 그 키가 도달 가능할 때만 도달 가능한 것으로 간주돼요. 특히 값이 통해서만 키에 대한 참조가 이어진다면 그 쌍은 제거돼요.
테이블 약함의 어떤 변경이든 효력이 생기는 것은 다음 수집 사이클부터일 수 있어요. 특히 더 강한 모드로 약함을 바꾸면, 그 변경이 효력을 발휘하기 전에 루아가 그 테이블의 일부 항목을 여전히 수집할 수 있어요.
명시적 생성(explicit construction)이 있는 객체만 약한 테이블에서 제거돼요. 숫자나 light C 함수 같은 값은 가비지 컬렉션의 대상이 아니므로 약한 테이블에서 제거되지 않아요(연결된 값이 수집되는 경우는 제외). 문자열은 가비지 컬렉션의 대상이지만 명시적 생성이 없고 값으로 동등성을 비교하기 때문에, 객체보다는 값에 가깝게 행동해요. 그래서 약한 테이블에서 제거되지 않아요.
부활된 객체(즉 finalize되고 있는 객체와 finalize되고 있는 객체를 통해서만 접근 가능한 객체)는 약한 테이블에서 특별한 행동을 보여요. 이들은 finalizer 실행 전에 약한 값에서 제거되지만, 약한 키에서는 finalizer를 실행한 다음 수집에서, 그런 객체가 실제로 해제될 때에만 제거돼요. 이 동작 덕분에 finalizer가 약한 테이블을 통해 객체에 연결된 속성에 접근할 수 있어요.
약한 테이블이 어떤 수집 사이클에서 부활된 객체들 중에 있다면, 다음 사이클까지 제대로 비워지지 않을 수 있어요.
2.6 코루틴 (Coroutines)
루아는 **코루틴(coroutine)**을 지원하며, 이를 협력적 멀티스레딩(collaborative multithreading)이라고도 불러요. 루아의 코루틴은 독립적인 실행 스레드를 나타내요. 하지만 멀티스레드 시스템의 스레드와 달리, 코루틴은 yield 함수를 명시적으로 호출해야만 실행을 중단해요.
코루틴은 coroutine.create를 호출해 만들어요. 그 유일한 인자는 코루틴의 메인 함수가 되는 함수예요. create 함수는 새 코루틴을 만들고 그것에 대한 핸들(thread 타입의 객체)을 돌려줄 뿐, 코루틴을 시작하지는 않아요.
코루틴은 coroutine.resume으로 실행해요. coroutine.create가 돌려준 스레드를 첫 번째 인자로 넘겨 coroutine.resume을 처음 호출하면, 코루틴은 메인 함수를 호출하며 실행을 시작해요. coroutine.resume에 넘긴 추가 인자들은 그 함수의 인자로 전달돼요. 코루틴이 실행을 시작하면 종료하거나 yield할 때까지 계속 실행돼요.
코루틴은 두 가지 방식으로 실행을 종료할 수 있어요. 정상적으로는 메인 함수가 (명시적으로든, 마지막 명령 다음에 암시적으로든) 반환할 때이고, 비정상적으로는 보호되지 않은 에러가 있을 때예요. 정상 종료의 경우 coroutine.resume은 true와 코루틴 메인 함수가 돌려준 값들을 반환해요. 에러의 경우 coroutine.resume은 false와 에러 객체를 반환해요. 이 경우 코루틴은 스택을 풀지 않기 때문에, 에러 이후에도 디버그 API로 스택을 살펴볼 수 있어요.
코루틴은 coroutine.yield를 호출해 yield해요. 코루틴이 yield하면, 그 yield가 중첩된 함수 호출 안(즉 메인 함수가 아니라 메인 함수가 직간접적으로 호출한 함수)에서 일어나더라도, 해당 coroutine.resume은 즉시 반환해요. yield의 경우 coroutine.resume은 역시 true와 coroutine.yield에 넘긴 값들을 반환해요. 다음에 같은 코루틴을 resume하면, coroutine.yield 호출이 coroutine.resume에 넘긴 추가 인자들을 반환하면서, yield했던 지점부터 실행을 이어가요.
coroutine.create와 마찬가지로 coroutine.wrap 함수도 코루틴을 만들지만, 코루틴 자체를 돌려주는 대신 호출하면 코루틴을 resume하는 함수를 돌려줘요. 이 함수에 넘긴 인자는 coroutine.resume의 추가 인자로 전달돼요. coroutine.wrap은 coroutine.resume이 돌려준 값 중 첫 번째(불리언 에러 코드)를 제외한 모두를 반환해요. coroutine.resume과 달리 coroutine.wrap이 만든 함수는 어떤 에러든 호출자에게 전파해요. 이 경우 그 함수는 코루틴도 닫아요 (coroutine.close 참고).
코루틴이 어떻게 동작하는지 예를 들어볼게요. 다음 코드를 보시죠.
function foo (a)
print("foo", a)
return coroutine.yield(2*a)
end
co = coroutine.create(function (a,b)
print("co-body", a, b)
local r = foo(a+1)
print("co-body", r)
local r, s = coroutine.yield(a+b, a-b)
print("co-body", r, s)
return b, "end"
end)
print("main", coroutine.resume(co, 1, 10))
print("main", coroutine.resume(co, "r"))
print("main", coroutine.resume(co, "x", "y"))
print("main", coroutine.resume(co, "x", "y"))
이걸 실행하면 다음과 같은 출력이 나와요.
co-body 1 10
foo 2
main true 4
co-body r
main true 11 -9
co-body x y
main true 10 end
main false cannot resume dead coroutine
C API로도 코루틴을 만들고 조작할 수 있어요. lua_newthread, lua_resume, lua_yield 함수를 참고해요.
더 알아보기
- 이 문서는 루아 5.4 레퍼런스 매뉴얼 2장의 번역이에요. 이어지는 문법(챕터 3 The Language), C API(챕터 4), 표준 라이브러리(챕터 6) 설명과 함께 보면 더 체계적으로 이해할 수 있어요.
- 공식 매뉴얼 전체: https://www.lua.org/manual/5.4/manual.html
collectgarbage,getmetatable,setmetatable,rawget,rawset,pcall,xpcall,error,coroutine,type등 각 함수의 상세한 설명은 매뉴얼의 표준 라이브러리 섹션에서 확인할 수 있어요.