프로그래밍 언어별 흔한 에러 해결 가이드 | C++, Python, Java, JavaScript

이 글의 핵심

에러 메시지는 버그 자체가 아니라 컴파일러나 런타임이 문제 위치를 알려 주는 힌트입니다. 이 글은 네 언어에서 입문자가 가장 자주 만나는 에러를 메시지 읽는 법부터 짚고, 처리되지 않은 Promise 거부 같은 비동기 에러까지 다룬 뒤, 컴파일·런타임·논리 에러별로 확인할 곳을 나누는 디버깅 절차와 도구 비교를 제시합니다.

에러 메시지를 읽는 순서

에러 메시지를 읽는 요령은 언어와 무관하게 비슷합니다. 먼저 에러의 종류(TypeError, NullPointerException, undefined reference)를 보고 어느 단계에서 실패했는지 판단합니다. 컴파일러나 링커가 낸 에러라면 코드가 실행조차 되지 않은 것이고, 런타임 예외라면 실행 중 특정 값 때문에 실패한 것입니다. 그다음 스택 트레이스에서 내 코드가 처음 등장하는 줄을 찾습니다. 맨 위 줄은 표준 라이브러리 내부인 경우가 많아서, 위에서부터 내려가며 내 파일 이름이 처음 보이는 곳이 실제로 고칠 위치일 가능성이 높습니다. 아래에서는 네 언어에서 가장 자주 보는 에러를 이 관점으로 정리합니다.


C++ 흔한 에러

Segmentation Fault

Segmentation fault (core dumped)

Linux 셸의 문구입니다. macOS의 zsh/bash는 Segmentation fault: 11로, 종료 코드는 둘 다 139(128 + SIGSEGV 11)입니다.

프로세스가 접근할 수 없는 메모리 주소를 읽거나 쓸 때 운영체제가 SIGSEGV를 보내 프로세스를 종료합니다. 흔한 원인은 널 포인터 역참조, 배열 범위 초과, 해제된 메모리 접근, 무한 재귀 같은 스택 오버플로입니다.

// 원인 1: 널 포인터 역참조
int* ptr = nullptr;
*ptr = 10;  // Segmentation fault
// 해결: 사용 전에 확인하거나, 애초에 널이 될 수 없게 참조를 사용
if (ptr != nullptr) {
    *ptr = 10;
}

// 원인 2: 배열 범위 초과
int arr[5] = {1, 2, 3, 4, 5};
arr[10] = 100;  // 미정의 동작, 세그폴트가 날 수도 안 날 수도 있음
// 해결: vector의 at()은 범위를 검사해 예외를 던짐
std::vector<int> vec = {1, 2, 3, 4, 5};
try {
    vec.at(10) = 100;
} catch (const std::out_of_range& e) {
    std::cerr << "범위 초과: " << e.what() << std::endl;
}

// 원인 3: 댕글링 포인터 (해제된 메모리 접근)
int* ptr2 = new int(10);
delete ptr2;
*ptr2 = 20;  // 이미 해제된 메모리
// 해결: 스마트 포인터로 소유권과 수명을 묶음
std::unique_ptr<int> smart_ptr = std::make_unique<int>(10);
# GDB로 크래시 위치 확인
g++ -g main.cpp -o main
gdb ./main
(gdb) run
(gdb) backtrace
# Valgrind로 메모리 에러 검사
valgrind --leak-check=full ./main
# AddressSanitizer (ASan)
g++ -fsanitize=address -g main.cpp -o main
./main

원인 중 널 포인터 역참조와 스택 오버플로는 거의 확실히 세그폴트를 냅니다. 주소 0 근처와 스택 끝의 가드 페이지는 운영체제가 일부러 매핑하지 않기 때문입니다. 반면 arr[10] = 100이나 해제된 메모리 쓰기는 그 주소가 여전히 프로세스의 유효한 메모리 안에 있으면 아무 일 없이 지나가고, 대신 옆에 있던 다른 변수나 힙 관리 정보를 망가뜨립니다. 그래서 실제 크래시는 한참 뒤 전혀 상관없어 보이는 곳(free() 내부, 다른 객체의 소멸자)에서 납니다. 이 경우 크래시 지점의 백트레이스는 피해 위치일 뿐이라, ASan처럼 잘못된 접근이 일어난 순간을 잡아 주는 도구가 필요합니다. ASan은 heap-use-after-free, stack-buffer-overflow 같은 종류와 해당 메모리가 할당·해제된 위치의 스택까지 함께 보여 주며, 공식 문서 기준으로 평균 2배 정도 느려지는 수준이라 테스트 빌드에서 상시 켜 두기 좋습니다. Valgrind는 재컴파일 없이 쓸 수 있는 대신 그보다 훨씬 느립니다.

