
console.log 대신 debugger 활용
console.log 100개 찍어가며 디버깅하던 시절을 끝내고, 브라우저 debugger와 breakpoint로 효율적으로 버그를 잡는 방법.

console.log 100개 찍어가며 디버깅하던 시절을 끝내고, 브라우저 debugger와 breakpoint로 효율적으로 버그를 잡는 방법.
분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

안드로이드는 Xcode보다 낫다고요? Gradle 지옥에 빠져보면 그 말이 쏙 들어갈 겁니다. minSdkVersion 충돌, Multidex 에러, Namespace 변경(Gradle 8.0), JDK 버전 문제, 그리고 의존성 트리 분석까지 완벽하게 해결해 봅니다.

서버에서 잘 오던 데이터가 갑자기 앱을 죽입니다. 'type Null is not a subtype of type String' 에러의 원인과, 안전한 JSON 파싱을 위한 Null Safety 전략을 정리해봤습니다.

버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

이틀 전, 결제 모듈에서 이상한 버그가 발생했다. 사용자가 결제 버튼을 누르면 가끔씩 두 번 결제가 되는 현상이었다. 코드를 훑어봤는데 도저히 어디서 문제가 생기는지 감이 안 왔다.
그래서 내가 한 짓은... console.log()를 찍기 시작했다.
function handlePayment(amount) {
console.log('1. handlePayment called', amount);
if (!amount || amount <= 0) {
console.log('2. Invalid amount', amount);
return;
}
console.log('3. Validating user');
const user = getCurrentUser();
console.log('4. User:', user);
if (!user) {
console.log('5. No user found');
return;
}
console.log('6. Creating payment');
const payment = createPayment(user, amount);
console.log('7. Payment created:', payment);
console.log('8. Submitting to server');
submitPayment(payment);
console.log('9. Submit complete');
}
함수 하나에 9개의 console.log. 그리고 이게 5개 파일에 걸쳐 있었다. 콘솔 창은 로그로 도배가 됐고, 스크롤 올려가며 어디서 뭐가 잘못됐는지 눈으로 쫓고 있었다.
1시간이 지났다. 로그는 50개를 넘어갔다. 그런데 더 웃긴 건, 로그를 보면 모든 값이 다 정상이었다는 것이다. "아니 그럼 대체 어디서??"
그때 옆자리 동료가 스쳐지나가며 한마디 던졌다. "debugger 쓰면 10초면 찾는데..." 그 순간 머리를 한 대 맞은 기분이었다.
동료가 보여준 화면은 충격이었다. 코드 한 줄에 debugger;를 추가하고 브라우저에서 실행하니 코드가 그 자리에서 멈췄다. 그리고 그 순간의 모든 변수 값, 콜 스택, 스코프가 오른쪽 패널에 깔끔하게 정리돼 있었다.
클릭 몇 번으로 함수를 한 줄씩 실행하면서 정확히 어느 순간에 값이 이상하게 변하는지 실시간으로 볼 수 있었다. console.log를 50개 찍어도 안 보이던 게, 디버거로는 3분 만에 보였다.
문제는 submitPayment() 안에서 이벤트 리스너가 중복 등록되고 있었다는 것이었다. 로그로는 함수가 한 번 호출되는 것만 보였지만, 디버거로 콜 스택을 보니 이벤트 핸들러가 두 번 쌓여 있는 게 명확히 보였다.
이게 진짜 디버깅이구나 싶었다. console.log는 마치 어두운 방에서 손전등으로 벽 한 군데씩 비추는 느낌이라면, 디버거는 방 전체에 불을 켜는 느낌이었다.
가장 간단한 방법은 코드에 debugger; 문을 넣는 것이다.
async function fetchUserData(userId) {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
debugger; // 여기서 실행이 멈춘다
return transformUserData(data);
}
브라우저 DevTools가 열린 상태에서 이 코드가 실행되면, debugger; 줄에서 실행이 일시정지된다. 이 순간:
debugger;를 코드에 넣는 건 임시방편이다. 커밋할 때 빼먹을 수도 있고, 여러 곳을 테스트하려면 계속 추가하고 지워야 한다.
더 나은 방법은 DevTools의 Sources 탭에서 줄 번호를 클릭해서 breakpoint를 걸어두는 것이다. 파란 점이 찍히고, 코드 실행이 그 줄에 도달하면 자동으로 멈춘다.
더 강력한 건 conditional breakpoint다. 줄 번호를 우클릭하면 조건을 입력할 수 있다. 예를 들어:
function processOrders(orders) {
orders.forEach((order, index) => {
// 100번째 주문에서만 멈추고 싶다면
// breakpoint 조건: index === 99
calculateTotal(order);
applyDiscount(order);
finalizeOrder(order);
});
}
루프가 1000번 돌아도, 조건이 맞는 순간에만 멈춘다. console.log로 이걸 하려면 if (index === 99) console.log(...)를 모든 곳에 추가해야 했을 것이다.
Scope 패널은 모든 변수를 보여주지만, 가끔은 너무 많아서 정작 내가 보고 싶은 값을 찾기 힘들 때가 있다. 이럴 때 Watch 패널이 빛을 발한다.
예를 들어, 복잡한 객체에서 특정 속성만 계속 추적하고 싶다면:
function updateShoppingCart(cart, item) {
// Watch: cart.items.length
// Watch: cart.total
// Watch: item.price * item.quantity
cart.items.push(item);
cart.total += item.price * item.quantity;
if (cart.total > 100) {
cart.discount = cart.total * 0.1;
}
return cart;
}
Watch 패널에 cart.items.length, cart.total 같은 표현식을 추가해두면, 코드가 한 줄씩 실행될 때마다 이 값들이 실시간으로 업데이트된다. 마치 엑셀에서 수식 결과를 보듯이.
Breakpoint에서 멈춘 후 가장 중요한 건 어떻게 "움직이느냐"다. DevTools 상단의 버튼들:
이게 왜 중요한가? 내 경험을 예로 들면:
function calculateOrderTotal(order) {
const subtotal = calculateSubtotal(order.items);
const tax = calculateTax(subtotal, order.region);
const shipping = calculateShipping(order.items, order.address);
debugger; // 여기서 멈춤
return subtotal + tax + shipping;
}
debugger에서 멈춘 후, 이미 calculateSubtotal, calculateTax 등은 실행이 끝난 상태다. 그런데 subtotal 값이 이상하다면? 다시 위로 올라가서 확인해야 한다.
하지만 calculateSubtotal 호출 직전에 breakpoint를 걸고, Step Into로 함수 안으로 들어가면, 그 함수 안에서 무슨 일이 일어나는지 한 줄 한 줄 볼 수 있다. 버그가 정확히 어디서 생기는지 추적하는 게 가능해진다.
가장 강력한 기능 중 하나는 Call Stack 패널이다. 현재 멈춘 지점까지 어떤 함수들을 거쳐서 왔는지 전체 경로를 보여준다.
예를 들어 에러가 깊숙한 곳에서 발생했다면:
handleSubmit (button.js:45)
→ validateForm (form.js:120)
→ validateEmail (validators.js:67)
→ checkEmailFormat (validators.js:23) ← 여기서 에러
Call Stack을 클릭하면 각 단계로 이동해서 그 순간의 변수 상태를 볼 수 있다. "이 함수에 어떤 인자가 넘어왔지?"를 추측이 아니라 실제로 확인할 수 있다.
이게 console.log와의 결정적 차이다. 로그는 과거의 스냅샷만 보여주지만, 디버거는 실행을 멈춰두고 그 순간의 전체 맥락을 보여준다.
프로덕션 환경에서는 코드가 Webpack, Vite 같은 번들러로 압축되고 난독화된다. 그럼 디버깅이 불가능할까? 아니다. Source Map 덕분에 가능하다.
Source Map은 번들된 코드와 원본 코드를 연결하는 지도다. DevTools는 이걸 자동으로 읽어서 원본 TypeScript나 JSX 코드를 보여준다.
설정 방법은 번들러마다 다르지만, Vite 기준으로는:
// vite.config.js
export default {
build: {
sourcemap: true, // 프로덕션에서도 source map 생성
},
}
이렇게 하면 배포된 사이트에서도 원본 코드를 보면서 디버깅할 수 있다. 단, source map 파일이 크므로 보안이 중요한 경우 프로덕션에서는 제외하는 게 좋다.
브라우저 DevTools도 좋지만, VS Code에서 직접 디버깅하면 더 편하다. 코드 에디터와 디버거가 한 화면에 있으니까.
.vscode/launch.json 설정:
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/src",
"sourceMaps": true
}
]
}
F5를 누르면 Chrome이 자동으로 열리고, VS Code에서 breakpoint를 걸고 변수를 보고 step through 할 수 있다. 브라우저와 에디터를 왔다갔다 할 필요가 없다.
Node.js 백엔드도 마찬가지다. "type": "node"로 바꾸면 서버 코드도 똑같이 디버깅 가능하다.
그럼 console.log는 완전히 버려야 할까? 아니다. 적재적소가 있다:
console.time(), console.timeEnd()로 성능 측정console.table()로 배열이나 객체를 표로 보기하지만 "버그가 어디서 생기는지 모르겠어서" console.log를 10개, 20개 찍는다면? 그건 디버거를 쓸 타이밍이다.
결국 이 차이를 이해했다. console.log는 추측을 기반으로 한다. "여기서 값이 이상할 것 같은데?"라고 생각하고 로그를 찍는다. 틀리면 다시 다른 곳에 찍는다.
디버거는 관찰을 기반으로 한다. 코드를 멈춰놓고 실제로 무슨 일이 일어나는지 본다. 추측이 아니라 사실을 본다.
처음엔 디버거가 복잡해 보였다. 버튼도 많고, 패널도 많고. 하지만 한두 번 써보니 오히려 console.log보다 훨씬 간단했다. 로그 찍고, 새로고침하고, 로그 읽고, 다시 로그 찍고... 이 사이클이 얼마나 비효율적이었는지 깨달았다.
이제는 버그가 생기면 일단 breakpoint부터 건다. 3분이면 대부분의 문제가 보인다. 50개의 console.log 대신 하나의 debugger 문이면 충분하다는 걸 배웠다.