altair의 프로젝트 일기

LOVE2d 게임엔진으로 지뢰찾기 만들기(2) - Lua 집중탐구 본문

IT/게임 제작

LOVE2d 게임엔진으로 지뢰찾기 만들기(2) - Lua 집중탐구

altair823 2024. 3. 5. 08:07

개요

 LOVE 엔진이 Lua로 작동하므로 가장 먼저 Lua에 대한 이해가 필수적이다. 이번 글에서는 LOVE 엔진과 게임에 대한 이야기는 잠시 미뤄두고 Lua에 대해 배우고 느낀 점을 소개하려 한다. 

Lua란?

 C/C++에 내장시키기 쉬운 언어로써 1993년에 개발되었다. 멀티 패러다임 언어로 C 스타일의 프로시저 방식, OOP, 함수형 프로그래밍 등을 모두 구현할 수 있다. 개인적으로 이 부분은 Rust와 매우 닮았다고 느꼈다. Lua의 데이터 타입은 다음과 같다. 

  • Nil(타 언어의 Null과 같다)
  • Boolean
  • Number
  • String
  • Table
  • Function
  • Userdata(C와의 호환성을 위한 자료형. Lua만 사용한다면 필요 없다)

 

Lua의 Table

 다른 것보다 테이블을 먼저 다루는 이유는, 테이블이 Lua의 알파이자 오메가이기 때문이다. 간단하게 설명하자면 테이블은 키가 존재할 수도, 존재하지 않을 수도 있는, 정수 인덱스로 값에 접근 가능한 map이다. 예를 들어보자. 

a = {}
b = {4, x = 3, [5] = 90}

print(b)      -- table: 0x56388868d6b0
print(b["x"]) -- 3
print(b.x)    -- 3
print(b[1])   -- 4
print(b[5])   -- 90

b.y = 45
print(b.y)    -- 45

 테이블은 { }으로 선언하고 그 안에 값을 채워 넣을 수 있다. 4의 경우처럼 키 값 없이 넣을 수도, x = 3 처럼 키 값을 "x"로 넣을 수도 있다. x가 문자열로서 키가 되었다는 점에 유의하라. [ ] 로 접근하면 문자열로 접근해야 하지만 b.x 처럼 접근하면 큰따옴표를 생략할 수 있다. 

 무엇보다 혼란스러운점은 이렇게 다양한 종류의 값이 한 테이블에 들어갈 수 있다는 점이다. 4는 키가 없이 저장되는데다가 인덱스 1로 접근 가능하며, 3은 "x"가 키이고, 90은 인덱스가 5이다. 4를 유심히 살펴보면 알 수 있듯이, 심지어 테이블의 시작 인덱스는 C 계열과 같은 0이 아니라 R과 같은 1이다. 이는 다음 예시를 보면 더욱 확실하다. 

a = {5, 6, 7, "x",'y'}
for i=1, #a do
  print(a[i])
end


Output:

5
6
7
x
y

 따라서 Lua의 테이블은 순서가 존재하고 키 값이 없을 때는 1부터 시작하는 인덱스를 부여하는 파이썬 딕셔너리라고 볼 수 있다. 실로 어마어마한 유연성이다. 

 

객체지향

 테이블은 그 활용에서 더욱 빛을 발한다. Rust나 Javascript처럼 Lua에서도 함수는 First-Class Object다. 즉 변수에 할당 가능하다. 이를 다음과 같은 방식으로 활용할 수 있다. 

function add(x, y)
  local z = x + y
  return z
end

a = {p = 43, q = 64}
a.func = add
print(a.func(2, 3))
print(a.p)


Output:

5
43

 p = 43과 q = 64가 들어있는 a 테이블에 func라는 키로 add 함수를 할당하고 있다. 뭔가 비슷한게 떠오른다면 맞다. add는 이제 객체 a의 static 메서드처럼 작동한다. 따라서 a 테이블은 그 자체로 타 언어의 클래스 역할을 한다. 여기서 조금 더 나아가면 객체지향을 온전히 구현할 수 있다. 

Object = {}
Object.__index = Object

function Object:inherit()
    local child = {}
    for k, v in pairs(self) do
        if k:find("__") == 1 then
            child[k] = v
        end
    end
    child.__index = child
    child.super = self
    setmetatable(child, self)
    return child
