왜 나는 이제 any를 쓰지 않기로 했는가

typescriptanytype-safetyeslintunknowngenerics

개요

이 글은 타입스크립트의 any 타입이 갖는 위험성을 실제 장애 사례와 ESLint 규칙을 통해 분석하고, 이를 대체할 구조적인 해법을 탐구한다. unknown과의 비교, 제네릭 타입 지정 등 실전 코드 예시를 통해, 실무에서 타입 안정성과 생산성을 함께 지키는 전략을 다룬다.

1. 타입스크립트와 any 사이의 딜레마

타입스크립트로 코드의 신뢰도를 쌓는 과정은 견고해 보인다. 하지만 복잡한 타입을 마주하거나 예상치 못한 에러가 발생하면, 개발자는 any라는 손쉬운 해결책의 유혹에 빠진다. any는 당장의 위기를 해결해주지만, 그 대가로 타입 시스템의 의미를 퇴색시키고 미래의 디버깅을 더욱 어렵게 만든다. 바로 이 지점이 타입스크립트의 장점을 스스로 포기하게 되는 'any의 딜레마'이다.

2. any는 무엇인가, 정말로 '아무거나' 허용하는가?

타입스크립트 공식 핸드북은 any를 다음과 같이 정의한다.

“값이 any 타입일 경우, 해당 값의 모든 속성에 접근할 수 있고, 함수처럼 호출할 수 있으며, 어떤 타입에도 할당할 수 있다. 구문적으로 유효한 모든 작업이 가능하다.”

let obj: any = { x: 0 };
 
obj.foo();           // 에러 없음
 
obj();               // 에러 없음
 
obj.bar = 100;       // 에러 없음
 
obj = "hello";       // 에러 없음
 
const n: number = obj; // 에러 없음

이는 타입스크립트 컴파일러에게 특정 코드 영역의 타입 검사를 비활성화하라고 지시하는 것과 같다. 편리함을 얻는 대가로 타입 안정성을 포기해야 하는 위험한 거래인 셈이다.

3. ESLint는 왜 any를 막으려 하는가?

@typescript-eslint/no-explicit-any 규칙은 any의 명시적 사용을 금지한다. 그 이유는 다음과 같다.

  • 타입 검사 무력화: 컴파일 타임에 오류를 감지하지 못한다.
  • 개발 도구 지원 불가: 자동완성 및 타입 추론 기능이 동작하지 않는다.
  • 코드 가독성 저하: 협업 시 데이터의 형태와 의도를 전달하기 어렵다.
  • 안전하지 않은 리팩터링: 리팩터링 과정에서 타입 시스템의 보호를 받을 수 없다.

하지만 실무에서는 // eslint-disable-next-line 주석 한 줄로 이 규칙을 쉽게 무시할 수 있다. 그 순간부터 타입 시스템은 부분적으로 무너지기 시작한다.

4. 현실에서 any를 사용하게 되는 순간들

현실적으로 다음과 같은 상황에서 any를 사용하고 싶은 유혹을 느끼게 된다.

  • 외부 라이브러리에 타입 정의가 제공되지 않는 경우
  • 서버 API의 응답 타입이 복잡하거나 동적으로 변하는 경우
  • 빠른 프로토타이핑 과정에서 타입 선언을 생략하고 싶은 경우
  • 복잡한 타입 추론 과정에서 발생하는 오류를 즉시 우회하고 싶은 경우
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = res as any;

5. any로 인한 실전 장애 사례 – 제네릭 컴포넌트의 타협

실제 프로젝트에서 사용하던 CommonGrid<T> 제네릭 컴포넌트에서 문제가 발생했다. rowData 속성은 제네릭 타입 T의 배열(T[])을 받도록 설계되었지만, any를 사용하여 타입 검사를 우회했다.

<CommonGrid
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  rowData={list as any[]}
/>

이로 인해 다음과 같은 문제가 발생했다.

  • 타입 추론이 불가능해져 자동완성 및 타입 가드 기능이 작동하지 않았다.
  • 컴포넌트 내부에서 row.key와 같이 존재하지 않는 속성에 접근하려다 런타임 에러가 발생했다.

필수 데이터가 누락된 채로 렌더링되었으나, 컴파일 단계에서는 아무런 오류도 발견되지 않았다.

6. any를 피하기 위한 구조적 해결책

any를 사용하는 대신, 다음과 같은 구조적 대안을 적용할 수 있다.

1. 제네릭 타입 명시적으로 선언

컴포넌트나 함수를 사용할 때 제네릭 타입을 직접 지정하여 타입 추론을 돕는다.

// 1. 데이터 타입 정의 (e.g., 상품 정보)
interface Product {
  id: number;
  name: string;
  price: number;
}
 
// 2. 제네릭을 받는 Grid 컴포넌트 정의
// 제네릭 T는 rowData 배열에 들어갈 데이터의 타입을 의미
interface GridProps<T> {
  rowData: T[];
}
 
