LuaTeX / LuaJITTeX

lualatex은 LuaTeX이 아닙니다. TeX Live 2024에서 lualatex의 실체를 따라가면 luatex이 아니라 luahbtex, 곧 HarfBuzz를 품은 다른 바이너리를 가리킵니다. fmtutil.cnf에도 lualatex luahbtex ... lualatex.ini라고 적혀 있어, 포맷 자체가 LuaHBTeX으로 만들어집니다. 실행하면 배너가 This is LuaHBTeX, Version 1.18.0 (TeX Live 2024)이라고 스스로를 밝히지만 그냥 지나치기 쉽습니다. 이 LaTeX용 엔진의 정체에서 출발해, Lua가 조판의 어디에 끼어드는지(\directlua와 콜백), LuaJITTeX은 지금 무엇이 되었는지, 그리고 “LuaLaTeX은 느리다”는 평판이 실측에서 얼마나 사실인지를 짚어 갑니다.

lualatex의 정체는 LuaHBTeX — HarfBuzz가 더하는 것

HarfBuzz 전체를 바이너리 안에 품고 있다는 점입니다. LuaHBTeX이 TeX Live에 들어온 것은 2020년이고, 그때 lualatex 뒤에 있는 프로그램이 교체되었습니다. 당시 릴리스 노트는 LuaTeX 자체는 여전히 평범한 LuaTeX이고 바뀐 것은 LaTeX용 포맷뿐이라고 못 박았습니다. 한 줄이면 확인됩니다. \directlua{tex.print(status.luatex_engine)}lualatex으로 조판하면 지면에 luahbtex이, luatex으로 조판하면 luatex이 찍힙니다. require("luaharfbuzz")를 시도하면 luahbtex에서는 성공해 HarfBuzz 8.3.0을 알려 주지만, 맨 luatex에서는 module 'luaharfbuzz' not found로 실패합니다. HarfBuzz는 디스크 어딘가의 파일이 아니라 실행 파일에 정적으로 링크되어 있기 때문에 나중에 패키지로 덧붙일 수 없고, 그래서 아예 별도의 바이너리로 배포됩니다.

shell
$ readlink $(which lualatex)
luahbtex

$ grep -E '^lualatex ' $(kpsewhich fmtutil.cnf)
lualatex luahbtex language.dat,language.dat.lua lualatex.ini

$ lualatex --version | head -1
This is LuaHBTeX, Version 1.18.0 (TeX Live 2024)

# Ask the engine what it is, from inside a document:
%   \directlua{tex.print(status.luatex_engine)}     -> luahbtex
%   \directlua{tex.print(_VERSION)}                 -> Lua 5.3
%   \directlua{local hb = require("luaharfbuzz")
%              tex.print(hb.version())}             -> 8.3.0

실무적 의미는 이렇습니다. 글자꼴 정형에는 두 갈래 길이 있고, 기본값에서는 luaotfload가 Lua로 직접 쓴 셰이퍼를 씁니다. fontspec에서 Renderer=Harfbuzz를 지정하면 HarfBuzz 쪽으로 넘어가는데, 로그를 보면 luaotfload.harf.finalize_hlist 같은 항목이 hpack_filterpost_linebreak_filter에 꽂히는 것이 보입니다. 티베트 문자나 벵골 문자처럼 정형이 복잡한 문자 체계에서는 HarfBuzz 쪽이 더 정확하며, 애초에 그것이 LuaHBTeX을 만든 동기였습니다. 라틴 문자만으로 된 문서라면 차이는 거의 드러나지 않습니다.

LuaTeX을 누가 무엇을 위해 만들었는가