Undefined Reference (링커 에러)

undefined reference to `myFunction()'

함수의 선언은 있는데 정의가 없거나, 정의가 들어 있는 라이브러리를 링크하지 않았거나, 선언과 정의의 네임스페이스·시그니처가 달라 서로 다른 함수로 취급될 때 납니다.

// header.h
void myFunction();

// main.cpp
#include "header.h"
int main() {
    myFunction();  // impl.cpp 없이 빌드하면 undefined reference
    return 0;
}

// impl.cpp
#include "header.h"
void myFunction() {
    // 구현
}
# 정의가 든 파일까지 함께 빌드
g++ main.cpp impl.cpp -o main

# 라이브러리 링크 누락 예
g++ main.cpp -o main
# undefined reference to `pthread_create'
g++ main.cpp -o main -pthread

undefined reference는 컴파일은 성공했고 링크 단계에서 실패했다는 뜻입니다. 컴파일러는 헤더의 선언만 보고 “이런 함수가 어딘가 있겠지”라고 넘어가고, 링커가 모든 오브젝트 파일과 라이브러리를 뒤져도 정의를 찾지 못할 때 이 에러가 납니다. 흔한 원인은 위 예제 외에도 몇 가지가 더 있습니다. 템플릿 함수를 .cpp에만 정의해서 다른 파일에서 인스턴스화할 수 없는 경우, C 라이브러리 헤더를 extern "C" 없이 포함해서 이름 맹글링이 맞지 않는 경우, 그리고 링크 순서 문제입니다. GNU ld는 명령줄 순서대로 라이브러리를 처리하므로 g++ -lfoo main.o처럼 라이브러리를 사용처보다 앞에 두면 정의가 있어도 찾지 못합니다. glibc 2.34부터는 pthread 함수가 libc에 통합되어 최신 배포판에서는 pthread_create 에러가 잘 나지 않지만, 이식성을 위해 -pthread 옵션은 계속 붙이는 것이 관례입니다. Windows MSVC에서는 같은 문제가 LNK2019: unresolved external symbol로 나타납니다.

타입이 맞지 않는 호출과 템플릿 에러 메시지

error: no matching function for call to 'std::vector<int>::push_back(const char [6])'

GCC의 문구입니다. clang은 같은 코드에 error: no matching member function for call to 'push_back'라고 짧게 쓰고 후보와 이유는 뒤의 note: 줄로 나눠 보여 줍니다.

std::vector<int> vec;
vec.push_back("hello");  // int 벡터에 문자열 리터럴
// 해결: 원소 타입을 맞춤
std::vector<std::string> names;
names.push_back("hello");

에러 메시지의 괄호 안 타입이 std::string이 아니라 const char [6]인 점에 주목하세요. 문자열 리터럴은 std::string이 아니라 널 문자를 포함한 문자 배열이고, 컴파일러는 인자의 실제 타입을 그대로 보여 줍니다. 템플릿 코드 안에서 이런 불일치가 일어나면 이 한 줄 뒤에 “candidate: …”와 “required from …” 목록이 수십 줄 이어지는데, 대부분 “이 오버로드는 이래서 안 맞았다”는 설명입니다. 첫 번째 error: 줄과 내 파일이 등장하는 required from here 줄만 먼저 읽으면 원인을 빠르게 좁힐 수 있습니다.


Python 흔한 에러

IndentationError와 TabError

IndentationError: expected an indented block

Python 3.10부터는 뒤에 이유가 붙어 expected an indented block after function definition on line 1처럼 나옵니다(3.9와 3.10에서 직접 확인).

# 에러: 함수 본문이 들여쓰기되지 않음
def greet():
print("Hello")

# 해결
def greet():
    print("Hello")

# 에러: 탭과 스페이스 혼용
def calculate():
    x = 10  # 스페이스 4칸
	y = 20  # 탭 1개 -> TabError: inconsistent use of tabs and spaces in indentation
    return x + y

