포스트

Neovim 플러그인 비동기 — vim.system · jobstart · vim.uv

외부 프로세스를 UI 멈춤 없이 돌리는 법. 0.10+ vim.system, 레거시 jobstart, 저수준 vim.uv와 fast-event 콜백만 vim.schedule로 넘겨야 하는 이유.

Neovim 플러그인 비동기 — vim.system · jobstart · vim.uv

linter를 돌리고, git을 부르고, 파일을 읽어 결과를 버퍼에 채우는 플러그인 — 전부 외부 작업을 UI를 멈추지 않고 처리해야 한다. Neovim은 단일 스레드 이벤트 루프라, 블로킹 호출 하나가 에디터 전체를 얼린다. vim 전역 API 지도에서 이름만 짚은 vim.schedule·vim.uv가 여기서 본론이 된다.

결론 먼저

  • 외부 프로세스 호출의 현대 표준은 vim.system(Neovim 0.10+). 콜백/:wait() 둘 다 된다.
  • 0.10 미만 호환이나 스트리밍이 필요하면 vim.fn.jobstart.
  • 빠른 일회성 블로킹이면 vim.fn.system — 단 UI를 멈추므로 긴 작업엔 금지.
  • 타이머·파일시스템·저수준 제어는 vim.uv(libuv, 구 vim.loop).
  • vim.uv 콜백과 vim.system 완료 콜백은 fast event에서 돈다. 대부분의 vim.api 호출은 금지되므로 에디터 상태를 만질 때 vim.schedule로 되돌린다. jobstart 콜백까지 같은 규칙이라고 일반화하면 안 된다.

vim.system — 0.10+ 표준

1
2
3
4
5
6
7
8
9
10
11
-- 비동기: 콜백으로 결과 받기
vim.system({ "rg", "--json", "TODO" }, { text = true }, function(obj)
  -- obj.code, obj.signal, obj.stdout, obj.stderr
  vim.schedule(function()
    print(obj.stdout)
  end)
end)

-- 동기: 결과를 바로 기다림 (짧은 호출만)
local obj = vim.system({ "git", "rev-parse", "HEAD" }, { text = true }):wait()
print(obj.stdout)

stdout/stderr는 기본 캡처에서도 Lua 문자열로 온다. { text = true }는 반환 타입을 바꾸는 옵션이 아니라 Windows 줄바꿈 \r\n\n으로 정규화하는 옵션이다. optscwd, env, stdin도 줄 수 있다. 새로 짜고 0.10+를 타깃해도 된다면 이게 1순위다.

vim.fn.jobstart — 레거시·스트리밍

0.10 미만을 지원하거나, 출력이 흘러나오는 대로 처리해야 할 때(긴 빌드 로그 등).

1
2
3
4
5
6
7
vim.fn.jobstart({ "npm", "run", "build" }, {
  stdout_buffered = false,
  on_stdout = function(_, data, _)
    -- data는 라인 배열. 마지막 원소는 부분 라인일 수 있음
  end,
  on_exit = function(_, code, _) ... end,
})

함정: on_stdoutdata는 라인 배열인데 마지막 원소가 잘린 부분 라인이고, 끝에 빈 문자열 ""이 sentinel로 온다. stdout_buffered = true를 주면 종료 시 전체를 한 번에 받아 이 처리를 피할 수 있다.

vim.fn.system — 블로킹 일회성

1
local out = vim.fn.system({ "date" })  -- 끝날 때까지 UI 멈춤

가장 간단하지만 프로세스가 끝날 때까지 에디터가 얼어붙는다. date 같은 즉답 명령엔 괜찮고, 네트워크·빌드처럼 오래 걸리는 건 절대 쓰면 안 된다.

vim.uv — libuv 저수준

타이머, 파일시스템, 직접 spawn 등 프로세스 호출 밑단. (vim.loop이 옛 이름, vim.uv로 통일되는 중.)

1
2
3
4
5
6
7
8
-- 타이머: 디바운스에 자주 씀
local timer = vim.uv.new_timer()
timer:start(200, 0, vim.schedule_wrap(function()
  -- 200ms 후 1회. schedule_wrap으로 메인 루프에서 실행
end))

-- 논블로킹 파일 stat
vim.uv.fs_stat(path, function(err, stat) ... end)

vim.defer_fn(fn, ms)는 이 타이머를 감싼 편의 함수다. 단순 지연이면 그걸 쓰고, 반복·취소가 필요하면 vim.uv.new_timer()를 직접 다룬다.

핵심: 콜백을 메인 루프로 되돌리기

vim.uv의 저수준 콜백과 vim.system의 완료 콜백은 fast event 컨텍스트에서 실행된다. 여기서 fast-safe가 아닌 vim.api.nvim_buf_set_lines 같은 호출을 하면 E5560이 난다. 반면 jobstart의 job 콜백은 같은 실행 컨텍스트라고 일반화하지 않는다. 확실하지 않은 콜백 경계에서는 vim.in_fast_event()로 확인할 수 있다.

1
2
3
4
5
6
7
vim.uv.fs_stat("README.md", function(err, stat)
  vim.schedule(function()
    assert(not err, err)
    local buf = vim.api.nvim_create_buf(false, true)
    vim.api.nvim_buf_set_lines(buf, 0, -1, false, { vim.inspect(stat) })
  end)
end)

규칙은 vim.uvvim.system의 fast-event 콜백에서 에디터 상태를 만질 때 vim.schedule로 감싼다이다. 모든 비동기 콜백을 같은 실행 컨텍스트로 취급하지 않는다. 콜백 자체를 vim.schedule_wrap으로 감싸는 패턴도 쓸 수 있다.

어느 걸 쓰나

상황선택
외부 프로세스, 0.10+vim.system
0.10 미만 호환 / 스트리밍 출력vim.fn.jobstart
즉답 명령, 결과 바로 필요vim.fn.system (블로킹 감수)
타이머·디바운스vim.defer_fn / vim.uv.new_timer
논블로킹 파일 I/O·저수준vim.uv

함정 정리

  1. vim.uvvim.system 완료 콜백은 fast event. fast-safe가 아닌 vim.api·버퍼 조작은 vim.schedule로 감싼다.
  2. vim.fn.system·:wait()UI를 멈춘다. 긴 작업에 쓰면 에디터가 얼어붙는다.
  3. vim.system{ text = true }는 문자열 변환이 아니라 CRLF 줄바꿈 정규화다.
  4. jobstarton_stdout data는 마지막 원소가 부분 라인. stdout_buffered로 피하거나 직접 이어 붙인다.
  5. vim.uv 타이머는 명시적으로 :stop()/:close() 안 하면 샌다.

더 깊이

다루는 것
vim 전역 API 지도vim.schedule·vim.uv가 전체에서 차지하는 위치
버퍼·윈도우·extmark 조작비동기 결과를 받아 그릴 대상
autocommand·이벤트 심화fast-event 컨텍스트와 디바운스
Lua 에러 처리콜백 안 에러를 pcall·vim.schedule로 안전하게 다루기

정식 레퍼런스는 :h vim.system(), :h jobstart(), :h vim.uv, :h vim.schedule().

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.