Published on

TS 5.5 noImplicitOverride 에러 해결법

Authors

서브클래스에서 부모 클래스 메서드를 재정의했는데 갑자기 빌드가 깨지고, This member must have an 'override' modifier 같은 메시지가 쏟아지면 대부분 noImplicitOverride가 켜진 상태입니다. TS 5.5로 올리면서 tsconfig가 강화되거나, 모노레포/공유 설정에서 옵션이 바뀌면서 기존 코드가 한꺼번에 에러로 변하는 일이 흔합니다.

이 글은 noImplicitOverride가 무엇을 강제하는지, 왜 필요한지, 그리고 실제 코드베이스에서 최소 리스크로 고치는 순서를 예제와 함께 설명합니다.

noImplicitOverride가 하는 일

noImplicitOverride는 클래스 상속에서 “부모의 멤버를 재정의(override)하는 의도”를 명시적으로 표현하도록 강제합니다.

  • 자식 클래스가 부모 클래스의 메서드/접근자/프로퍼티를 같은 이름으로 선언하면, 반드시 override 키워드를 붙여야 합니다.
  • 의도치 않은 “동명이인 멤버”로 인한 버그를 컴파일 타임에 차단합니다.

특히 대규모 코드에서 다음 같은 사고를 줄입니다.

  • 부모 메서드 시그니처가 바뀌었는데 자식이 조용히 다른 동작을 하게 되는 문제
  • 오타로 인해 새로운 메서드를 만든 줄 알았는데 실제로는 부모 메서드를 가려버린 문제
  • 리팩터링 중 상속 계층에서 동작이 바뀌었는데 리뷰에서 놓치는 문제

대표 에러 메시지와 의미

프로젝트에서 자주 보는 형태는 다음입니다.

  • This member must have an 'override' modifier because it overrides a member in the base class.

    • 부모에 같은 이름의 멤버가 있는데 override가 빠짐
  • This member cannot have an 'override' modifier because it is not declared in the base class.

    • override를 붙였지만 부모에 해당 멤버가 없음(오타, 접근 제한, 또는 베이스 타입이 다름)

이 두 메시지를 기준으로 해결 방향이 완전히 갈립니다.

재현 예제: override 누락

아래처럼 단순 상속 구조에서 자식이 부모 메서드를 재정의할 때 override를 빠뜨리면 에러가 납니다.

// tsconfig.json
// {
//   "compilerOptions": {
//     "noImplicitOverride": true
//   }
// }

class BaseService {
  fetch(id: string) {
    return `base:${id}`;
  }
}

class UserService extends BaseService {
  // TS 에러: override 누락
  fetch(id: string) {
    return `user:${id}`;
  }
}

해결은 의도대로 “재정의”하는 것이라면 override를 붙이는 것입니다.

class UserService extends BaseService {
  override fetch(id: string) {
    return `user:${id}`;
  }
}

해결 순서 1: 진짜 override인지부터 확인

가장 중요한 체크는 “이 멤버가 정말로 부모를 재정의해야 하는가?”입니다.

  • 의도적으로 부모 동작을 바꾸려는 코드라면 override 추가
  • 우연히 이름이 겹친 것이라면 이름을 바꾸거나 구조를 재설계

예를 들어, 아래 코드는 팀원이 fetchUser를 만들려다 실수로 fetch를 만들었을 수 있습니다.

class BaseService {
  fetch(id: string) {
    return `base:${id}`;
  }
}

class UserService extends BaseService {
  // 원래는 fetchUser를 의도했을 수도 있음
  fetch(userId: string) {
    return `user:${userId}`;
  }
}

override를 붙이면 “실수”가 “의도”로 굳어져 버리므로, 먼저 의도를 확인하는 것이 안전합니다.

해결 순서 2: override를 붙였는데도 에러가 나는 경우

override를 붙였더니 이번에는 다음 에러가 나는 패턴이 있습니다.

  • ... cannot have an 'override' modifier because it is not declared in the base class.

이때는 보통 아래 중 하나입니다.

1) 베이스 클래스가 아니라 인터페이스를 구현 중

implements는 상속이 아니라 “구현”입니다. 인터페이스 멤버에는 override 대상이 없습니다.

interface Cache {
  get(key: string): string | undefined;
}

class MemoryCache implements Cache {
  // override를 붙이면 에러 (베이스 클래스가 아님)
  get(key: string) {
    return undefined;
  }
}

이 경우는 override를 붙이지 않는 것이 맞습니다.

2) 베이스 클래스에 멤버가 private라서 상속 계층에서 보이지 않음

부모의 private 멤버는 자식이 재정의할 수 없습니다. 같은 이름을 쓰면 “재정의”가 아니라 “새 멤버”가 됩니다.

class Base {
  private log(msg: string) {
    console.log(msg);
  }
}

class Child extends Base {
  // override 불가: Base.log는 private
  log(msg: string) {
    console.log(`child:${msg}`);
  }
}

해결 방향은 보통 두 가지입니다.

  • 정말 상속에서 훅으로 쓰려면 protected로 바꾸기
  • 상속 훅이 아니라면 자식 멤버 이름 변경
class Base {
  protected log(msg: string) {
    console.log(msg);
  }
}

class Child extends Base {
  override log(msg: string) {
    console.log(`child:${msg}`);
  }
}