// T는 최소한 'id'를 포함하는 객체여야 함을 명시
function CommonGrid<T extends { id: number }>({ rowData }: GridProps<T>) {
  return (
    <table>
      <tbody>
        {rowData.map((row) => (
          // 제네릭 T 덕분에 'row' 객체의 속성을 타입스크립트가 정확히 인지
          // 따라서 'row.id', 'row.name', 'row.price' 등에 안전하게 접근할 수 있다.
          <tr key={row.id}>
            <td>{row.id}</td>
            {/* <td>{row.name}</td> */}
            {/* <td>{row.price}</td> */}
          </tr>
        ))}
      </tbody>
    </table>
  );
}
 
// 3. 부모 컴포넌트에서의 사용 비교
function ProductListPage() {
  // 서버로부터 받아온 상품 목록 데이터라고 가정합니다.
  const productList: Product[] = [
    { id: 101, name: '노트북', price: 1500000 },
    { id: 102, name: '무선 마우스', price: 35000 },
  ];
 
  return (
    <div>
      <h3>Before: any로 타입을 무력화한 경우</h3>
      {/* 'as any[]'를 쓰는 순간, productList가 'Product[]'라는
        중요한 정보를 잃게 된다. CommonGrid 컴포넌트 내부에서는 
        'row'가 무슨 타입인지 전혀 알 수 없어, 없는 속성에 접근해도
        런타임에 가서야 문제가 발생한다.
      */}
      <CommonGrid rowData={productList as any[]} />
 
      <hr />
 
      <h3>After: 제네릭 타입을 명시하여 안전성을 확보한 경우</h3>
      {/* <Product> 라고 명시해주면, CommonGrid는
        'rowData'가 'Product[]' 타입임을 명확히 알게 된다.
        결과적으로 컴포넌트 내부에서 완벽한 타입 추론과 보호를 받게 된다.
      */}
      <CommonGrid<Product> rowData={productList} />
    </div>
  );
}

2. 반환 값에 명시적 타입 지정

API 응답처럼 타입 추론이 불안정한 데이터를 변수에 할당할 때, 타입 선언(Type Annotation)을 통해 타입을 명확하게 지정하는 것이 좋다. 이는 as 키워드를 사용하는 '타입 단언'보다 안전한 방식이라고 할 수 있다.

타입을 미리 선언하면, 할당하려는 데이터의 구조가 선언된 타입과 일치하지 않을 경우 컴파일 시점에 즉시 오류를 발견할 수 있다.

// 1. 데이터 타입 정의
interface Product {
  id: number;
  name: string;
  price: number;
}
 
// 2. API 응답 데이터라고 가정
const response = {
  data: {
    products: [
      { id: 101, name: '노트북', price: 1500000 },
      { id: 102, name: '무선 마우스', price: 35000 },
    ],
  },
};
 
// Before: 타입 선언이 없는 경우
// 만약 response.data.products가 undefined이면 'listBefore'는 'any[]'로 추론될 수 있고,
// 이후 코드에서 타입 안정성을 잃게 됩니다.
const listBefore = response?.data?.products ?? [];
 
// After: 변수에 타입을 명시적으로 선언한 경우
// 'productList'는 항상 'Product[]' 타입임이 보장됩니다.
// 만약 'response.data.products'의 구조가 'Product'와 다르면
// 할당하는 시점에서 바로 타입 에러가 발생하여 실수를 방지합니다.
const productList: Product[] = response?.data?.products ?? [];

3. unknown 타입과 타입 가드 활용

unknown은 any의 타입-세이프(type-safe) 버전이다. any처럼 모든 값을 할당할 수 있지만, 타입을 좁히는(narrowing) 과정을 거치지 않으면 값에 접근하거나 조작하는 것 자체가 불가능하다. 이 '강제성'이 바로 unknown이 any보다 월등히 안전한 이유이다.

function processValue(value: unknown) {
  // 에러 발생: 'value' is of type 'unknown'.
  // 타입을 확인하기 전에는 어떤 속성이나 메서드도 사용할 수 없다.
  // value.toUpperCase();
 
  // 해결: 타입 가드(Type Guard)를 통해 타입을 좁힌다.
  if (typeof value === 'string') {
    // 이 블록 안에서 'value'는 'string' 타입임이 보장된다.
    console.log(value.toUpperCase());
  } else if (typeof value === 'object' && value !== null && 'message' in value) {
    // 복잡한 객체에 대한 타입 검사도 가능하다.
    console.log((value as { message: string }).message);
  }
}
 
processValue("hello world"); // "HELLO WORLD"
processValue({ message: "This is an object" }); // "This is an object"
processValue(123); // 아무 일도 일어나지 않고 안전하게 넘어감

