어떤 버그든 고치는 방법

I
Inkyu Oh

Front-End2025.11.19

overreacted 블로그 포스트 번역


나는 최근에 작은 앱을 만들다가 며칠 전 버그를 마주쳤다.
버그는 대략 이런 식이었다. 웹앱의 라우트를 상상해보자. 그 라우트는 일련의 단계들을 보여준다—기본적으로 카드들이다. 각 카드에는 다음 카드로 스크롤하는 버튼이 있다. 모든 것이 완벽하게 작동한다. 하지만 그 버튼에서 또한 서버를 호출하려고 하자마자, 스크롤이 더 이상 작동하지 않았다. 끊기고 깨졌다.
그래서 원격 호출을 추가하는 것이 어떻게든 스크롤을 깨뜨렸다.
나는 버그의 원인이 무엇인지 확실하지 않았다. 명백히 새로 추가된 원격 서버 호출(React Router actions을 통해 수행 중)이 어떻게든 내 scrollIntoView 호출을 방해하고 있었다. 하지만 어떻게 그런지 알 수 없었다. 처음에는 문제가 React Router가 내 페이지를 다시 렌더링한다는 것이라고 생각했다(액션이 데이터 재페치를 유발함). 하지만 원칙적으로 재페치가 진행 중인 스크롤을 방해할 이유가 없다. 서버는 동일한 항목들을 반환하고 있었으므로 아무것도 변경되지 않았어야 한다.
React에서 다시 렌더링은 항상 안전해야 한다. 뭔가 다른 것이 잘못되었다—내 코드에서, React Router에서, React에서, 또는 심지어 브라우저 자체에서.
이 버그를 어떻게 고칠까?
Claude가 이것을 고칠 수 있을까?



Step 0: 그냥 고쳐버리기

나는 Claude에게 문제를 고치라고 했다.
Claude는 몇 가지를 시도했다. scrollIntoView 호출을 포함하는 useEffect의 조건들을 다시 작성했고 버그가 고쳐졌다고 말했다. 하지만 그것은 도움이 되지 않았다. 그 다음 smooth 스크롤을 instant로 변경하려고 시도했고, 몇 가지 다른 것들도 시도했다.
매번 Claude는 문제가 해결되었다고 자랑스럽게 선언했다.
하지만 그렇지 않았다!
버그는 여전히 있었다.
이것이 Claude에 대해 불평하는 것처럼 들릴 수 있지만, 정말로 이 글을 쓰도록 한 동기는 인간 엔지니어들(나 자신 포함)이 같은 실수를 하는 것을 본다는 것이다. 그래서 나는 버그를 고치기 위해 내가 보통 따르는 과정을 문서화하고 싶었다.
Claude가 반복적으로 틀린 이유는 무엇일까?
Claude가 반복적으로 틀린 이유는 재현 사례(repro)가 없었기 때문이다.



Step 1: 재현 사례 찾기

재현 사례 또는 재현 케이스는 따라가면 버그가 여전히 발생하는지 여부를 판단할 수 있는 신뢰할 수 있는 방법을 제공하는 일련의 지시사항이다. 그것은 "테스트"이다. 재현 사례는 무엇을 할 것인지, 무엇이 예상되는 동작인지, 그리고 무엇이 실제 동작인지를 말한다.
내 관점에서, 나는 이미 좋은 재현 사례를 가지고 있었다:
  1. 버튼을 클릭한다.
  1. 예상되는 동작은 아래로 스크롤하는 것이었지만, 실제 동작은 스크롤 끊김이었다.
더 좋은 점은, 버그가 매번 발생했다는 것이다.
만약 내 재현 사례가 신뢰할 수 없었다면(예: 시도의 30%에서만 발생했다면), 나는 불확실성의 다양한 원인을 점진적으로 제거하거나(예: 네트워크를 기록하고 향후 시도에서 모킹하기), 모든 잠재적 수정을 여러 번 더 테스트해야 하는 생산성 저하를 감수해야 했을 것이다. 하지만 다행히 내 재현 사례는 신뢰할 수 있었다.
그리고도 Claude에게는 내 재현 사례가 본질적으로 존재하지 않았다.
문제는 내 재현 사례의 "스크롤 끊김"이 Claude에게 아무 의미도 없었다는 것이다. Claude는 눈이나 끊김을 직접 감지할 수 있는 다른 방법이 없다. 그래서 Claude는 본질적으로 재현 사례 없이 작동하고 있었다—버그를 고치려고 시도했지만 이를 검증하기 위해 구체적으로 아무것도 하지 않았다. 이것은 우리 중 최고의 사람들에게도 너무 흔하다.
이 경우, Claude는 내 재현 사례를 정확히 따를 수 없었다. 왜냐하면 화면을 "볼" 수 없었기 때문이다(몇 개의 스크린샷을 찍는 것도 끊김을 포착하지 못할 것이다). 그래서 내 첫 번째 재현 사례는 Claude가 이를 고치기를 원한다면 부적절했다. 이것이 Claude의 문제처럼 보일 수 있지만, 다른 사람들과 일할 때는 드물지 않다—때때로 버그는 한 대의 머신에서만 발생하거나, 특정 사용자를 위해, 또는 특정 설정으로만 발생한다.
다행히 트릭이 있다. 원래 문제를 진전시키는 데 도움이 될 것이라고 자신을 설득할 수 있다면, 재현 사례를 다른 재현 사례로 교환할 수 있다.
재현 사례를 변경하는 방법과 주의할 점들이 있다.



