프로젝트 안내 문서에 이런 규칙이 있었다. “domain 폴더는 데이터베이스나 HTTP를 직접 호출하지 않는다.” 하지만 얼마 뒤 도메인 파일에 import { db } from '../infrastructure/db'가 들어왔다. 코드를 작성할 때마다 안내 문서를 다시 읽지는 않으니, 문서에 적어 두는 것만으로는 부족했다.
규칙을 어기는 순간 확인할 수 있도록 폴더 간 의존성 규칙을 ESLint 설정으로 옮겼다. 에디터에서 오류를 표시하고 CI에서 위반을 막는 방법, 린트가 확인할 수 있는 범위와 한계를 정리한다.
1. 린트의 역할
먼저 여기부터 짚고 가자. 개발을 막 시작했으면 “린트를 돌려라”는 말은 자주 듣는데 그 역할이 무엇인지는 잘 안 알려 준다.
린트는 코드를 실행하지 않고 읽기만 해서 문제를 찾는 도구다. 자바스크립트/타입스크립트에서는 ESLint가 그 역할을 한다. 파일을 구문 트리로 바꿔 놓고, 규칙 하나하나가 그 트리를 훑으면서 “이 모양이 보이면 에러”를 판정한다.
그래서 린트가 잡는 것과 못 잡는 것이 여기서 나뉜다. console.log가 남아 있다, 안 쓰는 변수가 있다, any를 썼다. 이런 건 코드 모양만 봐도 안다. 반대로 “계산 결과가 틀렸다”는 못 잡는다. 그건 돌려 봐야 아는 거라 테스트의 몫이다.
여기서 중요한 건 import 문도 코드 모양이라는 점이다. import { db } from '../infrastructure/db'는 실행하지 않아도 보인다. 어느 파일이 무엇을 불러오는지가 전부 코드에 적혀 있으니까, 폴더 사이의 규칙은 린트가 판정하기에 딱 맞는 종류다. 린트를 오탈자 잡는 도구쯤으로 보기 쉬운데, 실제 쓰임은 그보다 넓다.
2. eslint.config.js는 .eslintrc와 뭐가 다른가
검색하면 아직도 .eslintrc.json 예제가 많이 나온다. 그런데 ESLint 9부터는 eslint.config.js가 기본이다. 파일 이름만 바뀐 게 아니라 설정을 읽는 방식이 통째로 다르다. 이름만 바뀐 것으로 보고 예전 예제의 extends를 그대로 옮기면 “이건 flat config가 아니라 eslintrc 형식 같다”는 에러부터 만난다.
예전 방식은 폴더마다 .eslintrc를 두면 하위 폴더가 그걸 물려받고, 상위와 하위가 자동으로 합쳐지는 구조였다. 편하긴 한데 어떤 규칙이 어디서 왔는지 추적하기가 어렵다. extends: ["airbnb"]라고 문자열을 적으면 ESLint가 알아서 그 패키지를 찾아 오는데, 그 안에서 무엇이 켜졌는지는 안 보인다.
flat config는 그 상속을 없앴다. 설정 파일은 프로젝트 루트에 하나뿐이고, 내용은 배열이다.
// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default [
{ ignores: ["dist/**", "coverage/**"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.ts"],
rules: {
"no-console": "error",
},
},
];
읽는 법은 위에서 아래다. 배열의 각 원소가 “어떤 파일에(files) 어떤 규칙을(rules)”을 담고 있고, 한 파일에 여러 원소가 걸리면 뒤에 오는 것이 앞을 덮는다. 그래서 순서가 곧 우선순위다. 규칙을 하나 껐는데 여전히 에러가 뜬다면 대개 그 아래에서 다시 켜고 있는 것이다.
바뀐 것 중에 처음 헷갈리는 자리가 셋이다.
예전 (.eslintrc) |
지금 (flat config) |
|---|---|
extends: ["plugin:x/recommended"] — 문자열로 적으면 알아서 찾아옴 |
직접 import 해서 배열에 펼친다 (...tseslint.configs.recommended) |
plugins: ["import"] — 이름만 적음 |
plugins: { import: importPlugin } — 실제 모듈을 객체로 넣는다 |
env: { browser: true } |
languageOptions: { globals: globals.browser } — globals 패키지에서 가져온다 |
표 1. .eslintrc와 flat config에서 같은 설정을 적는 방식 비교
셋 다 방향이 같다. 어느 패키지에서 온 설정인지가 코드에 그대로 보이게 바꾼 것이다. 대신 import를 직접 써야 해서 첫 줄이 길어진다.
files를 안 적은 원소는 다른 설정이 검사 대상으로 잡은 파일 전부에 걸린다. ignores만 있고 다른 키가 없는 원소는 전역 무시 목록이 된다. 위 예제의 첫 줄이 그것이다.
한 가지 덧붙이면, eslint/config에서 defineConfig를 가져오면 flat config 안에서도 extends를 쓸 수 있다. 타입 추론이 붙어서 오타도 잡아 준다. 다만 이게 9.x의 정확히 몇 번대부터 들어왔는지는 확인하지 못했다. 쓰기 전에 릴리스 노트를 보는 편이 낫다.
import { defineConfig } from "eslint/config";
export default defineConfig([
// 배열 내용은 위와 같다
]);
3. domain이 infrastructure를 부르면 에러가 나게 하려면
이제 도입에서 봤던 그 한 줄로 돌아간다. 하고 싶은 건 하나다. src/domain 아래의 파일이 infrastructure를 import 하면 에러.
쓸 규칙은 ESLint 내장 규칙인 no-restricted-imports 다. 플러그인을 안 깔아도 된다.
{
files: ['src/domain/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@/infrastructure/*', '**/infrastructure/*'],
message:
'domain 은 infrastructure 를 모릅니다. 필요한 동작은 domain 에 인터페이스로 선언하고, 실제 구현은 바깥에서 넣어 주세요.',
},
],
},
],
},
}
옵션 세 개만 알면 된다.
patterns는 글롭으로 막을 대상을 적는 자리다. 문자열 배열로 줘도 되지만, 객체로 주면 message를 같이 붙일 수 있어서 이쪽이 낫다. 객체 안의 group이 실제 글롭 배열이고, message는 에러가 났을 때 그대로 출력된다.
message를 공들여 쓰는 것이 이 설정에서 제일 중요한 부분이다. 규칙에 걸린 사람이 보는 건 이 문장 하나뿐이다. “금지됨”만 적혀 있으면 그 사람은 결국 어떻게 해야 하는지를 다른 데서 찾아야 하고, 못 찾으면 규칙을 끄는 쪽으로 간다. 그래서 막는 이유가 아니라 대신 할 방법을 적는다.
group에 글롭을 두 개 적은 이유도 있다. no-restricted-imports는 import 문에 적힌 문자열만 본다. 파일 경로를 실제로 계산하지 않는다. 그래서 @/infrastructure/db와 ../../infrastructure/db는 이 규칙에게 완전히 다른 문자열이다. 별칭 경로만 막아 두면 상대 경로로 쓴 import는 그대로 통과한다.
타입스크립트를 쓰면 하나 더 걸린다. import type { Row } from '...' 같은 타입 전용 import도 똑같이 막힌다. 이게 맞을 때도 있고 아닐 때도 있는데, 허용하고 싶으면 @typescript-eslint/no-restricted-imports로 바꾸고 패턴 안에 allowTypeImports: true를 넣는다.
문자열이 아니라 실제 경로를 기준으로 막고 싶으면 eslint-plugin-import의 import/no-restricted-paths를 쓴다. zones에 target과 from을 폴더로 적는 방식이라 별칭이든 상대 경로든 같이 걸린다. 대신 플러그인과 경로 resolver 설정이 하나 더 붙는다.
4. 폴더마다 다른 규칙을 걸려면 어떻게 하나
files를 붙인 원소를 여러 개 늘어놓으면 된다. 레이어가 셋이면 셋을 적는다.
export default [
// 1) 공통
{ files: ["src/**/*.ts"], rules: { "no-console": "error" } },
// 2) domain — 바깥을 전부 모른다
{
files: ["src/domain/**/*.ts"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["@/infrastructure/*", "**/infrastructure/*"],
message: "...",
},
{
group: ["react", "react-dom", "next/*"],
message: "domain 에는 화면 코드가 들어오지 않습니다.",
},
],
},
],
},
},
// 3) 테스트 — 실제 구현을 불러야 하므로 푼다
{
files: ["**/*.test.ts"],
rules: { "no-restricted-imports": "off" },
},
];
3번이 2번보다 뒤에 있어야 한다는 게 핵심이다. 앞뒤를 바꾸면 테스트 파일에도 domain 규칙이 그대로 남는다. flat config에서 규칙이 예상대로 안 걸리면 열에 아홉은 순서 문제다.
여기서 한 가지 습관을 붙여 두면 좋다. 규칙을 새로 넣은 날, 일부러 어기는 줄을 하나 써 보고 진짜로 에러가 나는지 확인한다. 글롭 하나가 틀리면 규칙은 아무 소리 없이 통과시킨다. 실패하지 않는 규칙은 없는 규칙과 같은데, 설정 파일에 적혀 있으니 있다고 믿게 된다. 확인하고 그 줄은 지운다.
5. Prettier와는 어떻게 나눠 두는가
여기서 처음 설정을 잡는 사람이 제일 많이 얽히는 지점이 나온다. ESLint도 코드 모양에 대해 뭐라고 하고, Prettier도 코드 모양을 바꾼다. 둘 다 켜 두면 서로 다른 답을 내놓는다. 저장하면 Prettier가 작은따옴표로 바꿔 놓고, 린트는 큰따옴표를 쓰라고 에러를 낸다.
역할을 이렇게 나눠 놓으면 겹치지 않는다.
| 무엇을 맡나 | |
|---|---|
| Prettier | 서식 — 따옴표, 줄바꿈, 들여쓰기, 세미콜론 |
| ESLint | 그 외 전부 — 안 쓰는 변수, any, 훅 규칙, import 경계 |
표 2. Prettier와 ESLint의 역할 분담
나누는 방법은 간단하다. eslint-config-prettier를 설치하고 배열 맨 뒤에 넣는다.
import eslintConfigPrettier from "eslint-config-prettier";
export default [
js.configs.recommended,
...tseslint.configs.recommended,
// ... 내 규칙들
eslintConfigPrettier, // 항상 마지막
];
이 설정은 규칙을 켜지 않는다. 끄기만 한다. quotes, semi, indent처럼 Prettier와 답이 갈릴 규칙을 전부 off로 만드는 목록이다. 그래서 맨 뒤여야 한다. 앞에 두면 뒤에서 다시 켠 서식 규칙이 그대로 살아난다.
한 가지 더. Prettier를 ESLint 안에서 돌리는 방법도 있다(eslint-plugin-prettier). 예전 글에서 많이 보이는데 요즘은 권하지 않는 방향이다. 서식이 한 칸 어긋난 것까지 전부 린트 에러로 뜨니 진짜 봐야 할 에러가 목록에 묻히고, 린트가 느려진다. Prettier는 따로 돌린다. 에디터에서 저장할 때, 커밋 직전에, CI에서.
6. 왜 문서가 아니라 린트여야 하나
앞의 설정을 다 넣어도 결국 같은 규칙이다. 안내 문서에 적어 둔 것과 내용이 다르지 않다. 그럼 무엇이 달라지는지를 보자.
규칙을 어기는 데 드는 비용이 달라진다. 문서에 적힌 규칙은 어겨도 아무 일이 없다. 리뷰어가 알아채면 그때 지적을 받는데, 리뷰어도 사람이라 급하면 못 본다. 그것도 나쁜 뜻이 있어서가 아니라, 지금 당장 화면을 띄워야 하고 그 import 한 줄이면 되기 때문이다.
린트로 옮기면 어기는 쪽에 일이 생긴다. 에디터에 빨간 줄이 뜨고, 커밋 훅이 있으면 커밋이 안 되고, CI가 빨간불을 낸다. 그냥 지나치려면 eslint-disable을 적고 그 줄을 PR에 남겨야 한다. 어기는 게 불가능해지는 건 아니고, 어긴 흔적이 코드에 남는다. 그 차이가 크다.
두 번째로 달라지는 건 알려 주는 시점이다. 문서는 다 짜고 나서 리뷰에서 지적을 받는다. 그때는 이미 그 구조 위에 몇 개 더 올라가 있다. 린트는 그 줄을 쓰는 순간에 알려 준다. 되돌릴 게 한 줄뿐일 때 알려 주는 것이다.
새로 합류한 사람에게 특히 그렇다. 안내 문서를 읽어도 “이 프로젝트에서 domain이 정확히 어디까지를 뜻하는지”는 며칠 지나야 감이 온다. 그동안 린트가 대신 알려 준다. message를 잘 써 놓으면 그 자리에서 답까지 준다.
도입 비용과 한계
좋기만 한 선택은 아니다. 규칙을 늘리면 아래를 같이 받는다.
| 치르는 것 | 무슨 일이 일어나나 |
|---|---|
| 거짓 양성 | 정당한 import 인데 글롭에 걸린다. 테스트, 마이그레이션 스크립트, 임시 도구 코드에서 자주 나온다 |
eslint-disable의 유혹 |
주석 한 줄이면 통과한다. 그게 늘어나면 그 규칙을 근거로 삼을 수 없게 된다 |
| 설정이 유지보수 대상이 됨 | 폴더 이름을 하나 바꾸면 글롭도 같이 고쳐야 한다. 안 고치면 규칙이 조용히 아무것도 안 막는다 |
| 린트 시간 | 규칙과 대상 파일이 늘어난 만큼 늘어난다 |
표 3. import 경계 규칙을 늘렸을 때 같이 따라오는 비용
거짓 양성은 예외를 정직하게 만드는 쪽으로 푼다. 앞의 files: ['**/*.test.ts']처럼 설정에 한 번 적어 두면, 예외가 어디에 몇 개인지 설정 파일 하나만 보면 된다. 반대로 소스 파일마다 eslint-disable을 흩어 두면 세어 보기 전에는 개수를 알 방법이 없다.
eslint-disable을 아예 못 쓰게 할 수도 있다. --report-unused-disable-directives로 쓸모없어진 주석을 잡거나, linterOptions.reportUnusedDisableDirectives를 설정에 넣는 방법이 있다. 다만 정당한 예외까지 같이 막히니 여기까지 갈지는 팀에서 정할 일이다.
확인하지 못한 것
defineConfig와extends지원이 ESLint 9의 정확히 어느 마이너 버전부터인지 확인하지 못했다..eslintrc형식이 어느 메이저에서 완전히 없어지는지도 확인하지 못했다. 9에서는 환경변수로 아직 쓸 수 있다는 것까지만 확인했다.- 규칙을 늘렸을 때 린트 시간이 실제로 얼마나 늘어나는지는 재보지 않았다. 위 표에 “늘어난다” 고만 적은 건 그래서다.
7. 그래서 이런 규칙은 어디에 적어야 지켜지나
답은 어기는 순간에 읽히는 자리다. 안내 문서는 그 자리가 아니고, eslint.config.js는 그 자리가 될 수 있다.
문서에는 왜 그렇게 나눴는지를 적고, 무엇이 금지인지는 no-restricted-imports의 message에 적는다. 금지 목록 자체는 eslint.config.js 한 곳에만 두었다. 문서에 같은 목록을 옮겨 적으면 경계를 바꿀 때마다 두 곳을 손봐야 한다.

답글 남기기