메인 콘텐츠로 건너뛰기
Naite의 콜스택 추적 기능을 활용하여 테스트를 효과적으로 디버깅하는 방법을 알아봅니다.

콜스택 추적 개요

자동 수집

Naite.t() 호출 시콜스택 자동 추적

호출 경로

함수 호출 순서파일 위치 정보

디버깅 지원

문제 위치 파악원인 추적

Viewer 통합

VSCode에서 시각화클릭으로 이동

콜스택이란?

콜스택(Call Stack)은 프로그램 실행 중 함수 호출 순서를 추적하는 데이터 구조입니다. Naite는 Naite.t() 호출 시점의 콜스택을 자동으로 수집하여, 로그가 어디서 기록되었는지 정확히 파악할 수 있게 합니다.

왜 콜스택이 중요한가?

복잡한 애플리케이션에서는 하나의 작업이 여러 함수를 거쳐 실행됩니다. 문제가 발생했을 때, 어떤 경로로 함수가 호출되었는지 알면 디버깅이 훨씬 쉬워집니다.
function saveUser() {
  console.log("Saving user..."); // 어디서 호출되었는지 모름
}

// A 경로: test1 → createUser → saveUser
// B 경로: test2 → updateUser → saveUser
// C 경로: test3 → importUsers → saveUser

// 같은 로그가 세 경로에서 나오는데
// 어느 경로인지 파악하기 어려움

기본 구조

Naite가 수집하는 콜스택 정보는 다음과 같습니다:
interface StackFrame {
  functionName: string | null;  // "createUser"
  filePath: string;              // "/Users/.../user.model.ts"
  lineNumber: number;            // 123
}

interface NaiteTrace {
  key: string;
  data: any;
  stack: StackFrame[];  // 콜스택 배열
  at: Date;
}
async function createUser() {
  Naite.t("user:create", { username: "john" });
}

test("사용자 생성", async () => {
  await createUser();
});
수집되는 콜스택:
[
  {
    functionName: "createUser",
    filePath: "/Users/.../user.model.ts",
    lineNumber: 15
  },
  {
    functionName: "test",
    filePath: "/Users/.../user.model.test.ts",
    lineNumber: 42
  },
  {
    functionName: "runWithMockContext",
    filePath: "/Users/.../bootstrap.ts",
    lineNumber: 58
  }
]
의미:
  1. createUser (15번 줄): 실제로 Naite.t()를 호출한 위치
  2. test (42번 줄): 테스트 코드에서 createUser를 호출
  3. runWithMockContext: Sonamu의 Context 래퍼 (여기서 종료)

콜스택 수집 원리

extractCallStack() 동작

Naite는 JavaScript의 Error 객체를 이용하여 콜스택을 수집합니다.
function extractCallStack(): StackFrame[] {
  // 현재 시점의 콜스택 생성
  const stack = new Error().stack;
  if (!stack) return [];

  const lines = stack.split("\n");

  // 콜스택 구조:
  // [0]: "Error"
  // [1]: "at extractCallStack"
  // [2]: "at Naite.t"
  // [3]: 실제 호출 위치부터 시작
  const frames = lines
    .slice(3)  // 위 3개 제외
    .map(parseStackFrame)
    .filter((frame): frame is StackFrame => frame !== null);

  // runWithContext 발견 시 거기서 자르기
  const contextIndex = frames.findIndex(
    (f) =>
      f.functionName?.includes("runWithContext") ||
      f.functionName?.includes("runWithMockContext")
  );

  return contextIndex >= 0 
    ? frames.slice(0, contextIndex + 1) 
    : frames;
}
1

Error 객체 생성

new Error().stack으로 현재 콜스택을 문자열로 얻습니다.
2

불필요한 프레임 제거

Error, extractCallStack, Naite.t 프레임을 제외합니다 (slice(3)).
3

파싱

각 라인을 StackFrame 객체로 파싱합니다.
4

Context 경계에서 종료

runWithContext를 만나면 거기서 자릅니다. 그 이상은 Vitest 내부 코드로 의미가 없습니다.

parseStackFrame() 로직

