에디터에 사진 넣기, 반나절이면 될 줄 알았습니다
왕복마다 사라지는 이미지, 전염되는 중앙 정렬, 그려지지 않는 오버레이. 사진 한 장에 벌어진 일들.
에디터에 사진 넣기, 반나절이면 될 줄 알았습니다
Fecit의 본문 에디터에 사진을 넣을 수 있게 됐습니다. 툴바에서 사진을 고르면 본문에 들어가고, 저장하는 순간 그 문서의 첨부로 함께 등록됩니다. 본문에서 지우고 저장하면 첨부에서도 알아서 정리됩니다.
기능 설명은 이게 전부입니다. 흔한 기능이고, 어디에나 있는 기능입니다. 그래서 금방 끝날 줄 알았습니다.
Fecit의 에디터는 세 벌입니다. 데스크톱은 웹 에디터, 모바일은 iOS와 Android 각각의 네이티브 에디터. 셋이 같은 문서 포맷을 읽고 씁니다. 사진 기능도 세 벌을 같이 고쳐야 했고, 문제도 세 벌로 왔습니다. 그중 기억에 남는 세 가지만 적어둡니다.
사건 1 — 왕복마다 사라지는 사진
iOS에서 사진을 고르면 첨부에는 등록되는데 본문에는 안 보였습니다. 업로드는 성공, 삽입 명령도 전송됨, 그런데 화면에는 없음.
추측으로 두 군데를 고쳤는데도 그대로였습니다. 여기서 멈추고 로그를 심었습니다. JS 경계에 하나씩, 네이티브 안에 하나씩. 그러자 한 줄이 나왔습니다.
updateValue: imgBefore=1 imgAfter=0 len=281
이미지가 포함된 문서(길이 281자)를 받아서 파싱했더니 이미지가 0개가 됐다는 뜻입니다. 삽입은 정상이었습니다. 문제는 그 값이 JS를 한 바퀴 돌아 다시 네이티브 파서로 들어올 때였습니다.
파서에는 “HTML 태그가 문서 중간부터 시작할 수도 있으니, 가장 앞에 있는 태그를 찾아 거기서부터 파싱한다”는 로직이 있습니다. 그 후보 목록에 <p>, <li>, <table> 같은 태그들이 들어 있는데 — iOS 목록에만 <img>가 없었습니다. 그래서 문서가 이미지로 시작하면, 파서는 이미지 뒤에 있는 <p>를 “가장 앞의 태그”로 잡고 그 앞부분을 통째로 버렸습니다.
Android 파서에는 <img>가 있었습니다. JS 파서에도 있었습니다. iOS만 빠져 있었습니다. 같은 코드를 세 벌 유지하면 언젠가 한 벌이 어긋나고, 어긋난 벌은 가장 조용하게 틀립니다. 에러도 없고 크래시도 없이, 그냥 이미지가 사라집니다.
사건 2 — 전염되는 중앙 정렬
사진을 넣으면 그 아래 줄의 글자가 중앙 정렬로 바뀌었습니다. 사진을 지워도 그대로였습니다. 화면을 나갔다 들어오면 돌아왔습니다.
원인은 이미지의 가운데 배치를 단락 정렬 속성으로 구현한 것이었습니다. iOS 텍스트 편집기는 새 글자의 속성을 주변에서 상속받는데, 이미지 단락의 “중앙 정렬”이 그 경로를 타고 일반 글자에 옮겨붙었습니다. 커서 위치를 감시해서 정렬을 되돌리는 가드를 넣었더니, 이번엔 한글 조합이 가드를 우회했습니다. 조합 중 범위까지 정규화하는 두 번째 가드를 넣었더니, 이번엔 조합 중 첫 음절이 잠깐 가운데에 보였다가 돌아왔습니다.
가드를 세 겹 쌓고 있을 때 이런 생각이 들었습니다. 범위를 제한하면 되지 않나?
정렬을 단락 속성으로 표현하는 한, 상속 경로는 계속 있습니다. 그래서 접근을 바꿨습니다. 이미지 블록의 셀을 항상 전체 폭으로 잡고, 좌/중/우 배치는 셀 안에서 그림을 그리는 위치로만 처리했습니다. 단락은 언제나 왼쪽 정렬입니다. 상속될 것이 없으니 전염도 없습니다.
세 겹의 가드보다 좋은 답은 정렬이 새어나갈 수 없는 구조였습니다.
사건 3 — invalidate() 했는데 안 그려짐
사진이 너무 쉽게 지워지는 문제가 있어서, 백스페이스를 두 번 눌러야 지워지게 했습니다. 첫 번째는 이미지에 “지워질 대상”이라는 표시를 씌우고, 두 번째에 실제로 지웁니다.
Android에서 이 표시가 안 보였습니다. 스팬 객체에 플래그를 켜고 invalidate()를 불렀는데, 로그를 심어 보니 스팬의 draw()가 아예 호출되지 않았습니다.
하드웨어 가속 TextView는 텍스트 렌더링을 디스플레이 리스트에 캐시합니다. 뷰를 무효화해도, 프레임워크가 보기에 텍스트도 스팬도 “변한 게 없으면” 캐시를 재사용합니다. 스팬 객체 내부의 필드를 바꾼 건 프레임워크 입장에선 변화가 아닙니다.
답은 같은 범위에 setSpan()을 다시 거는 것이었습니다. 그러면 SpanWatcher가 발화하고, TextView가 “이 스팬이 변했다”는 통지를 받아 해당 영역을 제대로 다시 그립니다. 뷰의 무효화와 텍스트 레이아웃의 무효화는 다른 것이었습니다.
남긴 것
이번 작업에서 제일 많이 되뇐 규칙은 이겁니다. 추측 수정이 두 번 빗나가면 멈추고 로그를 심는다. 사건 1도 3도, 로그 한 줄이 나오기 전까지의 추측은 전부 틀렸고, 로그가 나온 뒤에는 원인까지 한 걸음이었습니다.
그리고 세 벌의 에디터는 이제 목록 하나가 어긋나면 이미지가 통째로 사라진다는 걸 알았으니, 파서 후보 목록 옆에 “세 벌 동기 유지”라고 주석을 박아뒀습니다. 다음에 어긋나는 사람이 저일 수도 있으니까요.
사진 한 장 넣는 기능이었습니다.