[플밍노트] 재현 가능한 빌드, 재현 가능한 시드

#0

최근에 덤프 파일 심볼이 맞지 않는 문제가 있었다. wow64 프로세스 덤프를 뜬 건데 .reload를 해봐도 심지어 다운로드 받은 심볼을 지우고 다시 받아도, windbg를 업그레이드 해봐도 소용 없었다. 항간에 windows 10 rs4에서 심볼이 잘 안 맞춰진다는 이야기도 있어서 그런건가 싶어서 포풍 검색을 해봤지만 뾰족한 수가 없었다. 증상은 심플했는데 wow64 프로세스임에도 ntdll이 하나만 있었고, 로드된 dll 목록의 타임스탬프가 invalid라고 나왔다. 그리고 ntdll의 함수를 디스어셈블 하면 이상한 코드들이 나왔다. 여튼 그런 증상…​ 그렇게 퇴근 시간은 다가오고 오늘의 일을 내일로 미루고 퇴근을 했다…​

#1

다음날. 덤프를 다시 열어서 방망이 깎는 노인이 그랬듯 출력되는 메시지를 한줄 한줄 꼼꼼히 쳐다 보았다. 그랬더니 이상한 점이 보였다. 덤프를 열면 통상적으로 운영체제 버전이 표기되는데 64비트 운영체제가 아니라 32비트 운영체제로 나오는 것이었다. 순간, 많은 정황을 고려해 보았을 때 덤프를 64비트 PC에서 32비트 덤프 생성 도구로 덤프를 뜬 것이 아닐까라는 생각이 들었다. 덤프를 생성한 직원에게 물어보자 그렇게 생성했다고 하길래 64비트로 다시 생성해 달라고 했다. 새롭게 만들어진 덤프은 기다렸다는 듯이 잘 동작했다.

#2

뭐 이 즈음에는 대체로 내가 원한 모든 문제는 다 해결이 되었지만 그럼에도 불구하고 찜찜한 점이 하나 있었는데, 그건 여전히 dll들의 타임스탬프가 invalid라고 나오는 문제였다. 다른 모든 것은 기대한대로 동작했지만 타임스탬프 값은 여전히 오리무중…​ 다시 포풍 검색을 했다. 나에게 항상 해답을 주시는 레이몬드 첸 횽님께서 벌써 올해 1월에 관련 질문에 대한 답을 올려 놓으셨다. 이렇게 바지런 하시니 몸둘 바를…​

해당 글을 통해 알게 된 썰은 이렇다. 윈도우 10부터 재현 가능한 빌드(reproducible builds)로 방향을 정했고, 그 정책의 일환으로 타임스탬프를 타임스탬프가 아닌 해시 값으로 변경했다는 것이다. 재현 가능한 빌드라는 건 한 마디로 정리하면 동일 소스 코드로 컴파일을 하면 동일 바이너리가 생성된다는 것을 보장한다는 개념이다. 즉, 니가 소스 코드를 한 줄도 안 고쳤다면 넌 어디에서건 다시 컴파일해도 동일한 바이너리를 가지게 된다는 의미다. 이렇게 하는 이유는 당연히 버그에 보다 빠르게 대응하기 위함이겠거니…​ 내지는 높으신 분의 개취…​

#3

여기까지 오자 몇 해 전 우리에게 혁신적인 개념을 소개했던 직원의 아이디어에 대한 이름을 이제야 붙이 수 있게 되었다. 그 이름은 다름아닌 재현 가능한 시드(reproducible seed) 정도가 되겠다. 적어도 4-5년은 거슬러 올라가야 하는 이야기다. 우리는 당시 아주 난해한 버그를 마주하고 있었다. 100번 정도에 한 번씩 게임이 시작 시점에 크래시가 나는 문제였다. 지금에 비하면 아주 단촐했던 테스트 인력을 가지고 있던 당시로써는 문제를 발견해 낸 것만 해도 대단한 것이었다. 하지만 그 문제를 해결하는 것은 발견하는 것 이상의 노력이 필요했다. 재현을 하려면 100번을 실행해야 했기 때문이다. 어쨌든 그 당시 테스트 인력의 도움으로 어렵사리 덤프를 확보했다. 하지만 아무도 문제를 찾지 못했다. 딱 미제(미해결 과제)가 되기 좋은 사안이었는데 어디선가 혜성같이 등장한 구원 투수가 마법같이 문제의 원인을 알아냈다. 랜덤이 문제였다. 랜덤 결과가 특정 값이 될 경우 메모리가 오버래핑되는 문제가 있었던 것이다. 그 확률이 1/100정도 됐던 모양이다.

