프론트엔드 개발자의 기록

TypeScript 실무에서 겪는 어려움과 해결책 본문

개발기록

TypeScript 실무에서 겪는 어려움과 해결책

think53 2025. 6. 27. 00:45

TypeScript를 도입하며 코드 리뷰를 하다 보면 반복적으로 마주치는 문제들이 있습니다. 오늘은 실무에서 자주 겪는 TypeScript의 어려움들과 그 해결책을 정리해보고자 합니다.


1. any 타입의 남용과 타입 안전성 포기

문제점

TypeScript를 처음 시작할 때 가장 흔한 실수는 어려운 타입 정의를 만나면 any로 도피하는 것입니다.

// ❌ 나쁜 예
function fetchUserData(): any {
  return fetch('/api/user').then(res => res.json());
}

const userData: any = await fetchUserData();
userData.name; // 타입 체크 없음
userData.unknownProperty; // 런타임 에러 가능성

해결책

구체적인 타입을 정의하거나, 최소한 unknown 타입을 사용하여 타입 안전성을 유지합니다.

// ✅ 좋은 예
interface User {
  id: number;
  name: string;
  email: string;
}

function fetchUserData(): Promise<User> {
  return fetch('/api/user').then(res => res.json());
}

// 또는 unknown 사용 후 타입 가드 적용
function processUnknownData(data: unknown) {
  if (typeof data === 'object' && data !== null && 'name' in data) {
    // 이제 data는 { name: unknown } & object 타입
    console.log((data as { name: string }).name);
  }
}

2. 타입 추론 과신과 명시적 타입 정의 부족

문제점

TypeScript의 타입 추론이 뛰어나다고 해서 모든 경우에 의존하면 예상치 못한 타입 문제가 발생합니다.

// ❌ 문제가 될 수 있는 예
const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3
}; // TypeScript가 추론하는 타입이 예상과 다를 수 있음

// 나중에 이런 문제 발생 가능
function updateConfig(newConfig: typeof config) {
  // config의 타입이 변경되면 이 함수도 영향받음
}

해결책

중요한 인터페이스나 설정 객체는 명시적으로 타입을 정의합니다.

// ✅ 좋은 예
interface AppConfig {
  apiUrl: string;
  timeout: number;
  retries: number;
}

const config: AppConfig = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3
};

function updateConfig(newConfig: Partial) {
  // 타입이 명확하고 안정적
}

3. 제네릭 사용의 어려움

문제점

제네릭을 처음 접하면 언제, 어떻게 사용해야 할지 막막합니다.

// ❌ 제네릭이 필요한데 사용하지 않은 예
function getFirstItem(arr: any[]): any {
  return arr[0];
}

const numbers = [1, 2, 3];
const first = getFirstItem(numbers); // any 타입 반환

해결책

재사용 가능한 함수나 컴포넌트에서는 제네릭을 적극 활용합니다.

// ✅ 제네릭을 활용한 개선
function getFirstItem<T>(arr: T[]): T | undefined {
  return arr[0];
}

const numbers = [1, 2, 3];
const first = getFirstItem(numbers); // number | undefined 타입

// 더 복잡한 예시
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
  const response = await fetch(url);
  return response.json();
}

// 사용할 때 타입이 자동으로 추론됨
const userResponse = await fetchData<User>('/api/users');
userResponse.data.name; // 타입 안전

4. Union 타입과 타입 가드 활용 미숙

문제점

Union 타입을 다룰 때 적절한 타입 가드를 사용하지 않아 타입 에러가 발생합니다.

// ❌ 문제가 있는 예
type Status = 'loading' | 'success' | 'error';

interface LoadingState {
  status: 'loading';
}

interface SuccessState {
  status: 'success';
  data: string;
}

interface ErrorState {
  status: 'error';
  error: string;
}

type AppState = LoadingState | SuccessState | ErrorState;

function handleState(state: AppState) {
  // ❌ 타입 에러: Property 'data' does not exist on type 'AppState'
  if (state.status === 'success') {
    console.log(state.data); // 여전히 타입 에러
  }
}

해결책

