이 노트에 대하여

이 문서는 개인 Doom Emacs 설정을 장기적으로 유지보수 가능한 Lisp 시스템으로 만들기 위한 코딩 컨벤션이다. 현재 저장소는 아직 Doom module 자체는 아니지만, 향후 `modules/` 구조로 옮기거나 독립 패키지로 분리할 수 있도록 Doom의 현재 스타일과 Emacs Lisp 관습을 참고해 기준을 잡는다.

공개 URL은 고정한다: https://notes.junghanacs.com/notes/20240404T101052

히스토리

  • [2026-06-09 Tue 10:00] vanilla-first 테스트 게이트(Tier A/B/C) 원칙 추가.
  • [2026-06-08 Mon 08:28] 기존 Doom `lisp/demos.org` 덤프 성격의 노트를 지우고, 닷파일 코딩 컨벤션 문서로 재작성.
  • [2024-04-04 Thu 10:10] 생성.

목적

Doom Emacs 설정은 시간이 지나면 쉽게 다음 상태가 된다.

  • Doom macro와 vanilla Emacs Lisp가 섞인다.
  • 개인 함수 이름, hook 함수 이름, advice 함수 이름이 일관되지 않는다.
  • LLM/agent가 매번 다른 idiom으로 코드를 생성한다.
  • 동작은 하지만 길고, Emacs 표준 라이브러리로 줄일 수 있는 함수가 늘어난다.
  • `config.el`과 `lisp/*.el`의 경계가 흐려진다.

이 문서의 목적은 반대다.

  1. 새 코드는 비슷한 모양으로 생기게 한다.
  2. 오래된 코드는 한 번에 갈아엎지 않고, 파일 단위로 같은 기준에 맞춘다.
  3. 향후 Doom `modules/` 구조나 독립 패키지로 옮겨도 어색하지 않게 한다.
  4. 사람과 agent가 같은 기준으로 코드를 읽고 고치게 한다.

현재 전제

이 저장소는 Doom module이 아니라 개인 `$DOOMDIR`이다. 따라서 Doom module 규칙을 그대로 복사하지 않는다. 대신 다음처럼 나눈다.

  • Doom의 구조 규칙은 따른다.
  • Doom v2 compatibility shim에는 새로 기대지 않는다.
  • 개인 코드의 namespace는 `my/`로 통일한다.
  • Emacs 표준 라이브러리와 작은 함수 조합을 우선한다.
  • 장기적으로 module/package로 승격될 수 있는 형태를 유지한다.

Vanilla-first 테스트 게이트

이 저장소의 장기 리팩터 기준은 “로직은 vanilla, 설정은 Doom”이다. `emacs -Q`에서 도는 함수는 Doom 의존 없는 로직이고, 패키지로 추출 가능하며, agent-server 같은 headless 표면에서도 재사용하기 쉽다.

Tier A — vanilla logic

  • `emacs -Q —batch`에서 테스트할 수 있는 로직.
  • 입력→출력이 명확한 path/string/date/alist 변환 함수가 우선 대상.
  • Doom macro(`map!`, `after!`, `use-package!`)를 함수 본문에 넣지 않는다.
  • 리팩터 전에 characterization test로 현재 동작을 먼저 고정한다.

Tier B — Doom configuration

  • keybinding, UI, package setup, `use-package!`, `after!`, `map!` 중심 코드.
  • `setq` 값 자체를 테스트하지 않는다.
  • 네임스페이스와 주석 스타일은 정리하되, 테스트 게이트 대상은 아니다.

Tier C — package-dependent integration

  • `denote`, `ox-hugo`, straight package load가 필요한 경로.
  • Tier A의 `emacs -Q` runner에 억지로 끌어들이지 않는다.
  • 선택지는 둘이다.
    1. 순수 분기만 작은 helper로 추출해 Tier A에서 테스트한다.
    2. 별도 integration runner를 만들고 Tier C로 명시한다.

목표는 SKIP 0개가 아니라 SKIP의 의미를 정확히 분류하는 것이다. dead-path 때문에 생긴 SKIP은 버그이고, 패키지 의존 때문에 남기는 SKIP은 문서화된 경계여야 한다.

현재 Tier A runner는 `doomemacs-config/tests/run-tests.sh`이며, `tests/TESTING-GUIDELINES.org`가 세부 규칙의 SSOT다.

기준 문서와 레퍼런스