3) 베이스 타입이 실제로는 다른 타입(제네릭/믹스인/타입 좁히기)

특히 팩토리, 믹스인, 조건부 타입을 섞으면 “내가 생각한 베이스”와 “컴파일러가 보는 베이스”가 달라질 수 있습니다. 이 경우는 상속 선언부(extends ...)가 정확히 무엇을 가리키는지부터 확인해야 합니다.

자주 걸리는 포인트: 접근자(get/set)도 override 대상

메서드뿐 아니라 접근자도 override 대상입니다.

class Base {
  get name() {
    return "base";
  }
}

class Child extends Base {
  // override 필요
  override get name() {
    return "child";
  }
}

프로퍼티(필드)도 마찬가지입니다.

class Base {
  value = 1;
}

class Child extends Base {
  // override 필요
  override value = 2;
}

실무 대응 전략: 대규모 코드베이스에서 안전하게 고치기

1) 일괄 수정은 가능하지만, 먼저 “의도 확인” 구간을 만든다

대부분의 에러는 기계적으로 override를 붙이면 사라지지만, 그중 일부는 “원래 버그였던 겹침”일 수 있습니다.

권장 흐름은 다음입니다.

  1. CI에서 noImplicitOverride를 켠 브랜치를 만든다
  2. 에러 목록을 파일/모듈 단위로 나눈다
  3. 각 묶음에서
    • 명백한 재정의는 override 추가
    • 의심스러운 겹침은 이름 변경 또는 상속 구조 점검

2) ESLint/코드모드로 override 자동 부착

TypeScript ESLint에는 override 관련 규칙이 있고, 자동 수정이 가능한 경우가 많습니다.

예시 설정(프로젝트 상황에 맞게 조정):

// .eslintrc.cjs
module.exports = {
  parser: "@typescript-eslint/parser",
  plugins: ["@typescript-eslint"],
  rules: {
    "@typescript-eslint/explicit-member-accessibility": "off"
  }
};

다만 noImplicitOverride 자체는 tsc 옵션이므로, ESLint만으로 완벽히 대체하기보다는 tsc 에러를 기준으로 고치는 편이 정확합니다.

3) 점진 도입: 패키지별 tsconfig 분리

모노레포에서 한 번에 켜기 부담스럽다면 패키지별 tsconfig.json에서 먼저 켜고 점진적으로 확대할 수 있습니다.

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "noImplicitOverride": true
  }
}

TS 5.5 업그레이드와 함께 터지는 경우

TS 5.5로 올리면서 컴파일 옵션을 강화하는 흐름에서 noImplicitOverride와 함께 isolatedDeclarations 같은 옵션도 같이 켜지는 경우가 많습니다. 둘은 성격이 다르지만, “기존 코드가 더 엄격한 규칙을 만족해야 한다”는 점에서 동시에 이슈가 터집니다.

  • noImplicitOverride: 상속 관계에서 의도를 명시
  • isolatedDeclarations: 선언 파일 생성/분리 컴파일 친화성 강화

같이 대응해야 한다면, 먼저 상속 관련 에러를 정리해두면 이후 선언/빌드 단계에서 원인 파악이 쉬워집니다.

관련해서 TS 5.5의 선언 분리 컴파일 에러를 정리한 글도 함께 보면 좋습니다.

안티패턴: override를 피하려고 상속을 우회하는 것

가끔 에러를 피하려고 상속을 합성으로 바꾸거나, 부모 메서드를 호출하지 않고 새 메서드를 만드는 식으로 “규칙을 회피”하는 경우가 있습니다. 하지만 noImplicitOverride는 설계 개선을 유도하는 장치에 가깝습니다.

  • 상속을 유지할 거라면 override를 붙여 의도를 드러내는 편이 장기적으로 유지보수에 유리합니다.
  • 상속이 과한 구조라면, 이 기회에 합성으로 바꾸는 리팩터링을 검토할 수는 있습니다. 단, 그것은 에러 회피가 아니라 설계 변경이어야 합니다.

체크리스트: 가장 빠른 진단

아래 체크리스트대로 보면 대부분의 케이스는 5분 안에 분류됩니다.

  1. 이 멤버는 부모 멤버와 이름이 같은가
  2. 같은데 재정의 의도인가
    • 맞으면 override 추가
    • 아니면 이름 변경
  3. override를 붙였는데 “베이스에 없다”고 나오면
    • extends가 맞는지(implements 아님)
    • 부모 멤버가 private인지
    • 오타/시그니처 불일치인지
  4. 접근자/필드도 override 대상인지 확인

결론

TS 5.5에서 noImplicitOverride 에러는 귀찮지만, “상속에서의 의도”를 강제해 장기적으로 사고를 줄이는 장치입니다. 해결의 핵심은 단순히 override를 붙이는 것이 아니라, 정말로 재정의가 맞는지를 먼저 확인하는 것입니다.

  • 명백한 재정의: override 추가
  • 의도치 않은 이름 충돌: 이름 변경 또는 구조 개선
  • override가 안 먹는 케이스: implements, private, 베이스 타입 착각을 점검

이 과정을 한 번 정리해두면, 이후 상속 계층 리팩터링과 TS 업그레이드에서도 비슷한 문제를 훨씬 빠르게 처리할 수 있습니다.