Blog

Mac에서 Neovim 개발환경 처음부터 구축하기

12 minute read

이번 글에서는 Mac에서 거의 빈 상태의 Neovim부터 시작해 실제 개발에 사용할 수 있는 환경을 직접 구성합니다.

목표는 거대한 Neovim 배포판을 설치하는 것이 아닙니다. lazy.nvim을 기반으로 필요한 기능을 하나씩 추가하면서 각 구성 요소가 무엇을 담당하는지 이해할 수 있는 환경을 만드는 것입니다.

최종적으로 다음과 같은 구성을 만듭니다.

Neovim
├── lazy.nvim             Plugin Manager
├── Telescope             파일/텍스트 검색
├── Tree-sitter           Syntax Parsing
├── Mason                 LSP 도구 관리
├── LSP
│   ├── Pyright           Python
│   ├── gopls             Go
│   └── ts_ls             TypeScript/JavaScript
├── blink.cmp             자동완성
├── Diagnostics           오류/경고 표시
├── Conform
│   ├── Ruff              Python formatting
│   ├── gofmt             Go formatting
│   └── Prettier          JS/TS/JSON/YAML/Markdown
├── Oil.nvim              파일 탐색
├── Gitsigns              Git 변경사항
├── Claude Code           AI coding
├── which-key             단축키 안내
└── lualine               Status line

여기서는 Neovim 0.12 계열을 기준으로 합니다. 특히 LSP와 Tree-sitter 설정은 예전 블로그 글에서 흔히 볼 수 있는 방식과 다르므로 주의해야 합니다.


1. Neovim 설치

Homebrew를 이용합니다.

brew install neovim

확인합니다.

nvim --version

설정 디렉터리를 만듭니다.

mkdir -p ~/.config/nvim/lua/config
mkdir -p ~/.config/nvim/lua/plugins

최종 설정 구조는 다음처럼 가져갑니다.

~/.config/nvim/
├── init.lua
└── lua/
    ├── config/
    │   ├── options.lua
    │   └── keymaps.lua
    └── plugins/
        ├── telescope.lua
        ├── treesitter.lua
        ├── lsp.lua
        ├── completion.lua
        ├── oil.lua
        ├── git.lua
        ├── format.lua
        ├── which-key.lua
        └── statusline.lua

플러그인별 설정을 파일로 나누는 이유는 나중에 특정 기능을 수정하거나 제거하기 쉽기 때문입니다.


2. 기본 init.lua

~/.config/nvim/init.lua

require("config.options")
require("config.keymaps")

local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"

if not vim.loop.fs_stat(lazypath) then
  vim.fn.system({
    "git",
    "clone",
    "--filter=blob:none",
    "https://github.com/folke/lazy.nvim.git",
    "--branch=stable",
    lazypath,
  })
end

vim.opt.rtp:prepend(lazypath)

require("lazy").setup("plugins")

lazy.nvim플러그인 관리자입니다. LazyVim과는 다릅니다.

lazy.nvim → Neovim Plugin Manager
LazyVim   → lazy.nvim을 기반으로 미리 만들어진 Neovim 배포판

이번에는 LazyVim을 사용하지 않고 직접 환경을 구성합니다.


3. 기본 옵션

~/.config/nvim/lua/config/options.lua

local opt = vim.opt

-- Line numbers
opt.number = true
opt.relativenumber = true

-- Indentation
opt.tabstop = 2
opt.shiftwidth = 2
opt.softtabstop = 2
opt.expandtab = true
opt.smartindent = true

-- Search
opt.ignorecase = true
opt.smartcase = true

-- UI
opt.cursorline = true
opt.termguicolors = true
opt.signcolumn = "yes"
opt.scrolloff = 8

-- Editing
opt.wrap = false
opt.swapfile = false
opt.backup = false
opt.undofile = true

-- macOS clipboard
opt.clipboard = "unnamedplus"

-- Split
opt.splitright = true
opt.splitbelow = true

4. 기본 키맵

~/.config/nvim/lua/config/keymaps.lua

vim.g.mapleader = " "
vim.g.maplocalleader = " "

local keymap = vim.keymap.set

keymap("n", "<Esc>", "<cmd>nohlsearch<CR>")

