aster®

ESLint로 계층 간 import 규칙 검사하기

2026-07 · 도구

서로 다른 색의 두 블록 사이에 세로선 하나가 놓여 있다. 왼쪽 블록에서 출발한 화살표가 그 세로선에 닿는 지점에서 잘려 멈춰 있고, 오른쪽 블록에는 닿지 않았다.

프로젝트 안내 문서에 이런 줄이 있었다. "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 를 설정에 넣는 방법이 있다. 다만 정당한 예외까지 같이 막히니 여기까지 갈지는 팀에서 정할 일이다.

버전 지원과 검사 시간의 미확인 사항

7. 문서와 린트 설정에 남길 내용

eslint.config.js에 규칙을 정의하면 위반한 코드와 함께 오류 메시지를 보여줄 수 있다.

문서에는 왜 그렇게 나눴는지를 적고, 무엇이 금지인지는 no-restricted-imports 의 message 에 적는다. 금지 목록 자체는 eslint.config.js 한 곳에만 두었다. 문서에 같은 목록을 옮겨 적으면 경계를 바꿀 때마다 두 곳을 손봐야 한다.