end

 내가 게임 개발에 사용하고 있는 코드를 가져왔다. Object는 모든 객체의 최상위 부모를 담당한다. inherit 함수는 상속을 구현하고 있다. 먼저 테이블에 원하는 키가 존재하지 않을 때 Lua가 어떻게 작동하는지 알아보자. 

 모든 Lua 테이블은 테이블의 동작을 정의하는 메타메소드를 갖고 있으며 이들은 "__"로 시작한다. 만약 테이블에 원하는 키가 없다면 다음과 같은 순서로 이를 탐색한다. 

  1. __index를 살펴본다. 
    1. __index가 함수라면 함수를 호출하고 결과를 반환한다. 
    2. __index가 테이블이라면 그 테이블에서 키를 탐색한다. 
  2. __index 메타메소드가 없거나 1에서도 그 키를 찾지 못했다면 "__"로 시작하는 메타메소드들이 있는 메타테이블을 탐색한다. 

 조금 어려운게 사실이다. 하지만 이것만 알면 위의 코드도 충분히 이해할 수 있다. 위 코드는 다음과 같이 동작한다. 

  1. 제일 먼저 Object를 테이블로서 선언한다. 
  2. __index를 Object로 할당한다. 테이블에 원하는 키가 없을 때, __index에 할당된 테이블에서 그 키를 찾는다. 따라서 앞으로 Object의 자식에서 특정 키가 없을 때(e.g. 특정 함수가 없을 때) Object의 키들을 살펴볼 것이다. 예를 들어, Object 테이블에 inherit 라는 메소드가 있고, child 테이블에서 이 inherit 메소드를 호출하려고 하면, child 테이블에는 inherit 메소드가 없으므로 __index 메타메소드가 호출된다. 그런 다음 __index 메타메소드는 Object 테이블에서 inherit 메소드를 찾게 된다. 
  3. Object:inherit()는 Object.ingerit(self)의 문법적 sugar다. Object 인스턴스 a가 있을 때 a:inherit()와 a.inherit(a)는 동일하다. 
  4. Object 테이블은 메타테이블을 갖고 있고 그 메타테이블은 "__"로 시작하는 여러 키(메타메서드)들을 갖고 있다(e.g. __index, __newindex, __eq, __add 등). for 루프에서 이들을 모두 자식에게 그대로 상속한다. 
  5. 자식의 __index를 그 자식으로 할당한다. 이유는 2번과 같다.
  6. 자식의 super 키의 값을 부모인 Object로 할당한다. 이를 통해 부모에게 접근 가능하다. 
  7. 자식의 메타테이블을 Object로 설정한다. 이를 통해 Object의 메타메소드들을 자식 또한 그대로 상속받는다. child에게 특정 메타메소드가 없다면(child.__index를 탐색해서 없다면) child의 메타테이블인 Object를 탐색하고, 여기에도 없다면(Object.__index를 탐색해서 없다면) Object의 메타테이블을 탐색한다. child가 메타메소드를 오버라이딩하지 않았다면 Object의 메타메소드를 그대로 상속 받는 것이다. 
  1.  

여기서 머리를 쥐어 뜯으셔야 합니다

 

어지럽다면 정상이다. Lua로 객체지향을 구현하거나 깊게 Lua를 쓸 계획이 없다면 아래와 같은 결론만 기억해도 된다. 

 Lua에서는 테이블과 그 테이블에 키가 없을 때 탐색할 [또 다른 테이블 or 메타테이블]만으로
객체지향을 구현할 수 있다. 

 

자료구조

 Lua는 테이블을 제외한 자료구조가 아예 존재하지 않는데, 테이블을 사용해 여러 자료구조를 직접 구현할 수 있다. 

배열(Array)

테이블의 키가 없는 값들이 1부터 인덱싱된다는 사실을 기억하면 키가 없는 테이블 자체가 C 스타일의 배열이라고 생각할 수 있다. 다차원 배열 역시 테이블 속 테이블로써 구현할 수 있다. 

리스트(LinkedList)

list = nil -- head

list = {next = list, value = "val1"}
list = {next = list, value = "val2"}
list = {next = list, value = "val3"}

local l = list
while l do
        print(l.value)
        l = l.next
end


Output:

val3
val2
val1

Set

reserved = {
    ["while"] = true,
    ["end"] = true,
    ["function"] = true,
    ["local"] = true,
}