Step 2: 재현 사례 좁혀가기

작업 중인 재현 사례를 변경하는 것은 항상 위험하다. 위험은 새로운 재현 사례가 원래 버그와 아무 관련이 없고, 이를 해결하는 것이 시간 낭비라는 것이다.
하지만 때때로 재현 사례를 변경하는 것은 피할 수 없다(Claude는 내 화면을 볼 수 없으므로 다른 것을 생각해내야 한다). 그리고 때때로 반복에 엄청나게 유리하다(예: 10초가 걸리는 재현 사례는 10분이 걸리는 재현 사례보다 훨씬 더 가치 있다). 그래서 재현 사례를 변경하는 방법을 배우는 것이 중요하다.
이상적으로는 재현 사례를 더 간단하고, 더 좁고, 더 직접적인 재현 사례로 교환할 것이다.
내가 Claude에게 제안한 아이디어는 다음과 같다:
  1. 문서 스크롤 위치를 측정한다.
  1. 버튼을 클릭한다.
  1. 문서 스크롤 위치를 다시 측정한다.
  1. 예상되는 동작은 변화가 있다는 것이고, 실제 동작은 변화가 없다는 것이다.
내 생각은 이것이 내가 내 눈으로 본 문제와 대략 동등해 보인다는 것이었다. 이 재현 사례가 끊김을 포착하지는 않지만, 스크롤하지 못하는 것은 아마도 관련이 있을 것이다. 그것이 유일한 문제가 아니더라도, 이것을 자체적으로 고치는 것은 가치가 있다.
Claude는 몇 가지 console.log를 추가했고, Playwright MCP를 통해 페이지를 열었으며, 클릭했다. 실제로 스크롤 위치는 버튼 클릭에도 불구하고 변경되지 않았다.
좋아, 이제 Claude는 버그가 존재한다는 것을 검증할 수 있다!
재현 사례 찾기가 끝났을까?
실제로는 아니다!
재현 사례를 좁혀가는 것의 일반적인 함정은 좋은 것을 찾았다고 생각하지만 실제로는 새로운 재현 사례가 비슷한 방식으로 나타나는 관련 없는 문제를 포착한다는 것이다. 이것은 매우 비용이 많이 드는 실수이다. 왜냐하면 당신이 해결하고 싶었던 것과 다른 문제의 해결책을 몇 시간 동안 쫓을 수 있기 때문이다.
예를 들어, Claude가 단순히 스크롤 위치를 너무 일찍 읽고 있을 수 있으며, 버그가 고쳐졌더라도, 위치가 변경되지 않는 것을 여전히 "볼" 것이다. 이것은 매우 오도할 수 있다—올바른 수정이더라도, 테스트는 여전히 버그가 있다고 말할 것이고, Claude는 올바른 수정을 놓칠 것이다! 이것은 인간 엔지니어들에게도 발생한다.
이것이 재현 사례를 좁혀갈 때마다, 당신은 또한 긍정적인 결과("모든 것이 작동한다")가 새로운 재현 사례로도 여전히 가능한지 확인해야 하는 이유이다.
이것은 예제로 설명하기가 더 쉽다.
나는 Claude에게 네트워크 호출을 주석 처리하라고 했다(원래 버그를 드러낸 것). 새로운 재현 사례("스크롤 측정, 버튼 누르기, 스크롤 다시 측정")가 내가 고치고 싶었던 버그("클릭 시 스크롤 끊김")를 정말로 포착한다면, 나는 이미 검증한 변경(액션 호출 주석 처리)이 새로운 재현 사례의 동작도 고칠 것으로 예상해야 한다(스크롤 위치는 이제 달라야 한다).
그리고 그것이 일어났다! 실제로 네트워크 호출을 임시로 주석 처리하는 것이 Claude가 수행하고 있던 테스트도 고쳤다—스크롤 위치는 이제 달랐다.
이 시점에서, 코드를 양쪽 방향으로 몇 번 변경해보는 것이 가치가 있다(주석 처리, 주석 해제). 각 편집이 새로운 재현 사례 결과를 예측하는지 확인하기 위해. (또한 모든 두 번째 편집이 작동하는 것을 배제하기 위해 다른 편집을 하는 것도 가치가 있다.)
스크롤 측정은 여전히 그 네트워크 호출과 상관관계가 있는 것으로 보였다.
이것도 새로운 재현 사례가 같은 문제를 나타낸다는 것을 의미하지는 않는다. 그것은 관련 없는 것이거나, 단지 그것의 일부일 수 있다. 재현 사례를 변경하는 것은 항상 위험하다. 하지만 네트워크 호출이 스크롤 위치와 관련이 있다는 것은 최소한 원래 문제(스크롤 끊김)만큼 의심스럽고 자체적으로 해결할 가치가 있었을 것이다. 이것은 새로운 재현 사례를 계속하기에 충분한 이유이다.