keymap("n", "<C-h>", "<C-w>h")
keymap("n", "<C-j>", "<C-w>j")
keymap("n", "<C-k>", "<C-w>k")
keymap("n", "<C-l>", "<C-w>l")

keymap("n", "<leader>w", "<cmd>w<CR>", { desc = "Save" })
keymap("n", "<leader>q", "<cmd>q<CR>", { desc = "Quit" })

keymap("v", "<", "<gv")
keymap("v", ">", ">gv")

keymap("t", "<C-h>", [[<C-\><C-n><C-w>h]])
keymap("t", "<C-j>", [[<C-\><C-n><C-w>j]])
keymap("t", "<C-k>", [[<C-\><C-n><C-w>k]])
keymap("t", "<C-l>", [[<C-\><C-n><C-w>l]])

Leader key는 Space입니다.


5. Telescope — 파일과 코드 검색

먼저 검색 도구를 설치합니다.

brew install ripgrep
brew install fd

~/.config/nvim/lua/plugins/telescope.lua

return {
  {
    "nvim-telescope/telescope.nvim",
    dependencies = {
      "nvim-lua/plenary.nvim",
    },
    config = function()
      local builtin = require("telescope.builtin")

      vim.keymap.set("n", "<leader>ff", builtin.find_files)
      vim.keymap.set("n", "<leader>fg", builtin.live_grep)
      vim.keymap.set("n", "<leader>fb", builtin.buffers)
      vim.keymap.set("n", "<leader>fh", builtin.help_tags)
    end,
  },
}

주요 키:

Space ff → 파일 검색
Space fg → 프로젝트 전체 텍스트 검색
Space fb → 열린 buffer 검색
Space fh → Neovim help 검색

주의: 오래된 Telescope 버전 고정

tag = "0.1.8"처럼 구버전을 고정했을 때 Neovim 0.12 환경에서 ft_to_lang 관련 오류가 발생했습니다. 특정 구버전 tag를 고정하지 않고 업데이트된 Telescope를 사용합니다.


6. Tree-sitter — 코드를 구조적으로 이해하기

Tree-sitter는 소스 코드를 syntax tree로 분석합니다.

Tree-sitter → 코드의 문법적 구조 이해
LSP         → 코드의 의미와 프로젝트 관계 이해

CLI를 설치합니다.

brew install tree-sitter-cli
tree-sitter --version

~/.config/nvim/lua/plugins/treesitter.lua

return {
  {
    "nvim-treesitter/nvim-treesitter",
    lazy = false,
    build = ":TSUpdate",

    config = function()
      local treesitter = require("nvim-treesitter")

      treesitter.setup()

      treesitter.install({
        "lua",
        "vim",
        "vimdoc",
        "bash",
        "python",
        "go",
        "javascript",
        "typescript",
        "json",
        "yaml",
        "markdown",
      })
    end,
  },
}

오래된 자료의 require("nvim-treesitter.configs").setup(...) 설정은 현재 main branch와 맞지 않을 수 있습니다.

ENOENT ... cmd: 'tree-sitter' 오류가 발생한다면 tree-sitter-cli 설치 여부를 확인합니다.


7. LSP — 코드의 의미 이해하기

~/.config/nvim/lua/plugins/lsp.lua

return {
  {
    "williamboman/mason.nvim",
    config = function()
      require("mason").setup()
    end,
  },

  {
    "williamboman/mason-lspconfig.nvim",
    dependencies = {
      "williamboman/mason.nvim",
      "neovim/nvim-lspconfig",
    },
  },

  {
    "neovim/nvim-lspconfig",
    dependencies = {
      "saghen/blink.cmp",
    },

    config = function()
      local capabilities = require("blink.cmp").get_lsp_capabilities()

      vim.diagnostic.config({
        virtual_text = true,
        signs = true,
        underline = true,
        update_in_insert = false,
        severity_sort = true,
      })

      local servers = {
        pyright = {},
        gopls = {},
        ts_ls = {},
      }

      for server, server_config in pairs(servers) do
        server_config.capabilities = capabilities
        vim.lsp.config(server, server_config)
        vim.lsp.enable(server)
      end

      vim.keymap.set("n", "gd", vim.lsp.buf.definition)
      vim.keymap.set("n", "K", vim.lsp.buf.hover)
      vim.keymap.set("n", "<leader>rn", vim.lsp.buf.rename)
      vim.keymap.set("n", "<leader>ca", vim.lsp.buf.code_action)

      vim.keymap.set("n", "]d", function()
        vim.diagnostic.jump({ count = 1 })
      end, { desc = "Next diagnostic" })

      vim.keymap.set("n", "[d", function()
        vim.diagnostic.jump({ count = -1 })
      end, { desc = "Previous diagnostic" })

      vim.keymap.set("n", "<leader>e", vim.diagnostic.open_float, {
        desc = "Show diagnostic",
      })

      vim.keymap.set("n", "<leader>dq", vim.diagnostic.setloclist, {
        desc = "Diagnostics list",
      })
    end,
  },
}