for w in allwords() do
    if reserved[w] then
        -- `w' is a reserved word
        ...

 

 이런 방식으로 여러 자료구조를 직접 만들어 사용할 수 있다. 귀찮고 실수할 수 있는 부분이 많아보이는 것은 사실이다. 허나 개인적인 경험으로는 다양하지만 경직된 자료구조들보다, 아주 유연하고 기본적인 자료구조 하나만 주어졌을 때 자료구조의 동작과 구현에 구애받지 않고 더 나은 코드를 만들게 되더라. 순서가 있고 삽입, 삭제가 잦은 데이터를 LinkedList에 저장하고 눈 돌리는 것이 아니라 Lua 테이블에 키와 함께 저장하고 필요할 때 순회하며(Lua 테이블은 순서가 있으므로), 삭제할 키를 바로 삭제하고, insert 함수를 사용해 특정 위치에 삽입하는 것이다.

 단 하나의 자료구조로 이렇게 많은 일을 할 수 있다니 참 우아하다는 생각이 들었다. 

 

장점

 지금까지 가장 새롭고 신기했던 면을 이야기 했으니 언어 자체의 장점을 나열해보았다. 

 

가볍고 빠르다

 LuaJIT의 실행파일 용량은 634KB 밖에 안되며 실행시간은 파이썬보다 분명히 빠르다. 파이썬의 다양한 라이브러리 지원이 없었더라면 나는 한치의 의심도 없이 Lua를 썼을 것이다. 

 

멀티 패러다임

 절차지향, 객체지향, 함수형 프로그래밍까지 현존하는 거의 모든 프로그래밍 패러다임을 모두 때려박은 듯한 Lua는 Rust의 향기가 물씬 날만큼 유연하다. 스크립트 언어 특성 상 배우고 활용하기 쉽다보니 Rust보다 훨씬 빠르고 쉽게 함수형 프로그래밍을 익혔다. 함수형 프로그래밍이 다른 것보다 딱히 엄청 좋은 것 같진 않지만...

 

적은 자료형

 Lua 5.1은 그 흔한 "정수형" 자료형도 없다. 단지 "숫자"일 뿐이다. 배열도 없고 map도 없고 리스트도 없다. 오로지 테이블만 있을 뿐이다. 자료형이 적다는 것은 그 적은 자료형만 제대로 알면 마치 스위스 아미 나이프처럼 모든 곳에 쓸 수 있다는 뜻이다. 

 

낮은 진입장벽

 위 장점에서 파생되는 또 다른 장점이다. 자료형이 적고 키워드가 적다보니 외워야 할게 적고 진입하기 쉽다. 오죽하면 난 Lua 매뉴얼을 밥 먹으면서 2시간 만에 다 보고 하루 만에 Lua를 사용해 게임을 만들 수 있게 되었다. 

 

한계

 실컷 신기하고 좋은 점을 설파해놓고 언어의 한계를 말하니 우스꽝스럽지만... 할 말은 해야겠다. 

 

Dynamic Typed Variable

 C/C++, Java, Rust와 다르게 동적 자료형을 채택하고 있다. 개인적으로 동적 자료형 언어보다 정적 자료형 언어를 선호하는데 규모가 큰 프로젝트의 경우 인자들의 자료형을 매번 기억하는게 번거롭거나 심지어 불가능하기 때문이다. 적어도 파이썬 3.10 처럼 타입 힌트라도 인자에 적을 수 있도록 추가해줬으면 하는 마음이 있다. 게임 코드를 쓰다보면 인자가 많은 함수들이 등장하는데 이 인자들의 자료형을 알 방법이 함수 정의와 인자 이름 밖에 없으니 꽤나 헷갈리기 시작한다. 

 

파편화

 LuaJIT가 있다는 것은 행운이나, Lua 개발진이 만든 것이 아닌 커뮤니티가 만들었다는게 큰 걸림돌이다. 프로그래밍 언어의 발전에 있어서 변화는 필요하고 어떤 사람들은 그 변화를 달가워하지 않을 수 있다. 하지만 JIT 인터프리터가 변화를 달가워하지 않는 사람들 손에 쥐어져 있다보니 언어가 가고자하는 방향이 어디인지 알 수 없게 되었다.

 새로 입문하는 사람 입장에서 최신 버전인 5.4를 써야하는지, 빠르고 많은 사람들이 쓰는 5.1 버전을 써야하는지 고민된다. 쉽고 강력한 스크립트 언어이지만 파편화를 극복한 파이썬과 달리 아직 파편화의 늪에서 빠져나오지 못한 것이 안타깝다. 

 

꽤나 가파른 학습 곡선

 다른 언어들이 표준 라이브러리에서 여러 자료구조를 제공하는 이유는 분명하다. 실수를 줄이고, 이해하기 편하며, 대부분의 상황에서 원하는 성능을 얻기 위함이다. 파이썬 딕셔너리가 정수 인덱스를 지원하지 않는 이유도 분명하다. 그것이 모호하지 않기 때문이다. 

 Lua의 테이블은 유연하고 강력하지만 한 편으로는 모호하다. 키가 있는 값은 인덱스가 없고 키가 없는 값만 인덱스를 부여한다. 그렇다면 a = {3, 4, x = 9, 5}에서 5의 인덱스는 뭐라고 해야할까? (1) x = 9가 있으니 이를 세지 않고 5에 인덱스 3을 부여할까? 아니면 (2) x = 9를 3으로 가정하고 4를 부여할까? 

 Lua는 이에 대한 답변을 제공한다. 첫 번째 방식이다. 하지만 이렇게 여러 구조의 데이터가 같이 들어있는 테이블을 다루면 헷갈리기 마련이다. 초심자거나 큰 테이블이라면 더더욱. 

 한 테이블에 이렇게 여러 종류의 데이터를 담지 말고 테이블을 나누라고 할 수도 있다. 그렇다면 애초에 다른 구조의 데이터를 담을 수조차 없고 이름까지 달라 더욱 실수하기 어려우며, 어떤 경우 최적화까지 될 다른 자료형을 아예 새로 정의하지 않을 이유가 있는가?

 한 마디로 내가 느낀 Lua 코딩은 "테이블로 모든걸 해요! 코딩 서커스!" 이다. 

 

전역변수에 대한 제한없는 접근

 Lua에서 전역변수에 접근하는 것은 놀라울만큼 쉽다. 단지 "local" 키워드를 쓰지 않으면 된다. 심지어 함수 안에서 전역변수를 선언하고 초기화 할 수도 있다. 이것들이 합쳐지면 경악할만한 실수가 발생한다. 

print(z)

function add(x, y)
  z = x + y  -- forgot 'local' keyword!
  return z
end

print(add(3, 2))
print("can access globaly! - " .. z)



Output:

nil
5
can access globaly! - 5

 첫 print(z)는 nil을 반환한다. z가 선언된 적이 없으니 당연하다. 그러나 마지막 print 문은 이상해지는데, z에 대한 값을 5로 가져오고 있다. 이는 함수 안에서 z가 전역변수로 선언되었고, 함수가 2와 3을 인자로 받아 실행되면서 전역변수 z를 5로 할당했기 때문이다. 많은 프로그래밍 언어가 아무 prefix 없이 선언한 변수를 지역변수로 여기고 스코프 밖에서 선언하거나 static 과 같은 키워드를 따로 붙여야 전역변수 취급을 하는 것과 대조되는 부분이다. Lua는 local을 붙이지 않으면 오히려 전역변수로 저장한다. 매우 위험하다!

 전역변수는 언제나 통제되지 않는 변화를 만들 위험이 있다. 변수의 모든 접근을 눈으로 따라갈 수는 없기 때문이다. 그런 관점에서 볼 때 전역변수에 대한 접근은 언제나 지역변수보다 어려워야한다. 개인적으로 Lua의 설계 중 가장 이해되지 않는 부분이다. 

 

결론

 라이브러리 지원도 부족하고 대규모 프로젝트에도 부적합하다. 표준 라이브러리는 아주 기본적인 것들만 제공하고 커뮤니티는 아직도 옛날 5.1 버전을 쓴다. 실수할 함정 투성이지만 그 함정들을 교묘하게 벗어나 주머니에 쏙 들어가는 맥가이버칼 하나로 온갖 작품들을 깎아내고 있노라면 그 우아한 희열을 형용할 수 없다. 간단하고 배우기 쉬운 멀티 패러다임 언어를 찾는 사람은 한 번쯤 Lua를 써보는 것을 추천한다. 

'IT > 게임 제작' 카테고리의 다른 글

LOVE2d 게임엔진으로 지뢰찾기 만들기(1) - 발단  (0) 2024.03.05
Comments