최근에 자바스크립트 로직을 구현하면서 종종 "예상치 못한 오류"를 경험하게 되었다.
"예상치 못한 오류"라는 것은 단순히 디버깅을 하고 오류 지점을 특정하여 재발을 불가하는 조치를 취할 수 있는 오류가 아닌, 오류의 원인이나 이를 유발하는 어떠한 요소도 특정하지 못하여 재발이 언제든지 발생할 수 있는 조치 불가한 "문제"라 할 수 있겠다.
즉 쉽게 말해 원인을 찾을 수 없는 오류인데, 로직을 변경하지 않고 단순히 디버깅 혹은 info 로그를 삽입했을 뿐이지만 기존에 발생하던 오류가 사라지고, 이후 원복해도 오류를 재현할 수 없는 상황이 발생하였다.
문제는 이러한 원인미상의 문제가 자바스크립트와 자바 모두 최근 발생했다는 점인데, 일단 두 하이젠버그에 대해 모두 정리하고자 한다. 공부한 내용들은 도저히 자료가 없어서 챗지피티한테 물어본 내용들이다.
다만 지금까지 가장 많이 겪었던 자바스크립트 원인미상 오류에 대해 집중적으로 파헤치고 그 원인도 어느정도 이해가 갔지만, 자바의 경우 아무리 생각해도 하이젠 버그가 왜 발생하는지 도통 이유를 알 수 없었다.
그리고 이러한 오류를 미연에 방지하여, 테스트나 리팩토링 등에 발생한 비용을 최대한 절감할 수 있는 방안을 한 번 생각해보았는데 이에 대해 생각한 내용들을 남긴다.
위에서 기술한 것처럼 원인을 찾을 수 없는, 재현이 불가능하여 근본적인 조치를 취할 수 없는 오류를 지칭한다.
즉, 하이젠버그는 디버깅을 하거나 로그를 삽입하는 등 기존 로직을 변경하지 않았으며 별다른 특이사항이 없었음에도 불구하고, 오류 재현이 불가능하거나 원인을 찾을 수 없는 오류를 일컫는다.
원인미상의 오류가 발생하였는데, 로직을 변경하지 않고 로그만 넣어주거나 디버깅을 해주었는데 오류 재연이 불가능하다. 원복을 했음에도 해당 오류가 다시 발생하지 않는다.
이 경험을 누적하면서 느꼈던 부분은 개발을 진행할때 불필요한 시간소모가 너무 발생하였다는 점, 즉 단순히 원인 파악이 어려운 오류로 봐서는 곤란하다는 것이었다.
이제 오류를 대하는 마음가짐을 달리 해보자.
일단 지금까지 하이젠버그를 마주하였을 때를 생각하면서, 왜 원인미상의 오류가 발생하였는지를 생각해보았다.
보통 javascript에서 많이 발생하였고, java에서는 최근 1번 발생하였다.
javascript가 java에 비해 많이 "발생"한다기 보다는, 발생할 "여지"가 많은 것 같다.
사실 두 언어의 원리나 작동방식이 너무나도 다르고, 프레임워크나 환경적인 부분에서 하이젠버그를 발생시킬만한 공통 분모를 도저히 찾을 수 없었다. 그나마 하이젠버그가 발생하였을때 정황적으로 유사한 부분이 있었다면 해당 기능을 진행한지 "오랜 기간"이 지났을 경우였다.
즉 다시 말해 이전에 오류가 발생하지 않아 변경점이 없었던 기능들을 "오랜만에" 다시 작동시켰을때, 혹은 매개변수 혹은 객체를 "바꿨을때" 원인 미상의 오류를 겪었다.
온전히 정량적이고 객관적인 근거가 아닌 경험적 근거이기 때문에 하이젠버그의 원인을 확정할 수는 없다. 다만 확실한 것은 논리적인 비약은 전혀 없었다는 점이다.
그렇기에 내가 생각할 수 있는 하이젠버그의 원인은 구조적 문제점이라 판단할 수 밖에 없었고,
이 생각을 일단 기반으로 챗지피티에게 물어보았다. 참고로 하이젠버그에 대한 자료가 아예 없어서, 챗지피티를 활용해보았다.
Q. javascript 오류가 발생해서 오류 트레이싱을 하려고 로그를 붙였어.
그런데 로그만 붙여줬는데 잘 작동하네? 다른건 건드린게 아무것도 없어.
도저히 원인을 찾을 수 없는 미상의 오류, 왜 발생하는걸까?
비동기 타이밍 이슈
로그 추가로 인해 실행 순서나 타이밍이 미묘하게 바뀌면서, 원래 발생하던 타이밍 관련 버그가 사라짐.
예: setTimeout, Promise, async/await 같은 것들이 줄 바꿈 하나로도 동작이 달라질 수 있음.
메모리 접근 or GC 타이밍 변화
로그 추가로 인해 엔진 내부의 메모리 레이아웃이나 GC 타이밍이 바뀌어서, 오류가 발생하지 않게 됨.
Race Condition (경쟁 조건)
두 개 이상의 비동기 코드가 서로 예상하지 못한 순서로 실행될 때 문제가 생기는데,
로그 출력이 이 순서를 무의식적으로 바꿔버리는 경우가 있음.
디버거 혹은 콘솔의 개입
브라우저 콘솔이 특정 값을 평가할 때 내부적으로 코드를 건드리기도 하고,
콘솔 출력(특히 객체)을 보는 것만으로도 JavaScript에서 getter가 실행되기도 함.
로그로 인한 delay 발생
로그 추가가 시간적 지연(delay) 을 만들어서 스레드 간 타이밍 또는 비동기 흐름이 미묘하게 바뀌고, 그 결과 원래 발생하던 타이밍 문제(예: null 접근, 초기화 이전 접근 등)가 사라지거나 반대로 드러나는 현상.
일단 기본적으로 javascript는 비동기이므로, 두개 이상의 객체 혹은 함수가 있을때 절차적으로 처리하지 않고 다른 함수가 선행 함수의 동작이 종료하기까지 기다리지 않는다.
전체적으로 하이젠버그를 유발할 수 있는 가장 강력한 원인은 "비동기" 동작에서 발생하는 스레드 간의 미묘한 타이밍이나 변수와 관련한 메모리적 이유에 있다고 생각이 들었다.
또한 아래 내용도 같이 챗지피티한테 물어보았다.
Q. 그런데 로그를 찍으면, 예를 들어 힙에 저장된 값을 스택 메모리 변수와 매핑하는 과정에서 좀 시간적 차이가 발생해서, 매핑이 잘 되고(변수에 값이 안담기거나 하지 않고), 로그를 찍지 않으면 시간적 차이가 없어서 매핑이 잘 안되고(변수에 값이 담기기전에, 그 변수를 null인 상태로 사용한다던지)...이런 점들이 하이젠버그의 원인이 될 수 있나?
즉, 로그와 변수 타이밍에 대한 질문이었고 챗지피티는 충분히 하이젠버그의 가능성이 있다고 답해주었다.
로그
console.log 자체도 I/O 작업이고, 브라우저 콘솔에 출력하는 동안 이벤트 루프나 GC 타이밍이 바뀐다. 심지어 JIT 최적화가 "단순히 로그를 붙임으로 인해" 우회, 즉 기존 엔진 동작을 따라가지 않기도 한다(*로그 코드가 삽입되면 엔진이 최적화를 포기하거나 다르게 최적화함).
로그로 인해 엔진 최적화를 거부할 수 있다.
V8, SpiderMonkey 같은 엔진은 코드 흐름에 따라 동적으로 최적화하므로, 로그가 있느냐 없느냐로 내부 동작이 달라질 수 있고, 메모리 접근 순서 변경한다.
즉 로그를 찍는 동안 힙에 있던 값이 GC 되지 않고 유지되거나, 필요한 값이 레지스터에 로드될 시간이 생김(=즉 오류가 발생하는 구간이 로그로 인해 발생하지 않게 되고, 이 동작을 원복을 했음에도 그대로 유지하여 오류 재현이 불가능하다).
여기서 조금의 확신이 들었다. 원인 미상의 오류가 발생한다? 그것은 프레임워크의 구조를 개발자가 충분히 이해하지 못하였을 확률이 높다. 혹은 그 구조를 활용하지 못하고 있다던지.
java는 javascript와 달리 철저하게 절차적이기에, 사용자가 명시적으로 병렬처리를 지정해주지 않는 이상 순차적인 흐름을 유지한다.
근데 생각해보니, 정말 큰 틀, 프레임워크까지 depth를 올려보았을때 메인스레드의 실행과 서브스레드의 실행으로 java 역시 어떻게 보면 위와 같은 "타이밍" "변수" "메모리" 관련 문제점이 발생할 수도 있겠구나..라는 생각이 들었다.
즉, java는 다중 스레드를 통해 병렬처리를 한다. 메인 스레드에서 실행하는 스레드와 특정 기능을 동작하기 위해 실행하는 스레드는 다르고, 이는 javascript에서 동작하는 비동기적 처리의 "양상", 즉 "동작 시점과 종료지점을 예측할 수 없는" 상황적인 의미에서 매우 유사하다 볼 수 있겠다.
이를 기반으로 챗지피티한테 물어보았다.
Q. 생각해보니 큰 틀에서 보면, java도 (Spring 프레임워크에서) 다양한 스레드를 실행하기에 그리고 경쟁 조건 (race condition)이 발생할 수 있다는 생각이 들었다. 이 부분에 대해 구체적인 예(과정)을 들어서 설명해줄 수 있을까? 어떤 상태에 의해 경쟁 조건이 발생하게 되고, 이로 인해 java에서 하이젠버그가 어떻게 발생하게 되는지..
class Example {
static Object obj;
public static void main(String[] args) {
fetchDataAsync();
if (obj != null) {
doSomething(obj); // NPE 발생 가능
}
}
static void fetchDataAsync() {
new Thread(() -> {
// 네트워크 통신 or 디스크 I/O 등
try {
Thread.sleep(100); // 모의 비동기 지연
} catch (InterruptedException e) {
e.printStackTrace();
}
obj = new Object(); // 결과 도착
}).start();
}
static void doSomething(Object o) {
System.out.println("Using object: " + o.toString());
}
}
위 로직에서 main()이 fetchDataAsync()를 호출하자마자 if (obj != null)을 실행하는데, 백그라운드 스레드가 obj를 설정하기 전에(즉, stack에 변수를 생성하고 heap의 임시객체를 생성하여 변수참조를 진행하기 전에) 조건문이 먼저 실행되면,
obj는 아직 null → doSomething(obj)에서 NullPointerException 발생한다!
로그를 추가함으로 인해 메인스레드/서브스레드의 객체 참조 타이밍이 변화하여 하이젠버그가 발생할 수 있는 다른 케이스(따로 물어본건 아닌데 챗지피티 답변 중에 참고할만한 자료였다)
▶️ System.out.println("log"); 를 중간에 추가하면?
System.out.println("Before checking obj");
if (obj != null) {
doSomething(obj);
}
이 println() 호출은 CPU 시간 소모 + IO 지연이 생겨서, Worker 스레드가 obj를 설정할 시간을 벌어준다.
→ 우연히 NPE가 발생하지 않고 정상 동작하게 됨
그러면 개발자는 "어? 로그 찍으니까 잘 되네?" 라고 생각하지만,
나중에 로그 빼고 배포하면 다시 오류가 나는 상황이 발생 → 이것이 하이젠버그가 발생하는 이유이다!
즉 정리해보면, 프레임워크의 구조적 문제라기 보다는 "프레임워크를 충분히 숙지하고 있지 않기 때문에" 발생하고 있는 오류라 할 수 있겠다.
하이젠버그가 발생할 수 있는 요인을 어느정도 알았으니, 어떻게 개발을 진행하고 불필요한 소모를 줄일 수 있을지 생각해보았다.
하이젠버그는 원인미상의 오류라기 보다는, 프레임워크의 구조를 충분히 이해하지 못하였기에 발생하는 개발자 오류라 볼 수 있겠다.
다만 실무에서 구조적, 특히 워커스레드와 같은 부분들을 모두 고려하면서 오류를 파악하기에는 그 과정이 오히려 더 많은 소모가 발생할 수 있으므로, 원인 미상의 오류가 발생할 수 있다는 것을 인지하고 간단한 조치로 소모를 최소화할 수 있는 생각을 "장착"하는게 중요할 것 같다.
하이젠버그 - https://www.slideshare.net/slideshow/debugging-javascript-0-to-heisenberg/40172871#28