Python은 들여쓰기로 블록을 구분하므로, 화면에서 똑같이 보이는 탭과 스페이스 4칸을 서로 다른 들여쓰기로 봅니다. Python 3은 둘을 섞으면 TabError로 거부합니다. 에디터에서 탭을 스페이스로 바꾸도록 설정하고, .editorconfig로 프로젝트 전체에 같은 규칙을 적용하면 팀원 간 차이도 막을 수 있습니다.

# .editorconfig
[*.py]
indent_style = space
indent_size = 4

NameError와 UnboundLocalError

NameError: name 'x' is not defined
# 에러: 정의되지 않은 이름
print(x)

# 에러: 함수 안에서 전역 변수 수정
count = 0
def increment():
    count += 1  # UnboundLocalError

# 해결 1: global 선언
def increment():
    global count
    count += 1

# 해결 2: 인자로 받아 반환
def increment(count):
    return count + 1

두 번째 예제는 이름이 분명히 있는데도 에러가 난다는 점에서 헷갈립니다. Python은 함수를 컴파일할 때 함수 안 어디에서든 대입되는 이름을 지역 변수로 결정합니다. count += 1은 대입이므로 count는 함수 전체에서 지역 변수가 되고, 값을 읽는 시점에는 아직 지역 count에 값이 없어서 UnboundLocalError(NameError의 하위 클래스)가 납니다. Python 3.11 이상에서는 cannot access local variable 'count' where it is not associated with a value, 그 이전에는 local variable 'count' referenced before assignment라는 메시지로 나옵니다. global은 해결책이지만 전역 상태를 늘리므로, 가능하면 값을 인자로 받아 반환하는 형태가 테스트하기 쉽습니다. 중첩 함수에서 바깥 함수의 변수를 바꿀 때는 global 대신 nonlocal을 씁니다.

TypeError

TypeError: unsupported operand type(s) for +: 'int' and 'str'
result = 10 + "20"       # TypeError
result = 10 + int("20")  # 30
result = str(10) + "20"  # "1020"

def get_value():
    return None
x = get_value()
y = x + 10  # TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'

NoneType 에러는 에러가 난 줄보다 값을 만든 곳에 원인이 있는 경우가 대부분입니다. 함수가 어떤 분기에서 return을 빠뜨리면 조용히 None을 돌려주고, list.sort()처럼 제자리에서 수정하는 메서드의 반환값(None)을 변수에 받는 실수도 흔합니다(items = items.sort()). 이 에러를 보면 먼저 그 변수에 값을 대입한 함수가 모든 경로에서 값을 반환하는지부터 확인하는 것이 빠릅니다. 타입 힌트(def get_value() -> int | None:)를 달고 mypy나 Pyright를 돌리면 이런 가능성을 실행 전에 경고받을 수 있습니다.

IndexError

IndexError: list index out of range
arr = [1, 2, 3]
print(arr[5])  # IndexError

# 해결: 범위를 먼저 확인하거나 예외를 처리
if len(arr) > 5:
    print(arr[5])

try:
    print(arr[5])
except IndexError:
    print("인덱스 범위 초과")

인덱스가 범위를 벗어나는 경우는 대개 range(len(arr) + 1)처럼 반복 범위를 하나 더 잡았거나, 빈 리스트에서 arr[0]이나 arr[-1]을 읽은 경우입니다. 인덱스가 꼭 필요하지 않다면 for item in arr:나 enumerate(arr)로 순회하면 이런 실수 자체가 생기지 않습니다.


Java 흔한 에러

NullPointerException

java.lang.NullPointerException: Cannot invoke "String.length()" because "str" is null
String str = null;
int len = str.length();  // NullPointerException

// 해결 1: null 확인
if (str != null) {
    len = str.length();
}

// 해결 2: Optional로 "없을 수 있음"을 표현 (Java 8+)
int len2 = Optional.ofNullable(str).map(String::length).orElse(0);