이 문서는 아래 문서를 기준으로 작성했다. Doom은 계속 바뀌므로 큰 정리 작업 전에는 최신 파일을 다시 확인한다.

Doom Emacs upstream

  • Local: `~/doomemacs/docs/contributing.org`
  • Web: https://github.com/doomemacs/doomemacs/blob/master/docs/contributing.org
  • 핵심:
    • bbatsov Emacs Lisp style guide를 따른다.
    • no hanging parentheses.
    • `DEPRECATED`는 실제 제거 예정 코드에만 쓴다.
    • side-effect iteration은 `seq-do`보다 `mapc`.
    • Doom naming convention: command는 `doom/name`, hook은 `*-h`, advice는 `*-a`, strategy는 `*-fn`.

Doom module / config guide

  • Local: `~/doomemacs/docs/getting_started.org`
  • Web: https://github.com/doomemacs/doomemacs/blob/master/docs/getting_started.org
  • 핵심:
    • `init.el`: 아주 이른 설정. 비싸거나 실패하기 쉬운 작업 금지.
    • `config.el`: package 설정과 module 구성의 중심.
    • `packages.el`: package 선언만. 설정 금지.
    • `autoload/*.el`: 필요할 때 로드할 command/function.

Doom API demos

  • Local: `~/doomemacs/lisp/demos.org`
  • Web: https://github.com/doomemacs/doomemacs/blob/master/lisp/demos.org
  • 핵심:
    • `after!`, `add-hook!`, `map!`, `cmd!`, `cmds!`, `load!`, `package!` 등 Doom API의 실제 예시.
    • 예전 노트에 있던 `appendq!`, `prependq!`, `setq!` 데모는 최신 upstream에서 제거되었거나 compat 대상으로 밀렸다.

Doom compat module

  • Local: `~/doomemacs/modules/doom/compat/README.org`
  • Web: https://github.com/doomemacs/doomemacs/blob/master/modules/doom/compat/README.org
  • 핵심:
    • `:doom compat`은 v2 compatibility layer다.
    • 현재는 v3 전까지 hardcoded로 켜져 있지만, 새 코드가 의존할 대상은 아니다.
    • 피할 대상: `IS-MAC`, `IS-LINUX`, `IS-WINDOWS`, `IS-BSD`, `EMACS29+`, `setq!`, `featurep!`, `appendq!`, `prependq!`, `delq!`, `pushnew!` 등.

agent-shell contributing style

  • Local: `~/doomemacs/.local/straight/repos/agent-shell/CONTRIBUTING.org`
  • Web: https://github.com/xenodium/agent-shell/blob/main/CONTRIBUTING.org
  • 핵심:
    • 유지보수를 위해 idiom 수를 줄인다.
    • alist, `seq.el`, `map.el`을 선호한다.
    • `cl-defun`의 `&key`는 적극 사용하되, `cl-lib` surface는 불필요하게 넓히지 않는다.
    • LLM이 만든 깊은 `let*`/`when`/`if` nesting을 평평하게 만든다.
    • 새 기능에 곧장 `defcustom`을 만들지 않는다.
    • 의미 없는 LLM식 주석을 제거한다.

General Emacs Lisp style

저장소 구조 원칙

`init.el`

`init.el`은 Doom module 선택과 아주 이른 predicate 정의에만 쓴다.

Do:

  • `doom!` block 관리.
  • startup 초기에 필요한 가벼운 platform predicate 정의.
  • daemon/server-name처럼 init 시점이 중요한 설정.

Don’t:

  • package 설정을 길게 넣지 않는다.
  • `after!`와 `use-package!`를 남발하지 않는다.
  • 실패 가능성이 큰 파일 I/O, 외부 프로세스 호출, 네트워크 작업을 넣지 않는다.

예:

;; Detect from the environment, not a `uname' subprocess: init.el must avoid
;; slow or failure-prone work.  Termux-pkg Emacs reports system-type gnu/linux,
;; so `(eq system-type 'android)' does not catch it; TERMUX_VERSION/PREFIX do.
(defconst my/termux-p
  (and (or (getenv "TERMUX_VERSION")
           (string-match-p "termux" (or (getenv "PREFIX") "")))
       t)
  "Non-nil when running inside Termux/Android.")
 
(defconst my/system-macos-p
  (featurep :system 'macos)
  "Non-nil on macOS.  Local replacement for Doom's obsolete `IS-MAC'.")
 
(doom! :os
       (:if my/system-macos-p macos)
       tty)

`config.el`

`config.el`은 loader로 유지한다.

Do:

  • `(require ‘module-config)`만 둔다.
  • 아주 짧은 glue code만 둔다.

Don’t:

  • 새 기능 본문을 넣지 않는다.
  • 파일 하나로 다시 비대해지게 두지 않는다.

`lisp/*.el`

`lisp/*.el`은 one concern = one file.

예:

  • `termux-config.el`: Termux/Android.
  • `tty-config.el`: TTY protocol, clipboard, term-keys.
  • `korean-input-config.el`: Korean input and font fallback.
  • `workflow-shared.el`: user Emacs, agent server, export daemon이 공유해야 하는 data contract.

`autoload/`

실제로 lazy command가 필요할 때만 사용한다. 단순 helper를 무조건 autoload로 빼지 않는다.

Naming convention

개인 namespace

개인 코드의 public symbol은 `my/`를 기본으로 한다.

my/termux-p
my/current-device
my/org-download-image-dir
my/set-emoji-symbol-font

이유:

  • `rg “my/”`로 개인 코드 추적이 쉽다.
  • Doom/Emacs/package symbol과 충돌하지 않는다.
  • 향후 package로 분리할 때 rename 대상이 분명해진다.

Interactive command

사용자가 `M-x` 또는 keybinding으로 직접 호출하는 command는 `my/name` 형태를 쓴다.

(defun my/open-today-journal ()
  "Open today's journal."
  (interactive)
  ...)

Doom의 `doom/name`, `+module/name` command convention을 개인 namespace로 적용한 것이다.

Internal helper

큰 module 안에서 내부 helper가 필요하면 `my/feature—helper` 형태를 쓴다.

(defun my/termux--decode-arrow-key (sequence)
  "Return the arrow key event represented by SEQUENCE."
  ...)

단, 작은 파일에서는 과도하게 private helper를 쪼개지 않는다. 읽기 쉬운 작은 함수가 목적이지, namespace 장식이 목적이 아니다.

Hook / advice / strategy suffix

Doom naming convention을 개인 코드에도 적용한다.

SuffixMeaningExample
`-h`hook function`my/org-refresh-agenda-files-h`
`-a`advice function`my/consult—narrow-preview-a`
`-fn`strategy/callback function`my/project-root-fn`

이 suffix는 agent가 함수를 어디에 써야 하는지 바로 알게 해준다.

Doom v3-aware rules

새 코드에서 compat shim 금지

아래 symbol은 새 코드에 추가하지 않는다.

AvoidPrefer
`IS-MAC``(featurep :system ‘macos)` or `my/system-macos-p`
`IS-LINUX``(featurep :system ‘linux)`
`IS-WINDOWS``(featurep :system ‘windows)`
`EMACS29+``(>= emacs-major-version 29)`
`featurep!``modulep!`
`setq!``setopt` or `setq`
`appendq!``(cl-callf append var lists)`
`prependq!``(cl-callf2 append lists var)`
`pushnew!``add-to-list` or `cl-pushnew`

`map!`과 `use-package!`는 현재 Doom private config에서 여전히 실용적인 idiom이다. 대량 제거하지 않는다. 다만 독립 패키지로 분리할 코드는 vanilla API를 선호한다.

grep-ability가 필요하면 local predicate

inline form이 정석이어도 반복 추적이 필요하면 `my/…` predicate를 둔다.

(defconst my/emacs31-p
  (>= emacs-major-version 31)
  "Non-nil when running Emacs 31 or newer.")

Data and library preference

작은 구조화 데이터는 alist

작은 state/config/event는 alist와 `:kebab-case` keyword를 기본으로 한다.

'((:device . "thinkpad")
  (:socket . "server")
  (:agenda-visible-p . t))

이유:

  • Org/Elisp 문서에서 읽기 쉽다.
  • `map.el`과 잘 맞는다.
  • JSON/protocol의 camelCase와 내부 state를 구분할 수 있다.

alist access는 `map.el`

(map-elt state :device)
(map-nested-elt response '(usage totalTokens))

반복적인 `(cdr (assoc …))`를 줄인다.

list processing은 `seq.el`

값을 만드는 list transform은 `seq-*`를 선호한다.

(seq-filter #'file-exists-p paths)
(seq-map #'file-name-nondirectory paths)

side effect만 필요하면 Doom upstream style에 맞춰 `mapc`를 선호한다.

(mapc #'my/register-file files)

string helper는 `subr-x`

`string-empty-p`, `string-trim`, `string-join`, `when-let*` 등 이미 있는 helper를 쓴다.

Control flow

깊은 nesting 줄이기

Avoid:

(let ((buffer (get-buffer name)))
  (when buffer
    (with-current-buffer buffer
      (erase-buffer)
      (insert content))
    buffer))

Prefer:

(when-let* ((buffer (get-buffer name)))
  (with-current-buffer buffer
    (erase-buffer)
    (insert content))
  buffer)

`let`과 `let*`

  • binding이 서로 독립이면 `let`.
  • 뒤 binding이 앞 binding을 참조할 때만 `let*`.
  • 조건부 binding은 `when-let*`.

single-branch `if` 금지

Avoid:

(if (not (file-exists-p path))
    (user-error "File not found: %s" path))

Prefer:

(unless (file-exists-p path)
  (user-error "File not found: %s" path))

Customization policy

새 기능에 바로 `defcustom`을 만들지 않는다.

  • 처음에는 `defvar` 또는 `defconst`.
  • 사용 패턴이 안정되면 `defcustom` 승격.
  • `defcustom`은 public API다. 만들면 유지보수해야 한다.

Comments and docstrings

코드 주석은 영어

새로 생성되는 Elisp 코드의 주석은 영어를 기본으로 한다. 이유는 upstream package와 Doom style에 맞추고, agent가 패턴을 안정적으로 따라가게 하기 위해서다.

좋은 주석:

;; Keep this predicate local so the config does not depend on Doom v2 shims.

나쁜 주석:

;; 이전 코드에서 만든 변수를 사용한다.
;; 이 함수는 파일을 연다.

주석은 의도, 불변식, 회귀 방지 이유를 설명해야 한다. 코드가 이미 말하는 사실을 반복하지 않는다.

docstring에는 입력/출력 예시

변환 함수나 포맷 함수는 docstring에 예시를 넣는다.

(defun my/github-url-from-repo-path (path)
  "Return a GitHub URL for PATH under `~/repos/gh'.
 
Example:
  ~/repos/gh/doomemacs-config/lisp/org-config.el
  => https://github.com/junghan0611/doomemacs-config/blob/main/lisp/org-config.el"
  ...)

Package/config boundary

Doom config 안에 남길 코드

  • 개인 keybinding.
  • 개인 device/platform predicate.
  • Org/Denote workflow처럼 환경과 강하게 묶인 glue.
  • `~/org`, socket, NixOS path처럼 개인 시스템과 결합된 코드.

package 후보

  • 특정 개인 path에 의존하지 않는다.
  • 입력과 출력이 명확하다.
  • 다른 Doom/vanilla Emacs 사용자도 쓸 수 있다.
  • `my/` namespace를 떼고 package namespace로 바꿀 수 있다.

module 후보

  • Doom module file structure로 자연스럽게 나뉜다.
  • `packages.el`, `config.el`, `autoload/`, `doctor.el` 경계가 의미 있다.
  • 현재 `lisp/*.el` 하나가 이미 독립 concern으로 충분히 커졌다.

Refactoring order

한 번에 전체를 바꾸지 않는다. 다음 순서로 한다.

  1. Predicate와 namespace 정리.
    • `IS-*` → `my/…` 또는 `(featurep :system …)`.
  2. 긴 함수 찾기.
    • `rg “let\*|assoc|cdr|cl-loop|defcustom|setq!|featurep!” lisp`
  3. 파일 하나씩 정리.
    • 중첩 줄이기.
    • alist access를 `map-elt`로 바꾸기.
    • list transform을 `seq-*`로 바꾸기.
  4. module/package 후보 표시.
    • 개인 path dependency를 분리한다.
  5. 필요하면 Doom `modules/` 구조로 승격.

Agent instruction summary

Agent가 이 저장소에서 Elisp를 생성하거나 수정할 때는 다음을 따른다.

  • First search for an existing pattern in `lisp/`.
  • Keep `config.el` as a loader.
  • Put one concern in one `lisp/*-config.el` file.
  • Use `my/` namespace for custom symbols.
  • Do not add new Doom v2 compat shims.
  • Prefer Emacs built-ins, `seq.el`, `map.el`, and `subr-x` over hand-rolled code.
  • Keep comments in English and explain intent.
  • Do not add `defcustom` prematurely.
  • Run `doom sync` after `init.el`, `packages.el`, or module selection changes.

관련 링크