두 가지 콜스택 형식을 파싱합니다:
at FunctionName (filePath:lineNumber:columnNumber)
const matchWithFunc = line.match(/at\s+(.+?)\s+\((.+?):(\d+):\d+\)/);
if (matchWithFunc) {
  const functionName = matchWithFunc[1];
  const filePath = matchWithFunc[2];
  const lineNumber = Number.parseInt(matchWithFunc[3], 10);

  return { functionName, filePath, lineNumber };
}
runWithContext 종료 이유: Naite는 의미 있는 콜스택만 유지합니다. runWithContext는 Sonamu의 Context 경계이며, 그 위는 Vitest의 내부 코드로 디버깅에 도움이 되지 않습니다.

실전 디버깅 시나리오

1. 호출 경로 추적

복잡한 함수 호출 체인에서 특정 경로만 추적합니다.
async function processOrder(orderId: number) {
  Naite.t("order:process:start", { orderId });
  
  await validateOrder(orderId);
  await chargePayment(orderId);
  await sendNotification(orderId);
  
  Naite.t("order:process:done", { orderId });
}

async function chargePayment(orderId: number) {
  Naite.t("payment:charge", { orderId });
  // 결제 처리
}

test("주문 처리", async () => {
  await processOrder(123);
  
  // payment:charge가 어디서 호출되었는지 확인
  const trace = Naite.get("payment:charge").getTraces()[0];
  
  // 콜스택 출력
  console.log("콜스택:");
  trace.stack.forEach((frame, i) => {
    console.log(`${i + 1}. ${frame.functionName} (${frame.filePath}:${frame.lineNumber})`);
  });
  
  // 출력:
  // 1. chargePayment (/Users/.../payment.ts:45)
  // 2. processOrder (/Users/.../order.ts:23)
  // 3. test (/Users/.../order.test.ts:15)
  // 4. runWithMockContext (/Users/.../bootstrap.ts:58)
});
VSCode에서 확인: Naite Viewer에서 로그를 클릭하면 콜스택이 표시되고, 각 프레임을 클릭하면 해당 코드 위치로 바로 이동합니다.

2. 에러 발생 지점 파악

에러가 발생했을 때 정확한 위치를 찾습니다.
async function createUser(data: UserCreateInput) {
  Naite.t("user:create:start", data);
  
  try {
    // 유효성 검사
    await validateUser(data);
    
    // DB 저장
    const user = await db.insert("users").values(data);
    Naite.t("user:create:success", { userId: user.id });
    
    return user;
  } catch (error) {
    // 에러 발생 시 콜스택과 함께 기록
    Naite.t("user:create:error", {
      error: error.message,
      data,
    });
    throw error;
  }
}

test("에러 추적", async () => {
  try {
    await createUser({ username: "" }); // 유효하지 않은 입력
  } catch (error) {
    // 에러 로그의 콜스택 확인
    const errorTrace = Naite.get("user:create:error").getTraces()[0];
    
    // 에러가 발생한 파일과 라인 확인
    expect(errorTrace.stack[0].filePath).toContain("user.model.ts");
    expect(errorTrace.stack[0].lineNumber).toBeGreaterThan(0);
    
    // VSCode Viewer에서 클릭하면 해당 위치로 이동 가능
  }
});
일반적인 console.logconsole.error는 에러 메시지만 보여줍니다. 하지만 Naite의 콜스택을 사용하면:
  1. 정확한 파일과 라인: 에러가 발생한 정확한 코드 위치
  2. 호출 경로: 어떤 함수들을 거쳐 에러가 발생했는지
  3. VSCode 통합: 클릭 한 번으로 코드 위치로 이동
  4. 컨텍스트: 에러 발생 시점의 데이터와 함께 저장
예를 들어, validateUser에서 에러가 발생했다면:
[
  { functionName: "validateUser", filePath: "...", lineNumber: 78 },
  { functionName: "createUser", filePath: "...", lineNumber: 45 },
  { functionName: "test", filePath: "...", lineNumber: 12 }
]
이를 통해 78번 줄의 validateUser에서 에러가 발생했고, 이것이 45번 줄의 createUser에서 호출되었고, 12번 줄의 테스트에서 시작되었음을 알 수 있습니다.

