새 프로젝트를 세팅할 때 "린트도 같이 걸어주세요"라는 요청을 받는 일이 많다. 그런데 막상 물어보면 린트가 컴파일 에러를 잡는 건지, 코드 정렬을 해주는 건지 헷갈려 하는 경우가 적지 않다. 빌드 도구 설치 글을 여러 번 쓰다 보니 이 주제도 한 번은 정리해 두는 게 맞겠다 싶어 이 글을 쓴다.
린트(lint)는 소스 코드를 실행하지 않고 읽어서 오류 가능성이 있는 코드, 관례에 어긋난 스타일, 위험한 패턴을 찾아 경고하는 정적 분석 도구다. 컴파일러가 통과시키는 "문법상 합법이지만 의심스러운" 코드를 잡는 것이 목적이고, 코드 모양만 기계적으로 통일하는 포매터와는 역할이 다르다. 실무에서는 에디터, 커밋 훅, CI 세 곳에 나눠 거는 방식이 표준에 가깝다.
린터가 잡는 것은 이런 종류다. if (i != NaN) 처럼 문법은 맞지만 항상 참이 되는 비교, 선언만 하고 쓰지 않는 import os, 따옴표를 빼먹어 파일명에 공백이 들어가면 깨지는 rm -rf $TARGET/*. 컴파일이나 실행은 되지만 언젠가 사고가 나는 코드다. 아래에서는 린트의 유래, 잡아주는 범주, 포매터와의 차이, 언어별 대표 도구, ESLint와 Ruff와 ShellCheck 실행 예제, 실무 적용 위치, 도입할 때 자주 겪는 실수를 차례로 살펴본다.
1. 정의와 유래
린터(linter)는 코드를 실행하지 않고 텍스트와 구문 트리를 분석해 문제 가능성을 보고하는 프로그램이다. "린트한다(linting)"는 그 도구를 돌리는 행위를 가리킨다. 테스트가 코드를 실제로 실행해 결과를 검증한다면, 린터는 실행 없이 코드 자체를 읽는다는 점이 다르다.
이름은 유닉스 시절의 도구에서 왔다. 1978년 벨 연구소의 Stephen C. Johnson이 C 프로그램 검사기로 lint를 만들었고, 1979년 Unix V7을 통해 벨 연구소 밖으로 배포됐다. 당시 C 컴파일러는 타입 검사가 느슨해서 32비트 기기로 유닉스를 이식할 때 이식성 문제가 그대로 통과됐다. 컴파일러를 무겁게 만드는 대신 검사기를 따로 두자는 발상이었다.
이름의 유래는 세탁 건조기의 보풀 거름망이다. 천은 그대로 두고 떨어져 나온 보풀만 걸러낸다는 비유다. 지금도 린터의 성격을 잘 설명하는 이름이다. 프로그램의 동작은 건드리지 않고, 붙어 있는 지저분한 것만 지적한다.
2. 린터가 잡아주는 것
도구마다 규칙 이름은 다르지만 잡는 대상은 대체로 다섯 갈래로 나뉜다.
2.1 버그 가능성
선언만 하고 쓰지 않는 변수, 도달할 수 없는 코드, 항상 참이거나 항상 거짓인 조건, JavaScript의 ==와 === 혼용, 닫지 않은 파일이나 커넥션, 널 역참조 가능성 같은 것이다. 린터의 존재 이유에 가장 가까운 범주다.
2.2 스타일과 명명 관례
들여쓰기, 세미콜론, 변수 명명 규칙, import 정렬 순서 등이다. 이 범주는 요즘 포매터로 넘어가는 추세다(3절 참고).
2.3 모범 사례
eval 사용 금지, 예외를 삼키는 빈 catch 금지, 함수 순환 복잡도 상한 같은 팀 규약 성격의 규칙이다. 리뷰에서 매번 같은 지적을 반복하지 않으려고 규칙으로 굳히는 경우가 많다.
2.4 보안 냄새
소스에 하드코딩된 토큰이나 비밀번호, 사용자 입력을 문자열로 이어 붙여 셸이나 SQL에 넘기는 패턴 등이다. 전용 보안 스캐너만큼 깊지는 않지만 가장 싼 값에 1차로 걸러준다.
2.5 이식성과 호환
대상 런타임이 지원하지 않는 API 사용, 특정 셸에만 있는 확장 문법 사용 등이다. Python의 target-version, ESLint의 languageOptions.ecmaVersion처럼 대상 버전을 설정에 적어두면 검사 범위가 정해진다.
린터는 "확실한 오류"가 아니라 "가능성"을 경고한다. 따라서 오탐(false positive)이 나오는 것이 정상이고, 규칙을 끄거나 해당 줄에 예외 주석을 다는 것도 정상적인 운영 방식이다. 예외 주석은 도구별로 # noqa: F841(Ruff), // eslint-disable-next-line(ESLint), # shellcheck disable=SC2086(ShellCheck) 형태를 쓴다.
3. 린터와 포매터의 차이
포매터(Prettier, Black, gofmt, rustfmt)는 코드의 의미를 바꾸지 않고 모양만 통일한다. 줄바꿈, 들여쓰기, 따옴표 종류를 정해진 규칙대로 다시 출력할 뿐 판단하지 않는다. 그래서 결과가 항상 같고, 사람이 검토할 여지도 거의 없다.
린터는 의미와 논리까지 본다. "이 변수는 쓰이지 않는다", "이 비교는 항상 거짓이다" 같은 판단을 하고 경고를 낸다. 자동 수정은 안전하다고 판단되는 일부 규칙에서만 제공한다(eslint --fix, ruff check --fix).
요즘 관행은 역할을 나누는 쪽이다. 스타일은 포매터에 맡기고 린터는 버그와 관례 규칙에 집중한다. ESLint는 포매팅 규칙을 비권장으로 돌렸고, Ruff는 린터와 포매터를 한 바이너리에 함께 제공한다. 둘을 따로 쓸 때는 규칙 충돌을 막는 설정을 반드시 넣어야 한다. ESLint와 Prettier 조합이라면 eslint-config-prettier가 그 역할을 한다. 이걸 빼먹으면 저장할 때마다 포매터가 고친 것을 린터가 다시 지적하는 상황이 생긴다.
4. 언어와 대상별 대표 린터
- JavaScript, TypeScript: ESLint(설정 파일은
eslint.config.js형식의 flat config, TypeScript 지원은 typescript-eslint). 대안으로 Biome, oxlint. - Python: Ruff(린터와 포매터 겸용, Rust로 작성), Pylint, Flake8. 타입 검사는 mypy, pyright이며 이들은 린터와 구분한다.
- Java: Checkstyle(스타일), PMD(코드 냄새), SpotBugs(바이트코드 버그 패턴), Error Prone(컴파일 시점 검사).
- Go:
go vet(표준 도구), staticcheck, 여러 린터를 묶어 돌리는 golangci-lint. - Rust: clippy. C, C++: clang-tidy, cppcheck.
- 셸 스크립트: ShellCheck. Dockerfile: hadolint. YAML: yamllint.
- 인프라: 쿠버네티스 매니페스트는 kube-linter와 kubeconform(스키마 검증), Terraform은 tflint와
terraform validate. - 그 밖: SQL은 SQLFluff, Markdown은 markdownlint, 커밋 메시지는 commitlint.
여기서는 성격이 다른 셋을 골라 실제로 돌려본다. 대표적인 플러그인형 린터(ESLint), 린터와 포매터를 겸하는 도구(Ruff), 규칙이 고정된 단일 목적 도구(ShellCheck)다.
5. ESLint 최소 설정과 실행
예시 환경은 Node.js 22 LTS와 ESLint 10 계열로 고정한다. ESLint v10은 2026년 2월에 나왔고 Node.js ^20.19.0, ^22.13.0, >=24를 요구한다. v9.x는 2026-08-06에 지원이 끝났으므로 새로 붙인다면 v10으로 시작한다. v10에서는 구형 .eslintrc.* 설정이 완전히 제거됐고 eslint.config.*만 인식한다.
설치는 공식 초기화 명령을 쓴다. 질문 몇 개에 답하면 설정 파일과 의존성이 만들어진다.
$ npm init @eslint/config@latest
직접 쓴다면 eslint.config.js는 이 정도가 최소 형태다. @eslint/js의 recommended 규칙을 적용하고 필요한 규칙만 덧붙인다.
// eslint.config.js
import js from "@eslint/js";
import { defineConfig } from "eslint/config";
export default defineConfig([
{
files: ["**/*.js"],
plugins: { js },
extends: ["js/recommended"],
rules: {
"no-unused-vars": "warn",
},
},
]);
설정 파일 이름은 eslint.config.js, .mjs, .cjs를 쓸 수 있고 프로젝트 루트에 둔다. .ts 확장자도 가능하지만 추가 설정이 필요하다. 실행은 다음과 같다.
$ npx eslint .
기본 출력 형식은 stylish다. 공식 문서의 예제 파일 fullOfProblems.js를 검사하면 이런 모양으로 나온다.
/home/user/app/fullOfProblems.js 1:10 error 'addOne' is defined but never used no-unused-vars 2:9 error Use the isNaN function to compare with NaN use-isnan 3:16 error Unexpected space before unary operator '++' space-unary-ops 3:16 error The value assigned to 'i' is not used in subsequent statements no-useless-assignment 3:20 warning Missing semicolon semi 4:12 warning Unnecessary 'else' after 'return' no-else-return 5:1 warning Expected indentation of 8 spaces but found 6 indent 5:7 error Function 'addOne' expected a return value consistent-return 5:13 warning Missing semicolon semi ✖ 9 problems (5 errors, 4 warnings) 1 error and 4 warnings potentially fixable with the `--fix` option.
맨 오른쪽이 규칙 이름이다. 규칙 이름을 그대로 검색하면 공식 문서의 해당 규칙 페이지가 나오므로, 경고를 볼 때는 메시지보다 규칙 이름을 먼저 본다. 자동 수정 가능한 항목은 npx eslint . --fix로 처리한다.
여기서 한 번 걸리는 부분이 경고의 종료 코드다. warning만 있으면 ESLint는 0으로 끝나서 CI가 초록불이 된다. 경고도 실패로 다루려면 --max-warnings를 명시해야 한다. 기본값은 -1(제한 없음)이다.
$ npx eslint . --max-warnings 0
6. Ruff 최소 설정과 실행
Python 쪽 예시는 Python 3.12와 Ruff 0.16 계열로 고정한다. Ruff는 린터와 포매터를 한 바이너리에서 제공하므로 Flake8과 isort와 Black을 각각 붙이던 조합을 하나로 줄일 수 있다. 설정은 pyproject.toml, ruff.toml, .ruff.toml 중 아무 데나 쓰면 된다. 단 ruff.toml 계열에서는 [tool.ruff] 접두사를 빼고 [lint], [format]처럼 바로 쓰므로 아래 pyproject 블록을 그대로 복사하면 파싱 에러가 난다.
[tool.ruff] line-length = 88 target-version = "py312" [tool.ruff.lint] select = ["E", "F", "UP", "B", "SIM", "I"] ignore = ["E501"] [tool.ruff.format] quote-style = "double"
select의 접두사가 규칙 묶음이다. E는 pycodestyle, F는 Pyflakes, UP은 pyupgrade, B는 flake8-bugbear, SIM은 flake8-simplify, I는 isort다. 처음이라면 ["E", "F"]만 켜고 시작해도 충분하다. 포매터를 함께 쓰면서 E501(줄 길이)을 켜두면 서로 부딪히기 쉬우므로 위 예제처럼 빼는 편을 권한다.
실행과 출력은 이렇다. 공식 튜토리얼 예제 파일에서 쓰지 않는 import os를 잡은 경우다.
$ ruff check src/numbers/calculate.py:3:8: F401 [*] `os` imported but unused Found 1 error. [*] 1 fixable with the `--fix` option.
[*] 표시가 자동 수정 가능한 항목이다. ruff check --fix로 고치고, 포매팅은 별도 명령으로 돌린다.
$ ruff check --fix $ ruff format 1 file reformatted
7. ShellCheck로 셸 스크립트 검사
셸 스크립트는 문법 오류 없이 실행되다가 운영에서 사고를 내는 대표적인 영역이다. ShellCheck는 설정 없이 바로 쓸 수 있다(필요하면 .shellcheckrc에서 규칙을 끄거나 기본 비활성인 선택 규칙을 켤 수 있다).
$ shellcheck deploy.sh
In deploy.sh line 4:
rm -rf $TARGET/*
^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
rm -rf "$TARGET"/*
For more information:
https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbin...
SC2086은 따옴표 없는 변수 확장이 단어 분리와 글로빙을 일으킨다는 경고다. $TARGET에 공백이 섞이면 rm -rf가 엉뚱한 경로를 지우게 된다. 경고 코드를 그대로 shellcheck.net/wiki/ 뒤에 붙이면 설명 문서로 연결되므로 규칙을 찾기 쉽다.
출력 형식은 -f로 바꾼다. 기본은 tty이고 gcc, checkstyle, json, json1, diff, quiet를 지원한다. CI에서 기존 스크립트를 한꺼번에 고칠 때는 diff 형식이 유용하다.
$ shellcheck -f diff deploy.sh | git apply
심각도는 -S로 조절한다. error, warning, info, style 순이고 기본값은 style이다. 오래된 스크립트가 많으면 -S warning부터 시작해 범위를 좁힌다.
8. 실무 적용 위치: 에디터, 커밋 훅, CI
린터는 한 곳에만 걸면 효과가 반감된다. 피드백이 빠를수록 고치는 비용이 싸기 때문에 세 단계에 나눠 건다.
8.1 에디터
가장 싸게 먹히는 단계다. VS Code 확장이나 LSP를 붙이면 타이핑 중에 밑줄이 그어지고 저장할 때 자동 수정까지 걸 수 있다. 여기서 잡히면 커밋도, CI도, 리뷰어도 거치지 않는다. 팀에 도입할 때는 권장 확장 목록을 .vscode/extensions.json에 넣어두면 새로 합류한 사람이 헤매지 않는다.
8.2 커밋 전 훅
에디터 설정은 개인마다 다르므로 커밋 시점에 한 번 더 막는다. Python 계열은 pre-commit 프레임워크를 쓴다. .pre-commit-config.yaml을 두고 훅을 설치한다.
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.4
hooks:
- id: ruff-check
args: [ --fix ]
- id: ruff-format
$ pre-commit install pre-commit installed at .git/hooks/pre-commit $ pre-commit run --all-files [INFO] Initializing environment for https://github.com/astral-sh/ruff-pre-commit. ruff-check...............................................................Passed ruff-format..............................................................Passed
훅을 새로 추가한 직후에는 --all-files로 한 번 전체를 돌려보는 것이 좋다. 평소 커밋에서는 변경된 파일만 검사하기 때문에, 전체를 돌리기 전까지는 기존 파일의 위반이 드러나지 않는다.
Node 계열은 husky와 lint-staged 조합이 흔하다. 스테이징된 파일만 검사하므로 커밋이 느려지지 않는다.
$ npm install --save-dev husky lint-staged $ npx husky init
npx husky init은 .husky/pre-commit 스크립트를 만들고 package.json의 prepare 스크립트를 갱신한다. 훅 파일 내용을 npx lint-staged 한 줄로 바꾸고, 검사 대상은 package.json에 적는다. 예전 버전에서 쓰던 . "$(dirname "$0")/_/husky.sh" 같은 줄은 지금 버전에서는 필요 없다.
{
"lint-staged": {
"*.js": "eslint --fix",
"*.md": "prettier --write"
}
}
8.3 CI
훅은 git commit --no-verify로 우회할 수 있으므로 최종 관문은 CI다. PR마다 돌리고, 경고도 실패로 처리한다.
name: lint
on: [pull_request]
jobs:
eslint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v5
with:
node-version: 22
- run: npm ci
- run: npx eslint . --max-warnings 0
여기서 한 가지 주의할 점이 있다. CI에서는 --fix를 쓰지 않는다. 검사만 하고 실패시켜야 한다. 고치는 것은 개발자 쪽에서 하고, CI가 코드를 고쳐 커밋하기 시작하면 리뷰 이력이 지저분해진다.
린트 결과를 SARIF 형식으로 내보내면 GitHub code scanning 화면에 경고를 표시할 수 있다. 여러 언어를 섞어 쓰는 저장소라면 도구별 로그를 뒤지는 대신 한 화면에서 모아 보는 편이 낫다.
9. 기존 코드베이스에 도입할 때
이미 수만 줄이 쌓인 프로젝트에 린터를 처음 붙이면 경고가 수천 개 나오는 것이 보통의 결과다. 여기서 전체를 한 번에 고치겠다고 덤비면 거대한 diff가 생기고 리뷰가 불가능해진다.
현장에서는 baseline을 잡고 시작한다. 기존 위반은 일단 무시 대상으로 기록해 두고, 새로 쓰는 코드에만 엄격한 규칙을 적용하는 방식이다. Ruff는 ruff check --add-noqa로 기존 위반 줄에 noqa 주석을 자동으로 달아준다. ESLint라면 규칙을 warn으로 두고 --max-warnings 상한을 현재 개수로 잡은 뒤 그 숫자를 조금씩 내리는 방법을 쓸 수 있다.
규칙 선택도 마찬가지다. 처음부터 전부 켜지 말고 버그 가능성 범주만 켜서 시작한다. 팀이 규칙에 익숙해진 뒤에 복잡도 상한이나 명명 규칙을 붙이는 순서가 안전하다.
10. 자주 겪는 실수
- 규칙을 전부 켜기: 경고가 수천 개 쌓이면 사람들이 경고 자체를 보지 않게 된다. 한 번 무시하기 시작하면 되돌리기 어렵다.
- 포매터와 린터 규칙 충돌: 저장할 때마다 코드가 왔다갔다 한다. 스타일 규칙은 포매터에 몰아주고 린터 쪽 포매팅 규칙은 끈다.
- 린트 통과를 버그 없음으로 오해: 린터는 실행 경로와 비즈니스 로직을 모른다. 테스트를 대체하지 못한다.
- CI에서만 돌리기: PR을 올리고 몇 분 뒤에야 세미콜론 지적을 받는다. 에디터 단계가 가장 싸다.
- 자동 수정을 검토 없이 커밋:
--fix가 항상 의미를 보존한다는 보장은 없다. 안전하지 않은 수정을 별도 옵션으로 구분하는 도구도 있으므로, 수정 후에는 diff를 확인하고 테스트를 돌린다.
11. 정리
린터는 실행하지 않고 코드를 읽어 오류 가능성과 관례 위반을 지적하는 도구이고, 1978년 C 컴파일러의 느슨한 검사를 보완하려던 유닉스 도구에서 출발했다. 모양만 통일하는 포매터와 역할을 나눠, 스타일은 포매터에 맡기고 린터는 버그와 관례 규칙에 집중시키는 구성이 현재의 관행이다.
새로 시작하는 프로젝트라면 언어별 표준 도구(JS/TS는 ESLint, Python은 Ruff, 셸은 ShellCheck) 하나를 골라 기본 규칙만 켜고, 에디터와 커밋 훅과 CI 세 단계에 걸어두는 것으로 충분하다. 기존 프로젝트라면 baseline부터 잡고 새 코드에만 엄격하게 적용한다.
버전 번호와 규칙 목록은 도구 업데이트에 따라 달라질 수 있으므로, 설정을 쓰기 전에 해당 버전 공식 문서를 확인한다.