Emscripten으로 데이터 (새 탭에서 열림)
Figma의 저장 파일이 간헐적으로 손상되는 문제는 재현이 어려운 비결정적 이벤트와 C++의 메모리 안전성 문제 때문에 장기간 해결되지 않았다. 일반적인 메모리 디버깅 도구로 원인을 찾지 못한 뒤, 키보드·마우스 입력을 생성하는 퍼저로 이벤트를 반복 실행해 문제를 재현하는 데 성공했다. 최종 수정은 FlatBuffers의 잘못된 메모리 접근을 고치는 세 줄짜리 커밋이었지만, 원인을 추적하는 과정에서 Emscripten과 브라우저의 메모리 모델까지 분석해야 했다.
간헐적으로 발생한 저장 파일 손상
- Figma는 때때로 다시 읽을 수 없는 잘못된 저장 파일을 생성했다.
- 당시 저장 형식은 다음 구조였다.
- ZIP 파일
- 내부에 Google FlatBuffers로 인코딩된 문서
- 파일의 전체 바이트 구조는 대체로 정상처럼 보였지만, 뒤쪽 데이터를 가리키는 일부 오프셋이 0으로 변해 있었다.
- 데이터가 원래 위치가 아닌 곳에 기록된 현상은 다음과 같은 메모리 안전성 위반을 의심하게 했다.
- 초기화되지 않은 메모리 사용
- 해제된 메모리 접근(use-after-free)
- 배열 범위를 벗어난 읽기·쓰기
C++ 메모리 오류의 추적 난이도
- Figma 에디터는 C++로 작성되어 있었다.
- C++는 다음과 같은 장점 때문에 그래픽 소프트웨어에 적합하다.
- FreeType, HarfBuzz, Skia 같은 저수준 라이브러리 활용
- 하드웨어와 직접 연결되는 언어 기능
- 성숙한 디버깅 및 최적화 도구
- 높은 성능과 세밀한 메모리 제어
- 반면 C++는 언어 자체가 메모리 안전성을 보장하지 않는다.
- 복잡한 언어 설계, 오류가 발생하기 쉬운 표준 라이브러리 API, C에서 물려받은 레거시가 결합되어 대규모 프로젝트에서 메모리 오류를 완전히 피하기 어렵다.
일반적인 디버깅 방법의 한계
개발팀은 다음과 같은 방법을 시도했지만 문제를 발견하지 못했다.
- 메모리를 해제하지 않아 use-after-free 가능성 제거
- macOS의 malloc 디버깅 옵션 활성화
- Valgrind가 보고한 모든 문제 수정
- 컴파일러 업그레이드
- Clang 정적 분석기가 발견한 문제 수정
이런 도구들은 많은 오류를 찾아내지만, 실행 조건에 따라 드물게 발생하거나 특정 메모리 배치에서만 나타나는 문제까지 항상 잡아내지는 못한다.
이벤트 기록과 퍼징으로 재현성 확보
- 문제의 핵심은 비동기 타이머와 네트워크 이벤트 등 웹 앱의 비결정성이었다.
- 해결 전략은 다음과 같았다.
- 사용자 이벤트를 기록한다.
- 동일한 이벤트를 순서대로 재생한다.
- 문제가 발생할 때까지 세션을 반복 실행한다.
- 이벤트를 무작위로 제거하면서도 문제가 유지되는 최소 테스트 케이스를 만든다.
- 실제 사용자 세션에는 너무 다양한 이벤트가 포함되므로, 범위를 키보드와 마우스 이벤트로 제한했다.
- 실제 세션을 수집하는 대신 퍼저가 무작위 키보드·마우스 입력을 생성하도록 했다.
- 며칠 동안 실행한 결과 여러 건의 저장 실패를 재현할 수 있었고, 이후 반복 가능한 입력 시퀀스를 기반으로 본격적인 디버깅이 가능해졌다.
Emscripten이 C++를 브라우저에서 실행하는 방식
- Figma는 플러그인 없이 브라우저에서 실행되는 웹 앱이다.
- C++ 코드는 Emscripten을 통해 JavaScript로 컴파일된다.
- JavaScript는 기본적으로 메모리 안전성과 가비지 컬렉션을 제공하지만, WebGL과 Typed Arrays 덕분에 C의 메모리 모델을 흉내 낼 수 있다.
- Typed Array의 특징은 다음과 같다.
- 고정된 크기를 가진다.
- 모든 원소가 같은 타입이다.
- 여러 Typed Array가 하나의 ArrayBuffer를 공유할 수 있다.
- 같은 메모리를
Float32Array와Uint8Array등 서로 다른 타입으로 해석할 수 있기 때문에 C/C++의 포인터 캐스팅과 유사한 동작을 구현할 수 있다.
Emscripten의 포인터와 메모리 변환
Emscripten은 C++의 주요 요소를 JavaScript 구조로 변환한다.
- 포인터 읽기: Typed Array 읽기
- 포인터 쓰기: Typed Array 쓰기
- 레지스터: JavaScript 지역 변수
- 스택:
STACKTOP이라는 스택 포인터로 관리 - 타입 변환: 동일한 ArrayBuffer를 공유하는 Typed Array를 통해 구현
- 생성된 코드는 asm.js라는 제한적인 JavaScript 부분집합을 사용해 JIT 컴파일러가 타입을 추론하고 빠르게 최적화하도록 한다.
- 예를 들어
+$value는 값을 double로 취급하도록 JIT에 힌트를 주며, 비트 연산은 정수 연산으로 최적화될 가능성을 높인다.
실용적인 결론
재현이 어려운 데이터 손상 문제는 도구를 하나씩 추가하는 것만으로 해결되지 않을 수 있다. 비결정적 입력을 통제하고, 퍼징과 이벤트 재생으로 실패 조건을 반복 가능하게 만드는 것이 핵심이며, 컴파일된 실행 환경에서는 원본 언어뿐 아니라 Emscripten의 메모리 모델과 런타임 동작까지 함께 이해해야 한다.