적절한 타입 가드와 discriminated union을 활용합니다.

// ✅ 개선된 예
function handleState(state: AppState) {
  switch (state.status) {
    case 'loading':
      // state는 LoadingState 타입으로 좁혀짐
      console.log('로딩 중...');
      break;
    case 'success':
      // state는 SuccessState 타입으로 좁혀짐
      console.log(state.data); // 타입 안전
      break;
    case 'error':
      // state는 ErrorState 타입으로 좁혀짐
      console.log(state.error); // 타입 안전
      break;
  }
}

// 커스텀 타입 가드 활용
function isSuccessState(state: AppState): state is SuccessState {
  return state.status === 'success';
}

if (isSuccessState(state)) {
  console.log(state.data); // 타입 안전
}

5. 라이브러리 타입 정의 문제

문제점

서드파티 라이브러리의 타입 정의가 없거나 부정확할 때 어떻게 대처해야 할지 모르겠습니다.

// ❌ 타입 정의가 없는 라이브러리 사용
import someLibrary from 'some-library'; // any 타입

someLibrary.someMethod(); // 타입 체크 없음

해결책

모듈 선언을 통해 직접 타입을 정의하거나, 필요한 부분만 타이핑합니다.

// ✅ 타입 정의 파일 생성 (types/some-library.d.ts)
declare module 'some-library' {
  interface LibraryOptions {
    timeout?: number;
    retries?: number;
  }
  
  interface LibraryResponse {
    success: boolean;
    data: any;
  }
  
  function someMethod(options?: LibraryOptions): Promise<LibraryResponse>;
  
  export = { someMethod };
}

// 또는 필요한 부분만 타이핑
declare module 'some-library' {
  const someLibrary: {
    someMethod: (options?: { timeout?: number }) => Promise<any>;
  };
  export default someLibrary;
}

6. tsconfig.json 설정의 이해 부족

문제점

TypeScript 컴파일러 옵션을 제대로 이해하지 못해 예상치 못한 동작이 발생합니다.

// ❌ 너무 관대한 설정
{
  "compilerOptions": {
    "target": "es5",
    "strict": false,
    "noImplicitAny": false,
    "noImplicitReturns": false
  }
}

해결책

엄격한 타입 체크 옵션을 활성화하고, 프로젝트에 맞는 설정을 점진적으로 적용합니다.

// ✅ 권장 설정
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM"],
    "module": "ESNext",
    "moduleResolution": "node",
    "strict": true,
    "noImplicitAny": true,
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

7. 런타임과 컴파일 타임의 혼동

문제점

TypeScript의 타입은 컴파일 타임에만 존재한다는 것을 이해하지 못해 런타임 에러가 발생합니다.

// ❌ 런타임에서 타입 체크를 시도
interface User {
  name: string;
  age: number;
}

function processUser(data: unknown) {
  // ❌ 런타임에서 User 타입은 존재하지 않음
  if (data instanceof User) { // 에러!
    console.log(data.name);
  }
}

해결책

런타임 타입 체크를 위한 별도의 로직을 구현합니다.

// ✅ 런타임 타입 가드 구현
function isUser(data: unknown): data is User {
  return (
    typeof data === 'object' &&
    data !== null &&
    typeof (data as User).name === 'string' &&
    typeof (data as User).age === 'number'
  );
}

function processUser(data: unknown) {
  if (isUser(data)) {
    // 이제 data는 User 타입으로 좁혀짐
    console.log(data.name);
  }
}

// 또는 라이브러리 활용 (zod, yup 등)
import { z } from 'zod';

const UserSchema = z.object({
  name: z.string(),
  age: z.number()
});

type User = z.infer<typeof UserSchema>;

function processUser(data: unknown) {
  const result = UserSchema.safeParse(data);
  if (result.success) {
    console.log(result.data.name); // 타입 안전
  }
}

8. 복잡한 타입 정의와 가독성 문제

문제점

너무 복잡한 타입 정의로 인해 코드 가독성이 떨어지고 유지보수가 어려워집니다.