Neovim 0.12에서는 vim.lsp.config(...), vim.lsp.enable(...) 방식을 사용합니다.

LSP 서버를 설치합니다.

:MasonInstall pyright
:MasonInstall gopls
:MasonInstall typescript-language-server

상태 확인:

:checkhealth vim.lsp

8. blink.cmp — 자동완성

~/.config/nvim/lua/plugins/completion.lua

return {
  {
    "saghen/blink.cmp",
    version = "1.*",

    dependencies = {
      "rafamadriz/friendly-snippets",
    },

    opts = {
      keymap = {
        preset = "default",
      },

      completion = {
        documentation = {
          auto_show = true,
          auto_show_delay_ms = 500,
        },
      },

      sources = {
        default = {
          "lsp",
          "path",
          "snippets",
          "buffer",
        },
      },

      fuzzy = {
        implementation = "prefer_rust_with_warning",
      },
    },
  },
}

주요 키:

Ctrl-Space → completion 열기
Ctrl-n     → 다음 항목
Ctrl-p     → 이전 항목
Ctrl-y     → 선택
Ctrl-e     → 닫기

9. Oil.nvim — 파일 탐색

~/.config/nvim/lua/plugins/oil.lua

return {
  {
    "stevearc/oil.nvim",

    dependencies = {
      "nvim-tree/nvim-web-devicons",
    },

    config = function()
      require("oil").setup({
        default_file_explorer = true,
        view_options = {
          show_hidden = true,
        },
      })

      vim.keymap.set("n", "-", "<cmd>Oil<CR>", {
        desc = "Open parent directory",
      })
    end,
  },
}
-      → 상위 디렉터리
Enter  → 파일/디렉터리 열기

Telescope → 원하는 것을 바로 검색
Oil       → 주변 디렉터리 구조 탐색

10. Gitsigns — Git 변경사항

~/.config/nvim/lua/plugins/git.lua

return {
  {
    "lewis6991/gitsigns.nvim",

    config = function()
      require("gitsigns").setup({
        on_attach = function(bufnr)
          local gs = require("gitsigns")

          vim.keymap.set("n", "]c", function()
            gs.nav_hunk("next")
          end, { buffer = bufnr, desc = "Next Git hunk" })

          vim.keymap.set("n", "[c", function()
            gs.nav_hunk("prev")
          end, { buffer = bufnr, desc = "Previous Git hunk" })

          vim.keymap.set("n", "<leader>hp", gs.preview_hunk, {
            buffer = bufnr,
            desc = "Preview Git hunk",
          })

          vim.keymap.set("n", "<leader>hb", gs.blame_line, {
            buffer = bufnr,
            desc = "Git blame line",
          })

          vim.keymap.set("n", "<leader>hd", gs.diffthis, {
            buffer = bufnr,
            desc = "Git diff",
          })
        end,
      })
    end,
  },
}

11. Neovim Terminal

:terminal

또는:

:split | terminal

Terminal input mode에서 Normal mode로 돌아오기:

Ctrl-\ Ctrl-n

12. Claude Code 연결

Claude Code CLI를 Neovim terminal에서 실행하거나 Neovim integration을 사용할 수 있습니다.

실제 사용 중 Visual mode에서 선택 영역을 전달할 때 다음 오류가 발생했습니다.

[ClaudeCode] [server] [ERROR]
WebSocket server error:
Client read error: ECONNRESET

VS Code, Conductor 등 Claude Code integration을 사용하는 다른 IDE를 종료하자 정상 동작했습니다. WebSocket 관련 문제가 발생하면 다른 Claude Code IDE integration이 동시에 실행되고 있는지 확인합니다.


