API 에러 추적 개선하기: errorId 패턴으로 디버깅 시간 단축

"서버 오류가 발생했습니다" 메시지만으로는 어떤 에러인지 특정할 수 없었습니다. errorId 패턴으로 디버깅 시간을 90% 단축한 방법을 공유합니다.

1. 문제 상황

1.1 일반적인 에러 응답의 한계

// ❌ 일반적인 에러 응답
return NextResponse.json(
  { error: '서버 오류가 발생했습니다.' },
  { status: 500 }
);

문제:

1. 사용자가 "서버 오류" 메시지를 보고 문의함
2. 개발자가 로그를 확인해야 함
3. 같은 시간대에 여러 에러가 있으면 어떤 것인지 특정 불가
4. 문의 시점과 에러 발생 시점이 다르면 찾기 어려움

1.2 실제 시나리오

사용자: "데이터를 저장하려고 하니까 오류가 났어요"
개발자: "언제 발생했나요?"
사용자: "10분 전쯤이요"
개발자: (로그 확인) "10분 전에 에러가 5개 있는데..."

→ 어떤 에러가 이 사용자의 것인지 특정할 수 없음

1.3 원하는 상황

사용자: "에러 코드가 'a1b2c3d4'라고 나왔어요"
개발자: (로그 검색) "errorId: a1b2c3d4" → 정확한 에러 즉시 확인

→ 디버깅 시간 90% 단축

2. 해결 방법: errorId 패턴

2.1 핵심 아이디어

1. 에러 발생 시 고유 ID 생성
2. 서버 로그에 해당 ID와 함께 상세 정보 기록
3. 클라이언트에 간단한 메시지 + ID만 응답
4. 사용자 문의 시 ID로 정확한 로그 검색

2.2 기본 구현

// app/api/items/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    // 비즈니스 로직...

    return NextResponse.json({ success: true });
  } catch (error) {
    // 1. 고유 에러 ID 생성 (8자리 UUID)
    const errorId = crypto.randomUUID().slice(0, 8);

    // 2. 서버 로그에 상세 정보 기록
    console.error('Create item error:', {
      errorId,
      error: error instanceof Error ? error.message : String(error),
      stack: error instanceof Error ? error.stack : undefined,
      timestamp: new Date().toISOString(),
      // 추가 컨텍스트
      requestBody: body,
    });

    // 3. 클라이언트에 간단한 응답 (보안)
    return NextResponse.json(
      {
        error: '서버 오류가 발생했습니다.',
        errorId,  // 추적용 ID만 제공
      },
      { status: 500 }
    );
  }
}

2.3 로그 출력 예시

{
  "level": "error",
  "message": "Create item error:",
  "errorId": "a1b2c3d4",
  "error": "Unique constraint failed on the constraint: `Item_name_key`",
  "stack": "Error: Unique constraint...\n    at ...",
  "timestamp": "2026-01-11T12:34:56.789Z",
  "requestBody": { "name": "테스트", "type": "A" }
}

3. 전체 구현

3.1 에러 핸들러 유틸리티

// lib/core/error-handler.ts
import { NextResponse } from 'next/server';

interface ErrorContext {
  operation: string;
  [key: string]: unknown;
}

/**
 * API 에러 처리 유틸리티
 */
export function handleApiError(
  error: unknown,
  context: ErrorContext
): NextResponse {
  const errorId = crypto.randomUUID().slice(0, 8);

  // 구조화된 로그
  console.error(`[${context.operation}] Error:`, {
    errorId,
    message: error instanceof Error ? error.message : String(error),
    stack: error instanceof Error ? error.stack : undefined,
    timestamp: new Date().toISOString(),
    ...context,
  });

  // 클라이언트 응답
  return NextResponse.json(
    {
      error: '서버 오류가 발생했습니다.',
      errorId,
    },
    { status: 500 }
  );
}

3.2 API Route에서 사용

// app/api/items/route.ts
import { handleApiError } from '@/lib/core/error-handler';

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    // 비즈니스 로직...

    return NextResponse.json({ item });
  } catch (error) {
    return handleApiError(error, {
      operation: 'createItem',
      userId: session?.user?.id,
      requestBody: body,
    });
  }
}

export async function GET(request: NextRequest) {
  try {
    // 조회 로직...
    return NextResponse.json({ items });
  } catch (error) {
    return handleApiError(error, {
      operation: 'getItems',
      searchParams: Object.fromEntries(request.nextUrl.searchParams),
    });
  }
}

