뒤로가기
시니어 개발자처럼 디버깅하는 법: console.log를 넘어서

January 14, 2022

debuggingfrontend

입사 3개월 차에 결제 페이지가 터졌다.

특정 유저만 주문 내역이 안 보이는 버그였다. QA에서 올라온 티켓에는 "간헐적으로 주문 목록이 비어 있음"이라고만 적혀 있었다. 나는 코드를 열고, 눈으로 읽기 시작했다. API 호출하는 부분을 보고, 상태 관리 쪽을 보고, 컴포넌트 렌더링 로직을 봤다. 2시간을 썼다. console.log를 열다섯 개쯤 찍었다. 원인을 못 찾았다.

그때 팀의 시니어 개발자가 슬랙에 메시지를 보냈다.

"재현은 돼?"

나는 대답을 못 했다. 재현을 시도한 적이 없었다. 그냥 코드를 읽고 있었을 뿐이다.

그 사람은 내 자리로 와서 5분 만에 재현 조건을 찾았다. 신규 가입 유저가 첫 주문을 넣고 바로 주문 내역 페이지로 가면 발생한다. 거기서부터 컴포넌트를 하나씩 빼기 시작했다. 30분 만에 원인까지 도달했다. 나는 2시간 동안 코드를 읽었고, 그 사람은 30분 동안 코드를 줄였다.

그게 내가 "체계적 디버깅"이라는 걸 처음 배운 날이다. 나중에 Dan Abramov가 "How to Fix Any Bug"에서 거의 같은 방법론을 글로 정리한 걸 보고, 그때 배운 게 정확히 맞았다는 걸 확인했다.

1단계: 재현#

디버깅은 재현에서 시작한다. 재현할 수 없으면 고쳤는지도 확인할 수 없다.

"가끔 안 돼요"는 버그 리포트가 아니다. 재현이란 "이 순서대로 하면 100% 발생한다"는 조건을 찾는 것이다. 위의 결제 페이지 예시라면:

text
1. 신규 계정으로 가입
2. 상품 하나 주문
3. /orders 페이지로 이동
4. 주문 목록이 비어 있음
5. 새로고침하면 정상 표시

눈으로만 확인되는 버그는 코드로 측정 가능한 형태로 바꿔야 한다. "스크롤이 이상하다"는 주관적이지만, 이건 객관적이다:

tsx
const scrollBefore = container.scrollTop;
button.click();

setTimeout(() => {
  const scrollAfter = container.scrollTop;
  if (scrollAfter === scrollBefore) {
    console.error(`스크롤 안 됨: ${scrollBefore}${scrollAfter}`);
  }
}, 500);

나는 처음에 이 단계를 자주 건너뛰었다. "대충 여기가 문제인 거 아는데 뭘 재현까지 해." 근데 그 "대충"이 맞았던 적이 반도 안 됐다.

2단계: 격리#

재현에 성공하면, 이제 질문이 바뀐다. 원인을 찾는 게 아니라 원인이 아닌 걸 빼는 것이다.

실제로 내가 겪었던 주문 목록 버그의 코드를 단순화하면 이런 구조였다:

tsx
function OrderList() {
  const { data } = useQuery({ queryKey: ['orders'], queryFn: fetchOrders });
  const { user } = useAuth();

  return (
    <PageLayout>
      <UserHeader user={user} />
      <OrderTable items={data?.items ?? []} />
      <RecommendSection />
    </PageLayout>
  );
}

다 빼고 데이터만 찍어본다:

tsx
function OrderList() {
  const { data } = useQuery({ queryKey: ['orders'], queryFn: fetchOrders });
  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

데이터가 잘 나온다. API 문제는 아니다.

그러면 OrderTable을 넣어본다:

tsx
function OrderList() {
  const { data } = useQuery({ queryKey: ['orders'], queryFn: fetchOrders });
  return <OrderTable items={data?.items ?? []} />;
}

빈 테이블. OrderTable이 문제다.

한 번에 하나만 바꿔야 한다. 이걸 지키는 게 생각보다 어렵다. 버그를 보고 있으면 "아 이거 분명 비동기 타이밍 문제일 거야" 같은 추측이 자꾸 떠오른다. 추측을 따라가면 코드를 이리저리 고치게 되고, 5분 전에 뭘 하고 있었는지 까먹는다. 한 번 그렇게 삽질해보면 "한 번에 하나만"이 왜 중요한지 몸으로 안다.

3단계: 축소#

OrderTable이 범인이라는 건 알았는데, 이 컴포넌트가 150줄이다. 여기서부터는 이진 탐색이다.

tsx
function OrderTable({ items }: { items: Order[] }) {
  const filtered = useFilteredOrders(items);
  const sorted = useSortedOrders(filtered);

  return (
    <table>
      <thead>...</thead>
      <tbody>
        {sorted.map(order => (
          <OrderRow key={order.id} order={order} />
        ))}
      </tbody>
    </table>
  );
}

훅을 다 빼고 items를 직접 렌더링하면?

tsx
function OrderTable({ items }: { items: Order[] }) {
  return (
    <div>
      {items.map(order => <div key={order.id}>{order.name}</div>)}
    </div>
  );
}

정상이다. 그러면 useFilteredOrders만 넣어보면?

tsx
function OrderTable({ items }: { items: Order[] }) {
  const filtered = useFilteredOrders(items);
  return (
    <div>
      {filtered.map(order => <div key={order.id}>{order.name}</div>)}
    </div>
  );
}

빈 화면. 여기다.

절반 자르고 테스트. 또 절반 자르고 테스트. 150줄도 7번이면 한 줄까지 좁힐 수 있다. 이 과정에 추측은 필요 없다. 기계적으로 반복하면 된다.

처음 이 방법을 배웠을 때는 좀 허탈했다. 시니어의 비밀 병기가 고작 이진 탐색이라니. 근데 실전에서 진짜 어려운 건 방법을 모르는 게 아니라, 추측하고 싶은 유혹을 참는 거다.

4단계: 원인#

useFilteredOrders까지 좁혔다. 안을 보자.

tsx
function useFilteredOrders(orders: Order[]) {
  const [status] = useQueryState('status');

  return orders.filter(order => {
    if (!status) return true;
    return order.status === status;
  });
}

이 코드 자체는 문제가 없다. 근데 호출하는 쪽을 다시 보면:

tsx
<OrderTable items={data?.items ?? []} />

dataundefined이면 빈 배열이 전달된다. React Query의 data는 최초 로딩 시 undefined다. 신규 유저가 첫 주문 직후에 /orders로 오면 캐시가 없으니까 dataundefined이고, data?.items ?? []는 빈 배열이 된다. 빈 배열을 필터링하면 당연히 빈 배열.

새로고침하면 되는 이유도 설명된다. 첫 요청에서 캐시가 생기니까.

에러가 나면 차라리 빨리 알았을 텐데, ?? []가 에러를 조용히 삼켜버렸다.

tsx
function OrderList() {
  const { data, isLoading } = useQuery({
    queryKey: ['orders'],
    queryFn: fetchOrders,
  });

  if (isLoading) return <OrderSkeleton />;

  return (
    <PageLayout>
      <OrderTable items={data.items} />
    </PageLayout>
  );
}

수정은 간단했다. 로딩 상태를 명시적으로 처리하고, ?? []를 제거했다. 찾는 데 걸린 시간에 비하면 고치는 건 30초였다.

추측과 제거#

이 경험 이후로 디버깅할 때 의식적으로 순서를 따른다. 재현, 격리, 축소, 원인. 매번 이 순서를 지키는 건 아니고, 경험이 쌓이면 직감으로 바로 맞추는 경우도 많다. 시니어들이 코드를 보자마자 "아 이거네" 하는 건 같은 패턴을 수십 번 봤기 때문이다.

근데 직감이 안 맞을 때가 문제다. 예전에 스크롤 애니메이션이 안 먹는 버그가 있었는데, "비동기 타이밍 문제겠지" 하고 async/await를 1시간 동안 뜯어봤다. 원인은 부모 컴포넌트의 CSS overflow: hidden이었다.

그때 느꼈다. 추측이 맞으면 빠르다. 근데 틀리면 끝이 없다. 제거는 느리지만, 틀릴 수가 없다.

디버깅이 막힐 때 이 네 단계로 돌아와 보자.

  1. 재현 — 100% 다시 만들 수 있는가?
  2. 격리 — 관련 없는 코드를 빼도 재현되는가?
  3. 축소 — 최소한의 코드로 좁혔는가?
  4. 원인 — 왜 이 코드가 문제인지 설명할 수 있는가?

디버깅에서 가장 어려운 건 고치는 게 아니라 찾는 거다. 그리고 찾는 데에는 천재적인 직감보다, 지루할 만큼 성실한 제거가 더 잘 먹힌다. 적어도 내 경험에서는 그랬다.

Tip

AI 코딩 도구에게 버그를 맡길 때도 마찬가지다. "이거 고쳐줘"보다 "이 재현 조건에서 이 컴포넌트가 원인이야. 왜 빈 배열이 렌더링되는지 분석해줘"라고 줘야 쓸 만한 답이 온다.