Java 14부터는 NPE 메시지가 훨씬 친절해졌습니다(JEP 358, Helpful NullPointerExceptions). 예전에는 java.lang.NullPointerException 한 줄뿐이었지만, 지금은 위처럼 어떤 식의 어느 부분이 null이었는지 알려 줍니다. user.getAddress().getCity()처럼 한 줄에 호출이 여러 번 이어질 때 특히 유용합니다. 지역 변수 이름이 <local1>로 표시된다면 -g 옵션 없이 컴파일되어 디버그 정보가 없는 경우입니다. Optional은 “값이 없을 수 있음”을 반환 타입으로 표현하는 용도이며, 필드나 매개변수 타입으로 쓰는 것은 권장되지 않습니다.

ClassCastException

java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer
Object obj = "Hello";
Integer num = (Integer) obj;  // ClassCastException

// 해결 1: instanceof 패턴 매칭으로 확인과 캐스트를 한 번에 (Java 16+)
if (obj instanceof Integer n) {
    System.out.println(n + 1);
} else {
    System.out.println("Integer가 아닙니다");
}

// 해결 2: 제네릭으로 컴파일 타임에 타입 고정
List<Integer> list = new ArrayList<>();
list.add(10);
Integer first = list.get(0);  // 캐스팅 불필요

이 예외는 Object나 raw 타입 컬렉션처럼 컴파일러가 타입을 추적하지 못하는 곳에서 주로 납니다. 제네릭을 쓰면서도 이 예외가 난다면, 어딘가에서 raw 타입이나 unchecked 캐스트로 잘못된 원소가 들어간 경우가 많습니다. 이때는 예외가 난 줄이 아니라 원소를 넣은 곳을 찾아야 하므로, 컴파일 시 -Xlint:unchecked 경고를 확인하는 것이 출발점입니다.

ConcurrentModificationException

java.util.ConcurrentModificationException
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
for (Integer num : list) {
    if (num % 2 == 0) {
        list.remove(num);  // ConcurrentModificationException
    }
}

// 해결 1: Iterator.remove()
Iterator<Integer> it = list.iterator();
while (it.hasNext()) {
    if (it.next() % 2 == 0) {
        it.remove();
    }
}

// 해결 2: removeIf (Java 8+)
list.removeIf(num -> num % 2 == 0);

이름과 달리 이 예외는 멀티스레드와 무관하게 단일 스레드에서도 납니다. ArrayList는 구조가 바뀔 때마다 modCount를 올리고, for-each가 내부적으로 쓰는 반복자는 next()를 호출할 때마다 이 값이 처음과 같은지 확인합니다(fail-fast). 값이 달라졌으면 “순회 중에 누가 구조를 바꿨다”고 판단해 예외를 던집니다. 흥미로운 점은 끝에서 두 번째 요소를 지우면 예외가 나지 않는다는 것입니다. 삭제 후 크기가 줄어 hasNext()가 false를 돌려주면서 검사 없이 루프가 끝나고, 마지막 요소는 조용히 검사에서 빠집니다. 이 때문에 테스트 데이터에 따라 버그가 보였다 안 보였다 하므로, 순회 중 삭제는 처음부터 removeIf나 Iterator.remove()로 쓰는 것이 안전합니다.


JavaScript 흔한 에러

TypeError: Cannot read properties of undefined

TypeError: Cannot read properties of undefined (reading 'name')

Chrome과 최근 Node.js(V8)의 메시지입니다. 예전 V8은 Cannot read property 'name' of undefined, Firefox는 user is undefined처럼 표시합니다.

const user = undefined;
console.log(user.name);  // TypeError

// 해결 1: Optional Chaining (ES2020)
console.log(user?.name);  // undefined

// 해결 2: 기본값
const name = user?.name ?? 'Guest';  // 'Guest'

Optional chaining은 에러를 없애 주지만 원인을 숨길 수도 있습니다. API 응답에 반드시 있어야 하는 user가 없는 것이라면 ?.로 넘어가는 대신 그 지점에서 명확한 에러를 내는 편이 디버깅이 쉽습니다. 이 에러가 가장 자주 나는 곳은 비동기 데이터가 도착하기 전의 첫 렌더링입니다. React 컴포넌트가 useState()의 초깃값 undefined 상태로 한 번 렌더링된 뒤에 데이터가 들어오므로, 초깃값을 빈 객체·배열로 두거나 로딩 상태를 따로 처리해야 합니다. 또 ??는 null/undefined일 때만 기본값을 쓰고, ||는 0이나 빈 문자열도 기본값으로 바꾼다는 차이가 있습니다.

ReferenceError