Step 3: 다른 모든 것 제거하기

나는 새로운 브랜치를 만들고 Claude에게 다음 워크플로우를 따르도록 요청했다:
  1. 재현 사례를 실행하여 버그가 있는지 확인한다.
  1. 관련 코드에서 뭔가를 제거한다(컴포넌트 제거, 이벤트 핸들러 제거, 조건 단순화, 스타일 제거, 임포트 제거 등).
  1. 재현 사례를 다시 실행하여 버그가 여전히 있는지 확인한다.
  1. 버그가 여전히 있으면, 변경사항을 커밋한다.
  1. 버그가 없으면, 그것을 "해결"했을 수 있는 것에 대한 이론을 적어두고, 마지막 커밋으로 리셋한 다음 더 작은 청크를 삭제해본다.
나는 나가려고 했으므로 Claude에게 계속 진행하고 재현 사례를 더 이상 줄일 수 없는 것으로 좁혀질 때까지 쉬지 말라고 했다—추가 라이브러리가 없는(React Router도 없는) React 컴포넌트와 최소한의 로직.
내가 돌아왔을 때, Claude는 몇 가지 재현 사례를 만들었지만, 답답하게도, 그 중 어느 것도 버그를 보여주지 않았다:
⏺ I've done extensive investigation and created three progressively more
accurate minimal reproductions. All of them work correctly, but the real
page fails. I've tested:

- Plain React state updates
- React Router revalidation
- Component remounting
- New object references
- Synchronous loaders
- Various dependency arrays
- Multiple renders during scroll

Everything I can think of has been tested and ruled out. The bug
consistently reproduces in the real app but not in any repro. This
suggests there's something very specific about the page
environment that I haven't captured.

The workaround (deferring network call with setTimeout) works reliably,
which confirms that the issue is the timing between the
mutation/revalidation and the scroll. But I cannot determine WHY React
Router's revalidation breaks scrollIntoView in the real app when it
doesn't in the repros.

I've committed all the repros and investigation notes for future
reference.
이것이 재현 사례를 좁혀가는 것이 항상 작동하지 않는다는 것을 의미할까?
아니다.
이것은 Claude가 내 지시사항을 따르지 못했다는 것을 의미한다. 하지만 그것이 따르지 못한 방식은 흥미롭다. 왜냐하면 사람들(나 자신 포함)이 종종 같은 실수를 하기 때문이다.
Claude가 코드를 단순화하는 동안, 그것은 이론을 형성하기 시작했다. 아마도 이 effect는 버그가 있을 것이다. 아마도 리마운팅과 관련된 뭔가가 있을 것이다. 아마도 React가 뭔가 이상한 것을 하고 있을 것이다. 그리고 그것은 그 이론들을 테스트하기 시작했다. 그것들에 초점을 맞춘 격리된 재현 사례를 만들었다—그리고 그들이 버그를 보여주는지 확인했다.
이론을 만들고 그것들을 테스트하는 것은 훌륭하다! 우리는 확실히 그렇게 해야 한다.
하지만 내 지시사항을 다시 보자:
  1. 재현 사례를 실행하여 버그가 있는지 확인한다.
  1. 관련 코드에서 뭔가를 제거한다(컴포넌트 제거, 이벤트 핸들러 제거, 조건 단순화, 스타일 제거, 임포트 제거 등).
  1. 재현 사례를 다시 실행하여 버그가 여전히 있는지 확인한다.
  1. 버그가 여전히 있으면, 변경사항을 커밋한다.
  1. 버그가 없으면, 그것을 "해결"했을 수 있는 것에 대한 이론을 적어두고, 마지막 커밋으로 리셋한 다음 더 작은 청크를 삭제해본다.