luatex --credits를 치면 개발 팀으로 Hans Hagen, Hartmut Henkel, Taco Hoekwater, Luigi Scarso의 이름이 나오고, 이어서 흡수한 프로젝트 목록이 나옵니다. TeX(Knuth), e-TeX(Peter Breitenlohner 외), Omega(John Plaice, Yannis Haralambous), Aleph(Giuseppe Bilotta), pdfTeX(Hàn Thế Thành), MetaPost, Lua, 그리고 “LuajitTeX에서 사용”이라고 주석이 달린 LuaJIT(Mike Pall)입니다. 다국어 엔진이던 Omega와 Aleph의 핏줄이 섞여 있다는 사실은 잘 알려져 있지 않습니다. 개발의 출발점은 ConTeXt 진영과, 또 하나 Oriental TeX 프로젝트였습니다. 아랍어 학술 조판, 특히 교감본을 겨냥한 작업으로, 콜로라도 주립대학 철학과의 Idris Samawi Hamid가 제안한 지원금이 핵심 재프로그래밍을 뒷받침했고, 네덜란드의 NTG, TUG, 독일의 DANTE도 자금을 보탰습니다. “TeX을 프로그래밍 가능하게 만든다”는 발상은 아랍어를 아름답게 조판하고 싶다는 구체적인 요구에서 나왔습니다.

토대가 pdfTeX이므로 LuaTeX은 PDF를 곧바로 쓰고, e-TeX 확장과 pdfTeX 조판 기구의 대부분을 물려받았습니다. 다만 \pdf... 이름 공간은 정리되어, 상당수가 \pdfextension, \pdfvariable, \pdffeedback 세 창구에 키워드를 넘기는 형태가 되었고 \pdfoutput\outputmode, \pdfximage\saveimageresource로 이름이 바뀌었습니다. 일상적인 집필에서 이것들을 손으로 쓸 일은 거의 없고, LaTeX 패키지가 차이를 흡수해 줍니다. 입력은 처음부터 UTF-8이고, 글꼴은 XeTeX과 마찬가지로 fontspec으로 운영체제의 것을 이름 지정할 수 있지만, 실제로 읽어 들이는 것은 LuaTeX 자신의 Lua제 로더 luaotfload입니다. ConTeXt의 글꼴 로더를 Plain TeX과 LaTeX에 맞게 고쳐 만든 것으로, 지금은 LaTeX 팀의 latex3가 유지보수합니다.

\directlua — 문서 도중에 Lua를 실행하기

\directlua{...}는 안의 Lua를 그 자리에서 즉시 실행하고, tex.print(...)로 돌려보낸 문자열은 TeX의 입력 스트림으로 되돌아가 평범한 본문처럼 조판됩니다. 여기서 처음 해 보는 사람이면 반드시 걸리는 지점이 하나 있습니다. \directlua{tex.print(2^10)}1024가 아니라 1024.0을 찍습니다. LuaTeX이 쓰는 Lua 5.3에서 ^는 늘 부동소수점을 돌려주기 때문입니다. 정수가 필요하면 math.tointeger(2^10)이라고 씁니다. 같은 성질은 Lua의 계산 결과를 길이나 카운터에 대입할 때도 물어뜯으며, LuaTeX 매뉴얼도 tostring이나 string.format이 과학적 표기를 돌려주어 TeX 쪽을 혼란시킬 수 있다고 명시적으로 경고합니다.

latex
\documentclass{article}
\begin{document}
% careful: 2^10 is a float in Lua 5.3, so this prints 1024.0
Two to the tenth is \directlua{tex.print(2^10)}.

% and this prints 1024
Two to the tenth is \directlua{tex.print(math.tointeger(2^10))}.
\end{document}