13. Diagnostics

예:

def add(a: int, b: int) -> int:
    return a + b

result = add("1", 2)

Pyright가 타입 오류를 표시합니다.

]d       → 다음 diagnostic
[d       → 이전 diagnostic
Space e  → 상세 메시지
Space dq → diagnostics 목록

14. Formatting — Conform.nvim

사용 formatter:

Python                  → Ruff
Go                      → gofmt
JavaScript/TypeScript   → Prettier
JSON/YAML/Markdown      → Prettier

설치:

brew install ruff
npm install -g prettier

확인:

go version
which gofmt
ruff --version
prettier --version

gofmt에는 --version 옵션이 없으므로 gofmt --version으로 확인하지 않습니다.

~/.config/nvim/lua/plugins/format.lua

return {
  {
    "stevearc/conform.nvim",
    lazy = false,

    opts = {
      formatters_by_ft = {
        python = { "ruff_format" },
        go = { "gofmt" },
        javascript = { "prettier" },
        typescript = { "prettier" },
        javascriptreact = { "prettier" },
        typescriptreact = { "prettier" },
        json = { "prettier" },
        yaml = { "prettier" },
        markdown = { "prettier" },
      },

      format_on_save = {
        timeout_ms = 1000,
        lsp_format = "fallback",
      },
    },

    keys = {
      {
        "<leader>f",
        function()
          require("conform").format({
            async = true,
            lsp_format = "fallback",
          })
        end,
        desc = "Format buffer",
      },
    },
  },
}

lazy = false가 중요합니다. 없을 때는 Space f 수동 포맷은 됐지만 첫 저장에서 자동 포맷이 되지 않았습니다.

:w       → 저장 + 자동 format
Space f  → 수동 format

15. which-key — 단축키 안내

~/.config/nvim/lua/plugins/which-key.lua

return {
  {
    "folke/which-key.nvim",
    event = "VeryLazy",

    opts = {
      delay = 300,
    },

    config = function(_, opts)
      local wk = require("which-key")

      wk.setup(opts)

      wk.add({
        { "<leader>a", group = "AI / Claude" },
        { "<leader>h", group = "Git Hunk" },
        { "<leader>d", group = "Diagnostics" },
        { "<leader>f", group = "Find / Format" },
      })
    end,
  },
}

Normal mode에서 Space를 누르고 잠시 기다리면 등록된 명령을 확인할 수 있습니다.


16. Nerd Font 설치

brew install --cask font-jetbrains-mono-nerd-font

iTerm2:

Settings
→ Profiles
→ Text
→ Font
→ JetBrainsMono Nerd Font Mono

JetBrainsMonoNL Nerd Font MonoNL은 No Ligatures 버전입니다.

Nerd Font 설정 전에는 which-key 아이콘이 ?로 표시되었지만 설정 후 정상 표시됐습니다.


17. lualine — Status line

~/.config/nvim/lua/plugins/statusline.lua

return {
  {
    "nvim-lualine/lualine.nvim",

    dependencies = {
      "nvim-tree/nvim-web-devicons",
    },

    opts = {
      options = {
        theme = "auto",
        globalstatus = true,
      },

      sections = {
        lualine_a = { "mode" },
        lualine_b = { "branch", "diff", "diagnostics" },
        lualine_c = { "filename" },
        lualine_x = { "filetype" },
        lualine_y = { "progress" },
        lualine_z = { "location" },
      },
    },
  },
}

화면 아래에서 mode, branch, diff, diagnostics, filename, filetype, progress, location을 확인할 수 있습니다.


18. 최종 키맵

Key기능
Spacewhich-key
Space w저장
Space q종료
Space ff파일 검색
Space fg문자열 검색
Space fbbuffer 검색
-Oil 파일 탐색
gdDefinition
KHover
Space rnRename
Space caCode Action
]d다음 diagnostic
[d이전 diagnostic
Space eDiagnostic 상세
Space dqDiagnostics 목록
]c다음 Git hunk
[c이전 Git hunk
Space hpGit hunk preview
Space hbGit blame
Space hdGit diff
Space fFormat
Ctrl-h/j/k/lWindow 이동

19. 새 Mac에서 다시 설치할 때

순서는 다음과 같습니다.

1. Homebrew
2. Neovim
3. ~/.config/nvim 구성
4. lazy.nvim
5. Telescope
6. ripgrep / fd
7. tree-sitter-cli
8. Tree-sitter
9. Mason / LSP
10. Pyright / gopls / typescript-language-server
11. blink.cmp
12. Oil
13. Gitsigns
14. Claude Code
15. Ruff / Prettier
16. Conform
17. which-key
18. Nerd Font
19. lualine

시스템 패키지:

brew install \
  neovim \
  ripgrep \
  fd \
  tree-sitter-cli \
  ruff
npm install -g prettier
brew install --cask font-jetbrains-mono-nerd-font

Neovim에서:

:MasonInstall pyright
:MasonInstall gopls
:MasonInstall typescript-language-server

20. 직접 겪은 문제와 해결 방법

No specs found for module "plugins"

plugins/ 디렉터리가 비어 있는 상태에서 require("lazy").setup("plugins")를 실행해서 발생했습니다. 플러그인 spec을 하나 이상 추가하면 해결됩니다.

Telescope ft_to_lang 오류

오래된 Telescope 0.1.8을 고정해서 발생했습니다. tag = "0.1.8"을 제거하고 Telescope를 업데이트해서 해결했습니다.

nvim-treesitter.configs를 찾지 못함

오래된 Tree-sitter 설정 API를 사용한 것이 원인이었습니다. 현재 main branch API에 맞춰 설정했습니다.

Tree-sitter ENOENT: tree-sitter

brew install tree-sitter-cli

로 해결했습니다.

<C-l>이 netrw에서 동작하지 않음

netrw가 buffer-local <C-l> mapping을 가지고 있었습니다.

:verbose nmap <C-l>

로 확인할 수 있습니다.

:LspInfo가 없음

Neovim 0.12에서는:

:checkhealth vim.lsp

로 확인했습니다.

gofmt --version 오류

설치 오류가 아닙니다. gofmt--version 옵션이 없습니다.

go version
which gofmt

로 확인합니다.

:ConformInfo가 없음

Conform이 lazy-loaded 상태여서 아직 로드되지 않은 것이 원인이었습니다.

저장할 때 자동 format이 안 됨

Space f는 동작했지만 :w에서는 포맷되지 않았습니다. lazy = false를 추가하여 해결했습니다.

which-key 아이콘이 ?로 표시됨

Nerd Font가 설정되지 않은 문제였습니다. JetBrainsMono Nerd Font Mono를 설치하고 iTerm2 font로 지정해서 해결했습니다.

Claude Code ECONNRESET

VS Code/Conductor 등 Claude Code integration을 사용하는 다른 IDE가 동시에 실행 중이었습니다. 다른 IDE를 종료하자 정상 동작했습니다.


21. 최종 개발 Workflow

프로젝트에서:

cd ~/ws/src/my-project
nvim .

일상적인 흐름:

Telescope / Oil
       ↓
코드 탐색
       ↓
Tree-sitter + LSP
       ↓
blink.cmp
       ↓
코드 작성
       ↓
Claude Code
       ↓
Diagnostics
       ↓
Conform
       ↓
Gitsigns
       ↓
Git commit

작업 방식은 다음처럼 나눌 수 있습니다.

하나의 이슈를 집중해서 순차적으로 개발
→ Neovim + Claude Code

여러 이슈/에이전트를 병렬로 개발
→ Conductor + Git worktree

현재 정도면 실제 개발에 필요한 핵심 기능은 충분합니다. 이후에는 플러그인을 계속 추가하기보다 실제 프로젝트에서 사용하면서 불편한 점이 생길 때 필요한 기능만 추가하는 것이 좋습니다.


마무리

각 구성 요소의 역할을 정리하면 다음과 같습니다.

Tree-sitter  → 문법 구조
LSP          → 코드 의미
blink.cmp    → 자동완성
Telescope    → 검색
Oil          → 파일 탐색
Gitsigns     → Git 변경사항
Conform      → Formatting
which-key    → 키맵 발견
lualine      → 상태 정보
Claude Code  → AI-assisted development

~/.config/nvim 자체를 Git 저장소로 관리해두는 것도 권장합니다. 그러면 다음 Mac에서는 dotfiles를 clone하고 Homebrew/Mason의 외부 의존성만 설치하면 거의 그대로 환경을 복원할 수 있습니다.