그때 그 덤프에서 문제를 찾은 직원은 우리가 업무 특성상 랜덤을 많이 사용한다면 재현 가능한 시드 시스템을 도입해야 한다는 것을 주창했다. 그 메커니즘은 재현 가능한 빌드와 동일한데 시작 시에 랜덤 시드를 지정하면 프로그램 전체가 그 시드에 동기화되어서 동작하는 것을 보장하는 것을 의미한다. 이렇게 말하면 어려운데 그냥 쉽게 코드로 말하면 전역 의사 난수 시스템을 사용하고 시작 시에 시드를 고정할 수 있는 방법을 제공해야 하는 것을 말한다.

이렇게 할 경우 어쩌다 한 번 크래시 났을 때, 시드를 알고 있다면 그 다음에는 굳이 100번 실행할 필요가 없다. 해당 시드를 넣고 실행하면 동일한 실행 결과를 보장하기 때문이다. 랜덤이 문제라면 똑같이 문제가 발생할 것이다. 즉, 버그를 찾기가 엄청 쉬워지는 장점이 있다. 여기에 플러스 퍼징 테스트를 하기가 아주 쉬워진다. 그 시드 값만 순차적으로 증가시켜 가면서 쭉 실행을 시키면 문제가 있는지 없는지 손쉽게 찾을 수 있으니 말이다.

찾아낸 것도 대단하지만 그런 문제를 두 번 다시 겪지 않기 위해서 재현 가능한 시드라는 것을 주창했다는 것 또한 더 대단한 일이 아닐 수 없다 하겠다. 하지만 현실은 언제나 바쁘기에 그의 의견은 아직도 구현되진 못했다. 조선 최고의 천재라 일컬어지는 율곡 이이 선생의 십만양병설이 떠오르는 시점이 아닐 수 없다.

윈도우 10도 재현 가능한 빌드를 사용하는 마당에 오래 전 그 직원이 주창했던 아이디어를 반성하는 의미에서 구현해야 겠다는 생각이 든다. 구슬이 서 말이라도 꿰어야 보배고, 아이디어가 아무리 많아도 구현이 없으면 공허한 메아리일 뿐이니 말이다.

#4

다시 다음날. 반성하는 의미에서 우리가 사용하는 랜덤의 호출 경로를 추적하는 작업을 해 보았다. 그러다보니 이 문제가 생각보다 쉽지 않다는 것을 알게 되었고, 똑같은 짓을 몇 년 전에도 했다는 사실을 알게 되었다. 메멘토 같은 상황…​

전말은 이렇다. 일반적으로 사용하는 rand 함수는 시드를 가지고 있고 멀티스레드 환경에 최적화하기 위해서 해당 시드를 스레드 로컬 스토리지에 저장한다. 이 말은 이걸 전역으로 한 번 초기화 한다고 해서 해당 값에 바인딩된 rand를 호출하는 모든 상황에 적용되는 것은 아니라는 것을 의미한다. 즉, 제대로 돌아가게 만들기 위해서는 모든 스레드의 시작 시점에 해당 시드로 초기화를 하는 루틴을 삽입해야 한다는 것을 의미한다. 응당 그게 쉽지 않으니 달리 생각할 수 있는 방법은 의사 랜덤 생성 함수를 새롭게 만드는 방법을 생각해 볼 수 있다. 이렇게 구현할 경우 스레드 안정성을 보장하기 위해서는 락을 쓰거나 락리스한 방법으로 좁혀진다. 그런데 랜덤 값 하나 만들겠다고 락을 쓰는 것은 뭐 빈대잡겠다고 초가삼간 태우는 격이니 결국은 락리스한 랜덤으로 방법은 더 좁혀진다. 그럼 그런 것을 내가 직접 만들 수는 없으니 구글에 검색을 하게 된다. 그렇게 나온 결과물을 쳐다 보고 있으면 그런 결론에 도달한다. 아~ 다른 일부터 해야겠구나…​