// ❌ 가독성이 떨어지는 복잡한 타입
type ComplexType<T extends Record<string, any>, K extends keyof T> = {
  [P in K]: T[P] extends infer U 
    ? U extends (...args: any[]) => any 
      ? ReturnType<U> 
      : U extends object 
        ? ComplexType<U, keyof U> 
        : U
    : never;
};

해결책

타입을 단계별로 분해하고, 의미 있는 이름을 부여합니다.

// ✅ 가독성을 높인 타입 정의
type FunctionReturnType<T> = T extends (...args: any[]) => any 
  ? ReturnType<T> 
  : T;

type ProcessObjectProperty<T> = T extends object 
  ? ProcessedObject<T> 
  : T;

type ProcessedValue<T> = FunctionReturnType<ProcessObjectProperty<T>>;

type ProcessedObject<T extends Record<string, any>> = {
  [K in keyof T]: ProcessedValue<T[K]>;
};

// 또는 utility 타입 활용
type UserFields = Pick<User, 'name' | 'email'>;
type PartialUser = Partial<User>;
type RequiredUser = Required<User>;

9. 에러 처리와 타입 안전성

문제점

에러 처리에서 타입 안전성을 고려하지 않아 런타임 에러가 발생합니다.

// ❌ 에러 타입을 고려하지 않은 처리
async function fetchData() {
  try {
    const response = await fetch('/api/data');
    return response.json();
  } catch (error) {
    // error는 unknown 타입
    console.log(error.message); // 타입 에러 가능성
    throw error;
  }
}

해결책

에러 타입을 명시적으로 처리하고, Result 패턴 등을 활용합니다.

// ✅ 타입 안전한 에러 처리
class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message);
    this.name = 'ApiError';
  }
}

type Result<T, E = Error> = 
  | { success: true; data: T }
  | { success: false; error: E };

async function fetchData(): Promise<Result<any, ApiError>> {
  try {
    const response = await fetch('/api/data');
    if (!response.ok) {
      return {
        success: false,
        error: new ApiError(response.status, 'API 요청 실패')
      };
    }
    const data = await response.json();
    return { success: true, data };
  } catch (error) {
    return {
      success: false,
      error: new ApiError(0, '네트워크 오류')
    };
  }
}

// 사용
const result = await fetchData();
if (result.success) {
  console.log(result.data); // 타입 안전
} else {
  console.log(result.error.status); // 타입 안전
}

10. 점진적 마이그레이션 전략 부족

문제점

기존 JavaScript 프로젝트를 TypeScript로 마이그레이션할 때 체계적인 전략 없이 진행하여 혼란이 발생합니다.

해결책

단계별 마이그레이션 전략을 수립합니다.

// 1단계: allowJs 옵션으로 시작
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "strict": false
  }
}

// 2단계: 핵심 모듈부터 TypeScript로 변환
// utils.ts
export function formatDate(date: Date): string {
  return date.toISOString().split('T')[0];
}

// 3단계: 점진적으로 strict 옵션 활성화
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": true,
    "strict": true,
    "noImplicitAny": false // 점진적 적용
  }
}

// 4단계: 전체 프로젝트 TypeScript 완전 적용

마무리

TypeScript는 강력한 도구이지만, 제대로 활용하려면 시간과 경험이 필요합니다. 위에서 언급한 문제들은 대부분의 개발자가 한 번씩은 겪는 과정이므로, 너무 좌절하지 마시고 점진적으로 개선해나가시기 바랍니다.

특히 중요한 것은:

  1. any 타입 남용을 피하고 구체적인 타입 정의하기
  2. 타입 가드와 discriminated union 활용하기
  3. 런타임과 컴파일 타임의 차이점 이해하기
  4. 점진적이고 체계적인 도입 전략 수립하기

TypeScript를 마스터하는 것은 하루아침에 되는 일이 아니지만, 꾸준한 학습과 실습을 통해 분명히 개발 생산성과 코드 품질을 크게 향상시킬 수 있습니다.


"타입 안전성은 선택이 아닌 필수입니다. 처음엔 번거로워도, 나중에는 반드시 도움이 됩니다."