3.3 에러 유형별 응답

// lib/core/error-handler.ts

export function handleApiError(
  error: unknown,
  context: ErrorContext
): NextResponse {
  const errorId = crypto.randomUUID().slice(0, 8);

  // Prisma 에러 처리
  if (error instanceof Prisma.PrismaClientKnownRequestError) {
    // P2002: Unique constraint violation
    if (error.code === 'P2002') {
      console.warn(`[${context.operation}] Unique constraint:`, {
        errorId,
        code: error.code,
        meta: error.meta,
      });

      return NextResponse.json(
        { error: '이미 존재하는 데이터입니다.', errorId },
        { status: 409 }
      );
    }

    // P2025: Record not found
    if (error.code === 'P2025') {
      return NextResponse.json(
        { error: '데이터를 찾을 수 없습니다.', errorId },
        { status: 404 }
      );
    }
  }

  // 기본 500 에러
  console.error(`[${context.operation}] Error:`, {
    errorId,
    message: error instanceof Error ? error.message : String(error),
    stack: error instanceof Error ? error.stack : undefined,
    timestamp: new Date().toISOString(),
    ...context,
  });

  return NextResponse.json(
    { error: '서버 오류가 발생했습니다.', errorId },
    { status: 500 }
  );
}

4. 클라이언트 에러 표시

4.1 에러 컴포넌트

// components/shared/ui/ErrorMessage.tsx

interface ErrorMessageProps {
  message: string;
  errorId?: string;
}

export function ErrorMessage({ message, errorId }: ErrorMessageProps) {
  return (
    <div className="bg-red-50 border border-red-200 rounded-lg p-4">
      <p className="text-red-700">{message}</p>
      {errorId && (
        <p className="text-red-500 text-sm mt-2">
          오류 코드: <code className="font-mono">{errorId}</code>
          <br />
          <span className="text-gray-500">
            (문의 시 이 코드를 알려주세요)
          </span>
        </p>
      )}
    </div>
  );
}

4.2 API 호출에서 사용

async function createItem(data: ItemData) {
  const response = await fetch('/api/items', {
    method: 'POST',
    body: JSON.stringify(data),
  });

  const result = await response.json();

  if (!response.ok) {
    // errorId가 있으면 함께 표시
    throw new ApiError(result.error, result.errorId);
  }

  return result;
}

class ApiError extends Error {
  constructor(message: string, public errorId?: string) {
    super(message);
    this.name = 'ApiError';
  }
}

// React에서 사용
function ItemForm() {
  const [error, setError] = useState<ApiError | null>(null);

  const handleSubmit = async (data: ItemData) => {
    try {
      await createItem(data);
    } catch (err) {
      if (err instanceof ApiError) {
        setError(err);
      }
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && (
        <ErrorMessage message={error.message} errorId={error.errorId} />
      )}
      {/* ... */}
    </form>
  );
}

5. 로그 검색

5.1 로컬 개발

# Docker logs에서 검색
docker logs payroll-web 2>&1 | grep "a1b2c3d4"

# PM2 logs에서 검색
pm2 logs --lines 1000 | grep "a1b2c3d4"

5.2 클라우드 환경

# AWS CloudWatch
filter @message like /a1b2c3d4/

# Datadog
errorId:a1b2c3d4

# GCP Cloud Logging
jsonPayload.errorId="a1b2c3d4"

5.3 로그 구조화 (권장)

// 구조화된 JSON 로그 출력
console.error(JSON.stringify({
  level: 'error',
  operation: 'createItem',
  errorId: 'a1b2c3d4',
  message: 'Unique constraint failed',
  timestamp: '2026-01-11T12:34:56.789Z',
  context: {
    userId: 'user-123',
    requestBody: { name: '테스트' }
  }
}));

6. Before/After 비교

6.1 에러 응답

// ❌ Before: 에러 추적 불가
return NextResponse.json(
  { error: '서버 오류가 발생했습니다.' },
  { status: 500 }
);

// ✅ After: errorId로 추적 가능
return NextResponse.json(
  { error: '서버 오류가 발생했습니다.', errorId: 'a1b2c3d4' },
  { status: 500 }
);

6.2 디버깅 과정

❌ Before:
1. 사용자 문의 접수
2. 대략적인 시간대 파악
3. 해당 시간대 모든 에러 검토
4. 추측으로 원인 파악 시도
5. 디버깅 시간: 30분~1시간