ReferenceError: x is not defined
console.log(x);  // ReferenceError: x is not defined

console.log(y);  // undefined (var 선언은 호이스팅되어 undefined로 초기화)
var y = 10;

console.log(z);  // ReferenceError: Cannot access 'z' before initialization (TDZ)
const z = 10;

같은 ReferenceError라도 메시지가 둘로 나뉩니다. x is not defined는 그 이름이 스코프 체인 어디에도 없다는 뜻이라 오타나 import 누락을 의심하고, Cannot access 'z' before initialization은 선언은 있지만 실행이 아직 그 줄에 도달하지 않았다는 뜻이라 선언 순서나 모듈 간 순환 import를 의심합니다. var의 undefined는 에러가 나지 않아서 오히려 찾기 어려운 버그가 되므로, let/const의 에러가 더 안전한 동작이라고 보는 것이 맞습니다.

처리되지 않은 Promise 거부

Uncaught (in promise) TypeError: Failed to fetch      (브라우저)
TypeError: fetch failed                                (Node.js 18+, 처리하지 않으면 프로세스 종료)
// catch 없음: 네트워크 에러 시 처리되지 않은 거부
fetch('https://api.example.com/data')
  .then(res => res.json())
  .then(data => console.log(data));

// 해결: 상태 코드 확인 + 거부 처리
async function fetchData() {
  try {
    const res = await fetch('https://api.example.com/data');
    if (!res.ok) throw new Error('HTTP ' + res.status);
    const data = await res.json();
    console.log(data);
  } catch (err) {
    console.error('에러:', err);
  }
}

Node.js 15부터는 처리되지 않은 rejection이 경고가 아니라 프로세스 종료로 바뀌었습니다(--unhandled-rejections=throw가 기본값). 이전 버전에서 UnhandledPromiseRejectionWarning만 찍고 넘어가던 코드가 Node 버전을 올리면 서버를 종료시킬 수 있으므로, 업그레이드할 때는 await 없이 호출만 해 둔 비동기 함수가 없는지 확인해야 합니다.

fetch에는 흔히 놓치는 함정이 하나 더 있습니다. fetch는 네트워크 자체가 실패했을 때만 reject하고, 서버가 404나 500을 돌려주면 정상적으로 resolve됩니다. 상태 확인 없이 res.json()을 부르면 500 응답의 HTML 에러 페이지를 파싱하다 SyntaxError: Unexpected token '<'가 납니다. 이 에러를 보면 JSON 파싱 문제가 아니라 응답 상태 코드부터 확인해야 하며, 위 코드처럼 res.ok를 res.json() 앞에서 검사하는 것이 기본 패턴입니다.

this 바인딩 문제

TypeError: Cannot read properties of undefined (reading 'name')
class Person {
  constructor(name) {
    this.name = name;
  }
  greet() {
    console.log(`Hello, ${this.name}`);
  }
}
const person = new Person('Alice');

const greet = person.greet;
greet();  // TypeError: this가 undefined

setTimeout(person.greet, 1000);  // 에러 없이 "Hello, " 또는 "Hello, undefined"

// 해결 1: 화살표 함수로 감싸기
setTimeout(() => person.greet(), 1000);
// 해결 2: bind
setTimeout(person.greet.bind(person), 1000);
// 해결 3: 클래스 필드에 화살표 함수
class Person2 {
  constructor(name) {
    this.name = name;
  }
  greet = () => {
    console.log(`Hello, ${this.name}`);
  };
}

JavaScript의 this는 함수가 정의된 곳이 아니라 호출되는 방식으로 정해집니다. person.greet()처럼 점 앞에 객체가 있으면 그 객체가 this가 되지만, const greet = person.greet; greet();처럼 함수만 떼어 호출하면 this가 없습니다. 클래스 본문은 자동으로 strict mode라 이때 this는 undefined가 되고, this.name에서 위 에러가 납니다.