형제인 \latelua{...}는 반대로 그것이 놓인 페이지가 출력(shipout)되는 시점에 실행됩니다. 페이지 번호와 최종 좌표가 확정된 뒤에야 할 수 있는 일—PDF에 직접 그려 넣기, 주석 붙이기—이 이쪽 몫입니다. 둘을 함께 넣은 문서를 컴파일하면 로그에서 차이가 그대로 보입니다. \directlua의 출력은 [1 앞에, \latelua의 출력은 그 뒤에 나타납니다. 실무 팁 하나. 날것의 \directlua는 중괄호와 특수 문자에 예민하므로, 한 줄이 넘는 Lua를 쓸 때는 luacode 패키지의 luacode 환경을 쓰면 그대로 붙여 넣을 수 있고 안전합니다.

콜백 — 조판 도중에 자기 함수를 꽂아 넣기

LuaTeX의 진짜 힘은 콜백에 있습니다. TeX은 글을 노드—문자, 박스, 그리고 늘어나는 공백인 글루—의 연결 리스트로 조립해 갑니다. 콜백을 등록해 두면 그 길목마다 자기 Lua 함수가 불려서 노드 리스트를 들여다보고 고쳐 쓸 수 있습니다. TeX Live 2024의 LuaTeX이 공개하는 콜백은 74개이며, 매뉴얼은 이를 파일 탐색·데이터 처리·노드 리스트 처리·정보 보고·PDF 관련·글꼴 관련의 여섯 무리로 정리합니다. 아래 예시는 실제로 동작합니다. 한 문단이 몇 줄로 조판되었는지 세어 실행이 끝날 때 보고합니다.

latex
\documentclass{article}
\usepackage{luacode}
\begin{luacode*}
local count = 0
luatexbase.add_to_callback("post_linebreak_filter",
  function(head)
    for line in node.traverse_id(node.id("hlist"), head) do
      count = count + 1
    end
    return head
  end, "count lines")
luatexbase.add_to_callback("stop_run",
  function() texio.write_nl("LINES TYPESET: " .. count) end, "report")
\end{luacode*}
\begin{document}
This paragraph is broken into lines by TeX, and the Lua function
registered on post\_linebreak\_filter counts them as they go past.
\end{document}

예시가 날것의 callback.register 대신 luatexbase.add_to_callback을 쓰는 것은, 날것의 형태가 콜백 하나에 함수 하나만 허용하기 때문입니다. LaTeX 세계에서는 여러 패키지가 같은 지점에 끼어들고 싶어 하므로 luatexbase가 이를 순서 있는 목록으로 묶어 줍니다. 노드를 다루는 도구는 Lua에서 보이는 테이블들입니다. tex은 TeX 내부 상태(레지스터와 길이)로 난 창이고, node는 노드의 생성·순회·해제를, token은 토큰(TeX에서 의미의 최소 단위)을, font는 글꼴 데이터를, status는 실행 시 정보를 다룹니다. luaotfload 같은 고급 글꼴 기구도 이 토대 위에 Lua로 쓰여 있습니다.

콜백호출 시점쓰임새
process_input_buffer입력 줄을 읽어 들일 때마다입력 전처리(문자열을 받아 문자열을 돌려줌)
pre_linebreak_filter문단이 줄로 나뉘기 직전줄바꿈 전에 노드 리스트를 고쳐 쓰기
post_linebreak_filter줄바꿈 직후완성된 각 줄에 손대기
hpack_filter수평 박스가 조립될 때마다박스 내용물을 나중에 조정하기
ligaturing / kerning합자와 커닝을 조립하는 단계글꼴 자체의 자간 처리를 대체하기
stop_run실행의 맨 끝집계 보고와 뒷정리

LuaJITTeX은 지금 어떻게 되었는가

사라지지 않았습니다. TeX Live 2024에는 luajittexluajithbtex 두 실행 파일이 들어 있고, 둘 다 버전 1.18.0이며 fmtutil.cnf에 포맷 정의도 있습니다. 안에 든 것은 본가 Lua가 아니라 LuaJIT, 곧 실행 시점에 기계어로 컴파일하는 저스트인타임 구현입니다. 직접 물어보면 jit.versionLuaJIT 2.1.0-beta3을, _VERSIONLua 5.1을 돌려줍니다. 즉 LuaJITTeX은 언어 수준이 5.1에 묶여 있어, 5.3인 본류 LuaTeX과는 다른 언어 명세 위에서 돕니다. LuaTeX 매뉴얼도 이 점을 명시해, LuaJIT이 일반 Lua의 개발과 발을 맞추지 않기 때문에 LuaJITTeX이 뒤처진다고 적고 있습니다.

이 차이는 탁상공론이 아닙니다. 완전히 같은 한 줄 \directlua{tex.print(2^10)}을 네 바이너리로 조판하면 luatexluahbtex1024.0을, luajittexluajithbtex1024를 찍습니다. Lua 5.3은 정수와 부동소수점을 구별하지만 5.1은 구별하지 않기 때문입니다. 엔진을 바꾼 것만으로 문서의 내용이 달라지는 셈이고, 이것이 LuaJITTeX을 LuaLaTeX의 대체품으로 권하기 어려운 이유의 하나입니다. 그리고 더 결정적인 사정이 또 있습니다. fmtutil.cnf가 LuaJIT 계열에 정의하는 것은 Plain 형식의 포맷뿐이며, luajitlatex에 해당하는 것은 존재하지 않습니다. LaTeX을 LuaJIT 위에서 돌리려면 포맷을 직접 만들어야 하므로, 사실상 LaTeX 사용자의 선택지에 들어 있지 않다는 것이 현재의 정확한 표현입니다.

또 한 갈래의 후계는 LuaMetaTeX입니다. LuaTeX을 덜어 내고 다시 쓴 경량판으로, 현대 ConTeXt(LMTX / MkXL)의 토대입니다. 수식 조판이 새로 짜였고 새 프리미티브가 다수 들어갔으며 언어는 Lua 5.4 계열입니다. TeX Live 2024에는 luametatex 2.11.02가 함께 실려 있고, context 명령 자체가 이것을 가리키는 심볼릭 링크입니다. 역할 분담은 명확합니다. LaTeX을 쓴다면 LuaTeX, 최신 ConTeXt를 쓴다면 LuaMetaTeX입니다.

LuaLaTeX은 정말 느린가 — 실측

느린 것은 사실이지만, 비용이 어디에 있는지를 흔히 잘못 짚습니다. 시작 자체는 거의 같습니다. 거의 빈 한 쪽짜리 문서에서 pdfLaTeX 0.20초, XeLaTeX 0.30초, LuaLaTeX 0.32초였습니다(같은 기계, 여러 번 중 최속값). 격차가 벌어지는 곳은 조판량에 비례하는 부분입니다. 같은 417쪽 수식 문서에서 pdfLaTeX 0.40초, XeLaTeX 0.53초, LuaLaTeX 1.41초였습니다. 시작 시간을 빼고 조판 자체만 보면 XeLaTeX은 pdfLaTeX의 1.2배쯤, LuaLaTeX은 5배가 넘습니다. 뒤집어 말하면, 열 쪽 남짓한 논문에서는 셋의 차이를 체감할 수 없습니다.

측정pdfLaTeXXeLaTeXLuaLaTeX
1-page document0.20초0.30초0.32초
417-page document0.40초0.53초1.41초
typesetting only기준약 1.2배약 5.6배

실무적 대처는 셋입니다. 하나, 집필 중에는 속도보다 왕복 횟수를 줄이는 것입니다. latexmk 같은 자동 빌드로 바뀐 부분만 다시 도는 편이 엔진 선택보다 효과가 큽니다. 둘, 글꼴 캐시의 첫 회 비용과 혼동하지 않는 것입니다. luaotfload는 처음 보는 글꼴을 만나면 훑어서 색인을 만들기 때문에 그 한 번만 극단적으로 느립니다. “LuaLaTeX이 수십 초 걸린다”는 이야기의 대부분이 이것이며, 두 번째부터는 위 표의 값으로 돌아옵니다. 셋, CI에서는 한 번의 컴파일 비용보다 왕복과 재실행 횟수를 세는 것입니다. 상호 참조와 목차 때문에 두세 번 도는 설계라면 한 번당 격차가 그만큼 곱해집니다.

MetaPost 내장과 일본어(LuaTeX-ja)

LuaTeX에는 작도 엔진 MetaPost가 라이브러리 MPlib으로 내장되어 있어, 외부 프로그램을 부르지 않고 같은 프로세스 안에서 그림을 만들 수 있습니다. LaTeX에서는 luamplib 패키지를 통해 mplibcode 환경 안에 MetaPost 코드를 그대로 씁니다. 실제로 컴파일한 뒤 로그를 확인해 보면 외부 mpost를 띄운 흔적이 어디에도 없습니다. 수수해 보여도 중요한 점입니다. shell escape를 끈 환경이나 CI에서도 그림이 만들어진다는 뜻이기 때문입니다.

일본어 조판은 LuaTeX-ja(패키지 luatexja)가 맡습니다. pTeX이 지녔던 일본어 조판 지식—세로쓰기, JFM(Japanese Font Metrics)에 기반한 자간과 약물 처리, 일본어와 서양 문자 사이의 공백—을 Lua 쪽에서 콜백으로 다시 구현한 것으로, 이 페이지에서 살펴본 콜백 기구의 가장 큰 실사용 예라 할 수 있습니다. luatexja-fontspec를 쓰면 fontspec 방식으로 일본어 글꼴도 고를 수 있습니다. 다만 LuaHBTeX의 HarfBuzz 셰이핑과 LuaTeX-ja의 세로쓰기·CID 기구를 조합할 때는 주의가 필요합니다. LuaTeX-ja 매뉴얼은 HarfBuzz를 거쳐 정의한 일본어 글꼴이 의도치 않은 결과를 낼 수 있다고 경고합니다. 실제 원고에서는 TeX Live에 함께 실린 하라노아지 글꼴과 표준 설정으로 먼저 PDF를 뽑고, OpenType 기능이나 HarfBuzz 옵션은 작은 시험으로 확인한 뒤에 넣는 편이 안전합니다.

latex
\documentclass{ltjsarticle}
\usepackage{luatexja-fontspec}
% Harano Aji ships with TeX Live, so this builds anywhere
\setmainjfont{HaranoAjiMincho-Regular}
\setsansjfont{HaranoAjiGothic-Medium}
\begin{document}
\section{日本語}
Text and mathematics $E=mc^2$ go through the same engine.
\end{document}

LuaLaTeX으로 새 문서를 시작할 때

  • 유니코드 원고에서 시작합니다. inputencfontenc도 넣지 않습니다. pdfLaTeX 시절 선언을 남긴 채 옮기면 경고와 이중 글꼴 설정을 떠안게 됩니다.
  • 글꼴 선택은 fontspec 하나로 일원화합니다. 공유하는 원고라면 먼저 TeX Live 동봉 글꼴로 빌드가 되는 상태를 만든 뒤 바꿔 넣습니다.
  • 일본어는 luatexja입니다. 세로쓰기, JFM, 일본어와 서양 문자 사이의 공백까지 돌봐 줍니다.
  • 긴 Lua는 luacode 환경에 넣습니다. 날것의 \directlua는 중괄호와 특수 문자에 예민합니다.
  • 콜백은 luatexbase.add_to_callback으로 등록합니다. 날것의 callback.register는 한 지점에 함수 하나만 허용합니다.
  • 먼저 작은 견본을 만듭니다. 본문 글꼴·일본어 글꼴·수식·참고문헌을 한 쪽에서 모두 훑는 원고를 만들어 템플릿으로 삼습니다.

한 줄로 요약하면 조판 자체에 손을 대고 싶다면 LuaLaTeX, 운영체제 글꼴을 이름으로 쓰고 싶을 뿐이라면 XeLaTeX, 영문 위주이고 속도와 호환성이 필요하다면 pdfLaTeX입니다. 셋을 정면으로 비교한 페이지는 따로 있습니다. LaTeX 팀이 새 개발의 과녁으로 삼는 것이 LuaTeX이므로, 지금 새로 시작하는 유니코드·OpenType 작업 흐름에서는—특히 무언가를 프로그래밍하고 싶은 경우—LuaLaTeX이 점점 기본에 가까워지고 있습니다.