3. 복잡한 호출 체인 분석

A → B → C → D 순서로 호출되는 복잡한 체인을 분석합니다.
async function functionA() {
  Naite.t("flow:A", { step: "A" });
  await functionB();
}

async function functionB() {
  Naite.t("flow:B", { step: "B" });
  await functionC();
}

async function functionC() {
  Naite.t("flow:C", { step: "C" });
  await functionD();
}

async function functionD() {
  Naite.t("flow:D", { step: "D" });
}

test("호출 체인 분석", async () => {
  await functionA();
  
  // 각 로그의 콜스택 길이 확인
  const traceA = Naite.get("flow:A").getTraces()[0];
  const traceD = Naite.get("flow:D").getTraces()[0];
  
  console.log("A의 콜스택 길이:", traceA.stack.length);  // 2 (A → test)
  console.log("D의 콜스택 길이:", traceD.stack.length);  // 5 (D → C → B → A → test)
  
  // D의 콜스택에 모든 함수가 포함되어 있는지 확인
  const functions = traceD.stack.map(f => f.functionName);
  expect(functions).toContain("functionD");
  expect(functions).toContain("functionC");
  expect(functions).toContain("functionB");
  expect(functions).toContain("functionA");
  
  // 순서도 확인 가능
  expect(functions[0]).toBe("functionD");  // 가장 안쪽
  expect(functions[3]).toBe("functionA");  // 가장 바깥쪽
});
시각화:

4. fromFunction() 활용

특정 함수에서 호출된 로그만 필터링합니다.
test("특정 함수에서 호출된 로그만", async () => {
  await processOrder(123);
  
  // chargePayment 함수에서 직접 호출된 로그만
  const paymentLogs = Naite.get("*")
    .fromFunction("chargePayment", { from: "direct" })
    .result();
  
  // chargePayment의 호출 체인에 있는 모든 로그
  const allPaymentLogs = Naite.get("*")
    .fromFunction("chargePayment", { from: "both" })
    .result();
  
  console.log("직접 호출:", paymentLogs.length);
  console.log("체인 전체:", allPaymentLogs.length);
});
콜스택의 첫 번째 프레임(stack[0])만 체크합니다.
fromFunction("chargePayment", { from: "direct" })

// 매칭되는 경우:
// [chargePayment, processOrder, test]
//  ^^^^^^^^^^^^^ 첫 번째 프레임에 있음

// 매칭 안 되는 경우:
// [sendEmail, chargePayment, processOrder, test]
//  ^^^^^^^^^ 첫 번째 프레임에 없음

VSCode Viewer 통합

Naite Viewer는 콜스택 정보를 시각적으로 표시하고, 클릭 한 번으로 코드 위치로 이동할 수 있게 합니다.

콜스택 시각화

Naite Viewer에서 각 로그를 클릭하면 다음과 같이 표시됩니다:

Viewer 화면 예시

user:create:start
{ username: "john", email: "[email protected]" }

📍 직접 호출 위치:
  /Users/.../user.model.ts:15
  ↑ 클릭하면 이 위치로 이동

📚 전체 콜스택:
  1. createUser (user.model.ts:15)
     ↑ 클릭하면 해당 위치로 이동
  2. processUser (user.service.ts:45)
  3. test (user.model.test.ts:42)
  4. runWithMockContext (bootstrap.ts:58)
인터랙션:
  • 각 프레임을 클릭하면 VSCode 에디터가 해당 파일의 정확한 라인으로 이동
  • 코드 컨텍스트를 즉시 확인 가능
  • 디버깅 시간 단축

실전 활용 예시

1

Viewer에서 로그 확인

테스트 실행 후 Naite Viewer에서 의심스러운 로그를 찾습니다.
2

콜스택 확인

로그를 클릭하여 콜스택을 확인합니다. 어떤 함수들을 거쳐 왔는지 파악합니다.
3

코드 위치로 이동

콜스택의 각 프레임을 클릭하여 실제 코드를 확인합니다.
4

문제 파악 및 수정

