November 17, 2025
원문 : How to Fix Any Bug
며칠 전 작은 앱을 바이브 코딩하고 있던 중 버그 하나를 마주쳤습니다.
버그는 대략 이런 식이었습니다. 웹앱에 하나의 라우트가 있다고 가정해 봅시다. 그 라우트는 일련의 단계, 즉 카드들을 표시합니다. 각 카드는 다음 카드로 스크롤되는 버튼을 가지고 있습니다. 모든 것이 잘 작동했습니다. 하지만 그 버튼에서 서버 호출도 추가하려고 시도하자, 스크롤이 더 이상 제대로 작동하지 않았습니다. 화면이 흔들리고 이상해졌습니다.
즉, 원격 호출을 추가하자 스크롤이 망가진 것입니다.
무엇이 이 버그를 일으키는지 확실하지 않았습니다. 분명히 새로 추가한 원격 서버 호출(React Router의 actions를 통해 수행한)이 제 scrollIntoView 호출에 어떤 식으로든 간섭하고 있었습니다. 하지만 왜 그런지 알 수 없었습니다. 처음에는 React Router가 제 페이지를 다시 렌더링 하기 때문이라고 생각했습니다(액션이 데이터 refetch를 유발하기 때문에요). 하지만 원칙적으로는 진행 중인 스크롤에 refetch가 간섭할 이유가 없습니다. 서버는 동일한 아이템을 반환하고 있었기 때문에, 아무것도 바뀌지 않아야 했습니다.
리액트에서 리렌더링은 언제나 안전해야 합니다. 제 코드이든, React Router이든, 리액트이든, 혹은 브라우저 자체든 다른 무언가가 잘못된 것입니다.
이 버그를 어떻게 고칠 수 있을까요?
클로드에게 고쳐달라고 하면 될까요?
저는 클로드에게 문제를 고쳐달라고 했습니다.
클로드는 몇 가지 시도를 했습니다. scrollIntoView 호출이 포함된 useEffect의 조건을 다시 작성했고, 버그가 고쳐졌다고 말했습니다. 하지만 아무 효과가 없었습니다. 그다음엔 smooth 스크롤을 instant로 바꾸는 등의 시도를 했습니다.
클로드는 매번 당당하게 문제를 해결했다고 선언했습니다.
하지만 전혀 해결되지 않았습니다!
이게 클로드를 비판하려는 이야기는 아닙니다. 사실 제가 이 글을 쓰게 된 계기는 인간 엔지니어들(저 자신 포함)이 똑같은 실수를 하는 걸 자주 보기 때문입니다. 그래서 제가 보통 버그를 고치는 과정을 문서화하고 싶었습니다.
왜 클로드는 반복적으로 틀렸을까요?
클로드가 반복적으로 틀린 이유는 재현(Repro)이 없었기 때문입니다.
재현 또는 재현 케이스는, 일정한 절차를 따를 때 그 버그가 여전히 발생하는지 신뢰할 수 있게 알려주는 지침의 모음입니다. 그것이 바로 “테스트”입니다. 재현은 “무엇을 해야 하는지”, “무엇이 기대되는지”, 그리고 “무엇이 실제로 일어나는지”를 말해줍니다.
제 관점에서는 이미 좋은 재현 케이스가 있었습니다.
게다가 이 버그는 매번 발생했습니다.
만약 재현이 불안정했다면(예: 30% 확률로만 발생한다면), 여러 불확실한 요인을 점차 제거하거나(예: 네트워크를 기록하고 이후 시도에서는 그것을 모킹 하기), 아니면 잠재적인 수정 사항을 훨씬 여러 번 테스트해야 하는 생산성 손실을 감수해야 했을 것입니다. 하지만 다행히도 제 재현은 신뢰할 만했습니다.
그런데 클로드에게는 제 재현이 사실상 존재하지 않는 것이나 다름없었습니다.
문제는 제 재현에 나오는 “스크롤이 흔들린다”는 표현이 클로드에게 아무 의미도 없었다는 것입니다. 클로드는 볼 수 없으니까요. 직접 그 흔들림을 인식할 방법이 없습니다. 따라서 클로드는 사실상 재현 없이 문제를 고치려 한 셈입니다. 즉, 구체적으로 검증할 기준 없이 문제를 고치려 한 것입니다. 이것은 정말 흔한 실수입니다. 심지어 뛰어난 엔지니어들도 자주 범합니다.
이 경우 클로드는 제 화면을 “볼” 수 없었기 때문에 제 재현을 그대로 따를 수 없었습니다(몇 장의 스크린샷을 찍는다고 해도 흔들림은 포착되지 않았을 것입니다). 그래서 클로드가 이 버그를 고치게 하려면 제 첫 번째 재현은 부적합했습니다. 이것이 클로드의 한계처럼 보일 수도 있지만, 실제로 이런 상황은 사람 간의 협업에서도 흔합니다. 예를 들어, 버그가 특정 사용자, 특정 설정, 특정 환경에서만 발생할 수도 있죠.
다행히도, 하나의 트릭이 있습니다. 기존 재현을 다른 재현으로 바꾸더라도, 그것이 원래 문제 해결에 도움이 된다고 스스로 납득할 수 있다면 괜찮습니다.
다음은 재현을 바꾸는 방법과 주의해야 할 점들입니다.
작업 중인 재현 사례를 바꾸는 것은 언제나 위험이 따릅니다. 그 이유는 새 재현이 원래 버그와 아무 관련이 없을 수도 있기 때문입니다. 그럴 경우 완전히 엉뚱한 문제를 고치느라 시간을 낭비하게 될 수 있습니다.
하지만 어떤 때는 재현 사례를 바꾸는 것이 불가피할수도 있습니다(클로드는 화면을 볼 수 없으니 다른 방법을 찾아야 합니다). 그리고 어떤 경우에는 훨씬 효율적인 반복 작업을 가능하게 합니다(예: 10분 걸리는 재현보다 10초 걸리는 재현이 훨씬 가치가 큽니다). 따라서 재현을 바꾸는 기술은 매우 중요합니다.
이상적으로는, 더 단순하고, 더 좁고, 더 직접적인 재현으로 교체하는 것이 좋습니다.
클로드에게 저는 이렇게 제안했습니다.
제 생각에는 이 방식이 제가 직접 눈으로 본 문제와 대체로 유사하다고 느꼈습니다. 이 재현은 흔들림 자체를 포착하지는 못하지만, 스크롤이 이동하지 않는 현상은 그 문제와 연관되어 있을 가능성이 높습니다. 설령 완전히 동일한 현상은 아니더라도, 이것만으로도 고칠 가치가 충분합니다.
클로드는 console.log를 추가하고, Playwright MCP를 통해 페이지를 열고, 클릭을 시도했습니다. 실제로 버튼을 클릭해도 스크롤 위치가 변하지 않았습니다.
좋아요, 이제 클로드도 버그가 존재함을 “확인할 수 있게” 되었습니다!
그럼 이제 재현을 찾는 과정은 끝난 걸까요?
아직 아닙니다!
재현을 좁힐 때 흔히 하는 실수 중 하나는 “좋은 재현을 찾았다”라고 착각하지만, 실제로는 유사한 증상을 보이는 전혀 다른 문제를 잡고 있는 경우입니다. 이건 매우 큰 실수입니다. 원래 문제와 관련 없는 현상을 해결하느라 몇 시간을 날릴 수 있습니다.
예를 들어, 클로드가 단순히 스크롤 위치를 너무 일찍 읽고 있었을 가능성도 있습니다. 이 경우 버그가 실제로 고쳐져도 위치가 변하지 않은 것처럼 보일 것입니다. 그러면 완벽한 수정안이 있어도 “여전히 버그 있음”이라고 판단하게 됩니다! 사람 엔지니어도 이런 실수를 자주 합니다.
따라서 재현 사례를 좁힐 때에는, 새로운 재현 사례로도 정상 동작(“모든 것이 잘 작동”)을 여전히 재현할 수 있는지도 반드시 확인해야 합니다.
더 쉬운 예시로 설명하겠습니다.
저는 클로드에게 네트워크 호출(원래 버그를 유발했던)을 주석 처리하라고 했습니다. 만약 새 재현(“스크롤 위치 측정 → 버튼 클릭 → 다시 측정”)이 제가 원래 고치고자 했던 문제(“클릭 시 스크롤 흔들림”)를 잘 포착하고 있다면, 이미 그 문제를 해결한다고 확인된 수정(즉, 네트워크 호출을 주석 처리하는 것)이 새 재현에서도 정상 동작을 만들어야 합니다(스크롤 위치가 변화해야 함).
그리고 실제로 그렇게 됐습니다! 네트워크 호출을 주석 처리하자 스크롤 위치가 변했습니다.
이 시점에서는 코드를 몇 번 바꿔보면서(주석 처리했다가, 다시 되돌렸다가) 새 재현의 결과가 일관되게 예측되는지도 확인하는 게 좋습니다. (또한, 두 번 중 한 번만 작동하는 식의 우연이 아닌지도 확인해야 합니다.)
결국 스크롤 측정 결과는 네트워크 호출과 연관이 있어 보였습니다.
물론 그렇다고 해서 새 재현이 원래 문제를 완전히 대변한다고 단정할 수는 없습니다. 다른 원인일 수도 있고, 단지 일부 현상만 포착했을 수도 있습니다. 재현 사례를 변경하는 것은 항상 위험을 수반합니다. 하지만 네트워크 호출이 스크롤 위치와 관련이 있다는 건, 원래의 스크롤 흔들림 현상만큼이나 의심스럽습니다. 이 정도면 계속 진행할 충분한 이유가 있습니다.
저는 새 브랜치를 만들고 클로드에게 다음 워크플로우를 따르라고 지시했습니다.
저는 잠시 자리를 비워야 해서 클로드에게 “버그를 더 이상 줄일 수 없을 때까지 계속하라”라고 말했습니다. 즉, React Router조차 포함하지 않고 최소한의 로직만 남긴 리액트 컴포넌트 하나로 줄일 때까지요.
제가 돌아와 보니 클로드는 몇 가지 재현 케이스를 만들어 놓았는데, 안타깝게도 그 어느 것도 버그를 재현하지 못했습니다.
⏺ 제가 광범위한 조사를 수행하고 점점 더 정확한 최소 재현 세 가지를 만들었습니다.
모두 정상 작동하지만 실제 페이지에서는 실패합니다. 테스트한 것은 다음과 같습니다.
- 순수한 리액트 상태 업데이트
- React Router 재검증
- 컴포넌트 리마운트
- 새로운 객체 참조
- 동기 로더
- 다양한 의존성 배열
- 스크롤 중 다중 렌더링
생각할 수 있는 모든 가능성은 테스트를 거쳐 배제되었습니다.
버그는 실제 앱에서는 일관되게 재현되지만, 어떤 재현에서도 발생하지 않습니다.
이는 제가 캡처하지 못한 페이지 환경의 특정 요인이 있다는 뜻입니다.
setTimeout으로 네트워크 호출을 지연시키는 우회 방법은 안정적으로 작동합니다.
따라서 문제는 뮤테이션/재검증과 스크롤 타이밍 사이의 충돌입니다.
그러나 왜 React Router의 재검증이 실제 앱에서는 scrollIntoView를 망가뜨리는데
재현에서는 그렇지 않은지는 알 수 없습니다.
향후 참조를 위해 모든 재현 사례와 조사 기록을 커밋했습니다.이게 재현을 좁히는 게 항상 통하지 않는다는 뜻일까요?
아닙니다.
클로드가 제 지시를 제대로 따르지 못했다는 뜻입니다. 하지만 클로드가 그 지시를 어긴 방식이 흥미롭습니다. 사람들도 자주 같은 실수를 하기 때문입니다.
클로드는 코드를 단순화하는 과정에서 여러 이론을 세우기 시작했습니다. “아마 이 effect가 문제일 거야.” “컴포넌트가 리마운트 되면서 문제일 수도 있어.” “리액트가 이상한 일을 하는 건 아닐까.” 그리고 각 이론을 검증하기 위해 관련된 재현을 만들고 테스트했습니다.
이론을 세우고 검증하는 건 좋습니다! 반드시 필요한 과정이죠.
하지만 제 지시를 다시 봅시다.
여기에는 제가 의도한 구체적인 점이 있습니다. 항상, 버그가 여전히 존재하는 상태의 체크포인트를 유지하면서, 조금씩 해당 버그의 발생 가능 영역을 줄여가야 한다는 것입니다.
클로드는 자신이 세운 이론들을 테스트하느라 너무 몰두한 나머지, 실제로 버그가 존재하지 않는 여러 테스트 케이스를 만들어버렸습니다. 새로운 이론을 시험해 보는 것은 좋지만, 실패했다면 원래의 재현(여전히 버그가 있는 케이스)으로 돌아와서, 그 케이스에서 요소를 하나씩 제거하며 진행해야 합니다.
이건 잘 정의된 재귀(well-founded recursion) 개념을 떠올리게 합니다. 예를 들어, 피보나치 수를 계산하는 fib(n) 함수를 작성한다고 해봅시다.
function fib(n) {
if (n <= 1) {
return n;
} else {
return fib(n) + fib(n - 1);
}
}사실 이 함수에는 버그가 있습니다. 영원히 멈추지 않고 실행됩니다. 실수로 fib(n - 2) 대신 fib(n)을 적성했기 때문에, 결국 fib(n)이 자기 자신을 계속 호출하고, n이 작아지지 않기 때문에 결코 재귀에서 벗어날 수 없습니다.
잘 정의된 재귀를 이해하는 언어들은 이런 실수를 허용하지 않습니다. 예를 들어 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 튜토리얼이 아니지만, 이 비유를 이해해 주시길 바랍니다.
버그 재현을 줄여가는 과정도 이와 같습니다. 항상 조금씩 진전하고, 재현이 점점 더 작아지고 있음을 보장해야 합니다. 이는 규율을 유지하며 조각을 조금씩 제거하고, 버그가 여전히 지속되는 경우에만 커밋해야 함을 의미합니다. 결국 더 이상 제거할 것이 없을 때, 그때가 바로 원인을 찾은 순간입니다. 그것이 내 코드의 실수든, 아니면 더 이상 줄일 수 없는 외부 코드(예: 리액트)의 문제일지라도요.
원인을 찾을 때까지 이 과정을 계속 반복하십시오.
클로드는 이 버그를 끝내 해결하진 못했지만, 저로 하여금 해결에 매우 가까워지게 했습니다.
제가 다시 “지시를 제대로 따르라”라고 하자, 클로드는 정말로 불필요한 코드를 하나씩 제거해 갔습니다. 결국 문제는 하나의 파일에 국한되었습니다. 그 파일을 라우터 밖으로 옮기자, 갑자기 코드가 정상 작동했습니다. 다시 라우터 안으로 옮기면 버그가 재현되었습니다. 그 파일을 최상위 라우트로 만들면 또 정상 작동했습니다.
즉, 루트 레이아웃 안에 중첩될 때만 문제가 발생한 것이었습니다.
제 루트 레이아웃 코드는 다음과 같았습니다.
import { Outlet, ScrollRestoration } from 'react-router-dom';
export function RootLayout() {
return (
<div>
<ScrollRestoration />
<Outlet />
</div>
);
}아하. 알고 보니 React Router의 ScrollRestoration이 경로 변경 시마다 활성화되어야 하는데, 재검증(revalidation)마다 작동하는 버그가 (6월에 이미 수정됨) 있었던 것입니다. 제 네트워크 호출(액션을 통해 수행)이 라우트를 재검증하면서, scrollIntoView 중에 ScrollRestoration이 트리거 되어 스크롤 흔들림이 발생했던 것입니다.
이 “코드를 하나씩 제거하면서도 버그가 여전히 남아 있는지 확인하는” 접근법은 수없이 제 목숨을 구했습니다. (한 번은 Facebook의 리액트 트리 절반을 지우며 버그를 쫓은 적도 있습니다. 최종 재현은 약 50줄이었죠.) 더 이상 가설이 남지 않았을 때, 이 방법만큼 효과적인 건 없습니다.
만약 제가 프로젝트를 직접 설정했다면 React Router의 최신 버전을 썼을 것이고, 이 버그를 마주하지 않았을 겁니다. 하지만 클로드가 프로젝트를 설정했고, 이유를 알 수 없게도 핵심 의존성의 오래된 버전을 사용했습니다.
아, 뭐 어쩔 수 있겠습니까!
이게 바로 바이브 코딩의 묘미죠.
🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!