내가 그것을 하도록 하려고 했던 구체적인 것이 있다. 우리가 보장하려고 하는 것은 모든 시점에서, 우리는 버그가 여전히 발생하고 있는 체크포인트를 가지고 있고, 모든 단계에서, 우리는 그 버그의 표면 영역을 줄이고 있다는 것이다.
Claude는 자신의 이론을 테스트하는 데 너무 열중했고 결국 실제로 버그를 보여주지 않는 많은 테스트 케이스를 가지게 되었다. 다시 말하지만, 새로운 이론을 테스트하는 것은 나쁜 생각이 아니지만, 그들이 실패하면, 올바른 것은 원래 사례(여전히 버그를 보여주고 있는!)로 돌아가서 원인을 찾을 때까지 계속 제거하는 것이다.
이것은 well-founded recursion의 개념을 상기시킨다. Fibonacci 수를 계산하기 위해 fib(n) 함수를 구현하려는 이 시도를 생각해보자:
function fib(n) {
if (n <= 1) {
return n;
} else {
return fib(n) + fib(n - 1);
}
}
실제로, 이 함수는 버그가 있다—영원히 멈춘다. 실수로, 나는 fib(n - 2) 대신 fib(n)을 썼고, 그래서 fib(n)fib(n)을 호출할 것이고, 이것은 fib(n)을 호출할 것이고, 등등. n이 절대 "더 작아지지" 않기 때문에 재귀에서 벗어날 수 없을 것이다.
Well-founded recursion을 이해하는 언어들은 나에게 이 실수를 하도록 하지 않을 것이다. 예를 들어, Lean에서는 이것이 타입 에러가 될 것이다:
def fib (n : Nat) : Nat := /- error: fail to show termination for fib -/
if n ≤ 1 then
n
else
fib n + fib (n - 2)
Lean은 n이 "더 작아지지" 않는다는 것을 알고 있다(여기서 더 정확히 보자). 그래서 이 재귀가 영원히 멈춘다는 것을 알고 있다. 그것은 "시간이 지남에 따라 더 가까워지지" 않는다.
이것은 Lean 튜토리얼이 아니지만, 이 경솟된 은유를 용서해주기를 바란다.
나는 재현 사례를 줄이는 과정도 같다고 생각한다. 당신은 당신이 항상, 항상 증분 진전을 하고 있다는 것을 알고 싶고 재현 사례가 계속 더 작아진다. 이것은 당신이 규율 있게 남아있어야 하고 한 번에 한 조각씩 제거해야 하며, 버그가 여전히 지속되는 한에만 커밋해야 한다는 것을 의미한다. 어느 시점에서, 당신은 제거할 것이 부족해질 것이고, 이것은 당신의 코드의 실수를 제시하거나, 당신이 더 이상 줄일 수 없는 조각들의 실수를 제시할 것이다(예: React).
반복할 때까지 반복한다.



Step 4: 근본 원인 찾기

Claude는 결국 이것을 해결하지 못했지만, 그것은 나를 매우 가깝게 만들었다.
내가 내 지시사항을 실제로 따르라고 말했고, 제거하기만 하라고 말한 후, 그것은 충분한 코드를 제거했고 문제는 단일 파일에 포함되었다. 나는 그 파일을 라우터 밖으로 옮겼고, 갑자기 같은 코드가 작동했다. 그 다음 나는 그것을 라우터로 다시 옮겼고, 그것은 다시 깨졌다. 그 다음 나는 그것을 최상위 라우트로 만들었고 그것은 작동했다.
뭔가가 루트 레이아웃 내에 중첩되었을 때 깨지고 있었다.
내 루트 레이아웃은 이렇게 보였다:
import { Outlet, ScrollRestoration } from "react-router-dom";

export function RootLayout() {
return (
<div>
<ScrollRestoration />
<Outlet />
</div>
);
}
아하. 결국, 예전에 버그가 있었다(이미 6월에 고쳐짐). React Router의 ScrollRestoration이 라우트 변경마다가 아니라 모든 재검증에서 활성화되도록 하는 버그가 있었다. 내 네트워크 호출(액션을 통해)이 라우트를 재검증했으므로, scrollIntoView 중에 ScrollRestoration을 트리거했고, 끊김을 유발했다.
이 정확한 워크플로우—버그가 여전히 있는지 확인하면서 한 번에 한 가지씩 제거하기—내 엉덩이를 여러 번 구했다. (나는 한 번 Facebook의 React 트리의 절반을 삭제하면서 일주일을 보낸 적이 있다. 최종 재현 사례는 약 50줄의 코드였다.) 이론이 부족해진 후 이것만큼 효과적인 다른 방법을 알 수 없다.
만약 내가 프로젝트를 직접 설정했다면, 최신 버전의 React Router를 사용했을 것이고 이 버그를 마주치지 않았을 것이다. 하지만 프로젝트는 Claude에 의해 설정되었고, 어떤 이유로 나는 핵심 의존성의 오래된 버전을 사용해야 한다고 결정했다.
아, 뭐 어쩌겠는가!
vibecoding의 즐거움들.

0
12

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!