콜스택을 따라가며 문제의 원인을 찾고 수정합니다.
프로 팁: Viewer에서 여러 로그의 콜스택을 비교하면, 정상 경로와 비정상 경로의 차이를 빠르게 파악할 수 있습니다.

고급 패턴

1. 성능 병목 파악

콜스택과 시간 정보를 결합하여 성능 병목을 찾습니다.
async function slowOperation() {
  Naite.t("perf:start", { at: Date.now() });
  
  await step1(); // 느린 단계
  Naite.t("perf:step1", { at: Date.now() });
  
  await step2();
  Naite.t("perf:step2", { at: Date.now() });
  
  await step3();
  Naite.t("perf:step3", { at: Date.now() });
  
  Naite.t("perf:end", { at: Date.now() });
}

test("성능 분석", async () => {
  await slowOperation();
  
  const traces = Naite.get("perf:*").getTraces();
  
  // 각 단계별 소요 시간과 호출 위치 출력
  for (let i = 1; i < traces.length; i++) {
    const prev = traces[i - 1];
    const curr = traces[i];
    
    const duration = curr.at.getTime() - prev.at.getTime();
    
    console.log(`${prev.key}${curr.key}: ${duration}ms`);
    console.log(`  호출 위치: ${curr.stack[0].filePath}:${curr.stack[0].lineNumber}`);
    
    if (duration > 1000) {
      console.log(`  ⚠️ 병목 구간! (1초 초과)`);
    }
  }
});

2. Syncer 디버깅

Syncer의 복잡한 템플릿 생성 과정을 추적합니다.
test("Syncer 템플릿 생성 추적", async () => {
  await Sonamu.syncer.generateTemplate("model", {
    entityId: "User"
  });
  
  // renderTemplate이 어디서 어떻게 호출되었는지
  const renderLogs = Naite.get("syncer:*")
    .fromFunction("renderTemplate")
    .getTraces();
  
  for (const trace of renderLogs) {
    console.log(`\n${trace.key}:`);
    console.log(`  데이터:`, trace.data);
    console.log(`  콜스택:`);
    
    trace.stack.forEach((frame, i) => {
      console.log(`    ${i + 1}. ${frame.functionName} (${frame.filePath.split('/').pop()}:${frame.lineNumber})`);
    });
  }
  
  // 출력 예시:
  // syncer:renderTemplate:
  //   데이터: { template: "model", entityId: "User" }
  //   콜스택:
  //     1. renderTemplate (syncer.ts:145)
  //     2. generateTemplate (syncer.ts:98)
  //     3. generateAll (syncer.ts:45)
  //     4. test (syncer.test.ts:23)
});

3. 무한 루프 감지

재귀 함수의 깊이를 모니터링합니다.
async function recursiveFunction(depth: number) {
  Naite.t("recursive:call", { depth });
  
  if (depth > 100) {
    throw new Error("Too deep!");
  }
  
  if (depth < 10) {
    await recursiveFunction(depth + 1);
  }
}

test("재귀 깊이 확인", async () => {
  await recursiveFunction(0);
  
  const traces = Naite.get("recursive:call").getTraces();
  
  // 각 depth의 콜스택 길이 확인
  for (const trace of traces) {
    console.log(`Depth ${trace.data.depth}:`);
    console.log(`  콜스택 길이: ${trace.stack.length}`);
    
    // 콜스택에 recursiveFunction이 몇 개인지
    const recursiveCount = trace.stack.filter(
      f => f.functionName === "recursiveFunction"
    ).length;
    
    console.log(`  재귀 깊이: ${recursiveCount}`);
    
    if (recursiveCount > 50) {
      console.warn(`  ⚠️ 재귀가 너무 깊습니다!`);
    }
  }
});

4. 조건부 로깅

특정 조건에서만 상세 로깅을 활성화합니다.
async function processData(data: any[]) {
  const TRACE_SUSPICIOUS = process.env.TRACE_SUSPICIOUS === "true";
  
  for (let i = 0; i < data.length; i++) {
    const item = data[i];
    
    // 의심스러운 데이터만 추적
    if (TRACE_SUSPICIOUS && item.suspicious) {
      Naite.t("data:suspicious", {
        index: i,
        item,
      });
      
      // 콜스택으로 누가 이 데이터를 만들었는지 추적 가능
    }
  }
}