✅ After:
1. 사용자가 errorId 제공
2. 로그에서 errorId 검색
3. 정확한 에러 컨텍스트 확인
4. 디버깅 시간: 1~5분

6.3 사용자 경험

❌ Before:
"서버 오류가 발생했습니다."
→ 사용자: "뭐가 잘못된 건지 모르겠네..."

✅ After:
"서버 오류가 발생했습니다.
오류 코드: a1b2c3d4
(문의 시 이 코드를 알려주세요)"
→ 사용자: 명확한 문의 가능

7. 핵심 개념 정리

7.1 errorId 생성 방법 비교

방법 예시 장점 단점
UUID 앞 8자리 a1b2c3d4 충돌 확률 매우 낮음 길이 고정
nanoid V1StGXR8 더 짧음, 커스텀 가능 의존성 추가
타임스탬프 기반 1704960896789 시간 정보 포함 길이 김

7.2 로그에 포함해야 할 정보

{
  // 필수
  errorId: string,
  message: string,
  timestamp: string,

  // 권장
  operation: string,      // API 작업명
  userId?: string,        // 요청한 사용자
  stack?: string,         // 스택 트레이스

  // 상황별
  requestBody?: object,   // 요청 데이터
  searchParams?: object,  // 쿼리 파라미터
  headers?: object,       // 관련 헤더
}

7.3 보안 고려사항

✅ 해도 되는 것:
- errorId를 클라이언트에 노출
- 일반적인 에러 메시지 표시

❌ 하면 안 되는 것:
- 스택 트레이스를 클라이언트에 노출
- 상세 에러 메시지 노출 (SQL 에러 등)
- 내부 구조 힌트 노출

8. 베스트 프랙티스

8.1 체크리스트

□ 모든 API Route에 에러 핸들러 적용
□ 500 에러에 errorId 포함
□ 서버 로그에 충분한 컨텍스트 기록
□ 클라이언트에 errorId 표시
□ 로그 검색 방법 문서화
□ 팀원에게 errorId 활용법 공유

8.2 에러 핸들러 일관성

// 모든 API에 동일한 패턴 적용
export async function POST(request: NextRequest) {
  try {
    // 비즈니스 로직
  } catch (error) {
    return handleApiError(error, {
      operation: 'operationName',
      // 항상 동일한 컨텍스트 구조
    });
  }
}

8.3 에러 모니터링 연동

// Sentry 등 에러 모니터링 서비스 연동
import * as Sentry from '@sentry/nextjs';

export function handleApiError(error: unknown, context: ErrorContext) {
  const errorId = crypto.randomUUID().slice(0, 8);

  // Sentry에 errorId 태그 추가
  Sentry.captureException(error, {
    tags: { errorId },
    extra: context,
  });

  // ... 나머지 로직
}

9. 참고 자료


10. 다음 단계

errorId 패턴으로 에러 추적을 개선했다면, Rate Limiting과 함께 API 보안을 강화해보세요.

시리즈 목차:

  1. Next.js API에 Rate Limiting 구현하기 (메모리 기반)
  2. API 에러 추적 개선하기: errorId 패턴으로 디버깅 시간 단축 ← 현재 글

11. FAQ (자주 묻는 질문)

Q: errorId가 충돌할 가능성은?

A: UUID의 앞 8자리(32비트)를 사용하므로 약 40억 개의 조합이 가능합니다. 일반적인 서비스에서는 충돌 가능성이 극히 낮습니다. 걱정된다면 타임스탬프를 추가하거나 더 긴 ID를 사용하세요.

Q: errorId를 DB에 저장해야 하나요?

A: 선택 사항입니다. 로그만으로 충분한 경우가 많지만, 에러 통계나 장기 추적이 필요하면 별도 테이블에 저장할 수 있습니다.

Q: 400 에러에도 errorId를 붙여야 하나요?

A: 권장하지 않습니다. 400 에러는 클라이언트 입력 오류이므로 명확한 메시지로 충분합니다. 500 에러(서버 오류)에 주로 사용하세요.

Q: 이미 Sentry를 사용 중인데 필요한가요?

A: Sentry와 함께 사용하면 더 좋습니다. Sentry의 이벤트 ID는 길고 복잡하지만, 짧은 errorId는 사용자가 전달하기 쉽습니다. 둘을 연결해서 사용하세요.