setTimeout(person.greet, 1000)은 더 까다롭습니다. 함수만 넘기는 것은 같지만, 브라우저의 setTimeout은 콜백을 window를 this로 해서 호출하고 Node.js는 Timeout 객체를 this로 넘깁니다. 그래서 에러가 나지 않고 window.name(보통 빈 문자열)이나 undefined가 조용히 출력됩니다. 이벤트 리스너(button.addEventListener('click', this.handle))에서는 this가 이벤트가 걸린 요소가 되므로 역시 에러 없이 엉뚱한 값을 읽습니다. 에러가 나는 경우보다 나지 않는 경우가 더 찾기 어려우므로, 메서드를 콜백으로 넘길 때는 처음부터 화살표 함수나 bind를 쓰는 습관이 안전합니다. 해결 3의 클래스 필드 화살표 함수는 인스턴스마다 함수를 새로 만들기 때문에 프로토타입 메서드보다 메모리를 조금 더 쓰고, 상속한 클래스에서 super.greet()로 호출할 수 없다는 제약이 있습니다.


디버깅 절차

Python·Node.js·clang에서 이 글의 예제를 실행해 얻은 실제 에러 메시지

그림: 실제 실행 결과 — 같은 IndentationError도 Python 3.9와 3.10 이후가 문구가 다르고, 세그폴트 안내(macOS는 Segmentation fault: 11)나 C++ 오류 문구도 OS·컴파일러마다 다르다. 메시지 전체가 아니라 에러 종류와 줄 번호를 먼저 읽는다. (환경: macOS 27.0.1 · Python 3.9.6·3.10.16 · Node 26.8 · Apple clang 21)

flowchart TD
    A[에러 발생] --> B[에러 메시지 읽기]
    B --> C{컴파일 에러?}
    C -->|예| D[문법/타입 체크]
    C -->|아니오| E{런타임 에러?}
    E -->|예| F[스택 트레이스 확인]
    E -->|아니오| G[논리 에러]

    D --> H[에러 위치 수정]
    F --> I[디버거로 추적]
    G --> J[로그/테스트 추가]

    H --> K[재컴파일]
    I --> L[원인 파악]
    J --> L
    L --> M[수정 및 테스트]
언어디버거로깅테스트
C++GDB, LLDB, Visual Studio Debuggerspdlog, glogGoogle Test, Catch2
Pythonpdb, ipdblogging, logurupytest, unittest
JavaIntelliJ Debugger, jdbLog4j, SLF4JJUnit, TestNG
JavaScriptChrome DevTools, VS Codeconsole, winstonJest, Mocha

최소 재현 코드

에러가 큰 함수 안에서 난다면, 같은 에러를 내는 가장 작은 코드를 따로 만들어 봅니다. 다른 로직을 걷어 내고 문제의 입력과 연산만 남기면 원인이 바로 보이는 경우가 많고, 보이지 않더라도 다른 사람에게 질문하거나 이슈를 올릴 때 그대로 첨부할 수 있습니다. 재현 코드를 줄이는 도중에 에러가 사라졌다면, 방금 지운 부분이 원인과 관련 있다는 정보를 얻은 것입니다.

이분 탐색

어디서 문제가 생기는지 모를 때는 범위를 절반씩 줄입니다. 처리 단계의 중간에 로그를 찍어 그 시점의 값이 이미 틀렸는지 확인하고, 틀렸다면 앞쪽 절반, 맞았다면 뒤쪽 절반에서 다시 같은 일을 반복합니다.

같은 방법을 코드 대신 이력에 적용할 수도 있습니다. “어제까지는 됐는데 오늘 안 된다”면 git bisect start, git bisect bad(현재), git bisect good <정상이던 커밋>을 입력한 뒤 Git이 골라 주는 중간 커밋마다 테스트 결과를 알려 주면, 커밋 1000개 사이에서도 10번 안팎의 확인으로 문제를 만든 커밋을 찾아냅니다. 재현 스크립트가 있다면 git bisect run ./test.sh로 자동화할 수 있습니다.

로그와 디버거

import logging
logging.basicConfig(level=logging.DEBUG)

def process(x):
    logging.debug("x = %s", x)

print는 빠르게 쓸 수 있지만 나중에 지우는 것을 잊기 쉽습니다. logging처럼 레벨이 있는 로거를 쓰면 운영 환경에서는 DEBUG 로그를 끄고 필요할 때만 켤 수 있습니다. 값이 어디서 바뀌는지 모를 때는 로그를 여러 군데 넣기보다 디버거의 중단점과 감시(watch) 기능을 쓰는 편이 빠릅니다.

# Python pdb
python -m pdb script.py
# Node.js: 첫 줄에서 멈춘 뒤 chrome://inspect에서 연결
node --inspect-brk script.js