// 사용:
// TRACE_SUSPICIOUS=true pnpm test

콜스택 제한 사항

runWithContext에서 종료

Naite는 runWithContext 또는 runWithMockContext를 만나면 콜스택 수집을 종료합니다.
function extractCallStack(): StackFrame[] {
  // ...
  
  // runWithContext 계열 함수 발견 시 거기서 자르기
  const contextIndex = frames.findIndex(
    (f) =>
      f.functionName?.includes("runWithContext") ||
      f.functionName?.includes("runWithMockContext")
  );

  return contextIndex >= 0 
    ? frames.slice(0, contextIndex + 1) 
    : frames;
}
이유:
  • runWithContext는 Sonamu의 Context 경계
  • 그 위는 Vitest 내부 코드 (의미 없음)
  • 의미 있는 콜스택만 유지하여 가독성 향상

node:internal 경로

Node.js 내부 경로는 lineNumber를 0으로 설정합니다:
if (filePath.includes(":")) {
  return { functionName, filePath, lineNumber: 0 };
}
예시:
{
  functionName: "processTicksAndRejections",
  filePath: "node:internal/process/task_queues",
  lineNumber: 0
}
이러한 프레임은 Node.js 내부 동작이므로 디버깅에 큰 도움이 되지 않습니다.

익명 함수

화살표 함수나 익명 함수는 functionNamenull입니다:
// 익명 함수
const handler = async () => {
  Naite.t("handler:call", {});
};

// 콜스택:
// [
//   { functionName: null, filePath: "...", lineNumber: 15 }
// ]
디버깅을 쉽게 하려면 중요한 함수에는 명시적인 이름을 부여하세요:
// ✅ 좋은 방법
async function handleUser() { /* ... */ }

// ❌ 나쁜 방법
const handler = async () => { /* ... */ };

베스트 프랙티스

1

의미 있는 위치에 로깅

// ✅ 좋은 예: 중요한 분기점에
async function processUser(user: User) {
  Naite.t("user:process:start", { userId: user.id });
  
  if (user.isAdmin) {
    Naite.t("user:process:admin", { userId: user.id });
    await processAdmin(user);
  } else {
    Naite.t("user:process:regular", { userId: user.id });
    await processRegular(user);
  }
  
  Naite.t("user:process:done", { userId: user.id });
}
2

에러 상황에 로깅

// ✅ 좋은 예: 에러 발생 시 콜스택 기록
async function riskyOperation() {
  try {
    await dangerousCall();
  } catch (error) {
    Naite.t("error", {
      message: error.message,
      // 콜스택이 자동으로 수집되므로
      // 어디서 에러가 발생했는지 추적 가능
    });
    throw error;
  }
}
3

명시적 함수명 사용

// ✅ 좋은 예: 명시적 함수명
async function createUser() {
  Naite.t("user:create", {});
}

// ❌ 나쁜 예: 익명 함수
const create = async () => {
  Naite.t("user:create", {});
};
4

VSCode Viewer 활용

로컬 개발 시 Naite Viewer를 켜두고, 로그를 클릭하여 콜스택을 확인하세요. 코드 위치로 바로 이동할 수 있어 디버깅 속도가 크게 향상됩니다.

주의사항

콜스택 추적 사용 시 주의사항:
  1. 성능: 콜스택 수집은 비용이 있으므로 과도한 로깅을 피하세요.
  2. 깊이: 재귀가 깊으면 콜스택도 길어집니다. 무한 재귀를 주의하세요.
  3. 익명 함수: 익명 함수는 functionNamenull입니다. 중요한 함수는 명시적 이름을 부여하세요.
  4. 압축 코드: 프로덕션 빌드에서는 함수명이 난독화될 수 있습니다. 하지만 Naite는 테스트 전용이므로 문제없습니다.
  5. 테스트 전용: 프로덕션 코드에는 사용하지 않습니다 (NODE_ENV === "test"에서만 동작).

다음 단계