이처럼 unknown을 사용하면 개발자가 반드시 타입을 확인하고 처리하도록 유도하여, any 사용 시 발생할 수 있는 런타임 에러를 컴파일 시점에 예방할 수 있다.

4. @ts-expect-error 주석 사용

불가피하게 타입 오류를 무시해야 할 때, @ts-ignore보다 @ts-expect-error를 사용하는 것이 훨씬 안전하다.

@ts-ignore는 오류를 단순히 잊게 만들지만, @ts-expect-error는 해당 라인에 오류가 '반드시' 존재해야만 정상적으로 동작한다. 만약 나중에 코드가 수정되어 예상했던 오류가 사라지면, @ts-expect-error 자체가 새로운 오류가 되어 불필요한 예외 처리를 제거하도록 알려준다.

// 숫자를 문자로 바꾸는 함수
function stringifyNumber(value: number) {
  return String(value);
}
 
// --- 시나리오 1: 의도된 오류가 존재하는 경우 ---
// stringifyNumber는 number를 받지만, string을 전달해야 하는 특별한 이유가 있다고 가정합니다.
// @ts-expect-error 주석이 아래의 타입 에러를 정상적으로 무시합니다.
// @ts-expect-error: 레거시 API 호환성을 위해 일시적으로 허용
const result1 = stringifyNumber("123");
 
 
// --- 시나리오 2: 나중에 코드가 수정되어 오류가 사라진 경우 ---
// 만약 stringifyNumber 함수가 string 타입도 받도록 개선되었다면?
function stringifyNumberV2(value: number | string) {
  return String(value);
}
 
// 에러 발생: Unused @ts-expect-error directive.
// 기존 코드의 타입 에러가 사라졌으므로, @ts-expect-error 주석이 이제는
// 불필요하다는 새로운 에러가 발생합니다. 개발자는 이 주석을 안전하게 제거할 수 있습니다.
// @ts-expect-error: 레거시 API 호환성을 위해 일시적으로 허용
const result2 = stringifyNumberV2("123");

5. 스키마(Schema)로 타입 정의와 런타임 검증 한번에 해결하기

API 응답처럼 타입스크립트가 보장할 수 없는 외부 데이터는 any나 unknown을 사용할 수밖에 없다. 이때 zod와 같은 스키마 검증 라이브러리를 사용하면, 런타임에 데이터를 검증함과 동시에 해당 스키마로부터 타입스크립트 타입을 자동으로 추론해낼 수 있다.
상황에 따라 각 팀에 맞는 라이브러리를 개발하는 것도 방법일 것이다.

이는 타입을 두 번 정의할 필요가 없는, 매우 효율적이고 안전한 최신 방식이다.

import { z } from 'zod';
 
// 1. Zod 스키마로 데이터의 '규칙'을 정의.
//    (e.g., price는 양수여야 한다는 비즈니스 로직까지 포함 가능)
const ProductSchema = z.object({
  id: z.number(),
  name: z.string().min(1), // 이름은 최소 1글자 이상
  price: z.number().positive(), // 가격은 0보다 커야 함
});
 
// 2. 스키마로부터 TypeScript 타입을 자동으로 '추론'한다. (z.infer)
//    이제 'interface Product'를 수동으로 만들 필요가 없다!
type Product = z.infer<typeof ProductSchema>;
 
// 3. 제네릭을 활용해 공통 API 응답 스키마도 만들 수 있다.
const ApiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
  z.object({
    success: z.boolean(),
    data: dataSchema,
  });
 
// 4. 함수에서 데이터 검증 및 사용
function handleProductResponse(response: unknown) {
  // Product 데이터가 담긴 API 응답을 검증하기 위한 최종 스키마
  const ProductResponseSchema = ApiResponseSchema(ProductSchema);
 
  // 런타임에 데이터를 파싱하고 검증합니다.
  // 실패하면 에러를 던지고, 성공하면 완벽히 타입이 보장된 객체를 반환한다.
  const validated = ProductResponseSchema.parse(response);
 
  // 'validated.data'는 이제 'Product' 타입임이 100% 보장된다.
  // 자동완성도 완벽하게 동작한다.
  console.log(validated.data.name);
  console.log(validated.data.price);
}

이 패턴을 사용하면, 기존의 DTO(타입 정의)와 런타임 검증이라는 두 가지 목표를 하나의 스키마로 우아하게 달성하여 코드의 안정성과 유지보수성을 극대화할 수 있다.

7. 결론: 타입은 시간을 절약하는 도구다

any는 당장의 문제를 빠르게 해결하는 것처럼 보이지만, 그 대가는 결국 디버깅, QA, 리팩터링 과정에서 더 큰 비용으로 돌아온다. 개발은 혼자 하는 것이 아니라 협업하는 과정이다. 그 안에서 타입은 살아있는 사양서이자, 문서이며, 자동화된 테스트이고, 동료와의 신뢰다.