Lua 모듈
require/package.path, local M = {} return M 패턴, require 캐시, Neovim lua/ 디렉토리 자동 등록과의 연결.
Lua의 모듈 시스템은 단순하다. 파일 = 모듈, require로 로드, return M으로 노출. 하지만 package.path가 어떻게 만들어지고, require가 무엇을 캐싱하고, Neovim의 lua/ 자동 등록과 어떻게 맞물리는지 알면 본인 설정을 모듈로 쪼개기가 훨씬 쉬워진다.
결론 먼저
- 모듈은 그냥 Lua 파일이고,
return한 값이require의 결과가 된다. - 관례는
local M = {} ... return M. 노출할 것만M에 담는다. require("foo.bar")는package.path의?을foo/bar로 치환해서 찾는다.require는 결과를 캐시한다. 두 번째 호출은 같은 객체 반환.- Neovim은
runtimepath/lua/를 자동으로package.path에 더해준다.lua/foo/init.lua→require("foo").
가장 단순한 모듈
1
2
3
4
5
6
7
8
9
10
11
12
-- 파일: lua/mymath.lua
local M = {}
function M.add(a, b)
return a + b
end
function M.mul(a, b)
return a * b
end
return M
사용처:
1
2
local m = require("mymath")
print(m.add(1, 2)) -- 3
return M이 전부다. M은 그냥 컨벤션 (다른 이름이어도 됨). 노출하고 싶지 않은 헬퍼는 M에 안 담고 그냥 local로 둔다.
모듈 안 헬퍼는 local로
1
2
3
4
5
6
7
8
9
10
11
local M = {}
local function format_name(s) -- 내부용, 노출 X
return s:lower():gsub("%s", "_")
end
function M.greet(name)
return "hi " .. format_name(name)
end
return M
format_name은 M.format_name = ...로 안 했으니 require("mod").format_name로 접근 안 된다.
require의 동작
require("foo.bar")이 실제로 하는 일:
- 이미 로드돼 있나 확인 (
package.loaded["foo.bar"]). - 있으면 그 값 반환 (캐시).
- 없으면
package.path를 순회하며foo/bar.lua를 찾는다 (.은/로 바뀜). - 찾으면 실행해서
return값을package.loaded["foo.bar"]에 저장하고 반환. - 못 찾으면 에러.
package.path 확인:
1
2
print(package.path)
-- 보통 ./?.lua;./?/init.lua;/usr/share/lua/5.1/?.lua;... 식
?이 모듈 경로(슬래시로 변환된)로 치환된다.
1
2
3
4
5
require("foo.bar")
→ "foo/bar"
→ ./foo/bar.lua 시도
→ ./foo/bar/init.lua 시도
→ ...
init.lua가 디렉토리의 entry point다. foo/init.lua가 있으면 require("foo")로 로드된다. Python의 __init__.py와 같은 역할.
require 캐시의 함정
1
2
3
local a = require("mymath")
local b = require("mymath")
print(a == b) -- true
같은 객체다. 즉 모듈의 상태는 공유된다. 개발 중 모듈을 수정해도 require로 다시 불러도 옛 버전이 나온다. 강제 리로드:
1
2
package.loaded["mymath"] = nil
local fresh = require("mymath")
Neovim에서는 vim.loader나 lazy.nvim의 reload, 또는 plenary의 R("modname") 등으로 자주 처리한다.
Neovim과 package.path
Neovim 시작 시 runtimepath의 모든 lua/ 디렉토리가 package.path에 자동 추가된다.
1
2
3
4
~/.config/nvim/lua/
~/.local/share/nvim/lazy/<plugin>/lua/
/opt/homebrew/share/nvim/runtime/lua/
...
따라서:
1
2
3
~/.config/nvim/lua/config/options.lua → require("config.options")
~/.config/nvim/lua/plugins/init.lua → require("plugins") (init.lua 자동)
~/.config/nvim/lua/util/keys.lua → require("util.keys")
플러그인 코드도 똑같다.
1
2
nvim/lazy/telescope.nvim/lua/telescope/builtin.lua
→ require("telescope.builtin")
LazyVim 기본 구조의 정체.
init.lua의 두 가지 의미
헷갈리기 쉽다.
~/.config/nvim/init.lua: Neovim의 시작 파일.:lua없이 그냥 Lua가 실행된다.lua/foo/init.lua:foo모듈의 엔트리.require("foo")로 로드.
두 번째 init.lua는 lua/foo.lua와 동등하지만, foo/ 디렉토리 안에 서브모듈을 둘 때 쓴다.
1
2
3
lua/util/init.lua -- require("util")
lua/util/keys.lua -- require("util.keys")
lua/util/lsp.lua -- require("util.lsp")
모듈 안에서 다른 모듈 참조
1
2
3
4
5
6
7
-- lua/util/keys.lua
local lsp = require("util.lsp") -- 같은 디렉토리도 절대 경로로
local M = {}
function M.setup() lsp.attach() end
return M
상대 경로는 없다. 전부 runtimepath/lua/ 기준 절대 경로.
순환 import
A가 B를 require하고 B가 A를 require하는 상황. Lua는 부분 로드된 모듈을 반환해 동작하긴 한다.
1
2
3
4
5
6
-- a.lua
local M = {}
package.loaded["a"] = M -- 캐시에 미리 등록 (관용구)
local b = require("b")
function M.foo() return b.bar() end
return M
이 패턴이 안전. 하지만 가능하면 순환 의존성 자체를 피하는 게 낫다.
그 외 유용한 함수
pcall(require, "modname"): 모듈 없을 때 에러 대신 false 반환.1 2
local ok, mod = pcall(require, "optional-plugin") if not ok then return end
package.loaded["modname"] = nil: 캐시 제거.pairs(package.loaded): 현재 로드된 모듈 목록.
Neovim 컨텍스트
LazyVim의 표준 구조
1
2
3
4
5
6
7
8
9
10
11
12
~/.config/nvim/
├── init.lua -- "require 'config.lazy'" 정도
├── lua/
│ ├── config/
│ │ ├── lazy.lua -- lazy.nvim 부트스트랩
│ │ ├── options.lua -- vim.opt.*
│ │ ├── keymaps.lua -- vim.keymap.set
│ │ └── autocmds.lua
│ └── plugins/
│ ├── init.lua -- 또는 그냥 폴더 (lazy.nvim이 스캔)
│ ├── telescope.lua -- 한 파일 = 한 플러그인 spec
│ └── ...
init.lua의 첫 줄들:
1
2
3
4
5
-- ~/.config/nvim/init.lua
require("config.options")
require("config.keymaps")
require("config.autocmds")
require("config.lazy")
각 모듈 파일은 return M 패턴.
플러그인 spec 반환
1
2
3
4
5
-- lua/plugins/which-key.lua
return {
"folke/which-key.nvim",
opts = { ... },
}
여기서는 M 없이 바로 테이블을 return. lazy.nvim이 require("plugins.which-key") 결과를 spec으로 해석한다.
조건부 require로 옵셔널 의존성
1
2
3
4
5
6
local ok, dap = pcall(require, "dap")
if not ok then
vim.notify("dap not installed", vim.log.levels.WARN)
return
end
-- dap 사용
개발 중 모듈 강제 리로드
1
2
3
-- 빠른 디버깅용
package.loaded["my.module"] = nil
local m = require("my.module")
함정 정리
require는 캐시. 수정 후 다시 require해도 옛 버전.package.loaded[name] = nil또는 reload 헬퍼.return M빼먹으면require결과가 nil. 모듈 안 함수 못 부름.function M.foovslocal function foo: 전자만 외부로 노출.require("foo.bar")의.은 디렉토리 구분자.foo_bar.lua아니라foo/bar.lua.- 모듈 안에서 같은 패키지 모듈 참조도 절대 경로. 상대 경로 없음.
- lua/foo.lua와 lua/foo/init.lua가 동시에 있으면 어느 게 먼저인지
package.path순서에 의존. 헷갈리지 않게 하나만 두기.
Lua 시리즈
| 글 | 다루는 것 |
|---|---|
| Lua 종합 가이드 (Neovim 컨텍스트) | LuaJIT(5.1) 문법 한 번에 정리 — 타입·스코프·테이블·문자열 패턴·vim.* 헬퍼 |
| Lua 모듈 (현재 글) | require/package.path, local M = {} return M 패턴, Neovim lua/ 자동 등록 |
| Lua 메타테이블 | __index/__newindex/__call, OOP 클래스 패턴, vim.opt가 일반 테이블처럼 보이는 이유 |
| Lua 에러 처리 | error/assert로 던지고 pcall/xpcall로 잡기. Neovim 플러그인의 에러 관행 |