
프로젝트 안내 문서에 이런 줄이 있었다. "domain 폴더는 바깥을 모른다. 데이터베이스나 HTTP 를 직접 부르지 않는다." 읽을 때는 당연해 보이는 문장이다. 그런데 얼마 뒤 domain 안의 파일 맨 위에 import { db } from '../infrastructure/db' 가 들어와 있었다. 규칙을 문서에 적어 두는 것만으로는 코드를 작성하는 순간의 위반을 막지 못했다.
규칙을 위반한 import를 작성하면 바로 확인할 수 있도록 에디터와 CI에 검사를 추가하기로 했다.
이 검사는 ESLint 설정 파일에 정의했다. 폴더 사이의 import 를 규칙으로 옮겨서, 어기면 에디터에서 빨간 줄이 뜨고 CI 가 막게 만드는 데까지 간다.
1. 린트가 검사하는 것
먼저 여기부터 짚고 가자. 개발을 막 시작했으면 "린트를 돌려라" 는 말은 자주 듣는데 그 역할이 무엇인지는 잘 안 알려 준다.
린트는 코드를 실행하지 않고 읽기만 해서 문제를 찾는 도구다. 자바스크립트/타입스크립트에서는 ESLint 가 그 역할을 한다. 파일을 구문 트리로 바꿔 놓고, 규칙 하나하나가 그 트리를 훑으면서 "이 모양이 보이면 에러" 를 판정한다.
그래서 린트가 잡는 것과 못 잡는 것이 여기서 나뉜다. console.log 가 남아 있다, 안 쓰는 변수가 있다, any 를 썼다. 이런 건 코드 모양만 봐도 안다. 반대로 "계산 결과가 틀렸다" 는 못 잡는다. 그건 돌려 봐야 아는 거라 테스트의 몫이다.
여기서 중요한 건 import 문도 코드 모양이라는 점이다. import { db } from '../infrastructure/db' 는 실행하지 않아도 보인다. 어느 파일이 무엇을 불러오는지가 전부 코드에 적혀 있으니까, 폴더 사이의 규칙은 린트가 판정하기에 딱 맞는 종류다. 린트를 오탈자 잡는 도구쯤으로 보기 쉬운데, 실제 쓰임은 그보다 넓다.
2. Flat Config와 기존 설정 형식
검색하면 아직도 .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. 계층 간 금지 import 설정하기
이제 도입에서 봤던 그 한 줄로 돌아간다. 하고 싶은 건 하나다. 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. ESLint와 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. 에디터와 CI에서 경계 위반 확인하기
앞의 설정을 다 넣어도 결국 같은 규칙이다. 안내 문서에 적어 둔 것과 내용이 다르지 않다. 그럼 무엇이 달라지는지를 보자.
규칙을 어기는 데 드는 비용이 달라진다. 문서에 적힌 규칙은 어겨도 아무 일이 없다. 리뷰어가 알아채면 그때 지적을 받는데, 리뷰어도 사람이라 급하면 못 본다. 그것도 나쁜 뜻이 있어서가 아니라, 지금 당장 화면을 띄워야 하고 그 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 한 곳에만 두었다. 문서에 같은 목록을 옮겨 적으면 경계를 바꿀 때마다 두 곳을 손봐야 한다.