---
title: "#매뉴얼: #둠이맥스 닷파일 이맥스리스프 코딩 컨벤션"
url: "https://notes.junghanacs.com/notes/20240404T101052"
author: "Junghan Kim"
date: "2024-04-04T10:10:00+09:00"
lastmod: "2026-06-09T10:00:00+09:00"
tags: ["coding","convention","doomemacs","dotfiles","emacslisp","manual"]
description: "Doom Emacs 닷파일을 장기적으로 정리하고 모듈화하기 위한 Emacs Lisp 코딩 컨벤션과 참조 문서."
---

> [!abstract] 이 노트에 대하여
> 
> 이 문서는 개인 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\`의 \`&amp;key\`는 적극 사용하되, \`cl-lib\` surface는 불필요하게 넓히지 않는다.
    -   LLM이 만든 깊은 \`let\*\`/\`when\`/\`if\` nesting을 평평하게 만든다.
    -   새 기능에 곧장 \`defcustom\`을 만들지 않는다.
    -   의미 없는 LLM식 주석을 제거한다.

### General Emacs Lisp style

-   Web: <https://github.com/bbatsov/emacs-lisp-style-guide>
-   Web: <https://www.gnu.org/software/emacs/manual/html_node/elisp/>

## 저장소 구조 원칙

### \`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, 외부 프로세스 호출, 네트워크 작업을 넣지 않는다.

예:

```emacs-lisp
;; 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/\`를 기본으로 한다.

```emacs-lisp
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\` 형태를 쓴다.

```emacs-lisp
(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\` 형태를 쓴다.

```emacs-lisp
(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을 개인 코드에도 적용한다.

| Suffix  | Meaning                    | Example                           |
|---------|----------------------------|-----------------------------------|
| \`-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은 새 코드에 추가하지 않는다.

| Avoid          | Prefer                                                 |
|----------------|--------------------------------------------------------|
| \`IS-MAC\`     | \`(featurep :system 'macos)\` or \`my/system-macos-p\` |
| \`IS-LINUX\`   | \`(featurep :system 'linux)\`                          |
| \`IS-WINDOWS\` | \`(featurep :system 'windows)\`                        |
| \`EMACS29+\`   | \`(&gt;= 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를 둔다.

```emacs-lisp
(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를 기본으로 한다.

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

이유:

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

### alist access는 \`map.el\`

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

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

### list processing은 \`seq.el\`

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

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

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

```emacs-lisp
(mapc #'my/register-file files)
```

### string helper는 \`subr-x\`

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

## Control flow

### 깊은 nesting 줄이기

Avoid:

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

Prefer:

```emacs-lisp
(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:

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

Prefer:

```emacs-lisp
(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가 패턴을 안정적으로 따라가게 하기 위해서다.

좋은 주석:

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

나쁜 주석:

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

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

### docstring에는 입력/출력 예시

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

```emacs-lisp
(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.

## 관련 링크

-   Repository: <https://github.com/junghan0611/doomemacs-config>
-   Convention note: <https://notes.junghanacs.com/notes/20240404T101052>
-   Doom Emacs: <https://github.com/doomemacs/doomemacs>
-   agent-shell: <https://github.com/xenodium/agent-shell>
